diff --git a/AGENTS.md b/AGENTS.md index e0d621a0..5d79151f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,8 @@ Unit tests run on `bun test` (repo root, `bunfig.toml`); component and visual te **One suite at a time.** A run takes a machine-wide lock (`$TMPDIR/ca-test-lock`, shared by every worktree and checkout; `CA_TEST_LOCK_DIR` overrides it) and a second run waits, printing who holds it, instead of starting more browsers. A holder that died is taken over, and a nested `test-run` (the commands above call each other) reuses its parent's lock. +**The package build skips itself when nothing it reads changed.** `bun run build` in `packages/ui` is `scripts/build-package.ts`: it hashes the bytes of every input (`INPUTS` in `packages/ui/scripts/build-inputs.ts`: the package sources and scripts, the site's demos, docs, data and changelog parser, `bun.lock`, the versions of bun and svelte-package; `scripts/content-hash.ts`) as they are on disk, uncommitted edits included, and stops with `build: dist is current` when the hash is the one `dist/.ca-input-hash` holds (0.1 s instead of 12 to 15 s; the land gate builds, then `validate` on the push builds again). The stamp is written only after the last step succeeds. `tests/build-cache.test.ts` fails when a file the build scripts import is not under an input. A release never builds from the skip: `release.js` and `test:full` set `CA_BUILD_COLD=1`; set it yourself to force a build. + **Nothing the tooling writes grows without bound.** Every store has an age limit and a size cap in one table (`STORES`, `scripts/caches.ts`); `scripts/test-run.ts` enforces them each time it takes the lock (so nothing else is running), and a top-level run also empties the previous run's reports, traces and attachments (`site/test-results`, `site/playwright-report`, `packages/ui/.vitest`): a run keeps its own only. Playwright leaves one empty directory per browser launch in `$TMPDIR` and never removes it (2,059 had piled up); they are reaped after an hour. `tests/caches.test.ts` runs a store twice and asserts the footprint does not grow. A cache or artifact directory a script adds goes into `STORES` in the same commit. **Never call `vitest` / `playwright test` directly for browser tests: always go through the scripts above.** They run under `scripts/test-run.ts`, which: diff --git a/packages/ui/package.json b/packages/ui/package.json index b7dd31ac..a518e9db 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -134,7 +134,7 @@ "!dist/**/*.spec.*" ], "scripts": { - "build": "svelte-package -i src/lib && node ../../scripts/prune-dist.js dist && bun scripts/build-manifest.ts && bun scripts/build-equivalents.ts && bun scripts/build-bin.ts", + "build": "bun scripts/build-package.ts", "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "build:watch": "svelte-package -i src/lib --watch", "test": "bun run test:unit && bun ../../scripts/test-run.ts vitest run", diff --git a/packages/ui/scripts/build-inputs.ts b/packages/ui/scripts/build-inputs.ts new file mode 100644 index 00000000..021d40be --- /dev/null +++ b/packages/ui/scripts/build-inputs.ts @@ -0,0 +1,38 @@ +/** What the package build reads and the tools that transform it: the key of its skip (`build-package.ts`). */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const PKG = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const ROOT = join(PKG, "../.."); + +/** Everything the five build steps read, relative to the repo root. */ +export const INPUTS = [ + "packages/ui/src", + "packages/ui/scripts", + "packages/ui/bin", + "packages/ui/equivalents", + "packages/ui/migrations", + "packages/ui/MIGRATION.md", + "packages/ui/package.json", + "packages/ui/svelte.config.js", + "packages/ui/tsconfig.json", + "scripts/prune-dist.js", + "scripts/content-hash.ts", + "site/src/demos", + "site/src/data", + "site/src/lib/changelog", + "site/src/content/docs", + "site/functions/_lib", + "site/public/api/v1", + "bun.lock", +]; + +const version = (pkgJson: string) => + existsSync(pkgJson) ? JSON.parse(readFileSync(pkgJson, "utf8")).version : "none"; + +export const toolVersions = () => [ + `bun ${Bun.version}`, + `svelte-package ${version(join(PKG, "node_modules/@sveltejs/package/package.json"))}`, + `svelte ${version(join(ROOT, "node_modules/svelte/package.json"))}`, +]; diff --git a/packages/ui/scripts/build-package.ts b/packages/ui/scripts/build-package.ts new file mode 100644 index 00000000..77e0164b --- /dev/null +++ b/packages/ui/scripts/build-package.ts @@ -0,0 +1,48 @@ +/** + * `bun run build` of the package: `svelte-package`, prune, manifest, equivalents, bin. When the bytes of every input + * are the same as the last build that finished, `dist/` is already what the build would write, and the build says + * so and stops: the land gate builds the package, then `validate` on the push builds it again, and every fresh + * worktree starts without one (9.6 of its 12.7 s is `svelte-package`). + * + * CA_BUILD_COLD=1 always build (the release gate sets it: a release is never built from a cache) + * + * The stamp is `dist/.ca-input-hash`, written after the last step succeeded, so a build that failed halfway + * leaves no stamp and the next one runs. The key is `scripts/content-hash.ts`: the bytes on disk, uncommitted edits + * included, plus the versions of the tools that transform them. `dist/` missing means no stamp, so it builds. + * The generated files the manifest step writes into the tree (`site/public/api/v1`, the skill's API.md) are inputs + * of nothing here and unchanged when the inputs are, so skipping them leaves the tree as a build would. + */ +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { contentKey, upToDate } from "../../../scripts/content-hash.ts"; +import { INPUTS, PKG, ROOT, toolVersions } from "./build-inputs.ts"; + +const stamp = join(PKG, "dist/.ca-input-hash"); + +const key = contentKey(ROOT, INPUTS, toolVersions()); +if (!process.env.CA_BUILD_COLD && existsSync(join(PKG, "dist/index.js")) && upToDate(stamp, key)) { + console.log( + `build: dist is current (inputs ${key.slice(0, 10)}); CA_BUILD_COLD=1 forces a build` + ); + process.exit(0); +} + +const steps = [ + ["svelte-package", "-i", "src/lib"], + ["node", "../../scripts/prune-dist.js", "dist"], + ["bun", "scripts/build-manifest.ts"], + ["bun", "scripts/build-equivalents.ts"], + ["bun", "scripts/build-bin.ts"], +]; +const bins = [join(PKG, "node_modules/.bin"), join(ROOT, "node_modules/.bin")].join(":"); +for (const cmd of steps) { + const p = Bun.spawnSync(cmd, { + cwd: PKG, + stdio: ["inherit", "inherit", "inherit"], + // `svelte-package` is a package binary: resolve it as `bun run` does, so the script also works run directly + env: { ...process.env, PATH: `${bins}:${process.env.PATH}` }, + }); + if (p.exitCode !== 0) process.exit(p.exitCode ?? 1); +} +// the manifest step rewrites tracked inputs (API.md, the index): stamp what is on disk NOW, not what we hashed first +writeFileSync(stamp, contentKey(ROOT, INPUTS, toolVersions())); diff --git a/packages/ui/src/lib/components/Avatar/Avatar.svelte b/packages/ui/src/lib/components/Avatar/Avatar.svelte index b6e0e63a..3517a419 100644 --- a/packages/ui/src/lib/components/Avatar/Avatar.svelte +++ b/packages/ui/src/lib/components/Avatar/Avatar.svelte @@ -242,3 +242,4 @@ } + diff --git a/packages/ui/tests/build-cache.test.ts b/packages/ui/tests/build-cache.test.ts new file mode 100644 index 00000000..680ef6db --- /dev/null +++ b/packages/ui/tests/build-cache.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { INPUTS } from "../scripts/build-inputs"; + +// The package build skips itself when the bytes of its inputs are the ones the last finished build had. A release is +// never built from that skip, and INPUTS must cover everything the build steps import: a directory missing from it +// leaves a stale `dist` current after an edit there (an atlas data edit, a changelog parser change), every test green. +const ROOT = join(import.meta.dir, "../../.."); +const read = (p: string) => readFileSync(join(ROOT, p), "utf8"); + +describe("release builds cold", () => { + it("the release script and the release gate both set CA_BUILD_COLD", () => { + expect(read("scripts/release.js")).toContain('CA_BUILD_COLD = "1"'); + expect(read("scripts/test-full.ts")).toContain('CA_BUILD_COLD: "1"'); + }); +}); + +/** Every local file the build entry scripts import, transitively (static `from` and dynamic `import()`). */ +function importClosure(entries: string[]): string[] { + const seen = new Set(); + const queue = entries.map((e) => join(ROOT, e)); + const resolveSpec = (from: string, spec: string) => + [ + resolve(dirname(from), spec), + `${resolve(dirname(from), spec)}.ts`, + join(resolve(dirname(from), spec), "index.ts"), + ].find((c) => existsSync(c) && statSync(c).isFile()); + for (let file = queue.pop(); file; file = queue.pop()) { + if (seen.has(file)) continue; + seen.add(file); + for (const m of readFileSync(file, "utf8").matchAll( + /(?:from|import\()\s*["'](\.[^"']+)["']/g + )) { + const target = resolveSpec(file, m[1]); + if (target && !target.includes("node_modules")) queue.push(target); + } + } + return [...seen].map((f) => relative(ROOT, f)); +} + +describe("build inputs", () => { + it("cover every file the build steps import", () => { + const closure = importClosure([ + "packages/ui/scripts/build-manifest.ts", + "packages/ui/scripts/build-equivalents.ts", + "packages/ui/scripts/build-bin.ts", + ]); + expect(closure.length).toBeGreaterThan(20); + const missing = closure.filter((f) => !INPUTS.some((i) => f === i || f.startsWith(`${i}/`))); + expect(missing).toEqual([]); + }); +}); + +// The skip path runs the real script: a typo in the entry point (an undefined name) type-checks and passes every unit +// test above, and then fails the land gate the first time a build is needed. `dist` is current right after any build. +describe("the build entry point", () => { + it.skipIf(!existsSync(join(ROOT, "packages/ui/dist/.ca-input-hash")))( + "stops with 'dist is current' when nothing changed since the last build", + () => { + const run = Bun.spawnSync([process.execPath, "scripts/build-package.ts"], { + cwd: join(ROOT, "packages/ui"), + env: { ...process.env, CA_BUILD_COLD: "" }, + }); + const out = run.stdout.toString(); + // either current (exit 0, says so) or the tree changed since: then it must have rebuilt successfully + expect(run.exitCode, run.stderr.toString()).toBe(0); + expect(out.length).toBeGreaterThan(0); + }, + 120_000 + ); +}); diff --git a/packages/ui/tests/content-hash.test.ts b/packages/ui/tests/content-hash.test.ts new file mode 100644 index 00000000..961d479d --- /dev/null +++ b/packages/ui/tests/content-hash.test.ts @@ -0,0 +1,106 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { contentKey, upToDate } from "../../../scripts/content-hash"; +import { cleanTmp, tmpDir } from "./tmp"; + +// A cached result is trusted only when the BYTES of every input are the same: not a git object id (an uncommitted +// edit has none), not a file time (a rebase rewrites them). Two worktrees with different edits must never share a +// key, and a change anywhere in the input set must miss. +afterAll(cleanTmp); + +const tree = (files: Record) => { + const dir = tmpDir("ca-key-"); + for (const [name, text] of Object.entries(files)) { + mkdirSync(join(dir, name, ".."), { recursive: true }); + writeFileSync(join(dir, name), text); + } + return dir; +}; +const base = { + "src/a.ts": "export const a = 1;", + "src/deep/b.ts": "export const b = 2;", + lock: "v1", +}; +const inputs = ["src", "lock"]; + +describe("contentKey", () => { + it("is the same for the same bytes, wherever the tree lives", () => { + expect(contentKey(tree(base), inputs)).toBe(contentKey(tree(base), inputs)); + }); + + it("misses when one byte of an input changes, committed or not", () => { + const dir = tree(base); + const before = contentKey(dir, inputs); + writeFileSync(join(dir, "src/a.ts"), "export const a = 2;"); + expect(contentKey(dir, inputs)).not.toBe(before); + }); + + it("misses when a file deep in a directory input changes (the transitive case)", () => { + const dir = tree(base); + const before = contentKey(dir, inputs); + writeFileSync(join(dir, "src/deep/b.ts"), "export const b = 3;"); + expect(contentKey(dir, inputs)).not.toBe(before); + }); + + it("misses when a dependency pin changes", () => { + const a = tree(base); + const b = tree({ ...base, lock: "v2" }); + expect(contentKey(a, inputs)).not.toBe(contentKey(b, inputs)); + }); + + it("misses when a file is added or removed, even with the same total bytes", () => { + const a = tree(base); + const b = tree({ + "src/a.ts": "export const a = 1;", + "src/deep/c.ts": "export const b = 2;", + lock: "v1", + }); + expect(contentKey(a, inputs)).not.toBe(contentKey(b, inputs)); + }); + + it("two worktrees with different edits never share a key", () => { + const main = tree(base); + const feature = tree({ ...base, "src/a.ts": "export const a = 'edited';" }); + const other = tree({ ...base, "src/deep/b.ts": "export const b = 'edited';" }); + const keys = new Set([main, feature, other].map((d) => contentKey(d, inputs))); + expect(keys.size).toBe(3); + }); + + it("the tool versions are part of the key", () => { + const dir = tree(base); + expect(contentKey(dir, inputs, ["bun 1.4.4"])).not.toBe(contentKey(dir, inputs, ["bun 1.5.0"])); + }); + + it("an input that does not exist is part of the key, not an error", () => { + const dir = tree(base); + expect(contentKey(dir, [...inputs, "missing"])).not.toBe(contentKey(dir, inputs)); + }); + + it("a file named like an Object.prototype member is still an input", () => { + const dir = tree(base); + const before = contentKey(dir, inputs); + writeFileSync(join(dir, "src/constructor"), "changes the build"); + expect(contentKey(dir, inputs)).not.toBe(before); + }); + + it("ignores the output it stamps itself and generated noise", () => { + const dir = tree(base); + const before = contentKey(dir, inputs); + mkdirSync(join(dir, "src/node_modules/x"), { recursive: true }); + writeFileSync(join(dir, "src/node_modules/x/i.js"), "noise"); + writeFileSync(join(dir, "src/.DS_Store"), "noise"); + expect(contentKey(dir, inputs)).toBe(before); + }); +}); + +describe("upToDate", () => { + it("is true only for the key a build stamped, and false for a missing stamp", () => { + const dir = tmpDir("ca-stamp-"); + const stamp = join(dir, ".ca-input-hash"); + expect(upToDate(stamp, "k1")).toBe(false); + writeFileSync(stamp, "k1"); + expect(upToDate(stamp, "k1")).toBe(true); + expect(upToDate(stamp, "k2")).toBe(false); + }); +}); diff --git a/scripts/content-hash.ts b/scripts/content-hash.ts new file mode 100644 index 00000000..cbcdf76f --- /dev/null +++ b/scripts/content-hash.ts @@ -0,0 +1,53 @@ +/** + * The key of a cached result: a sha256 over the BYTES of every input as it is on disk right now, uncommitted edits + * included. Not a git object id (an edit nobody committed has none, and two worktrees would share the id of the + * committed file), not a file time (a rebase rewrites them). The path of each file is hashed with its bytes, so + * adding, removing or renaming a file changes the key even when the total size does not. + * + * contentKey(root, ["src", "bun.lock"], ["bun 1.4.4"]) -> 64 hex chars + * + * `inputs` are files or directories relative to `root`, directories walked in name order; `extra` carries what is + * not on disk (tool versions). A missing input is part of the key (its name, marked absent), never an error. + * `node_modules`, test artifacts and editor noise are skipped: they are outputs, not inputs. + */ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SKIP: Record = { + node_modules: true, + __screenshots__: true, + __traces__: true, + ".vitest": true, + ".DS_Store": true, + ".git": true, +}; + +function* walk(path: string): Generator { + if (!statSync(path).isDirectory()) return yield path; + for (const name of readdirSync(path).sort()) + if (!Object.hasOwn(SKIP, name)) yield* walk(join(path, name)); +} + +export function contentKey(root: string, inputs: string[], extra: string[] = []): string { + const hash = createHash("sha256"); + for (const input of [...inputs].sort()) { + const path = join(root, input); + if (!existsSync(path)) { + hash.update(`absent\0${input}\0`); + continue; + } + for (const file of walk(path)) { + hash.update(`${relative(root, file)}\0`); + hash.update(readFileSync(file)); + hash.update("\0"); + } + } + for (const e of extra) hash.update(`extra\0${e}\0`); + return hash.digest("hex"); +} + +/** Whether the stamp a build left holds `key`; false when there is no stamp. */ +export function upToDate(stampFile: string, key: string): boolean { + return existsSync(stampFile) && readFileSync(stampFile, "utf8") === key; +} diff --git a/scripts/release.js b/scripts/release.js index 9fee0b55..8354aa1b 100755 --- a/scripts/release.js +++ b/scripts/release.js @@ -49,6 +49,8 @@ if (args.has("--smoke")) { step("Type check packages/ui"); tryRun("svelte-check", "bun", ["run", "--cwd", "packages/ui", "check"]); step("Build package"); + // a release is never built from a cache (packages/ui/scripts/build-package.ts) + process.env.CA_BUILD_COLD = "1"; tryRun("bun run build:pkg", "bun", ["run", "build:pkg"]); step("Tarball smoke test"); smokeTest(); diff --git a/scripts/test-full.ts b/scripts/test-full.ts index 676a2951..42a6685c 100644 --- a/scripts/test-full.ts +++ b/scripts/test-full.ts @@ -22,7 +22,8 @@ import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const testRun = (cmd: string[]) => [join(ROOT, "scripts/test-run.ts"), ...cmd]; -const env = { ...process.env, E2E_PREBUILT: "1" }; +// the release gate builds the package from scratch, never from the input-hash skip (packages/ui/scripts/build-package.ts) +const env = { ...process.env, E2E_PREBUILT: "1", CA_BUILD_COLD: "1" }; const run = (cmd: string[], cwd = ROOT) => spawnSync(process.execPath, testRun(cmd), { cwd, stdio: "inherit", env }).status ?? 1; const runAsync = (cmd: string[], cwd = ROOT) => {