Files
msd-core/tests/context-drift.test.cjs
Tom Boucher 4dfc46bbe7 enhance(#3348): add a context-drift pre-check gate to plan-phase (#4147)
* test(#3348): add failing-first coverage for the context-drift gate

* feat(#3348): add context-drift pre-check gate for plan-phase

Compares each phase's *-RESEARCH.md/*-PATTERNS.md/*-VALIDATION.md/*-SPEC.md
effective last-changed time (git commit time, falling back to mtime for
uncommitted edits) against *-CONTEXT.md's, so plan-phase no longer silently
reuses an upstream artifact that predates a decision added to CONTEXT.md
after that artifact was derived from it. Deterministic, no model call.

New `gsd_run verify context-drift <phase>` command, sibling to the existing
verify.codebase-drift/verify.schema-drift gates in the drift capability.
Warn-only by default (workflow.context_drift_precheck), with an opt-in
workflow.context_drift_action: block escape hatch. Wired at plan:pre in
plan-phase.md, before both the RESEARCH.md and PATTERNS.md reuse decisions.

* fix(#3348): address code-review findings — raw-text-match, stale comment, import placement, duplicated phase resolution

* fix(#3859): pin the real commit's diff.ignoreSubmodules to match the empty-diff probe

The #3859 empty-diff guard decides whether a submodule bump would land using
`--ignore-submodules=dirty`, overriding the caller's `diff.ignoreSubmodules`
config. The real `git commit -- <paths>` that follows was never given the
same override, so under a bare `diff.ignoreSubmodules=all` repo config the
two calculations disagree: driven on git 2.39.5 (Debian bookworm, the
linux-node24 test-matrix image), the guard correctly stands aside but the
scoped commit itself then silently fails (exit 1, no error text) for a
gitlink bump it had just confirmed would be recorded, surfacing as
commit_failed instead of committed:true.

Pin `-c diff.ignoreSubmodules=dirty` onto the scoped commit call too, so the
probe and the commit it protects can never diverge. Harmless when no
submodule path is involved (driven: identical outcome on an ordinary scoped
file, with and without the flag).

* fix(#3348): guard resolvePhaseDirByToken's exact-match fallback against path traversal

* fix(#3348): retarget phase-enumeration-drift exemption to the consolidated resolvePhaseDirByToken helper

cmdVerifySchemaDrift's inline readdirSync was already function-scoped-exempt
in lint-phase-enumeration-drift.cjs as a single-phase LOOKUP (not a
current-milestone enumeration). This PR's refactor pass lifted that block
into a shared helper, resolvePhaseDirByToken, also used by the new
cmdVerifyContextDrift — the guard tracks exemptions by enclosing function
name, so the readdirSync now lives in an unexempted function and started
firing. Move the exemption to resolvePhaseDirByToken (same written reason,
now covering both callers) instead of migrating to listAllPhaseDirs, which
would introduce two real behavior deltas here: it catches readdirSync
failures internally (old code let them throw) and sorts results by phase
number before matchPhaseDirs picks matches[0] (old code used raw,
OS-dependent readdirSync order).

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

* fix(#3348): satisfy lint:ci — slash form, capability registry regen

- docs/features/context-drift-gate.md used the deprecated /gsd: colon
  form; docs are never passed through the install-time slash-form
  converters, so lint-docs-command-form requires the hyphen form.
  Regenerated docs/FEATURES.md from the corrected fragment.
- Regenerated gsd-core/bin/lib/capability-registry.cjs after editing
  capabilities/drift/capability.json (lint:generated-sync).

* fix(#3859): pin the real commit's diff.ignoreSubmodules via env, not argv -c

The prior fix pinned `-c diff.ignoreSubmodules=dirty` onto the scoped commit's
argv via `commitArgs.unshift(...)`. `-c key=val` must precede the `commit`
subcommand, so this shifted `commitArgs[0]` from `'commit'` to `'-c'` for
every scoped commit call, breaking 17 position-based assertions in the
commit-files pathspec regression suite that read `a[0] === 'commit'` to find
the commit invocation among recorded git calls.

`execGit` already accepts an `env` option merged onto `process.env` before
spawning. Git honors `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/`GIT_CONFIG_VALUE_0`
as a per-invocation config override functionally identical to `-c key=val`,
expressed via env instead of argv. Passing that env alongside the existing
commitArgs (still `['commit', ..., '--', ...stagedPaths]`, argv unchanged)
fixes the real commit's effective diff.ignoreSubmodules to match the
empty-diff guard's probe without moving anything in argv position 0. Scoped
to exactly the canScope branch, matching the probe's own preconditions and
leaving no behavior change for commits the probe never evaluated.

No test file changes needed — the 17 previously-failing assertions test
argv[0] against the array passed into execGit, which never changes.

* fix(#3348): register verify-context-drift in the check subcommand router

The drift capability's new plan:pre gate declares check.query
"verify.context-drift", which normalizes to `check verify-context-drift`,
but no such subcommand was routed — phase6-capstone-conformance's
uniform-block-field test failed with "Unknown check subcommand" for
every declared gate query.

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

* fix(#3348): extend #1592's exact-key-list snapshot for the new context-drift config keys

tests/capability-registry.test.cjs asserted an exact, hardcoded snapshot
of the drift capability's config keys. #3348 legitimately adds two new
keys (workflow.context_drift_precheck, workflow.context_drift_action)
for its own plan:pre context-drift gate — extend the expected set
(and clarify the assertion message) without weakening the test's
exactness.

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

* fix(#3348): reconcile E2's exemption-migration pin with the resolvePhaseDirByToken extraction

#3348 (an earlier commit on this branch, e4b80ad81) extracted
cmdVerifySchemaDrift's inline phasesDir readdirSync/matchPhaseDirs block
into the shared resolvePhaseDirByToken helper (also used by the new
cmdVerifyContextDrift), and retargeted lint-phase-enumeration-drift.cjs's
function-scoped exemption from cmdVerifySchemaDrift to
resolvePhaseDirByToken accordingly — cmdVerifySchemaDrift no longer
contains a line the guard's detectors match, so it needs no exemption.

tests/phase-locator.test.cjs's E2 test still pinned the exemption to the
old name (cmdVerifySchemaDrift), unaware of the migration. Update E2 to
match the same "migrated call site's exemption must move, not
duplicate" pattern the test already applies to cmdRoadmapAnalyze and
cmdInitMilestoneOp just below it: drop cmdVerifySchemaDrift from the
still-exempt list and add symmetric assertions that it no longer
carries the exemption while resolvePhaseDirByToken now does.

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

* fix(#3348): fix two self-contradicting/nondeterministic tests in context-drift.test.cjs

'always exits 0 (query command contract)' included the no-phase-arg
case, which contradicts the file's own earlier
'errors with usage message on missing phase arg' test (that case
legitimately exits 1 via the Usage error) — drop it from the
always-exits-0 cases.

'degrades to mtime comparison outside a git repo' and '...in a repo
with no commits' relied on real wall-clock ordering between two
back-to-back writeFileSync calls to prove CONTEXT.md is newer than
RESEARCH.md; on a fast filesystem both can land in the same mtime
tick, producing a tie that computeContextDrift's strict `<` correctly
treats as not-stale, so stale_artifacts comes back empty. Make both
tests deterministic via explicit fs.utimesSync instead of relying on
timing (CONTRIBUTING.md: never assert elapsed wall-clock time).

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

* fix(#3348): add context_drift_precheck:false to the plan:pre all-off fixture

The "all plan:pre when-keys false" fixture explicitly disables every
known workflow.* plan:pre toggle, but didn't yet know about the new
workflow.context_drift_precheck key (defaults to true), so the new
drift context-drift gate stayed active and broke the
empty-activeHooks assertion.

Emitted-Drift-Ack-Growth: plan-phase.md — adds the #3348 context-drift plan:pre pre-check section (new ## 4.6); this PR's own diff, not incidental drift.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3348): backfill changeset PR number (pr:0 -> 4147)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:39:37 -04:00

376 lines
17 KiB
JavaScript

'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempGitProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const VERIFY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs');
const { computeContextDrift } = require(VERIFY_PATH);
describe('computeContextDrift', () => {
test('returns no stale artifacts when there is nothing to compare', () => {
assert.deepStrictEqual(computeContextDrift(1000, []), []);
});
test('treats a newer upstream artifact as fresh', () => {
const stale = computeContextDrift(1000, [{ file: '01-RESEARCH.md', effectiveMs: 2000 }]);
assert.deepStrictEqual(stale, []);
});
test('flags an upstream artifact older than CONTEXT.md', () => {
const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 1000 }]);
assert.deepStrictEqual(stale, ['01-RESEARCH.md']);
});
test('reports exactly the stale subset, not all entries', () => {
const stale = computeContextDrift(2000, [
{ file: '01-RESEARCH.md', effectiveMs: 1000 },
{ file: '01-PATTERNS.md', effectiveMs: 3000 },
{ file: '01-VALIDATION.md', effectiveMs: 500 },
]);
assert.deepStrictEqual(stale, ['01-RESEARCH.md', '01-VALIDATION.md']);
});
test('treats an equal timestamp as not stale (strict greater-than)', () => {
const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 2000 }]);
assert.deepStrictEqual(stale, []);
});
test('flags an artifact exactly one second (1000ms) older', () => {
const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 1000 }]);
assert.deepStrictEqual(stale, ['01-RESEARCH.md']);
});
test('treats an artifact exactly one second (1000ms) newer as fresh', () => {
const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 3000 }]);
assert.deepStrictEqual(stale, []);
});
test('handles an empty entries array without throwing', () => {
assert.doesNotThrow(() => computeContextDrift(0, []));
});
test('does not throw on a zero or negative timestamp', () => {
assert.deepStrictEqual(computeContextDrift(0, [{ file: 'a.md', effectiveMs: -5 }]), ['a.md']);
assert.deepStrictEqual(computeContextDrift(-5, [{ file: 'a.md', effectiveMs: 0 }]), []);
});
});
describe('verify context-drift CLI', () => {
let tmp;
beforeEach(() => {
tmp = createTempGitProject('gsd-context-drift-cli-');
});
afterEach(() => cleanup(tmp));
function phaseDirPath(name) {
return path.join(tmp, '.planning', 'phases', name);
}
test('errors with usage message on missing phase arg', () => {
const r = runGsdTools(['verify', 'context-drift'], tmp);
assert.strictEqual(r.success, false);
assert.match(r.error || '', /Usage: verify context-drift <phase>/);
});
test('treats an empty phase arg as missing', () => {
const r = runGsdTools(['verify', 'context-drift', ''], tmp);
assert.strictEqual(r.success, false);
assert.match(r.error || '', /Usage: verify context-drift <phase>/);
});
test('treats a whitespace phase arg as not found, not a usage error', () => {
const r = runGsdTools(['verify', 'context-drift', ' '], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'phase-not-found');
});
test('degrades gracefully for an unresolvable phase', () => {
const r = runGsdTools(['verify', 'context-drift', '99'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'phase-not-found');
assert.strictEqual(data.block, false);
});
test('does not interpret shell metacharacters in the phase arg', () => {
const r = runGsdTools(['verify', 'context-drift', '1; echo pwned'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'phase-not-found');
});
test('does not path-traverse via a hostile phase arg', () => {
const r = runGsdTools(['verify', 'context-drift', '../../etc'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'phase-not-found');
});
test('does not escape phasesDir via a deep traversal payload that would otherwise resolve to a real path', () => {
// Deep enough that path.join's .. collapsing would reach outside the temp
// sandbox entirely (unlike a shallow '../../etc', which lands harmlessly
// inside the sandbox as a nonexistent path and would pass for the wrong
// reason). validatePath must reject this by real-path containment, not by
// accidental non-existence.
const r = runGsdTools(['verify', 'context-drift', '../../../../../../../../../../etc'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'phase-not-found');
});
test('skips when no CONTEXT.md exists', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n');
const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'no-context-md');
assert.strictEqual(data.block, false);
});
test('skips when no upstream artifacts exist', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n');
const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'no-upstream-artifacts');
});
test('excludes AI-SPEC.md and UI-SPEC.md from the SPEC.md comparison', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n');
fs.writeFileSync(path.join(dir, '01-AI-SPEC.md'), '# ai spec\n');
fs.writeFileSync(path.join(dir, '01-UI-SPEC.md'), '# ui spec\n');
const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.reason, 'no-upstream-artifacts');
});
test('degrades to mtime comparison outside a git repo', () => {
const plain = createTempDir('gsd-context-drift-nogit-');
try {
const dir = path.join(plain, '.planning', 'phases', '01-setup');
fs.mkdirSync(dir, { recursive: true });
// Deterministic mtimes (CONTRIBUTING.md: never assert elapsed wall-clock
// time) — two back-to-back writeFileSync calls can land in the SAME
// mtime granularity tick on a fast filesystem, producing a tie that
// computeContextDrift's strict `<` correctly treats as not-stale. Set
// distinct mtimes explicitly instead of relying on real timing.
const now = Date.now();
fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n');
fs.utimesSync(path.join(dir, '01-RESEARCH.md'), new Date(now - 5000), new Date(now - 5000));
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n');
fs.utimesSync(path.join(dir, '01-CONTEXT.md'), new Date(now), new Date(now));
const r = runGsdTools(['verify', 'context-drift', '01-setup'], plain);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, false);
assert.deepStrictEqual(data.stale_artifacts, ['01-RESEARCH.md']);
} finally {
cleanup(plain);
}
});
test('degrades to mtime comparison in a repo with no commits', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
// Deterministic mtimes — see the identical rationale in 'degrades to mtime
// comparison outside a git repo' above.
const now = Date.now();
fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n');
fs.utimesSync(path.join(dir, '01-RESEARCH.md'), new Date(now - 5000), new Date(now - 5000));
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n');
fs.utimesSync(path.join(dir, '01-CONTEXT.md'), new Date(now), new Date(now));
const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, false);
assert.deepStrictEqual(data.stale_artifacts, ['01-RESEARCH.md']);
});
test('fresh RESEARCH.md (committed after CONTEXT.md) is not flagged', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\nD-01\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'context'], { cwd: tmp });
fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'research'], { cwd: tmp });
const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, false);
assert.deepStrictEqual(data.stale_artifacts, []);
assert.strictEqual(data.block, false);
});
test('uses mtime, not a stale commit time, for a dirty CONTEXT.md', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\nD-01\n');
fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'context+research'], { cwd: tmp });
fs.appendFileSync(path.join(dir, '01-CONTEXT.md'), 'D-02\n');
const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, false);
assert.deepStrictEqual(data.stale_artifacts, ['01-RESEARCH.md']);
});
test('#3348 regression: uncommitted new decisions flag existing RESEARCH and PATTERNS as stale', () => {
const dir = phaseDirPath('03-feature');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\nD-01\nD-02\n...\nD-09\n');
fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research from D-01..D-09\n');
fs.writeFileSync(path.join(dir, '03-PATTERNS.md'), '# patterns from D-01..D-09\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'phase 3: context, research, patterns'], { cwd: tmp });
fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-10\nD-11\nD-12\nD-13\n');
const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, false);
assert.deepStrictEqual(
data.stale_artifacts.slice().sort(),
['03-PATTERNS.md', '03-RESEARCH.md'],
);
});
test('defaults to warn when config.json is absent', () => {
const dir = phaseDirPath('03-feature');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n');
fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'research'], { cwd: tmp });
fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n');
assert.ok(!fs.existsSync(path.join(tmp, '.planning', 'config.json')));
const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.action, 'warn');
assert.strictEqual(data.block, false);
});
test('defaults to warn when config.json is malformed', () => {
const dir = phaseDirPath('03-feature');
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), '{ not valid json');
fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n');
fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'research'], { cwd: tmp });
fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n');
const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.action, 'warn');
assert.strictEqual(data.block, false);
});
test('falls back to warn for an unrecognized context_drift_action value', () => {
const dir = phaseDirPath('03-feature');
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_drift_action: 'yolo' } }),
);
fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n');
fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'research'], { cwd: tmp });
fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n');
const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.action, 'warn');
assert.strictEqual(data.block, false);
});
test('sets block:true when context_drift_action is block and drift is found', () => {
const dir = phaseDirPath('03-feature');
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_drift_action: 'block' } }),
);
fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n');
fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'research'], { cwd: tmp });
fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n');
const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.action, 'block');
assert.strictEqual(data.block, true);
});
test('never blocks when nothing is stale, even with action:block', () => {
const dir = phaseDirPath('03-feature');
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { context_drift_action: 'block' } }),
);
fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'context'], { cwd: tmp });
fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n');
gitOrThrow(['add', '.'], { cwd: tmp });
gitOrThrow(['commit', '-m', 'research'], { cwd: tmp });
const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.deepStrictEqual(data.stale_artifacts, []);
assert.strictEqual(data.block, false);
});
test('honors --raw', () => {
const dir = phaseDirPath('01-setup');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n');
const r = runGsdTools(['verify', 'context-drift', '01-setup', '--raw'], tmp);
assert.strictEqual(r.success, true, r.error);
});
test('always exits 0 (query command contract)', () => {
// Only cases that are legitimately part of the "always exits 0" JSON-output
// contract belong here — a missing phase arg is a DIFFERENT, already-covered
// contract ('errors with usage message on missing phase arg' above correctly
// asserts exitCode !== 0 / r.success === false for exactly that case).
const cases = [
['verify', 'context-drift', '99'],
];
for (const args of cases) {
const r = runGsdTools(args, tmp);
assert.strictEqual(r.exitCode, 0, `args=${JSON.stringify(args)} exitCode=${r.exitCode}`);
}
});
});