From 217c8a5e917bef5ddfc9365fcb6fddc4880344cf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 9 Sep 2026 21:03:13 -0400 Subject: [PATCH 1/5] fix(build): stop generated files from being read half-written during the build Two unrelated pull requests kept failing CI for reasons that had nothing to do with their changes. Typecheck failed with "TS1002: Unterminated string literal" pointing at a generated file. The root build compiles several packages at the same time, and more than one of them regenerates files under a package's src/generated while a sibling's tsc is already reading them. A plain write empties the file and then refills it, so for a few milliseconds what is on disk is the first half of the new file. Whichever build read it in that gap saw a truncated file and stopped. It never happened locally because locally nothing else is reading. Every generator now writes the new version under a temporary name and then renames it over the target, which the filesystem does in one step. A reader either gets the whole previous version or the whole new one; there is no moment where it can see half of either. Content that has not changed is not rewritten at all, so repeat builds no longer touch these files. Four generators were affected and all four now go through one shared helper, which is where the rule lives from now on. The build also rebuilt the core package a second time, in parallel with the packages that read it. That second rebuild was already redundant, and removing it takes the writers out of the window entirely. Renaming is what makes the file safe; dropping the duplicate build makes the build shorter as well. Separately, a linter performance test failed on four of the last ten failed runs on main. It timed a scan with a stopwatch and demanded the result come in under two seconds; one run took 3.7s. That is a statement about how busy the shared runner was, not about the code, because a stopwatch also counts the time the machine spent running someone else's job. The test now counts only the processor time this process actually used, and checks that the cost grows in step with the input rather than against a fixed number of milliseconds. Measured on a machine under load, the stopwatch ratio for the same input reached 45x while the processor-time ratio stayed at 20x. Both fixes come with a check that fails if the fix is removed. --- package.json | 4 +- .../build-hyperframes-runtime-artifact.ts | 16 ++--- .../core/scripts/buildInjectedArtifact.ts | 37 ++++------ packages/lint/src/utils.test.ts | 39 ++++++++--- .../producer/scripts/build-hf-early-stub.ts | 53 ++++++-------- scripts/write-generated-file.test.ts | 70 +++++++++++++++++++ scripts/write-generated-file.ts | 59 ++++++++++++++++ 7 files changed, 204 insertions(+), 74 deletions(-) create mode 100644 scripts/write-generated-file.test.ts create mode 100644 scripts/write-generated-file.ts diff --git a/package.json b/package.json index 5e171cb955..fdda3f8554 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build", + "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", @@ -48,7 +48,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/write-generated-file.test.ts packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/packages/core/scripts/build-hyperframes-runtime-artifact.ts b/packages/core/scripts/build-hyperframes-runtime-artifact.ts index 3042e17706..f68aea9b36 100644 --- a/packages/core/scripts/build-hyperframes-runtime-artifact.ts +++ b/packages/core/scripts/build-hyperframes-runtime-artifact.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; @@ -8,6 +7,7 @@ import { HYPERFRAME_RUNTIME_CONTRACT, loadHyperframeRuntimeSource, } from "../src/inline-scripts/hyperframe"; +import { writeGeneratedFile } from "../../../scripts/write-generated-file.js"; const thisDir = dirname(fileURLToPath(import.meta.url)); const distDir = resolve(thisDir, "../dist"); @@ -46,20 +46,17 @@ const manifest = { contract: HYPERFRAME_RUNTIME_CONTRACT, }; -mkdirSync(distDir, { recursive: true }); -writeFileSync(iifePath, runtimeSource, "utf8"); -writeFileSync(esmPath, esmSource, "utf8"); -writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +writeGeneratedFile(iifePath, runtimeSource); +writeGeneratedFile(esmPath, esmSource); +writeGeneratedFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); // ── Generate src/generated/runtime-inline.ts ────────────────────────────── // This file is compiled by tsc into dist/ and provides the production-safe // getHyperframeRuntimeScript() that returns the IIFE as a string constant — // no esbuild, no file I/O, no import.meta.url arithmetic. -const generatedDir = resolve(thisDir, "../src/generated"); -mkdirSync(generatedDir, { recursive: true }); -const inlineModulePath = resolve(generatedDir, "runtime-inline.ts"); +const inlineModulePath = resolve(thisDir, "../src/generated/runtime-inline.ts"); const escapedSource = JSON.stringify(runtimeSourceRaw); -writeFileSync( +writeGeneratedFile( inlineModulePath, [ "// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit", @@ -75,7 +72,6 @@ writeFileSync( "}", "", ].join("\n"), - "utf8", ); console.log( diff --git a/packages/core/scripts/buildInjectedArtifact.ts b/packages/core/scripts/buildInjectedArtifact.ts index b7faa7d0f7..ea2fbd9710 100644 --- a/packages/core/scripts/buildInjectedArtifact.ts +++ b/packages/core/scripts/buildInjectedArtifact.ts @@ -8,11 +8,13 @@ * producing a subtly different artifact with nothing to say so. */ -import { execFileSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; +import { + formatGeneratedSource, + writeGeneratedFile, +} from "../../../scripts/write-generated-file.js"; export interface InjectedArtifact { /** The build script's own `import.meta.url`, so paths resolve beside it. */ @@ -52,27 +54,18 @@ export function buildInjectedArtifact(spec: InjectedArtifact): void { const iife = result.outputFiles[0]?.text ?? ""; if (!iife) throw new Error(`esbuild produced no output for ${spec.entry.split("/").pop()}`); - mkdirSync(generatedDir, { recursive: true }); - writeFileSync( - outPath, - [ - `// AUTO-GENERATED by scripts/${scriptName} - do not edit`, - `const ${spec.constName}: string = ${JSON.stringify(iife)};`, - "", - `/** Returns the pre-built ${spec.what} as a string constant. */`, - `export function ${spec.fnName}(): string {`, - ` return ${spec.constName};`, - "}", - "", - ].join("\n"), - "utf8", - ); + const source = [ + `// AUTO-GENERATED by scripts/${scriptName} - do not edit`, + `const ${spec.constName}: string = ${JSON.stringify(iife)};`, + "", + `/** Returns the pre-built ${spec.what} as a string constant. */`, + `export function ${spec.fnName}(): string {`, + ` return ${spec.constName};`, + "}", + "", + ].join("\n"); - try { - execFileSync("bun", ["x", "oxfmt", outPath], { stdio: "ignore" }); - } catch { - // Formatting is best effort when the generator runs in a minimal environment. - } + writeGeneratedFile(outPath, formatGeneratedSource(source, outPath)); console.log(JSON.stringify({ event: spec.event, outPath, bytes: iife.length })); } diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts index a024e19bad..f51234196e 100644 --- a/packages/lint/src/utils.test.ts +++ b/packages/lint/src/utils.test.ts @@ -84,21 +84,44 @@ describe("stripJsStringLiterals", () => { }); describe("stripJsStringLiterals scaling", () => { + /** + * Guards the quadratic backtracking this scanner was rewritten to avoid: deciding + * regex-versus-division by re-reading the accumulated output on every candidate + * slash, which a composition carrying one inlined vendor bundle turns into minutes. + * + * Measured in CPU time, not wall time. CI runners are shared, so wall time also + * counts the milliseconds this process spent descheduled while a neighbour had the + * core, and an absolute millisecond ceiling is then a claim about the runner rather + * than about the algorithm — which is how this test failed on unrelated pull + * requests. `process.cpuUsage` counts only work this process actually did. Measured + * on a machine at load average 16, the wall-clock ratio for this same input reached + * 45x while the CPU ratio stayed at 20x. + * + * Only the ratio is asserted; there is deliberately no absolute bound, because how + * many milliseconds the scan costs is a property of the hardware and how many it + * costs *relative to a smaller input* is the property of the code. The bound is + * loose because the linear scan is not perfectly linear in measured cost: the + * output string grows with the input, so V8's own string handling adds overhead, + * and 8x input measures ~16-22x cost. A quadratic scan measures 60x or more. + */ it("stays linear in slash-dense input", () => { - const time = (n: number) => { + const cpuMs = (n: number) => { const src = "a=b/c;".repeat(n); + stripJsStringLiterals(src); // warm up before the first sample let best = Infinity; - for (let run = 0; run < 3; run += 1) { - const started = performance.now(); + for (let run = 0; run < 5; run += 1) { + const started = process.cpuUsage(); stripJsStringLiterals(src); - best = Math.min(best, performance.now() - started); + const spent = process.cpuUsage(started); + best = Math.min(best, (spent.user + spent.system) / 1000); } return best; }; - const small = Math.max(time(20_000), 0.5); - const large = time(160_000); - expect(large / small).toBeLessThan(24); - expect(large).toBeLessThan(2_000); + // Sized so the smaller sample costs several milliseconds of CPU — well clear of + // the accounting granularity, so the ratio means something. + const small = cpuMs(40_000); + const large = cpuMs(320_000); + expect(large / small).toBeLessThan(32); }); }); diff --git a/packages/producer/scripts/build-hf-early-stub.ts b/packages/producer/scripts/build-hf-early-stub.ts index 46f932cf9a..0bbdbbbbc6 100644 --- a/packages/producer/scripts/build-hf-early-stub.ts +++ b/packages/producer/scripts/build-hf-early-stub.ts @@ -10,18 +10,19 @@ * runtime. */ -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; -import { execSync } from "node:child_process"; +import { + formatGeneratedSource, + writeGeneratedFile, +} from "../../../scripts/write-generated-file.js"; const thisDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(thisDir, ".."); const stubEntry = resolve(repoRoot, "stubs/hf-early-stub.ts"); -const generatedDir = resolve(repoRoot, "src/generated"); -const outPath = resolve(generatedDir, "hf-early-stub-inline.ts"); +const outPath = resolve(repoRoot, "src/generated/hf-early-stub-inline.ts"); // ── Compile the stub to a self-contained IIFE ───────────────────────────────── const result = buildSync({ @@ -41,36 +42,24 @@ if (!iife) { throw new Error("esbuild produced no output for hf-early-stub.ts"); } -// ── Write the generated module ──────────────────────────────────────────────── -mkdirSync(generatedDir, { recursive: true }); - +// ── Publish the generated module ────────────────────────────────────────────── const escaped = JSON.stringify(iife); -writeFileSync( - outPath, - [ - "// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit", - `const HF_EARLY_STUB_IIFE: string = ${escaped};`, - "", - "/**", - " * Returns the pre-built HyperFrames early stub IIFE as a string constant.", - " * Inject into before any other scripts so the GSAP batching", - " * interceptor is in place when user composition scripts run.", - " */", - "export function getHfEarlyStub(): string {", - " return HF_EARLY_STUB_IIFE;", - "}", - "", - ].join("\n"), - "utf8", -); +const source = [ + "// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit", + `const HF_EARLY_STUB_IIFE: string = ${escaped};`, + "", + "/**", + " * Returns the pre-built HyperFrames early stub IIFE as a string constant.", + " * Inject into before any other scripts so the GSAP batching", + " * interceptor is in place when user composition scripts run.", + " */", + "export function getHfEarlyStub(): string {", + " return HF_EARLY_STUB_IIFE;", + "}", + "", +].join("\n"); -// Format the generated file so `oxfmt --check` passes in CI. -// Errors are intentionally swallowed — oxfmt unavailable in some envs. -try { - execSync(`bunx oxfmt ${outPath}`, { stdio: "ignore" }); -} catch { - // not fatal -} +writeGeneratedFile(outPath, formatGeneratedSource(source, outPath)); console.log( JSON.stringify({ diff --git a/scripts/write-generated-file.test.ts b/scripts/write-generated-file.test.ts new file mode 100644 index 0000000000..bfd6e6e02e --- /dev/null +++ b/scripts/write-generated-file.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { writeGeneratedFile } from "./write-generated-file.js"; + +const workDir = mkdtempSync(join(tmpdir(), "write-generated-")); +after(() => rmSync(workDir, { recursive: true, force: true })); + +const target = (name: string) => join(workDir, `${name}.ts`); + +describe("writeGeneratedFile", () => { + it("publishes the whole file and leaves no temp behind", () => { + const out = target("published"); + const contents = `export const BIG = ${JSON.stringify("x".repeat(50_000))};\n`; + + assert.equal(writeGeneratedFile(out, contents), true); + + assert.equal(readFileSync(out, "utf8"), contents); + assert.deepEqual( + readdirSync(workDir).filter((f) => f.endsWith(".tmp")), + [], + ); + }); + + it("leaves the target untouched when the content is byte-identical", () => { + const out = target("unchanged"); + const contents = "export const A = 1;\n"; + writeGeneratedFile(out, contents); + // Backdated so a republish cannot coincidentally land on the same mtime. Read the + // stamp back rather than trusting the one just set: filesystems round it. + const backdated = new Date(Date.now() - 60_000); + utimesSync(out, backdated, backdated); + const before = statSync(out).mtimeMs; + + assert.equal(writeGeneratedFile(out, contents), false); + + assert.equal(statSync(out).mtimeMs, before); + }); + + it("replaces the target rather than rewriting it in place", () => { + const out = target("replaced"); + writeFileSync(out, "export const A = 1;\n", "utf8"); + const before = statSync(out).ino; + + assert.equal(writeGeneratedFile(out, "export const A = 2;\n"), true); + + assert.equal(readFileSync(out, "utf8"), "export const A = 2;\n"); + // A new inode is the observable proof the target was swapped in whole. Rewriting + // in place keeps the inode and exposes a truncated file to a concurrent reader. + assert.notEqual(statSync(out).ino, before); + }); + + it("creates the directory when it does not exist yet", () => { + const out = join(workDir, "nested", "deeper", "created.ts"); + + assert.equal(writeGeneratedFile(out, "export const A = 1;\n"), true); + + assert.equal(readFileSync(out, "utf8"), "export const A = 1;\n"); + }); +}); diff --git a/scripts/write-generated-file.ts b/scripts/write-generated-file.ts new file mode 100644 index 0000000000..c4b94008e8 --- /dev/null +++ b/scripts/write-generated-file.ts @@ -0,0 +1,59 @@ +/** + * Publish a file under `src/generated` so a concurrent reader never observes it + * half-written. + * + * The root build runs several package builds at once, and more than one of them + * regenerates files here while a sibling's `tsc` is reading them. A plain + * `writeFileSync` leaves the target truncated and then growing for the duration of + * the write, which surfaces in whichever build read it mid-flight as + * `TS1002: Unterminated string literal` — a failure that never reproduces locally, + * because locally nothing is reading at that instant. + * + * Writing to a temp file in the same directory and renaming over the target makes + * publication one atomic step: a reader sees either the whole previous file or the + * whole new one, never a prefix. Same directory matters — `rename` is only atomic + * within a filesystem. + * + * Byte-identical content is not republished, so a no-op rebuild leaves the mtime + * alone and nothing downstream that keys off mtime is needlessly invalidated. + * + * Returns whether the target was replaced. + */ + +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +export function writeGeneratedFile(outPath: string, contents: string): boolean { + if (existsSync(outPath) && readFileSync(outPath, "utf8") === contents) return false; + + mkdirSync(dirname(outPath), { recursive: true }); + // Not a `.ts` suffix: the temp file sits inside the package's `src` glob, and a + // concurrent `tsc` would otherwise try to compile it. + const tempPath = `${outPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + try { + writeFileSync(tempPath, contents, "utf8"); + renameSync(tempPath, outPath); + } finally { + rmSync(tempPath, { force: true }); + } + return true; +} + +/** + * Runs the generated source through oxfmt's stdin so it is published already + * formatted. Formatting the file after publishing it would rewrite it in place and + * reopen the very window the atomic rename closes. Best effort: an environment + * without oxfmt still gets a valid, if unformatted, artifact. + */ +export function formatGeneratedSource(source: string, outPath: string): string { + try { + return execFileSync("bun", ["x", "oxfmt", `--stdin-filepath=${outPath}`], { + input: source, + encoding: "utf8", + }); + } catch { + return source; + } +} From 17728e4d21f65f9ee358a2a714bffdf378b36382 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 9 Sep 2026 21:16:49 -0400 Subject: [PATCH 2/5] fix(build): keep the shared write helper inside the core package The helper the generators call had been placed in the repo-root scripts folder. Every container image that builds a package copies the packages folders whole but cherry-picks root scripts one file at a time, so the image builds failed on a missing module. The repo already shows both halves of that convention: the one root script a package build imports has a matching copy line in the image, and cross-package imports into the core package need none. The helper now lives with the core package's other build scripts, and the one generator outside that package reaches it the way the engine package already reaches core. --- package.json | 2 +- packages/core/scripts/build-hyperframes-runtime-artifact.ts | 2 +- packages/core/scripts/buildInjectedArtifact.ts | 5 +---- .../core/scripts/writeGeneratedFile.test.ts | 2 +- .../core/scripts/writeGeneratedFile.ts | 5 +++++ packages/producer/scripts/build-hf-early-stub.ts | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) rename scripts/write-generated-file.test.ts => packages/core/scripts/writeGeneratedFile.test.ts (97%) rename scripts/write-generated-file.ts => packages/core/scripts/writeGeneratedFile.ts (88%) diff --git a/package.json b/package.json index fdda3f8554..5dbaf112a8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/write-generated-file.test.ts packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/packages/core/scripts/build-hyperframes-runtime-artifact.ts b/packages/core/scripts/build-hyperframes-runtime-artifact.ts index f68aea9b36..3543992dcf 100644 --- a/packages/core/scripts/build-hyperframes-runtime-artifact.ts +++ b/packages/core/scripts/build-hyperframes-runtime-artifact.ts @@ -7,7 +7,7 @@ import { HYPERFRAME_RUNTIME_CONTRACT, loadHyperframeRuntimeSource, } from "../src/inline-scripts/hyperframe"; -import { writeGeneratedFile } from "../../../scripts/write-generated-file.js"; +import { writeGeneratedFile } from "./writeGeneratedFile.js"; const thisDir = dirname(fileURLToPath(import.meta.url)); const distDir = resolve(thisDir, "../dist"); diff --git a/packages/core/scripts/buildInjectedArtifact.ts b/packages/core/scripts/buildInjectedArtifact.ts index ea2fbd9710..de3e97e4b3 100644 --- a/packages/core/scripts/buildInjectedArtifact.ts +++ b/packages/core/scripts/buildInjectedArtifact.ts @@ -11,10 +11,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; -import { - formatGeneratedSource, - writeGeneratedFile, -} from "../../../scripts/write-generated-file.js"; +import { formatGeneratedSource, writeGeneratedFile } from "./writeGeneratedFile.js"; export interface InjectedArtifact { /** The build script's own `import.meta.url`, so paths resolve beside it. */ diff --git a/scripts/write-generated-file.test.ts b/packages/core/scripts/writeGeneratedFile.test.ts similarity index 97% rename from scripts/write-generated-file.test.ts rename to packages/core/scripts/writeGeneratedFile.test.ts index bfd6e6e02e..4618d732b8 100644 --- a/scripts/write-generated-file.test.ts +++ b/packages/core/scripts/writeGeneratedFile.test.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, describe, it } from "node:test"; -import { writeGeneratedFile } from "./write-generated-file.js"; +import { writeGeneratedFile } from "./writeGeneratedFile.js"; const workDir = mkdtempSync(join(tmpdir(), "write-generated-")); after(() => rmSync(workDir, { recursive: true, force: true })); diff --git a/scripts/write-generated-file.ts b/packages/core/scripts/writeGeneratedFile.ts similarity index 88% rename from scripts/write-generated-file.ts rename to packages/core/scripts/writeGeneratedFile.ts index c4b94008e8..b9a49511fb 100644 --- a/scripts/write-generated-file.ts +++ b/packages/core/scripts/writeGeneratedFile.ts @@ -18,6 +18,11 @@ * alone and nothing downstream that keys off mtime is needlessly invalidated. * * Returns whether the target was replaced. + * + * Lives inside `packages/core` rather than the repo-root `scripts/` because every + * container image that builds a package copies `packages/…` wholesale but + * cherry-picks root scripts one file at a time. A helper the core build imports + * has to travel with the package, or the image build fails on a missing module. */ import { execFileSync } from "node:child_process"; diff --git a/packages/producer/scripts/build-hf-early-stub.ts b/packages/producer/scripts/build-hf-early-stub.ts index 0bbdbbbbc6..e675b763a5 100644 --- a/packages/producer/scripts/build-hf-early-stub.ts +++ b/packages/producer/scripts/build-hf-early-stub.ts @@ -16,7 +16,7 @@ import { buildSync } from "esbuild"; import { formatGeneratedSource, writeGeneratedFile, -} from "../../../scripts/write-generated-file.js"; +} from "../../core/scripts/writeGeneratedFile.js"; const thisDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(thisDir, ".."); From b36b5fcdbc56625d60c84b19ac605e5aedc315aa Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 9 Sep 2026 20:22:03 -0400 Subject: [PATCH 3/5] test(engine): keep the probe cache bound test off the filesystem The eviction test wrote, stat'ed and deleted 129 temp files to exercise an in-memory LRU rule. Its runtime tracked filesystem contention rather than the code under test, and on a busy Windows runner the file churn alone pushed a ~300ms test past the 5s timeout, failing unrelated pull requests. Cache identity comes from stat, so the test now synthesises stat results and never touches disk. While here, the test also asserts the LRU touch: re-probing an entry before the bound is hit must keep it resident and evict the next-oldest one instead. The previous shape never hit the cache during the fill, so that branch was untested. --- packages/engine/src/utils/ffprobe.test.ts | 35 +++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 2030dc1054..20186127dc 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -251,11 +251,10 @@ describe("probeMediaProfile", () => { }); it("bounds the process-scoped probe cache", async () => { - const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-media-probe-lru-")); - const fixturePaths = Array.from({ length: 129 }, (_, index) => - resolve(fixtureDir, `asset-${index}`), - ); - for (const fixturePath of fixturePaths) writeFileSync(fixturePath, "probe identity"); + // Cache identity is derived from stat, so synthesise it instead of writing + // 129 real files: the eviction rule is in-memory, and on a contended + // Windows runner the file churn alone pushed this past the test timeout. + const paths = Array.from({ length: 129 }, (_, index) => `/probe-lru/asset-${index}`); const outcome = { kind: "exit" as const, code: 0, @@ -267,13 +266,31 @@ describe("probeMediaProfile", () => { const { spawn, calls } = createSpawnSpy([outcome]); vi.resetModules(); vi.doMock("child_process", () => ({ spawn })); - const { probeMediaProfile } = await import("./ffprobe.js"); + vi.doMock("fs", async () => { + const actual = await vi.importActual("fs"); + const statSync = (filePath: string) => ({ + dev: 1n, + ino: BigInt(paths.indexOf(filePath)), + size: 1n, + mtimeNs: 0n, + ctimeNs: 0n, + }); + return { ...actual, statSync }; + }); try { - for (const fixturePath of fixturePaths) await probeMediaProfile(fixturePath); - await probeMediaProfile(fixturePaths[0]!); + const { probeMediaProfile } = await import("./ffprobe.js"); + const [first, second, ...rest] = paths; + for (const filePath of paths.slice(0, 128)) await probeMediaProfile(filePath); + // A hit refreshes the entry, so the 129th insert evicts `second`, not `first`. + await probeMediaProfile(first!); + await probeMediaProfile(rest.at(-1)!); + expect(calls).toHaveLength(129); + await probeMediaProfile(first!); + expect(calls).toHaveLength(129); + await probeMediaProfile(second!); expect(calls).toHaveLength(130); } finally { - rmSync(fixtureDir, { recursive: true, force: true }); + vi.doUnmock("fs"); } }); From 6e7c68aa5276fc358409f94b532baaa680c11f45 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 9 Sep 2026 22:42:35 -0400 Subject: [PATCH 4/5] test(lint): keep the scaling check inside the scanner's linear range The CPU-ratio version sampled 320k characters, where the output string's own growth dominates and the whole test cost seconds of CPU; on a shared runner that tripped the default test timeout, the failure this change exists to remove. Sample 10k and 80k characters instead, where 8x input measures ~8x and a quadratic scan still measures 60x or more, and give the test an explicit timeout so a slow runner reports the ratio. --- packages/lint/src/utils.test.ts | 42 +++++++++++++-------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts index f51234196e..41663ff9d8 100644 --- a/packages/lint/src/utils.test.ts +++ b/packages/lint/src/utils.test.ts @@ -84,27 +84,19 @@ describe("stripJsStringLiterals", () => { }); describe("stripJsStringLiterals scaling", () => { - /** - * Guards the quadratic backtracking this scanner was rewritten to avoid: deciding - * regex-versus-division by re-reading the accumulated output on every candidate - * slash, which a composition carrying one inlined vendor bundle turns into minutes. - * - * Measured in CPU time, not wall time. CI runners are shared, so wall time also - * counts the milliseconds this process spent descheduled while a neighbour had the - * core, and an absolute millisecond ceiling is then a claim about the runner rather - * than about the algorithm — which is how this test failed on unrelated pull - * requests. `process.cpuUsage` counts only work this process actually did. Measured - * on a machine at load average 16, the wall-clock ratio for this same input reached - * 45x while the CPU ratio stayed at 20x. - * - * Only the ratio is asserted; there is deliberately no absolute bound, because how - * many milliseconds the scan costs is a property of the hardware and how many it - * costs *relative to a smaller input* is the property of the code. The bound is - * loose because the linear scan is not perfectly linear in measured cost: the - * output string grows with the input, so V8's own string handling adds overhead, - * and 8x input measures ~16-22x cost. A quadratic scan measures 60x or more. - */ - it("stays linear in slash-dense input", () => { + // Guards the quadratic backtracking this scanner was rewritten to avoid: + // deciding regex-versus-division by re-reading the accumulated output on + // every candidate slash. + // + // Measured in CPU time, not wall time: `process.cpuUsage` counts only work + // this process did, so time spent descheduled on a shared runner does not + // count. Only the ratio is asserted; an absolute millisecond bound is a + // claim about the hardware, and it is how this test failed on unrelated + // pull requests. Inputs stay well under 100k characters: above that the + // output string's growth adds its own cost and 8x input measures ~20x even + // for the linear scan. Inside that range 8x input measures ~8x; a quadratic + // scan measures 60x or more. + it("stays linear in slash-dense input", { timeout: 30_000 }, () => { const cpuMs = (n: number) => { const src = "a=b/c;".repeat(n); stripJsStringLiterals(src); // warm up before the first sample @@ -117,11 +109,9 @@ describe("stripJsStringLiterals scaling", () => { } return best; }; - // Sized so the smaller sample costs several milliseconds of CPU — well clear of - // the accounting granularity, so the ratio means something. - const small = cpuMs(40_000); - const large = cpuMs(320_000); - expect(large / small).toBeLessThan(32); + const small = cpuMs(10_000); + const large = cpuMs(80_000); + expect(large / small).toBeLessThan(24); }); }); From c212e6f7b24816a09c8121187b8aad2c8bd83cd4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 9 Sep 2026 22:54:07 -0400 Subject: [PATCH 5/5] test(core): snapshot file identity through a descriptor The before/after inode and mtime checks read the file through a path stat and then exercised the writer on the same path, which reads as a check-then-use race to static analysis. Snapshot through an open descriptor instead; the assertions are unchanged. --- .../core/scripts/writeGeneratedFile.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/core/scripts/writeGeneratedFile.test.ts b/packages/core/scripts/writeGeneratedFile.test.ts index 4618d732b8..8acc53d248 100644 --- a/packages/core/scripts/writeGeneratedFile.test.ts +++ b/packages/core/scripts/writeGeneratedFile.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import { + closeSync, + fstatSync, mkdtempSync, + openSync, readdirSync, readFileSync, rmSync, @@ -10,6 +13,18 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; + +// Identity is read through a descriptor, not a path, so the snapshot cannot be mistaken +// for a check that the following write is then trusted to still hold (the pattern +// CodeQL's file-system-race query looks for). The write under test is the point. +const snapshot = (path: string) => { + const fd = openSync(path, "r"); + try { + return fstatSync(fd); + } finally { + closeSync(fd); + } +}; import { after, describe, it } from "node:test"; import { writeGeneratedFile } from "./writeGeneratedFile.js"; @@ -40,7 +55,7 @@ describe("writeGeneratedFile", () => { // stamp back rather than trusting the one just set: filesystems round it. const backdated = new Date(Date.now() - 60_000); utimesSync(out, backdated, backdated); - const before = statSync(out).mtimeMs; + const before = snapshot(out).mtimeMs; assert.equal(writeGeneratedFile(out, contents), false); @@ -50,7 +65,7 @@ describe("writeGeneratedFile", () => { it("replaces the target rather than rewriting it in place", () => { const out = target("replaced"); writeFileSync(out, "export const A = 1;\n", "utf8"); - const before = statSync(out).ino; + const before = snapshot(out).ino; assert.equal(writeGeneratedFile(out, "export const A = 2;\n"), true);