SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).
- Build reproducibility: dist text normalized to LF, dist/build-info.json
(commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
(eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
the logo in the existing document-button look, reusing onDocumentOpen and
documentOpenLabel, no duplicate Open in the dropdown), leading slot,
hiddenControls sub-items documentNew / documentOpen / layerFreeze /
layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
SWHeaderPropertyPanel gains children, title, autoFocus and optional
selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
(useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
overview 52/54 with the two pre-existing historical assertions).
Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
5.7 KiB
JavaScript
99 lines
5.7 KiB
JavaScript
// Build reproducibility of @hm-design/sw (2026-10-19).
|
|
// Copies the current packages/sw source (+ root tsconfig.app.json it extends) into three directories
|
|
// outside the repository — two LF at different depths and one CRLF copy (as made outside git) — links
|
|
// the repository node_modules (same locked tools), builds each twice and packs it.
|
|
// dist must be byte-identical everywhere; the tarball must be identical for every LF checkout.
|
|
// Usage: node packages/sw/tests/build-reproducibility.mjs <empty-dir-outside-the-repo> <evidence-dir>
|
|
import { execFileSync } from "node:child_process"
|
|
import { createHash } from "node:crypto"
|
|
import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
|
const repoRoot = path.resolve(packageRoot, "../..")
|
|
const outside = dir => { const relative = path.relative(repoRoot, dir); return relative.startsWith("..") || path.isAbsolute(relative) }
|
|
const work = path.resolve(process.argv[2] ?? "")
|
|
const evidence = path.resolve(process.argv[3] ?? "")
|
|
if (!process.argv[2] || !process.argv[3] || !outside(work)) throw new Error("Pass <empty dir outside the repo> <evidence dir>")
|
|
await mkdir(work, { recursive: true })
|
|
if ((await readdir(work)).length) throw new Error(`${work} is not empty`)
|
|
await mkdir(evidence, { recursive: true })
|
|
|
|
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim()
|
|
const env = { ...process.env, SW_BUILD_COMMIT: `${commit}+reproducibility-check` }
|
|
const sha = data => createHash("sha256").update(data).digest("hex")
|
|
// SVG assets are excluded: they are inlined as base64, so their bytes are fixed at checkout by
|
|
// packages/sw/.gitattributes (eol=lf) and checked separately with `git ls-files --eol`.
|
|
const textFile = name => /\.(tsx?|css|mjs|json|md)$/.test(name)
|
|
|
|
async function copySource(target, crlf) {
|
|
const sw = path.join(target, "packages/sw")
|
|
for (const entry of ["src", "ai", "README.md", "build.mjs", "package.json", "tsconfig.json", "tsconfig.build.json"]) {
|
|
await cp(path.join(packageRoot, entry), path.join(sw, entry), { recursive: true })
|
|
}
|
|
await cp(path.join(repoRoot, "tsconfig.app.json"), path.join(target, "tsconfig.app.json"))
|
|
if (crlf) {
|
|
const convert = async directory => {
|
|
for (const item of await readdir(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, item.name)
|
|
if (item.isDirectory()) await convert(file)
|
|
else if (textFile(item.name)) await writeFile(file, (await readFile(file, "utf8")).replace(/\r?\n/g, "\r\n"))
|
|
}
|
|
}
|
|
await convert(sw)
|
|
}
|
|
// Same locked tools: a directory junction to the repository node_modules (removed with rmdir semantics below).
|
|
await symlink(path.join(repoRoot, "node_modules"), path.join(target, "node_modules"), "junction")
|
|
return sw
|
|
}
|
|
|
|
async function hashes(sw) {
|
|
const out = {}
|
|
const walk = async directory => {
|
|
for (const item of await readdir(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, item.name)
|
|
if (item.isDirectory()) await walk(file)
|
|
else out[path.relative(sw, file).replaceAll("\\", "/")] = sha(await readFile(file))
|
|
}
|
|
}
|
|
await walk(path.join(sw, "dist"))
|
|
return out
|
|
}
|
|
|
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
|
|
const runs = []
|
|
const layouts = [["lf", false], ["deeper/path/lf", false], ["crlf-copy", true]]
|
|
for (const [name, crlf] of layouts) {
|
|
const target = path.join(work, name)
|
|
const sw = await copySource(target, crlf)
|
|
for (const attempt of [1, 2]) {
|
|
execFileSync(process.execPath, ["build.mjs"], { cwd: sw, env, stdio: "pipe" })
|
|
const files = await hashes(sw)
|
|
const packDir = path.join(target, `pack-${attempt}`)
|
|
await mkdir(packDir)
|
|
execFileSync(npm, ["pack", "--ignore-scripts", "--pack-destination", packDir], { cwd: sw, env, stdio: "pipe", shell: process.platform === "win32" })
|
|
const tarball = (await readdir(packDir)).find(file => file.endsWith(".tgz"))
|
|
runs.push({ checkout: crlf ? "CRLF" : "LF", path: target, attempt, tarball, tarballSha256: sha(await readFile(path.join(packDir, tarball))), files })
|
|
}
|
|
}
|
|
// Leave the junctions behind as junctions only (never recurse into the repository node_modules).
|
|
for (const [name] of layouts) await rm(path.join(work, name, "node_modules"), { force: true })
|
|
|
|
const reference = runs[0]
|
|
const differences = runs.slice(1).map(run => ({
|
|
run: `${run.checkout} #${run.attempt}`,
|
|
tarballSame: run.tarballSha256 === reference.tarballSha256,
|
|
differentFiles: Object.keys({ ...reference.files, ...run.files }).filter(file => reference.files[file] !== run.files[file]),
|
|
}))
|
|
// Index and working tree of every package file must be LF (or binary) so a fresh checkout matches.
|
|
const eol = execFileSync("git", ["ls-files", "--eol", "--", "."], { cwd: packageRoot, encoding: "utf8" }).trim().split("\n")
|
|
const notLf = eol.filter(line => !/^i\/(lf|-text)\s+w\/(lf|-text)\s/.test(line))
|
|
// dist must match everywhere; the tarball also ships ai/ and README.md verbatim, so it must match for LF
|
|
// checkouts (guaranteed by .gitattributes). A CRLF copy made outside git only has to keep dist identical.
|
|
const pass = differences.every(item => item.differentFiles.length === 0 && (item.run.startsWith("CRLF") || item.tarballSame)) && notLf.length === 0
|
|
const report = { status: pass ? "PASS" : "FAIL", commit, notLf, buildCommit: env.SW_BUILD_COMMIT, runs: runs.map(({ files, ...run }) => ({ ...run, fileCount: Object.keys(files).length })), referenceFiles: reference.files, differences }
|
|
await writeFile(path.join(evidence, "build-reproducibility-result.json"), JSON.stringify(report, null, 2) + "\n")
|
|
console.log(report.status, JSON.stringify(differences))
|
|
if (!pass) process.exitCode = 1
|