test(sw): detect untracked source files and public API changes

- source-changes.mjs: source guards list untracked, staged, deleted and renamed files, not only git diff --name-only.
- overview / storybook-unused-removal: production source guards use the helper (assertions, paths, approved list unchanged).
- public-api.check.mjs + public-api.approved.json: the built @hm-design/sw entry must export exactly the approved
  names and kinds, keep the package.json export settings and keep every declaration reachable from dist/index.d.ts
  byte-identical. The approved surface was written from a build of 553e4df (approvedMainCommit).
This commit is contained in:
shkim4 committed 2026-10-21 06:08:16 +09:00
1 parent 96f7a846cb
commit 1e05eaf1de
5 files changed
+385 -5

No files matched your search

+3 -2
View File
@@ -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' }
+238
View File
@@ -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"
}
}
+109
View File
@@ -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 <dist-dir> <package.json> <out.json>
// 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 <dist-dir> <package.json> <out.json>")
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
}
+31
View File
@@ -0,0 +1,31 @@
// Source guard helper: every file under `paths` whose current checkout differs from `ref`.
// `git diff --name-only <ref>` 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))
@@ -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' }