* test(#2657): add failing-first regression for tracked bin/lib compiled artifacts Nine gsd-core/bin/lib/*.cjs artifacts are tracked in git despite having src/*.cts sources, violating ADR-457's build-at-publish contract. This regression test asserts the ADR-457 end state (untracked, gitignored, empty-set reported by the #2656 sync guard) and fails until the tracking is fixed. * fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts Nine gsd-core/bin/lib/*.cjs artifacts (api-coverage, assumption-delta, claude-orchestration, claude-orchestration-command-router, external-job, markdown-table, runtime-artifact-install-plan, state-transition, write-set) were tracked in git despite each having a matching src/*.cts source, letting the committed bytes drift silently from source (#2653 demonstrated this for api-coverage.cjs). Seven had no .gitignore entry at all; two (markdown-table.cjs, write-set.cjs) had a pattern added by #2248 but were never git rm --cached. Both gaps produce the same tracked-file symptom. Untracks all nine and adds the seven missing .gitignore entries next to their two siblings, reaching ADR-457's end state: bin/lib/*.cjs is a gitignored build artifact built via prepare/pretest/prepublishOnly, never checked-in source of truth. The #2656 artifact-sync guard is regime-agnostic by design and needed no code change; it now reports the empty-set end state. * test(#2657): consolidate repeated still-tracked/unmatched assertion shape Code-review finding (Standards axis, Duplicated Code): the three 'none of the nine should still be in bad state X' checks shared an identical filter-then-assert-empty shape. Extracted assertNoneStillBad() as a shared helper; behavior is unchanged. * fix(#2657): make the .gitignore-match assertion existence-independent The regression test's check-ignore assertion used --no-index, which locally exercises the pattern correctly but was reported failing on gsd-test's fresh shallow clone. Switched to plain 'git check-ignore -q' (no --no-index): verified via a real git worktree checkout at both origin/next (fails: all nine report not-ignored, since check-ignore correctly special-cases the still-tracked pre-fix state) and this branch's tip (passes: all nine report ignored). Plain check-ignore is also semantically stronger than --no-index here, since it honors the 'a tracked path is never reported ignored' rule that --no-index bypasses -- exactly the property under test for the two paths whose .gitignore pattern predates this fix (#2248) but were never untracked. Also reconciled the .gitignore comment: it previously read 'these seven' beside seven new lines with no indication of the other two (of nine total) that already had a pattern from #2248. Annotated both groups so the count is unambiguous at the point of the diff. * test(#2657): make every git invocation self-diagnosing b6f915bc0 failed in the runner with a shape that turned out not to be about .gitignore content or the merge: two of the five failures in this file were 'Command failed' / 'Got unwanted exception' -- git itself erroring, not answering. The old code used execFileSync + try/catch, which conflates 'git said no' with 'git could not run' -- both looked like the same negative result to the test, exactly the failure mode that produced 'unmatched: <all nine>' twice on two different assertions for two different reasons. Switched every git invocation to spawnSync (never throws) and made every assertion check the exit status explicitly before interpreting output: - git ls-files: must exit 0, or the assertion fails loud with cwd, exit status, and stderr instead of silently reading an error as 'nothing tracked'. - git check-ignore -q: only exit 0 (ignored) and exit 1 (not ignored) are legitimate answers per check-ignore(1); any other status is now a thrown infrastructure failure, never read as 'not ignored'. - trackedCompiledArtifacts(): a thrown error is now reported as what it is (its internal git call failed), not swallowed into a 'still tracked' verdict. - the sync-guard subprocess check now reports cwd/stderr/stdout on a non-zero exit instead of a bare doesNotThrow. This is a genuine, independent test defect (a test that reads a failed command's empty output as a meaningful answer can pass or fail for the wrong reason) as well as the mechanism for finally surfacing why b6f915bc0 failed in the runner: the next run's assertion messages will show the resolved cwd and git's actual stderr instead of an opaque 'unmatched: <all nine>'. * fix(#2657): trust the repo root for git calls under dubious-ownership Root cause of the failing runner verdict, harvested from the diagnostics commit: every git invocation in the container exits 128 with 'fatal: detected dubious ownership in repository at /work' -- the checkout there is owned by a different uid than the process running the tests, and git refuses to operate at all. The old assertions read that hard failure as 'not ignored' / 'still tracked', producing the all-nine symptom seen on both b6f915bc0 (plain check-ignore) and 34052f836 (--no-index). Nothing was ever wrong with the untracking, the .gitignore content, or the merge -- confirmed by exhaustive local reproduction (git worktree, real shallow clone, the runner's exact clone+checkout+merge sequence from its own Go source) that could never surface the bug because this machine owns its own checkouts. Fixed at both git() call sites in this exact seam by passing '-c safe.directory=<repo root>' per-invocation (never written to any config file, so trust is scoped to the single call): - tests/fix-2657-untrack-compiled-artifacts.test.cjs - scripts/lint-compiled-artifact-sync.cjs -- a SHIPPED script with the identical defect (its own git ls-files failed the same way in the same run), which would fail identically for any containerized CI lane whose checkout uid differs from the running user, not just this branch. Folded in under the no-defer rule rather than filed separately, since it sits in the exact tracked-compiled-artifact guard this issue is about. The status-code guards added in 31858818e stay in place -- they are what turned an unexplainable 'unmatched: <all nine>' into a one-line diagnosis, and they must keep any future infrastructure fault from silently reading as a substantive result. * chore(#2657): backfill changeset PR number to 3011 * chore(#2657): backfill changeset PR number to 3011 --------- Co-authored-by: sim <sim@local>
152 lines
6.1 KiB
JavaScript
152 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* lint-compiled-artifact-sync — fail when a *tracked* compiled artifact under
|
|
* gsd-core/bin/lib/ has drifted from its src/*.cts source.
|
|
*
|
|
* ADR-457 compiles src/*.cts to gsd-core/bin/lib/*.cjs at build time and expects
|
|
* those artifacts to be gitignored. Most are (see .gitignore). A handful are
|
|
* still tracked because the migration that moved the module into src/ did not
|
|
* also add the emitted .cjs to .gitignore. While a compiled artifact remains
|
|
* tracked, the committed bytes are what ships to anyone who reads the repo
|
|
* without building — so they must match the source.
|
|
*
|
|
* #2653: gsd-core/bin/lib/api-coverage.cjs sat four days behind its .cts after
|
|
* PR #2551 changed the source without regenerating the artifact, shipping a
|
|
* module that silently lacked the entire #2366 fix while CI stayed green.
|
|
*
|
|
* This check is deliberately regime-agnostic: it asserts a property of whatever
|
|
* is tracked right now. If the remaining artifacts are later untracked and
|
|
* gitignored (the ADR-457 end state), the tracked set becomes empty and this
|
|
* script passes trivially — no edit required.
|
|
*
|
|
* Usage: node scripts/lint-compiled-artifact-sync.cjs [--check]
|
|
* Exit 0 when every tracked artifact matches a fresh compile; 1 otherwise.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
const LIB_DIR = path.join('gsd-core', 'bin', 'lib');
|
|
const SRC_DIR = 'src';
|
|
|
|
/** Frozen reason codes so tests assert on structure, not prose. */
|
|
const REASON = Object.freeze({
|
|
OK: 'ok_artifacts_in_sync',
|
|
DRIFTED: 'fail_artifact_drifted',
|
|
BUILD_FAILED: 'fail_build_failed',
|
|
MISSING_EMIT: 'fail_missing_emit',
|
|
});
|
|
|
|
function git(args) {
|
|
// -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently
|
|
// owned by a different uid than the one running the test process, and git
|
|
// refuses to operate at all on such a repo ("detected dubious ownership")
|
|
// unless explicitly trusted. Scoped per-invocation (not written to any
|
|
// config file) so this never widens trust beyond this one call.
|
|
return execFileSync('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
}
|
|
|
|
/**
|
|
* Tracked .cjs files under gsd-core/bin/lib/ that have a matching src/*.cts.
|
|
* Uses `git ls-files` so the set is derived from what git actually tracks
|
|
* rather than a hand-maintained list that could itself drift.
|
|
*/
|
|
function trackedCompiledArtifacts() {
|
|
const tracked = git(['ls-files', LIB_DIR]).split('\n').filter((l) => l.endsWith('.cjs'));
|
|
const out = [];
|
|
for (const rel of tracked) {
|
|
const stem = path.basename(rel, '.cjs');
|
|
const subdir = path.dirname(path.relative(LIB_DIR, rel));
|
|
const srcRel = path.join(SRC_DIR, subdir === '.' ? '' : subdir, `${stem}.cts`);
|
|
if (fs.existsSync(path.join(REPO_ROOT, srcRel))) out.push({ artifact: rel, source: srcRel });
|
|
}
|
|
return out.sort((a, b) => (a.artifact < b.artifact ? -1 : a.artifact > b.artifact ? 1 : 0));
|
|
}
|
|
|
|
/** Compile the whole project to a throwaway outDir so the work tree is untouched. */
|
|
function compileToTemp() {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-artifact-sync-'));
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
path.join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
'-p', path.join(REPO_ROOT, 'tsconfig.build.json'),
|
|
'--outDir', tmp,
|
|
// A throwaway outDir must not reuse the in-tree incremental state, or
|
|
// tsc skips emit for files it believes are already current.
|
|
'--incremental', 'false',
|
|
'--tsBuildInfoFile', 'null',
|
|
],
|
|
{ cwd: REPO_ROOT, encoding: 'utf8', stdio: 'pipe' },
|
|
);
|
|
return { ok: true, dir: tmp };
|
|
} catch (err) {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
const detail = [err.stdout, err.stderr].filter(Boolean).join('\n').trim();
|
|
return { ok: false, detail };
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const pairs = trackedCompiledArtifacts();
|
|
if (pairs.length === 0) {
|
|
console.log('ok compiled-artifact-sync: no tracked compiled artifacts (ADR-457 end state)');
|
|
return 0;
|
|
}
|
|
|
|
const build = compileToTemp();
|
|
if (!build.ok) {
|
|
console.error(`FAIL compiled-artifact-sync: ${REASON.BUILD_FAILED}`);
|
|
console.error(build.detail);
|
|
return 1;
|
|
}
|
|
|
|
const drifted = [];
|
|
const missing = [];
|
|
try {
|
|
for (const { artifact, source } of pairs) {
|
|
const fresh = path.join(build.dir, path.relative(LIB_DIR, artifact));
|
|
if (!fs.existsSync(fresh)) { missing.push({ artifact, source }); continue; }
|
|
const a = fs.readFileSync(path.join(REPO_ROOT, artifact));
|
|
const b = fs.readFileSync(fresh);
|
|
if (!a.equals(b)) drifted.push({ artifact, source, committed: a.length, expected: b.length });
|
|
}
|
|
} finally {
|
|
fs.rmSync(build.dir, { recursive: true, force: true });
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
console.error(`FAIL compiled-artifact-sync: ${REASON.MISSING_EMIT}`);
|
|
for (const m of missing) console.error(` ${m.artifact} — no emit produced from ${m.source}`);
|
|
return 1;
|
|
}
|
|
|
|
if (drifted.length > 0) {
|
|
console.error(`FAIL compiled-artifact-sync: ${REASON.DRIFTED}`);
|
|
for (const d of drifted) {
|
|
console.error(` ${d.artifact} (${d.committed} bytes) != compile of ${d.source} (${d.expected} bytes)`);
|
|
}
|
|
console.error('');
|
|
console.error('The committed artifact is what ships to anyone reading the repo without');
|
|
console.error('building, so it must match its source. Fix with:');
|
|
console.error(' npm run build:lib && git add ' + drifted.map((d) => d.artifact).join(' '));
|
|
console.error('');
|
|
console.error('Alternatively, per ADR-457 these artifacts are meant to be gitignored —');
|
|
console.error('untracking them (git rm --cached + .gitignore) also resolves this.');
|
|
return 1;
|
|
}
|
|
|
|
console.log(`ok compiled-artifact-sync: ${pairs.length} tracked artifact(s) match their source`);
|
|
return 0;
|
|
}
|
|
|
|
if (require.main === module) process.exitCode = main();
|
|
|
|
module.exports = { REASON, trackedCompiledArtifacts };
|