Files
msd-core/sdk/scripts/_gen-helpers.mjs
Tom Boucher dde56a1b77 chore: enforce sdk/dist freshness in gen-*.mjs + add staleness guard helper (#169)
* feat: add requireFreshDist helper for gen-*.mjs scripts

Adds sdk/scripts/_gen-helpers.mjs exporting requireFreshDist(distPath, tsSourcePath).
Performs a synchronous mtime check before any generator reads sdk/dist/ — if
the dist file is missing or older than its TS source, exits 1 with a clear
actionable error naming both paths, both mtimes, and the npm run build:sdk hint.

Single source of truth so all 9 generators import one function rather than
duplicating the logic. See issue #168.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: enforce dist freshness in all 9 gen-*.mjs generators

Each generator now calls requireFreshDist() before reading sdk/dist/.
If the dist artifact is missing or stale relative to its TS source,
the generator exits 1 with a clear error instead of silently emitting
stale CJS output.

Addresses the PR #154 incident where an agent edited a TS source,
regenerated the CJS without rebuilding, and silently overwrote a fix.
gen-configuration.mjs also had its existing throw-on-missing guard
replaced with requireFreshDist() which additionally catches staleness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add staleness-check regression for gen-*.mjs

tests/gen-staleness-check.test.cjs covers three cases for all 9 generators:
- exits 1 with "does not exist" + build hint when dist file is absent
- exits 1 with stale-dist error (paths + mtimes) when TS source is newer than dist
- exits 0 when dist is newer than TS source (skipped if sdk/dist not built)

Uses child_process.spawnSync to exercise the real gen-*.mjs entry path.
27/27 passing locally with sdk/dist present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(gen-staleness-check): isolate dist-mutation tests with GSD_REPO_ROOT temp dirs

Subtests A and B in gen-staleness-check.test.cjs were renaming/creating real
sdk/dist files in the live repo tree while the suite ran at --test-concurrency=4.
Other parallel test processes (e.g. frontmatter-cli.test.cjs) spawned subprocesses
that attempted dynamic ESM import of sdk/dist/query/schema-detect.js while it was
temporarily absent, producing unhandled ENOENT failures unrelated to the staleness
guard logic under test.

Fix: add a GSD_REPO_ROOT env override to _gen-helpers.mjs so requireFreshDist()
resolves dist/ts paths against the provided root instead of the repo root derived
from import.meta.url. The test creates an isolated tmpdir tree per subtest (with
the real TS source copied in) and passes GSD_REPO_ROOT=<tmpdir> to the generator
subprocess, so no real dist files are ever touched during subtests A or B.

Subtest C (exits 0 on fresh dist) still uses the real repo tree because it needs
actual compiled output to exercise the generator end-to-end, but only sets the TS
source mtime (safe under concurrency) — it does not remove or rename any dist file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:33:54 -04:00

84 lines
3.3 KiB
JavaScript

/**
* Shared helpers for gen-*.mjs generator scripts.
*
* Provides requireFreshDist(), a pre-generation guard that verifies the
* compiled sdk/dist artifact is newer than its TypeScript source. If the
* dist file is missing or stale, the function exits 1 with a clear,
* actionable error message so the developer knows to run `npm run build:sdk`.
*
* Single source of truth — import from here rather than duplicating the mtime
* logic in each generator.
*
* @example
* import { requireFreshDist } from './_gen-helpers.mjs';
* requireFreshDist('sdk/dist/query/secrets.js', 'sdk/src/query/secrets.ts');
*/
import { statSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// Resolve the repo root from this file's location (sdk/scripts/ → repo root).
// GSD_REPO_ROOT env override allows tests to redirect to a temp directory so
// they can freely create/delete dist fixtures without mutating the real tree.
const REPO_ROOT = process.env.GSD_REPO_ROOT
? resolve(process.env.GSD_REPO_ROOT)
: resolve(fileURLToPath(import.meta.url), '..', '..', '..');
/**
* Assert that a compiled dist file is at least as new as its TypeScript source.
*
* Design notes:
* - Single Responsibility: each generator is responsible for verifying its
* own preconditions before touching dist. This keeps the guard co-located
* with the code that depends on it.
* - Composability: callers that have already built can call generators
* directly; the guard is a cheap mtime check, not a rebuild trigger.
* - Debuggability: the error message names both paths and both mtimes so the
* developer can see at a glance what is stale and what to do about it.
* - Performance: two synchronous statSync calls — effectively zero cost
* relative to the import/transform work the generator does next.
*
* NOT auto-building: auto-building inside the generator couples it to the
* build toolchain, bloats its dependency graph, and removes composability —
* a caller that already built cannot skip the redundant rebuild. The guard
* pattern is the correct separation: tell the developer what to do, not do it
* for them.
*
* @param {string} distPath - Repo-relative path to the compiled dist file,
* e.g. 'sdk/dist/query/secrets.js'
* @param {string} tsSourcePath - Repo-relative path to the TypeScript source
* file, e.g. 'sdk/src/query/secrets.ts'
*/
export function requireFreshDist(distPath, tsSourcePath) {
const distAbs = resolve(REPO_ROOT, distPath);
const tsAbs = resolve(REPO_ROOT, tsSourcePath);
if (!existsSync(distAbs)) {
console.error(
`ERROR: ${distPath} does not exist. Run \`npm run build:sdk\` first.`,
);
process.exit(1);
}
if (!existsSync(tsAbs)) {
console.error(
`ERROR: ${tsSourcePath} does not exist. Cannot verify dist freshness.`,
);
process.exit(1);
}
const distMtime = statSync(distAbs).mtimeMs;
const tsMtime = statSync(tsAbs).mtimeMs;
if (distMtime < tsMtime) {
console.error(
`ERROR: ${distPath} is stale relative to ${tsSourcePath} ` +
`(dist mtime ${new Date(distMtime).toISOString()}, ` +
`ts mtime ${new Date(tsMtime).toISOString()}). ` +
`Run \`npm run build:sdk\` first.`,
);
process.exit(1);
}
}