* 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>
376 lines
17 KiB
JavaScript
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}`);
|
|
}
|
|
});
|
|
});
|