* fix(#4488): report state update as successful when the value is already correct `cmdStateUpdate` unconditionally overwrote `updateCore`'s own `updated:true` signal with `reconcileReportedFields`'s disk-diff result. That diff reports `[]` -- by design -- whenever `readModifyWriteStateMd`'s #948 no-op guard fires because the transform's output was byte-identical to the input, which happens precisely when the requested value already equals what's on disk. The field genuinely was found and matched; there was simply nothing left to change. Collapsing that into the same `false`/"not found" response as a genuine miss produced an actively wrong diagnostic message and a silent same-day no-op in gsd-ship + gsd-extract-learnings, which both write `Last Activity` to today's date. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4488): backfill changeset pr number to 4581 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4488): untrack tdd-red-evidence.cjs, completing its ADR-457 gitignore migration Bundled discovery from this PR's own CI run: tests/lint-compiled-artifact- sync.test.cjs's full tsc compile (which runs whenever ANY compiled artifact remains tracked) SIGTERM'd under shard contention. gsd-core/bin/lib/tdd-red- evidence.cjs (introduced by #3770/PR #4279) was the sole remaining tracked artifact -- a tenth, later, separate instance of the #2657/#2653 migration-gap defect class this test file's closed nine-item list doesn't cover. Untracked it and added the .gitignore entry, same fix shape as the original nine. This eliminates the slow tsc-compile path entirely (verified: 0.1s vs ~7s locally) rather than papering over a timeout. Added a generic regression test asserting the tracked set is fully empty. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
11 KiB
JavaScript
236 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression test for #2657.
|
|
*
|
|
* Nine compiled `.cjs` artifacts under gsd-core/bin/lib/ were tracked in git
|
|
* despite each having a matching src/*.cts source, violating ADR-457's
|
|
* build-at-publish contract ("bin/lib/*.cjs" must be a gitignored build
|
|
* artifact, never checked-in source of truth). A tracked compiled artifact
|
|
* can silently drift from its source without anyone noticing — #2653
|
|
* demonstrated exactly this for api-coverage.cjs, which shipped four days
|
|
* behind its .cts with CI green throughout.
|
|
*
|
|
* This asserts the ADR-457 end state for all nine: none tracked, all
|
|
* gitignored, and the regime-agnostic sync guard (added in #2656,
|
|
* scripts/lint-compiled-artifact-sync.cjs) reports the empty tracked set.
|
|
*
|
|
* Two of the nine (markdown-table.cjs, write-set.cjs) already had a
|
|
* .gitignore pattern before this fix (added by #2248) but were never
|
|
* `git rm --cached`; the other seven had no .gitignore pattern at all. Both
|
|
* gaps produce the same `git ls-files` symptom, so both are covered by the
|
|
* same assertions here.
|
|
*
|
|
* ── Diagnostics discipline ────────────────────────────────────────────────
|
|
* Every git invocation below uses `spawnSync` (never throws) and every
|
|
* assertion explicitly checks the exit status BEFORE interpreting output.
|
|
* A git command that errors (bad cwd, dubious-ownership refusal, missing
|
|
* binary, anything) must never be silently read as a legitimate "not
|
|
* ignored" / "still tracked" answer — that conflates "the property does not
|
|
* hold" with "I could not determine whether the property holds", which is a
|
|
* distinct defect from the bug this file guards against. On any failure,
|
|
* the assertion message includes the resolved cwd, exit status, and stderr,
|
|
* so a red run is self-diagnosing without a second round-trip.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const { runGit, runNode, OUTCOME } = require('./helpers/process-seam.cjs');
|
|
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const { trackedCompiledArtifacts } = require('../scripts/lint-compiled-artifact-sync.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const LIB_DIR = 'gsd-core/bin/lib';
|
|
|
|
const NINE_ARTIFACTS = [
|
|
'api-coverage.cjs',
|
|
'assumption-delta.cjs',
|
|
'claude-orchestration-command-router.cjs',
|
|
'claude-orchestration.cjs',
|
|
'external-job.cjs',
|
|
'markdown-table.cjs',
|
|
'runtime-artifact-install-plan.cjs',
|
|
'state-transition.cjs',
|
|
'write-set.cjs',
|
|
].map((name) => `${LIB_DIR}/${name}`);
|
|
|
|
/**
|
|
* Run a command via the process seam (never throws) and return a legacy
|
|
* `{status, stdout, stderr, signal}` shape. `cmd` is either `'git'` (routed
|
|
* through `runGit`) or `process.execPath` (routed through `runNode`) — the
|
|
* only two callers below. Throws immediately, with full context, only on a
|
|
* genuine spawn failure (binary not found, etc.) — a condition no caller
|
|
* here can meaningfully interpret as a match/no-match answer.
|
|
*/
|
|
function run(cmd, args, opts) {
|
|
const options = { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS, ...opts };
|
|
const result = cmd === 'git' ? runGit(args, options) : runNode(args, options);
|
|
if (result.outcome === OUTCOME.SPAWN_FAILED) {
|
|
throw new Error(
|
|
`${cmd} ${args.join(' ')} failed to spawn (cwd=${REPO_ROOT}): ${result.stderr || result.code}`,
|
|
);
|
|
}
|
|
return { ...toLegacyResult(result), signal: result.signal };
|
|
}
|
|
|
|
/** Render a failed command's full context for an assertion message. */
|
|
function describeFailure(cmd, args, result) {
|
|
return (
|
|
`${cmd} ${args.join(' ')} (cwd=${REPO_ROOT}) exited ${result.status}` +
|
|
(result.signal ? ` (signal ${result.signal})` : '') +
|
|
`\n stderr: ${(result.stderr || '(empty)').trim()}` +
|
|
`\n stdout: ${(result.stdout || '(empty)').trim()}`
|
|
);
|
|
}
|
|
|
|
function git(args) {
|
|
// -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently
|
|
// owned by a different uid than the one running node --test, 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), matching the same fix applied to
|
|
// scripts/lint-compiled-artifact-sync.cjs's own git() helper, which has
|
|
// the identical defect (#2657 diagnostic run: `git ls-files` there failed
|
|
// with the same "dubious ownership" fatal in the runner).
|
|
return run('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args]);
|
|
}
|
|
|
|
/** `git ls-files <LIB_DIR>`, asserting success before trusting the output. */
|
|
function trackedLibFiles() {
|
|
const args = ['ls-files', LIB_DIR];
|
|
const result = git(args);
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`git ls-files must exit 0 before its output can be trusted as "nothing tracked":\n${describeFailure('git', args, result)}`,
|
|
);
|
|
return new Set(result.stdout.split('\n').filter(Boolean));
|
|
}
|
|
|
|
/**
|
|
* `git check-ignore -q <path>` has exactly two legitimate outcomes: exit 0
|
|
* (ignored) and exit 1 (not ignored) — check-ignore(1). Any other exit code
|
|
* or a signal is an infrastructure failure, not a "not ignored" answer, and
|
|
* must not be conflated with one.
|
|
*/
|
|
function isIgnored(artifactPath) {
|
|
const args = ['check-ignore', '-q', artifactPath];
|
|
const result = git(args);
|
|
if (result.status === 0) return true;
|
|
if (result.status === 1) return false;
|
|
throw new Error(
|
|
`git check-ignore for ${artifactPath} returned neither a match (0) nor a legitimate ` +
|
|
`no-match (1) exit code — this is an infrastructure failure, not evidence the path ` +
|
|
`is unignored:\n${describeFailure('git', args, result)}`,
|
|
);
|
|
}
|
|
|
|
// Shared shape for both "none of the nine should still be in state X" checks
|
|
// below: derive the still-bad subset via `isBad`, then assert it's empty.
|
|
function assertNoneStillBad(isBad, failureLabel) {
|
|
const stillBad = NINE_ARTIFACTS.filter(isBad);
|
|
assert.deepEqual(
|
|
stillBad,
|
|
[],
|
|
`expected none of the nine ${failureLabel}; still: ${stillBad.join(', ') || '(none)'}`,
|
|
);
|
|
}
|
|
|
|
describe('fix-2657: compiled .cjs artifacts are gitignored, not tracked (ADR-457)', () => {
|
|
test('none of the nine ADR-457 migration-gap artifacts are tracked by git', () => {
|
|
const tracked = trackedLibFiles();
|
|
assertNoneStillBad((p) => tracked.has(p), 'to be tracked');
|
|
});
|
|
|
|
test('every one of the nine paths is ignored per git', () => {
|
|
// Deliberately WITHOUT --no-index: git-check-ignore(1) operates on the
|
|
// pathname alone and does not require the file to exist on disk (true
|
|
// both with and without --no-index — this repo's gsd-test runner checks
|
|
// out a fresh shallow clone per sha, where an untracked, gitignored path
|
|
// exists as a pattern match only, never as a file on disk). Plain
|
|
// check-ignore is preferred here over --no-index specifically because it
|
|
// also honors git's "a still-TRACKED path is never reported ignored"
|
|
// rule (check-ignore(1)) — which is exactly the property under test: a
|
|
// path that still matches a .gitignore pattern while ALSO remaining
|
|
// tracked (the pre-fix state for two of the nine, whose pattern
|
|
// predates this fix per #2248) must still read as "not ignored," the
|
|
// same as the seven with no pattern at all. --no-index would blur that
|
|
// distinction by reporting the two as ignored regardless of tracking.
|
|
assertNoneStillBad((p) => !isIgnored(p), 'to be reported not-ignored by git');
|
|
});
|
|
|
|
test('trackedCompiledArtifacts() reports the ADR-457 empty-set end state for the nine', () => {
|
|
let pairs;
|
|
try {
|
|
pairs = trackedCompiledArtifacts();
|
|
} catch (err) {
|
|
// trackedCompiledArtifacts() (scripts/lint-compiled-artifact-sync.cjs)
|
|
// wraps its OWN internal `git ls-files gsd-core/bin/lib` call, with cwd
|
|
// resolved from that script's own __dirname (should equal REPO_ROOT
|
|
// here regardless of caller). A throw means THAT invocation failed —
|
|
// not that any artifact is still tracked. Surface it, don't mask it.
|
|
assert.fail(
|
|
`trackedCompiledArtifacts() threw instead of returning a result — this indicates its ` +
|
|
`internal git invocation failed, not that any of the nine is still tracked:\n` +
|
|
`${err && err.stack ? err.stack : err}`,
|
|
);
|
|
}
|
|
const stillPresentArtifacts = new Set(pairs.map((p) => p.artifact));
|
|
assertNoneStillBad((p) => stillPresentArtifacts.has(p), 'to appear in trackedCompiledArtifacts()');
|
|
});
|
|
|
|
test('lint-compiled-artifact-sync exits 0 with nothing left to check', () => {
|
|
// #4093 CI: this spawn is NOT the PROBE class the shared default below
|
|
// describes. The script's "nothing left to check" path still runs a FULL
|
|
// `tsc -p tsconfig.build.json` compile to a throwaway outDir whenever any
|
|
// compiled artifact remains tracked, and under CI shard load that compile
|
|
// can exceed even a generous budget, dying to a SIGTERM with empty piped
|
|
// stdout (observed on ubuntu shard 1/3 both pre- and post-#4488). Per
|
|
// helpers/timeouts.cjs's own rule, a call site that genuinely differs from
|
|
// its class — "a real `tsc` compile" — keeps its own local constant with
|
|
// its own justifying comment; see tests/ensure-runtime-build.test.cjs's
|
|
// BUILD_TIMEOUT_MS for the other instance. 60s is that same class, sized
|
|
// for the cold-cache CI case.
|
|
//
|
|
// #4488: as of this fix the tracked set is fully empty (see the
|
|
// "fully-empty ADR-457 end state" test above) — trackedCompiledArtifacts()
|
|
// returning [] means main() short-circuits at its own line ~98 BEFORE ever
|
|
// invoking `compileToTemp()`, so this spawn no longer runs a real tsc
|
|
// compile at all. The 60s budget above stays as a guard for whenever a
|
|
// future module reintroduces a tracked artifact (re-widening the migration
|
|
// gap this whole file exists to catch), not because this run needs it now.
|
|
const TSC_COMPILE_TIMEOUT_MS = 60000;
|
|
const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')];
|
|
const result = run(process.execPath, args, { timeoutMs: TSC_COMPILE_TIMEOUT_MS });
|
|
assert.equal(result.status, 0, describeFailure(process.execPath, args, result));
|
|
});
|
|
|
|
test('trackedCompiledArtifacts() reports the fully-empty ADR-457 end state (#4488)', () => {
|
|
// #4488 CI: tdd-red-evidence.cjs (introduced by #3770/PR #4279, AFTER the
|
|
// original "nine" this file's other tests pin) was never gitignored —
|
|
// a tenth, later instance of the exact #2657/#2653 migration-gap defect
|
|
// class, incidentally surfaced by a #4488 CI run timing out on the real
|
|
// tsc compile this test's sibling above must run while ANY artifact stays
|
|
// tracked. Generic (not tied to the closed NINE_ARTIFACTS list above) so
|
|
// it also catches any future module that lands compiled-but-tracked.
|
|
let pairs;
|
|
try {
|
|
pairs = trackedCompiledArtifacts();
|
|
} catch (err) {
|
|
assert.fail(
|
|
`trackedCompiledArtifacts() threw instead of returning a result:\n` +
|
|
`${err && err.stack ? err.stack : err}`,
|
|
);
|
|
}
|
|
assert.deepEqual(
|
|
pairs,
|
|
[],
|
|
`expected zero tracked compiled artifacts (ADR-457 end state); still tracked: ` +
|
|
`${pairs.map((p) => p.artifact).join(', ') || '(none)'}`,
|
|
);
|
|
});
|
|
});
|