* test(#3333): fold the runtime & install surface fix-* cluster — Wave 1 Folds 11 legacy tests/fix-*.test.cjs regression files into their module's main test suite: 6 folded into existing suites (host-integration-descriptors, effort-surface-axis, trae-imperative-reference, hermes-skills-migration, gsd-agent-isolation-guard), 5 renamed to become the module's sole suite (cursor-hook-workspace-roots, cursor-subagent-isolation, lint-compiled-artifact-sync, hooks-commonjs-marker, shared-hooks-dir-resolution). All 195 test() blocks preserved with zero drops; lint-test-file-count.cjs and eslint remain clean. No production code changed. Wave 1 of 7 in #3315 (H3 of epic #3053). * test(#3333): replace try/finally with t.after() in isolation-guard tests CONTRIBUTING.md bans try/finally inside test bodies (masks failures, not an approved pattern). The fold in the prior commit carried 27 instances forward verbatim from the deleted fix-3045-dispatch-isolation-resolver.test.cjs into an otherwise-clean file. Converts each to the approved per-test t.after() cleanup pattern — same cleanup call, registered instead of finally-wrapped. No assertion, fixture, or test-name change; test( count unchanged at 50. Found by the Standards review pass on Wave 1 (#3333, H3 of epic #3053). * fix(#3333): restore raw NUL byte mangled by the fold in hermes-skills-migration.test.cjs The prior fold commit copied fix-2284-hermes-agent-delegate-task-projection's "collision-robust" test via a text-based Read/Write pipeline, which silently turned a raw NUL byte (0x00) embedded in two string literals into a regular space character. That corrupted the test's actual purpose (proving a NUL byte survives a string-rewrite operation untouched) and produced a genuine gsd-test failure: `24 !== 1` for `out.split(' ').length`, because splitting on a space finds every space in the sentence instead of the single NUL byte the test meant to isolate. Root-caused by diffing the raw bytes (via `git cat-file blob` + `cat -v`) between the pre-fold source and the folded target — confirmed exactly two bytes differ. Restored via a byte-precise patch (latin1 round-trip) touching only those two lines; test( count and every other byte unchanged. * fix(#3333): use \x00 escape sequence instead of a raw NUL byte in test fixture The prior commit restored a byte-exact raw NUL byte matching the original fix-2284 source, and the production function (applyClaudeCodeBrandSwap) was confirmed correct in a standalone repro. But the same raw byte still failed through gsd-test's remote pipeline. Root cause is upstream of gsd-core: some step in that transfer path does not carry a raw 0x00 byte through untouched. A raw embedded NUL byte was never necessary here — `\x00` as a 4-character escape sequence in the source text produces the identical runtime character (U+0000) without ever putting a raw byte in the tracked file, sidestepping any byte-oriented transfer step. Applied at both call sites (the fixture string and the split() delimiter). No behavior change; test( count unchanged at 76. * fix(#3333): harden copyWithPathReplacement against a source file vanishing mid-copy (TOCTOU) Surfaced by this PR's own gsd-test run: tests/install-minimal-hooks.test.cjs and tests/opencode-command-dir-plural.test.cjs intermittently crashed with ENOENT reading gsd-core/workflows/zzz-e5-drift-fixture.md. Root cause is unrelated to test-file consolidation — tests/planning-prompt-drift.test.cjs writes that fixture directly into the real, shared gsd-core/workflows/ tree (main() hardcodes its scan root to the real repo) and deletes it in t.after(); copyWithPathReplacement's readdirSync-then-read loop has no protection against the listed file vanishing before it gets there, so a concurrently-running install path can crash entirely on what is otherwise a completely benign race. Fixed by skipping (not crashing on) a listed entry that no longer exists by the time the loop reaches it. Added a regression test that deterministically reproduces the race (readdirSync snapshot still lists the file; it is deleted immediately after) and proves both outcomes: no throw, and the vanished entry's destination is never partially written. Per CLAUDE.md's no-defer rule, a defect surfaced while verifying this PR is fixed inline rather than deferred — this overrides one-concern-per-PR. * fix(#3333): fix third NUL-byte-mangled occurrence missed by prior fix passes The fold originally mangled three raw-NUL-byte occurrences to spaces, not two — the earlier byte-restore and escape-sequence commits both only targeted the fixture string and the split() delimiter, missing out.includes('[ ]') a few lines below (should read out.includes('[\x00]')). A remote gsd-test run kept failing on this exact assertion even after both prior fixes, which is what surfaced the miss. Verified via a standalone repro using the file's real (not retyped) fixture content: all six assertions in the collision-robust test now pass. Zero raw NUL bytes remain in the file; test( count unchanged at 76. * chore(#3333): add changeset for the copyWithPathReplacement TOCTOU fix Fixed-type fragment for the production defect fixed inline in this PR (bin/install.js's copyWithPathReplacement). Exempt from docs/ requirements per CONTRIBUTING.md (only Added/Changed/Deprecated/Removed require it). * chore(#3333): backfill changeset PR number (pr:0 -> pr:3341) --------- Co-authored-by: sim <sim@local>
191 lines
8.8 KiB
JavaScript
191 lines
8.8 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', () => {
|
|
const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')];
|
|
const result = run(process.execPath, args);
|
|
assert.equal(result.status, 0, describeFailure(process.execPath, args, result));
|
|
});
|
|
});
|