diff --git a/packages/sw/tests/overview.browser.mjs b/packages/sw/tests/overview.browser.mjs index 045e289..a2515e0 100644 --- a/packages/sw/tests/overview.browser.mjs +++ b/packages/sw/tests/overview.browser.mjs @@ -7,6 +7,7 @@ import http from 'node:http' import { parse } from '@babel/parser' import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds, integratedChangedFiles, integratedStoryIds, retiredPropertyPanelIds, retiredUnusedDemoIds, movedHomeMenuIds, retiredHomeMenuCompareIds, movedToPopupIds, approvedMainCommit, homeMenuSidebarIds, withApprovedEntryChanges, approvedHeaderDeclarations, movedHeaderDeclarations } from './storybook-inventory.mjs' import { loadPlaywright } from './playwright-runtime.mjs' +import { sourceChanges, unapprovedChanges } from './source-changes.mjs' const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-overview-qa') await mkdir(out, { recursive: true }) @@ -46,8 +47,8 @@ check('retained non-Overview declarations byte-identical to 50bf5bd or their app check('retired Dropdown IDs absent', retiredDropdownIds.every(id => !after.some(e => e.id === id))) // The package source may differ from the approved main merge only in the integrated-improvement files (their own suites // verify them). Approved package changes after 50bf5bd are listed as sinceBaseline; update approvedMainCommit on the next approved merge. -const sourceFiles = (...refs) => execFileSync('git', ['diff', '--name-only', ...refs, '--', 'packages/sw/src'], { encoding:'utf8' }).trim().split(/\r?\n/).filter(Boolean) -const sourceDiff = { unapproved: sourceFiles(report.approvedMainCommit).filter(file => !integratedChangedFiles.includes(file)), sinceBaseline: sourceFiles(report.baseCommit).length } +// Untracked new source files count as changes (source-changes.mjs). +const sourceDiff = { unapproved: unapprovedChanges(report.approvedMainCommit, ['packages/sw/src'], integratedChangedFiles), sinceBaseline: sourceChanges(report.baseCommit, ['packages/sw/src']).length } check('package production source unchanged since the approved main merge except the integrated-improvement files', sourceDiff.unapproved.length === 0, sourceDiff) const root = path.resolve('storybook-static'), mime = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.ttf':'font/ttf','.png':'image/png' } diff --git a/packages/sw/tests/public-api.approved.json b/packages/sw/tests/public-api.approved.json new file mode 100644 index 0000000..61e4a30 --- /dev/null +++ b/packages/sw/tests/public-api.approved.json @@ -0,0 +1,238 @@ +{ + "package": "@hm-design/sw", + "approvedCommit": "553e4df4a865aaf6514b1c2b86cb2e3e9a658e5f", + "manifest": { + "name": "@hm-design/sw", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./styles.css": "./dist/styles.css" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": null, + "files": [ + "dist", + "ai" + ] + }, + "exports": { + "SWFileEntry": "type", + "SWFooter": "value", + "SWFooterAddSlot": "value", + "SWFooterAddSlotProps": "type", + "SWFooterAuxButton": "value", + "SWFooterAuxButtonProps": "type", + "SWFooterAuxGroup": "value", + "SWFooterAuxGroupProps": "type", + "SWFooterAuxIconName": "type", + "SWFooterAuxItem": "type", + "SWFooterCategory": "type", + "SWFooterCommandLine": "value", + "SWFooterCommandLineProps": "type", + "SWFooterGuide": "value", + "SWFooterGuideContent": "type", + "SWFooterGuideProps": "type", + "SWFooterLayout": "type", + "SWFooterLayoutTabs": "value", + "SWFooterLayoutTabsProps": "type", + "SWFooterMenu": "value", + "SWFooterMenuItem": "type", + "SWFooterMenuProps": "type", + "SWFooterProps": "type", + "SWFooterSideAction": "type", + "SWFooterSideButton": "value", + "SWFooterSideButtonProps": "type", + "SWFooterTool": "type", + "SWFooterToolButton": "value", + "SWFooterToolButtonProps": "type", + "SWFooterToolIconName": "type", + "SWHeader": "value", + "SWHeaderActions": "value", + "SWHeaderActionsProps": "type", + "SWHeaderColorControl": "value", + "SWHeaderColorInputValue": "type", + "SWHeaderColorMenu": "value", + "SWHeaderColorMenuProps": "type", + "SWHeaderColorMode": "type", + "SWHeaderColorOption": "type", + "SWHeaderColorValue": "type", + "SWHeaderControlConfig": "type", + "SWHeaderControlId": "type", + "SWHeaderDimensionStyle": "value", + "SWHeaderDimensionStyleMenu": "value", + "SWHeaderDimensionStyleMenuProps": "type", + "SWHeaderDimensionStyleOption": "type", + "SWHeaderDimensionStyleValue": "type", + "SWHeaderDocument": "type", + "SWHeaderDocumentTitle": "value", + "SWHeaderDocumentTitleProps": "type", + "SWHeaderFrameControlId": "type", + "SWHeaderHideableControlId": "type", + "SWHeaderHome": "value", + "SWHeaderHomeMenu": "type", + "SWHeaderHomeMenuChangeReason": "type", + "SWHeaderHomeProps": "type", + "SWHeaderInput": "value", + "SWHeaderInputProps": "type", + "SWHeaderInputValueType": "type", + "SWHeaderLayer": "type", + "SWHeaderLayerControl": "value", + "SWHeaderLayerControlProps": "type", + "SWHeaderLayerMenu": "value", + "SWHeaderLayerMenuProps": "type", + "SWHeaderLayerValue": "type", + "SWHeaderLinePreview": "value", + "SWHeaderLineStyle": "value", + "SWHeaderLineStyleMenu": "value", + "SWHeaderLineStyleMenuProps": "type", + "SWHeaderLineStyleValue": "type", + "SWHeaderLineType": "type", + "SWHeaderLineWeight": "type", + "SWHeaderMenuId": "type", + "SWHeaderProperties": "value", + "SWHeaderPropertiesProps": "type", + "SWHeaderProperty": "type", + "SWHeaderPropertyBar": "value", + "SWHeaderPropertyBarProps": "type", + "SWHeaderPropertyGroup": "type", + "SWHeaderPropertyMenus": "type", + "SWHeaderPropertyPanel": "value", + "SWHeaderPropertyPanelProps": "type", + "SWHeaderPropertyPicker": "type", + "SWHeaderPropertySelection": "type", + "SWHeaderPropertyShapeContent": "type", + "SWHeaderPropertyShapeContext": "type", + "SWHeaderPropertyType": "type", + "SWHeaderProps": "type", + "SWHeaderSubControlId": "type", + "SWHeaderTextStyle": "value", + "SWHeaderTextStyleMenu": "value", + "SWHeaderTextStyleMenuProps": "type", + "SWHeaderTextStyleOption": "type", + "SWHeaderTextStyleValue": "type", + "SWHeaderWindowControls": "value", + "SWHeaderWindowControlsProps": "type", + "SWHomeMenu": "value", + "SWHomeMenuAccent": "type", + "SWHomeMenuCloseReason": "type", + "SWHomeMenuItem": "value", + "SWHomeMenuItemData": "type", + "SWHomeMenuItemProps": "type", + "SWHomeMenuProps": "type", + "SWHomeMenuSection": "value", + "SWHomeMenuSectionData": "type", + "SWHomeMenuSectionProps": "type", + "SWHomeMenuSelectEvent": "type", + "SWPopup": "value", + "SWPopupPropertyRow": "value", + "SWPopupPropertyRowProps": "type", + "SWPopupProps": "type", + "SWPopupRow": "value", + "SWPopupRowProps": "type", + "SWPopupSection": "value", + "SWPopupSectionProps": "type", + "SWRecentFiles": "value", + "SWRecentFilesProps": "type", + "SWSelect": "value", + "SWSelectOption": "type", + "SWSelectProps": "type", + "SWSlider": "value", + "SWSliderAppearance": "type", + "SWSliderProps": "type", + "SWTheme": "value", + "SWThemeProps": "type", + "SWToolButton": "value", + "SWToolButtonAppearance": "type", + "SWToolButtonProps": "type", + "SWToolGroup": "value", + "SWToolGroupProps": "type", + "SWViewerBadge": "value", + "SWViewerBadgeProps": "type", + "SWViewerButton": "value", + "SWViewerButtonProps": "type", + "SWViewerCommand": "type", + "SWViewerField": "value", + "SWViewerFieldProps": "type", + "SWViewerHeader": "value", + "SWViewerHeaderButton": "value", + "SWViewerHeaderButtonProps": "type", + "SWViewerHeaderGroup": "type", + "SWViewerHeaderItem": "type", + "SWViewerHeaderProps": "type", + "SWViewerHint": "value", + "SWViewerIcon": "value", + "SWViewerIconProps": "type", + "SWViewerInput": "value", + "SWViewerInputProps": "type", + "SWViewerMenu": "value", + "SWViewerMenuEntry": "type", + "SWViewerMenuProps": "type", + "SWViewerPanel": "value", + "SWViewerPanelPlacement": "type", + "SWViewerPanelProps": "type", + "SWViewerSection": "value", + "SWViewerSectionProps": "type", + "SWViewerSlider": "value", + "SWViewerSliderProps": "type", + "SWViewerStatus": "value", + "SWViewerStatusProps": "type", + "SWViewerStatusState": "type", + "SWViewerSwitch": "value", + "SWViewerSwitchProps": "type", + "SW_FOOTER_MODEL": "value", + "parseSWHeaderColor": "value", + "parseSWHeaderNumber": "value", + "swFooterAuxIcons": "value", + "swFooterToolIcons": "value", + "swHeaderLinePatterns": "value", + "useSWHomeMenuClose": "value" + }, + "declarations": { + "components/sw-footer/icons.d.ts": "298baed14722ace8208737e6679bd034455ad8418d5dcedc056a3a5c3cd10c53", + "components/sw-footer/index.d.ts": "044a553758b2ea111189eb1f01f1dd33a5bb93a6274e67f0bf2a246007116d3a", + "components/sw-footer/SWFooter.d.ts": "fb14ff8aa430886eed0da433bc0a9150bc2f7785afba78ad6be301366583f2b1", + "components/sw-footer/SWFooterCommandLine.d.ts": "205b4fdbb1d713bba90a880459aea82acb6d1e970316b7a519b936080611c367", + "components/sw-footer/SWFooterLayoutTabs.d.ts": "ec54e985840a42b9d10e3f057cd68da42def8285a0f175719e00cdc70a4c5fd4", + "components/sw-footer/SWFooterParts.d.ts": "8ad09cc362fed70ccd6e04f567bb23a4b4cd8bcb5184498424032e76f45b4845", + "components/sw-footer/types.d.ts": "de3470c42a7d89cf7f8776a533defb7c72d905e27a95d1d94a71d4d79c2af147", + "components/sw-header/components/SWHeaderActions.d.ts": "c572d028522a56ac4bf1a5d7eb3d6e260b718fa3cac197d87757ec49f508af34", + "components/sw-header/components/SWHeaderColorMenu.d.ts": "ff361ed274150c32a8a6931b31348b85de0463db642bd8c6af3c1bbb79fa5fda", + "components/sw-header/components/SWHeaderDocumentTitle.d.ts": "ca96a9b886740d5b68b852d7494c35fe139c22df74f4fd13d342638a1b6ea439", + "components/sw-header/components/SWHeaderDropdown.d.ts": "06ce5aa914826b6dc85df923a24baded80d16f43aebce39845f5b18d9d996504", + "components/sw-header/components/SWHeaderField.d.ts": "741fdd1b0e7da4cf8f7adeaec021eb4eb103d7312f34e80135d00c3510159901", + "components/sw-header/components/SWHeaderFields.d.ts": "43950332583b4c74231a793e18651b45494165626cc45e3dfcf50169ec189edc", + "components/sw-header/components/SWHeaderHome.d.ts": "aa1e56611e92f309d588fcb12c089bfb0cd336de050d9aca2f043118ba01ae54", + "components/sw-header/components/SWHeaderInput.d.ts": "6b379ee70b9bcbf6e9a0808ca891b86838a9bfa355bfa12f036088b26241dd9c", + "components/sw-header/components/SWHeaderLayerMenu.d.ts": "0fb65b14f52cd2abf54e74ea09ed4b783fe1665416dfd910bd6cd7d3e1995711", + "components/sw-header/components/SWHeaderLineStyleMenu.d.ts": "d8ca8fc2543a1313793ceaa0e9e852f04dd6107027b2c51efcc080db60c34d29", + "components/sw-header/components/SWHeaderProperties.d.ts": "20ae22aac2d49c546d23c726ce5f210bd0f89024d60f24b2d75d9eba9524704f", + "components/sw-header/components/SWHeaderPropertyBar.d.ts": "38fcf8305edfa6ff15917520cdbb446ac69d5200b17f5524304d106b8aeb7b91", + "components/sw-header/components/SWHeaderPropertyPanel.d.ts": "c7830fe2db87f3c16312d01ce19d5e2305f5e829db988a88bf680ef86058df0b", + "components/sw-header/components/SWHeaderStyleListMenus.d.ts": "6bbcf310fda8deed5c607fc85e8fb730b84c743a5a85be68d2549afa0341891d", + "components/sw-header/components/SWHeaderWindowControls.d.ts": "db8bfbd14340e03c28e407f58fc08cef55b9eef49f406ba53fc50e994f5f0834", + "components/sw-header/index.d.ts": "958a64efd05f7b8ab683ecb342fdcdef488db0be97cf9a2bf5ad3e3ac4632ab7", + "components/sw-header/SWHeader.d.ts": "3adac1db372451d9f86a6f6df1df0e9696cd40c3ed7945531ee378da122136d8", + "components/sw-header/types.d.ts": "fa7602374753bf7b6b48c0fa9f1c30a932ed12ccb4d16488c836e964b76f9122", + "components/sw-home-menu/index.d.ts": "b1f67a4e16c7b3a64b93f348bfb17fcd2ada999527cb93ccb77ca6308564091c", + "components/sw-home-menu/SWHomeMenu.d.ts": "2ce530d253374a8b20a20d8b6ddd5c733abfc8d518162d5a7490d432986a662c", + "components/sw-home-menu/SWRecentFiles.d.ts": "27787ea59e60f903216b17a265af581c735369a9add7686fd7b41aa42019af0a", + "components/sw-home-menu/types.d.ts": "4fa5ec3f052df201b823f36d2872a68f283ea301b28846e535794eac6246df50", + "components/sw-popup/SWPopup.d.ts": "9af7b5f34bed9c863e343018ed64dc6960d09f5664fdfc1c26fcc7204acdd041", + "components/sw-select/SWSelect.d.ts": "9cf523e8a8329a2be7bdfba56c1a2c647b2676480efa3f6dfa26a971ea0a9c3b", + "components/sw-slider/SWSlider.d.ts": "7d9c48299cb5644d859753e1bc56ae43dc7ab282e64432d57d3e29b2960aaad5", + "components/sw-theme/SWTheme.d.ts": "cdcc2e7c4b80241afa4d0b7c3da671b7086c16f9b9c231c1927c931757e4244c", + "components/sw-tool/SWTool.d.ts": "23b1eb156578cd3f1dee325afbd81988d88c4a3f421f7350a0e8c47c652e804c", + "components/sw-viewer/index.d.ts": "381caef510e7a4c903c70a12e24b6f5c1908c2ee98c3816066639583b0f7d658", + "components/sw-viewer/SWViewerControls.d.ts": "ca32e8a2a7de2cfefec36273ffb473271ad438cfb663f44a25871ee6aed9222d", + "components/sw-viewer/SWViewerHeader.d.ts": "2ca96432c4a0d2e14f7033ebde1c5a25980bf50903e2789d3657d5c3edc96039", + "components/sw-viewer/SWViewerIcon.d.ts": "3c81582860e481b037b395b3584ff93af73500e4040d8423c9da15c2b4dd108e", + "components/sw-viewer/SWViewerMenu.d.ts": "15fa615c3472d84037780f3c93387acbc6b4d05f5fb56731b0a99262b3f7f109", + "components/sw-viewer/SWViewerPanel.d.ts": "49b040d3f3d00de30e3fc0493fd2ce78e309616ec97a7ce226dfef300b640f92", + "components/sw-viewer/types.d.ts": "f9d0e3b4fc4efc1e4b0c925a5ce6860b67f2c6b7ec2c7ef20b63d3c816a9c5ac", + "index.d.ts": "34f0607f63df53d5a1186a4ad763a8bf7b433e20645d868f6f7368ed0853828c" + } +} diff --git a/packages/sw/tests/public-api.check.mjs b/packages/sw/tests/public-api.check.mjs new file mode 100644 index 0000000..4498f8c --- /dev/null +++ b/packages/sw/tests/public-api.check.mjs @@ -0,0 +1,109 @@ +// Public API guard of @hm-design/sw (2026-10-21): the built package must expose exactly the approved surface. +// Usage: node packages/sw/tests/public-api.check.mjs [dist-dir] [package.json] (build first: npm run build -w @hm-design/sw) +// node packages/sw/tests/public-api.check.mjs --snapshot +// The approved surface is public-api.approved.json, written with --snapshot from a build of a reviewed commit +// (approvedCommit, read from that build's build-info.json). It records: +// 1. manifest: the package.json fields that decide what a consumer can import (exports, main, types, module, files). +// 2. exports: every name the entry exports, "value" when dist/index.js exports it at runtime, otherwise "type". +// 3. declarations: SHA-256 of every .d.ts reachable from dist/index.d.ts through relative imports. A changed public +// type, an internal type a public one references, or an internal module newly reachable from the entry all change it. +// Internal modules the entry never reaches (e.g. components/shared) and implementation-only edits leave it unchanged. +// The check never rewrites the snapshot. Change it only in a reviewed commit that approves the API change, written from +// a build of that commit; never regenerate it from an arbitrary HEAD to make a failing check pass. +import { createHash } from "node:crypto" +import { execFileSync } from "node:child_process" +import { existsSync } from "node:fs" +import { readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { approvedMainCommit } from "./storybook-inventory.mjs" + +const packageRoot = fileURLToPath(new URL("../", import.meta.url)) +const snapshotFile = fileURLToPath(new URL("./public-api.approved.json", import.meta.url)) +const manifestFields = ["exports", "main", "types", "module", "files"] + +// Names a declaration file exports, following `export *` and `export { } from` into other reachable files. +const nameList = text => text.split(",").map(part => part.trim()).filter(Boolean) + .map(part => ({ type: /^type\s/.test(part), local: part.replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim(), name: part.replace(/^type\s+/, "").split(/\s+as\s+/).pop().trim() })) +async function surface(dist) { + const files = new Map() + const load = async file => { + if (!files.has(file)) { + if (!existsSync(path.join(dist, file))) throw new Error(`missing declaration ${file}`) + const text = (await readFile(path.join(dist, file), "utf8")).replace(/\r\n?/g, "\n") + files.set(file, text) + for (const match of text.matchAll(/(?:from\s+|import\()["'](\.\.?\/[^"']+)["']/g)) await load(target(file, match[1])) + } + return files.get(file) + } + const target = (from, specifier) => path.posix.join(path.posix.dirname(from), specifier.replace(/\.js$/, ".d.ts")) + const exportsOf = async (file, seen = new Set()) => { + if (seen.has(file)) return new Map() + seen.add(file) + const text = await load(file), names = new Map() + for (const match of text.matchAll(/^export\s+(?:declare\s+)?(?:abstract\s+)?(const|let|var|function|class|enum|interface|type|namespace)\s+(\w+)/gm)) names.set(match[2], match[1] === "interface" || match[1] === "type" ? "type" : "value") + if (/^export\s+default\b/m.test(text)) names.set("default", "value") + for (const match of text.matchAll(/^export\s+(type\s+)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/gm)) { + const inner = await exportsOf(target(file, match[3]), new Set(seen)) + for (const item of nameList(match[2])) names.set(item.name, match[1] || item.type ? "type" : inner.get(item.local) ?? "missing") + } + for (const match of text.matchAll(/^export\s+(type\s+)?\{([^}]*)\}\s*;?\s*$/gm)) for (const item of nameList(match[2])) names.set(item.name, match[1] || item.type || new RegExp(`(?:^|\s)(?:interface|type)\s+${item.local}\b`, "m").test(text) ? "type" : "value") + for (const match of text.matchAll(/^export\s+\*\s+from\s+["']([^"']+)["']/gm)) for (const [name, kind] of await exportsOf(target(file, match[1]), new Set(seen))) if (name !== "default" && !names.has(name)) names.set(name, kind) + for (const match of text.matchAll(/^export\s+\*\s+as\s+(\w+)\s+from/gm)) names.set(match[1], "value") + return names + } + const declared = await exportsOf("index.d.ts") + const runtime = Object.keys(await import(pathToFileURL(path.join(dist, "index.js")).href)) + const exports = Object.fromEntries([...declared.keys(), ...runtime].sort().map(name => [name, runtime.includes(name) ? "value" : declared.has(name) ? "type" : "untyped"])) + const declarations = Object.fromEntries([...files].sort(([a], [b]) => a.localeCompare(b)).map(([file, text]) => [file, createHash("sha256").update(text).digest("hex")])) + // Declared values the runtime lacks, or runtime values without a declaration, would break consumers either way. + const mismatch = [...declared].filter(([name, kind]) => kind === "missing" || (kind === "value" && !runtime.includes(name))).map(([name]) => name) + return { exports, declarations, mismatch } +} +const manifestOf = async file => { + const json = JSON.parse(await readFile(file, "utf8")) + return { name: json.name, ...Object.fromEntries(manifestFields.map(field => [field, json[field] ?? null])) } +} + +if (process.argv[2] === "--snapshot") { + const [dist, manifestFile, out] = process.argv.slice(3).map(item => path.resolve(item)) + if (!out) throw new Error("Pass --snapshot ") + const info = JSON.parse(await readFile(path.join(dist, "build-info.json"), "utf8")) + const commit = info.commit.split("+")[0] + if (!/^[0-9a-f]{40}$/.test(commit) || info.dirty === true) throw new Error(`build-info.json must name one clean commit, got ${info.commit} (dirty ${info.dirty})`) + const { mismatch, ...api } = await surface(dist) + if (mismatch.length || Object.values(api.exports).includes("untyped")) throw new Error(`declarations and runtime disagree: ${JSON.stringify({ mismatch, exports: api.exports })}`) + await writeFile(out, JSON.stringify({ package: info.name, approvedCommit: commit, manifest: await manifestOf(manifestFile), ...api }, null, 2) + "\n") + console.log(`wrote ${out}: ${Object.keys(api.exports).length} exports, ${Object.keys(api.declarations).length} declaration files from ${commit}`) +} else { + const dist = path.resolve(process.argv[2] ?? path.join(packageRoot, "dist")) + const manifestFile = path.resolve(process.argv[3] ?? path.join(packageRoot, "package.json")) + const approved = JSON.parse(await readFile(snapshotFile, "utf8")) + const results = [] + const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) } + const ancestor = (a, b) => { try { execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: packageRoot, stdio: "ignore" }); return true } catch { return false } } + check("approved surface comes from a reviewed commit (ancestor of approvedMainCommit and HEAD)", ancestor(approved.approvedCommit, approvedMainCommit) && ancestor(approved.approvedCommit, "HEAD"), { approvedCommit: approved.approvedCommit, approvedMainCommit }) + + const manifest = await manifestOf(manifestFile) + const changedFields = ["name", ...manifestFields].filter(field => JSON.stringify(manifest[field]) !== JSON.stringify(approved.manifest[field])) + check("package.json export settings unchanged (exports, main, types, module, files)", changedFields.length === 0, Object.fromEntries(changedFields.map(field => [field, { approved: approved.manifest[field], current: manifest[field] }]))) + const targets = JSON.stringify(manifest.exports).match(/"\.\/[^"]+"/g)?.map(item => item.slice(1, -1)) ?? [] + const missingTargets = [...new Set([...targets, manifest.main, manifest.types].filter(item => item?.startsWith("./dist/")))].filter(item => !existsSync(path.join(path.dirname(manifestFile), item))) + check("every package.json export target exists in dist", missingTargets.length === 0, { missing: missingTargets }) + + const current = await surface(dist) + const added = Object.keys(current.exports).filter(name => !(name in approved.exports)) + const removed = Object.keys(approved.exports).filter(name => !(name in current.exports)) + const kindChanged = Object.keys(current.exports).filter(name => name in approved.exports && current.exports[name] !== approved.exports[name]) + // A rename shows up as one removed and one added name; both sides are listed. + check(`entry exports exactly the ${Object.keys(approved.exports).length} approved names (no addition, removal or rename)`, added.length === 0 && removed.length === 0, { added, removed }) + check("each export keeps its kind (runtime value vs type only); runtime and declarations agree", kindChanged.length === 0 && current.mismatch.length === 0, + { kindChanged: kindChanged.map(name => ({ name, approved: approved.exports[name], current: current.exports[name] })), declaredWithoutRuntime: current.mismatch }) + const newFiles = Object.keys(current.declarations).filter(file => !(file in approved.declarations)) + const goneFiles = Object.keys(approved.declarations).filter(file => !(file in current.declarations)) + const changedFiles = Object.keys(current.declarations).filter(file => file in approved.declarations && current.declarations[file] !== approved.declarations[file]) + check("declarations reachable from the entry are the approved files (no internal module newly exposed)", newFiles.length === 0 && goneFiles.length === 0, { newlyReachable: newFiles, noLongerReachable: goneFiles }) + check("reachable declarations byte-identical to the approved build (no public type change)", changedFiles.length === 0, { changed: changedFiles }) + console.log(`${results.filter(Boolean).length}/${results.length} PASS`) + process.exitCode = results.every(Boolean) ? 0 : 1 +} diff --git a/packages/sw/tests/source-changes.mjs b/packages/sw/tests/source-changes.mjs new file mode 100644 index 0000000..68e18a5 --- /dev/null +++ b/packages/sw/tests/source-changes.mjs @@ -0,0 +1,31 @@ +// Source guard helper: every file under `paths` whose current checkout differs from `ref`. +// `git diff --name-only ` alone misses files that were never added (a new internal module, a new component), so +// the guards also list untracked files. Ignored files (.gitignore: dist, storybook-static, node_modules, *.log) stay out. +// Each entry: { path, status, staged }. status: M modified, A added (tracked), D deleted, T type change, +// R renamed (both sides are listed, `from` / `to` name the other side), U unmerged, ? untracked. +// staged: the index already holds the change (git add / git rm / git mv). Run from the repository root, like the guards. +import { execFileSync } from 'node:child_process' + +const git = args => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 26 }).split('\0').filter(Boolean) + +export const sourceChanges = (ref, paths) => { + const staged = new Set(git(['diff', '--cached', '--name-only', '--no-renames', '-z', ref, '--', ...paths])) + const entries = [], diff = git(['diff', '--name-status', '-M', '--no-ext-diff', '-z', ref, '--', ...paths]) + for (let i = 0; i < diff.length;) { + const status = diff[i++][0] + if (status === 'R' || status === 'C') { + const from = diff[i++], to = diff[i++] + if (status === 'R') entries.push({ path: from, status, to, staged: staged.has(from) }) + entries.push({ path: to, status, from, staged: staged.has(to) }) + } else { + const file = diff[i++] + entries.push({ path: file, status, staged: staged.has(file) }) + } + } + for (const file of git(['ls-files', '--others', '--exclude-standard', '--full-name', '-z', '--', ...paths])) entries.push({ path: file, status: '?', staged: false }) + return entries.sort((a, b) => a.path.localeCompare(b.path)) +} + +// Changes outside the explicitly approved file list (exact repository paths). An approved file moved to a new path is +// still reported at the new path; an unapproved file renamed onto nothing approved is reported at both paths. +export const unapprovedChanges = (ref, paths, approved) => sourceChanges(ref, paths).filter(entry => !approved.includes(entry.path)) diff --git a/packages/sw/tests/storybook-unused-removal.browser.mjs b/packages/sw/tests/storybook-unused-removal.browser.mjs index 347cfe2..7ca1d60 100644 --- a/packages/sw/tests/storybook-unused-removal.browser.mjs +++ b/packages/sw/tests/storybook-unused-removal.browser.mjs @@ -9,6 +9,7 @@ import http from 'node:http' import { parse } from '@babel/parser' import { swStoryEntries, retiredUnusedDemoIds, integratedChangedFiles, movedToPopupIds, approvedMainCommit, approvedAddedStoryIds, withApprovedEntryChanges, approvedHeaderDeclarations, movedHeaderDeclarations } from './storybook-inventory.mjs' import { loadPlaywright } from './playwright-runtime.mjs' +import { sourceChanges, unapprovedChanges } from './source-changes.mjs' const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-unused-story-removal-qa/verify') const qa = 'docs/sw/storybook-unused-story-removal-qa/before' @@ -65,9 +66,9 @@ check('source: retained exports (Stories and ComposedDemo) byte-identical to c8e // files (verified by their own suites), may differ from the approved main merge in nothing. sinceBaseline lists the approved // changes after c8e5fbe; update approvedMainCommit (storybook-inventory.mjs) on the next approved merge. const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'packages/sw/README.md', 'packages/ui', 'packages/org-chart', 'packages/org-chart-react', 'packages/center-graphic', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-header-dropdown.stories.tsx', 'src/sw/sw-composed-controls.css', 'src/sw/assets', '.storybook'] -const changedSince = ref => execFileSync('git', ['diff', '--name-only', ref, '--', ...untouched], { encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean) -const unexpected = changedSince(approvedMainCommit).filter(file => !integratedChangedFiles.includes(file)) -check('source: other packages, Overview, Dropdown CSF, assets and Storybook config unchanged since the approved main merge; SW package only within the integrated-improvement files', unexpected.length === 0, { unexpected, sinceBaseline: changedSince(baseCommit).length }) +// Untracked new files count as changes (source-changes.mjs). +const unexpected = unapprovedChanges(approvedMainCommit, untouched, integratedChangedFiles) +check('source: other packages, Overview, Dropdown CSF, assets and Storybook config unchanged since the approved main merge; SW package only within the integrated-improvement files', unexpected.length === 0, { unexpected, sinceBaseline: sourceChanges(baseCommit, untouched).length }) // ---- Browser const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.gif': 'image/gif', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }