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:
1 parent
96f7a846cb
commit
1e05eaf1de
5 files changed
+385
-5
No files matched your search
@@ -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' }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
Reference in new issue
Block a user