Files
msd-core/tests/planning-prompt-drift.test.cjs
Tom Boucher 03a3f779ce fix(#3640): isolate the drift cli e2e fixture via a --root override (#3722)
* test(#3640): failing-first --root isolation rows for the drift cli e2e

* fix(#3640): add --root override to the drift cli scan root

* fix(#3640): harden --root validation; typed exit-2 usage rows

---------

Co-authored-by: sim <sim@local>
2026-08-20 16:15:22 -04:00

449 lines
22 KiB
JavaScript

/**
* Tests for the prompt-layer plan/summary-COUNTING drift guard (epic #3180,
* ADR-3180 Decision 4(e)) — `scripts/lint-planning-prompt-drift.cjs`.
*
* Covers:
* - `findPromptDrift` — the per-line detection shape (a `*...PLAN.md` /
* `*...SUMMARY.md` set glob AND a counting operator on the same line),
* and its documented near-miss exclusions.
* - `diffAgainstBaseline` — the three ratchet invariants (known / fresh /
* stale), keyed on TEXT not line number, exercised on synthetic input.
* - `loadBaseline` / `scanRepo` against the real, committed repo state —
* the guard's actual contract.
*
* Uses fs.mkdtempSync directly for the one synthetic-tree fixture, matching
* the sibling drift-guard test suites' own drift-guard sections — cleaned
* up in `t.after()`, never a fixed path.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const drift = require('../scripts/lint-planning-prompt-drift.cjs');
const { findPromptDrift, scanRepo, loadBaseline, diffAgainstBaseline, toPosixRel, writeBaseline } = drift;
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const DRIFT_SCRIPT = path.join(REPO_ROOT, 'scripts', 'lint-planning-prompt-drift.cjs');
// ─── POSITIVE ───────────────────────────────────────────────────────────
describe('findPromptDrift — positive detection', () => {
test('X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l) is detected', () => {
const line = 'X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l)';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].found, '*-PLAN.md');
assert.strictEqual(out[0].text, line);
});
test('the *-SUMMARY.md variant is detected', () => {
const line = 'X=$(ls dir/*-SUMMARY.md 2>/dev/null | wc -l)';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].found, '*-SUMMARY.md');
});
test('a grep -c variant is detected', () => {
const line = "Y=$(grep -cE '^' dir/*-PLAN.md)";
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].found, '*-PLAN.md');
});
});
// ─── NEGATIVE — each with a comment saying WHY it must not fire ──────────
describe('findPromptDrift — negative: documented near-misses', () => {
test('grep -cE task-heading count inside ONE NAMED plan (no glob) is NOT detected', () => {
// A real line in gsd-core/workflows/execute-plan.md: it counts <task>
// elements INSIDE one already-named plan file — no `*` glob token
// anywhere near PLAN.md — so it is not a plan-COUNT re-derivation. A
// false positive here would redden lint:ci on an untouched file.
const line = "grep -cE '^\\s*<task[[:space:]>]' .planning/phases/[current-phase-dir]/{phase}-{plan}-PLAN.md";
const out = findPromptDrift(line, 'gsd-core/workflows/execute-plan.md');
assert.deepStrictEqual(out, []);
});
test('a *-UAT.md count is NOT detected', () => {
// UAT artifacts are a different derivation this guard does not own —
// PLAN_SUMMARY_GLOB_RE requires the literal PLAN.md or SUMMARY.md
// suffix, which "UAT.md" never satisfies.
const line = 'X=$(ls dir/*-UAT.md 2>/dev/null | wc -l)';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.deepStrictEqual(out, []);
});
test('a line that globs plan files but does not count them is NOT detected', () => {
// Reading/iterating (cat, backup, cross-reference) over a *-PLAN.md
// glob without a counting operator is not this derivation — every
// non-counting *-PLAN.md/*-SUMMARY.md glob in plan-phase.md is exactly
// this shape and is deliberately left alone.
const line = 'cat dir/*-PLAN.md';
const out = findPromptDrift(line, 'gsd-core/workflows/fake.md');
assert.deepStrictEqual(out, []);
});
});
// ─── RATCHET MECHANICS — diffAgainstBaseline on synthetic inputs ─────────
describe('diffAgainstBaseline — ratchet invariants (synthetic)', () => {
test('a violation whose (file, text) pair is in the baseline is KNOWN: neither fresh nor stale', () => {
const baseline = [{ file: 'a.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('a violation absent from the baseline is FRESH: fails', () => {
const baseline = [];
const violations = [
{ file: 'a.md', line: 1, found: '*-PLAN.md', text: 'Y=$(grep -c dir/*-PLAN.md)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].text, 'Y=$(grep -c dir/*-PLAN.md)');
assert.deepStrictEqual(stale, []);
});
test('a baseline entry matching nothing this run is STALE: fails', () => {
const baseline = [{ file: 'a.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' }];
const violations = [];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].text, 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)');
});
test('keying is on TEXT not line number: the same trimmed text at a different line is still KNOWN', () => {
// This is what stops the baseline rotting on an unrelated edit that
// merely shifts line numbers (a new paragraph, a reworded step).
const baseline = [{ file: 'a.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' }];
const violations = [
{ file: 'a.md', line: 999, found: '*-PLAN.md', text: 'X=$(ls *-PLAN.md 2>/dev/null | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
// ─── count-aware ratchet (Finding-3 fix): duplicate (file, text) pairs no
// longer make a partial migration invisible ───────────────────────────
test('a pair with count:2 fully matched by TWO occurrences is KNOWN: neither fresh nor stale', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 2 }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
{ file: 'a.md', line: 40, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('a pair with count:2 but only ONE occurrence this run is a PARTIAL-migration STALE, naming both numbers', () => {
// This is the exact defect Finding 3 closes: migrating only ONE of two
// byte-identical sites must not be invisible to the ratchet just because
// the OTHER site still matches the (file, text) pair.
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 2 }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].count, 2);
assert.strictEqual(stale[0].actualCount, 1);
});
test('a pair with count:2 and ZERO occurrences this run is fully STALE (both sites migrated)', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 2 }];
const { fresh, stale } = diffAgainstBaseline([], baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].actualCount, 0);
assert.strictEqual(stale[0].count, 2);
});
test('a pair with count:1 but a THIRD occurrence appears this run: the excess occurrence is FRESH (new copy)', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)', count: 1 }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
{ file: 'a.md', line: 55, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(stale, []);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].line, 55);
});
test('an entry with no `count` field defaults to acknowledging exactly ONE occurrence', () => {
const baseline = [{ file: 'a.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' }];
const violations = [
{ file: 'a.md', line: 10, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
{ file: 'a.md', line: 55, found: '*-PLAN.md', text: 'DISK_PLANS=$(ls *-PLAN.md | wc -l)' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(stale, []);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].line, 55);
});
});
// ─── scanRepo — tree-walk mechanics on a synthetic tree ───────────────────
describe('scanRepo — synthetic tree', () => {
test('a violation in a fresh temp tree is reported with its file, line, and text', (t) => {
const root = createTempDir('gsd-planning-prompt-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(
path.join(root, 'gsd-core', 'workflows', 'fake.md'),
'X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l)\n',
);
const violations = scanRepo(root);
assert.strictEqual(violations.length, 1);
// Always POSIX-separated regardless of the host OS's native separator
// (`path.join` would build native separators here, which is exactly the
// Windows-vs-POSIX mismatch this guard's baseline keying must not have —
// see the Windows-shaped-path coverage below).
assert.strictEqual(violations[0].file, 'gsd-core/workflows/fake.md');
assert.strictEqual(violations[0].line, 1);
assert.strictEqual(violations[0].found, '*-PLAN.md');
});
test('a clean temp tree with no re-derivations reports zero violations', (t) => {
const root = createTempDir('gsd-planning-prompt-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(root, 'gsd-core', 'workflows', 'clean.md'), 'no globs or counts here\n');
const violations = scanRepo(root);
assert.deepStrictEqual(violations, []);
});
});
// ─── WINDOWS PATH-SEPARATOR NORMALIZATION — the #3223 regression ─────────
//
// `scanTree` (scripts/lib/drift-scan.cjs) builds its repo-relative path via
// `path.relative()`, which uses NATIVE separators. On Windows that is
// `gsd-core\workflows\progress.md`, while the committed baseline
// (`scripts/baselines/planning-prompt-drift-baseline.json`) stores POSIX
// paths — an un-normalized Windows path silently fails to match ANY
// baseline entry, so every violation reports FRESH and every baseline entry
// reports STALE (a 100% guard failure on Windows, caught by GitHub Actions'
// Windows CI lane on PR #3223; the Linux-only remote runner this repo
// otherwise gates on cannot see this class at all).
//
// This coverage drives the pure functions with a Windows-shaped path
// directly — no mocking of the filesystem and NOT gated on
// `process.platform` — so it fails identically on every OS pre-fix and
// passes identically on every OS post-fix. Skipping it on non-Windows would
// recreate the exact blind spot that let this ship.
describe('Windows-shaped repo-relative paths are normalized to POSIX', () => {
const WINDOWS_REL = 'gsd-core\\workflows\\progress.md';
const POSIX_REL = 'gsd-core/workflows/progress.md';
const WINDOWS_LINE = 'X=$(ls dir/*-PLAN.md 2>/dev/null | wc -l)';
test('toPosixRel converts a Windows-shaped separator run to POSIX, and is a no-op on an already-POSIX path', () => {
assert.strictEqual(toPosixRel(WINDOWS_REL), POSIX_REL);
assert.strictEqual(toPosixRel(POSIX_REL), POSIX_REL);
});
test('findPromptDrift on a Windows-shaped relPath reports a POSIX `file`, regardless of input separator', () => {
const out = findPromptDrift(WINDOWS_LINE, WINDOWS_REL);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].file, POSIX_REL);
assert.ok(!out[0].file.includes('\\'), 'reported file must carry no backslashes');
});
test('a violation produced from a Windows-shaped path matches a POSIX baseline entry: classified KNOWN, not fresh and not stale', () => {
const baseline = [{ file: POSIX_REL, text: WINDOWS_LINE }];
const violations = findPromptDrift(WINDOWS_LINE, WINDOWS_REL);
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('a violation produced from a Windows-shaped path does NOT match if left un-normalized (sanity check the assertion above is meaningful)', () => {
// Same inputs as the previous test, but bypassing toPosixRel to prove the
// KNOWN classification above is actually exercising normalization, not a
// coincidence of the fixture.
const baseline = [{ file: POSIX_REL, text: WINDOWS_LINE }];
const violations = [{ file: WINDOWS_REL, line: 1, found: '*-PLAN.md', text: WINDOWS_LINE }];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(stale.length, 1);
});
test('--update (writeBaseline) serializes a POSIX `file` for a Windows-shaped input', (t) => {
const root = createTempDir('gsd-planning-prompt-drift-update-');
t.after(() => cleanup(root));
const violations = findPromptDrift(WINDOWS_LINE, WINDOWS_REL);
writeBaseline(root, violations);
const written = JSON.parse(fs.readFileSync(path.join(root, 'scripts', 'baselines', 'planning-prompt-drift-baseline.json'), 'utf8'));
assert.strictEqual(written.entries.length, 1);
assert.strictEqual(written.entries[0].file, POSIX_REL);
assert.ok(!written.entries[0].file.includes('\\'), 'written baseline entry must carry no backslashes');
});
});
// ─── BASELINE INTEGRITY — both directions, against the real repo ─────────
test('loadBaseline on the committed baseline returns ZERO entries — Phase 8 (#3218) burned the ratchet to zero', () => {
// Pre-#3218 this asserted 6 entries / 7 occurrences (the shell
// re-derivations). #3218 migrated all 7 sites to `gsd_run query find-phase`
// and, per ADR-3180 Decision 4(e), emptied the baseline rather than
// acknowledging them going stale — a stale entry left behind after its
// site migrates ALSO fails (see the "scanRepo matches the baseline
// exactly" test below), so an empty baseline is the only way this guard
// can be green on an EARNED zero rather than a baseline still covering
// sites that no longer fire.
const { entries, errors } = loadBaseline(REPO_ROOT);
assert.deepStrictEqual(errors, []);
assert.deepStrictEqual(entries, []);
});
test('scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale', () => {
// The guard's actual contract: every re-derivation this run finds is
// already acknowledged in the baseline, and every baseline entry still
// fires — no fresh, no stale, in either direction.
const violations = scanRepo(REPO_ROOT);
const { entries: baseline, errors } = loadBaseline(REPO_ROOT);
assert.deepStrictEqual(errors, []);
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
// ─── CLI end-to-end against an ISOLATED --root tree (#3640) ─────────────────
//
// This block REPLACES the original E5 shape, which wrote its fixture
// directly into the real, shared gsd-core/workflows/ because main() hardcoded
// its scan root — and any parallel consumer of the real tree could then
// observe the fixture mid-lifecycle (the emitted-provenance install baked it
// into all 19 manifests and later failed its existsSync pass once t.after()
// removed it; the #3333 TOCTOU ENOENT crash in copyWithPathReplacement was
// the same writer's first documented symptom). These rows drive the SAME
// real CLI, through the same exit path, with an explicit --root override
// pointing at an isolated temp tree — the guard's fail path is proven
// end-to-end with zero writes into the shared source tree.
describe('CLI end-to-end: --root scan-root override (#3640)', () => {
// The E5 fixture line, shared by every row that needs a violation present.
const DRIFT_FIXTURE_LINE = 'FIXTURE_COUNT=$(ls .planning/phases/zzz/*-PLAN.md 2>/dev/null | wc -l)\n';
// A scan-root skeleton: the SCAN_DIR tree main() walks plus a valid empty
// baseline, so the run exercises the real loadBaseline -> diff -> report
// path rather than erroring on a missing baseline.
function makeScanRoot(prefix) {
const root = createTempDir(prefix);
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.mkdirSync(path.join(root, 'scripts', 'baselines'), { recursive: true });
fs.writeFileSync(
path.join(root, 'scripts', 'baselines', 'planning-prompt-drift-baseline.json'),
`${JSON.stringify({ entries: [] }, null, 2)}\n`,
);
return root;
}
test('a fresh, unacknowledged plan-count re-derivation under --root exits 1 and names itself in stderr', (t) => {
const root = makeScanRoot('gsd-planning-prompt-drift-root-');
t.after(() => cleanup(root));
fs.writeFileSync(path.join(root, 'gsd-core', 'workflows', 'zzz-e5-drift-fixture.md'), DRIFT_FIXTURE_LINE);
const result = runNode([DRIFT_SCRIPT, '--root', root]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 1);
// The reported rel path and the echoed violation text are DATA, not
// formatter prose — the same two anchors the E5 block asserted on. The
// exit code carries the verdict; these anchors carry the WHICH.
assert.match(result.stderr, /gsd-core\/workflows\/zzz-e5-drift-fixture\.md/);
assert.match(result.stderr, /FIXTURE_COUNT=/);
});
test('a clean tree under --root exits 0 (clean-fixture control for the row above)', (t) => {
const root = makeScanRoot('gsd-planning-prompt-drift-clean-');
t.after(() => cleanup(root));
fs.writeFileSync(path.join(root, 'gsd-core', 'workflows', 'clean.md'), 'no globs or counts here\n');
const result = runNode([DRIFT_SCRIPT, '--root', root]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 0);
});
test('an empty tree with a valid empty baseline under --root exits 0', (t) => {
const root = makeScanRoot('gsd-planning-prompt-drift-empty-');
t.after(() => cleanup(root));
const result = runNode([DRIFT_SCRIPT, '--root', root]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 0);
});
test('the default invocation (no --root) still scans the real repo green', () => {
// The override must not change the guard lint:ci actually runs: bare
// invocation scans the real repo against the committed (empty) baseline.
const result = runNode([DRIFT_SCRIPT]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 0);
});
test('a --root scan leaves the real gsd-core/workflows directory untouched', (t) => {
const realWorkflows = path.join(REPO_ROOT, 'gsd-core', 'workflows');
const before = fs.readdirSync(realWorkflows).sort();
const root = makeScanRoot('gsd-planning-prompt-drift-untouched-');
t.after(() => cleanup(root));
fs.writeFileSync(path.join(root, 'gsd-core', 'workflows', 'zzz-e5-drift-fixture.md'), DRIFT_FIXTURE_LINE);
const result = runNode([DRIFT_SCRIPT, '--root', root]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 1);
// The #3640 acceptance criterion, as behavior: the E5 scenario's fixture
// never appears in the shared tree the parallel chunks observe.
assert.deepStrictEqual(fs.readdirSync(realWorkflows).sort(), before);
});
// ─── Usage errors: exit 2, the code distinct from the guard's findings
// exit (1) — a typed discriminator, so these rows assert the exit code
// alone and never parse stderr prose. ─────────────────────────────────
test('--root without a value is a usage error (exit 2)', () => {
const result = runNode([DRIFT_SCRIPT, '--root']);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 2);
});
test('--root with an empty-string value is a usage error (exit 2)', () => {
const result = runNode([DRIFT_SCRIPT, '--root', '']);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 2);
});
test('--root with a flag-shaped value is a usage error, never a scan root (exit 2)', () => {
// `--root --update` must not resolve the FLAG into a cwd-relative path —
// composed with --update that would mkdir a stray baseline tree inside
// the shared source directory (#3640's own write class).
const result = runNode([DRIFT_SCRIPT, '--root', '--update', '--update']);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 2);
});
test('--root naming an existing FILE is a usage error, not an ENOTDIR crash (exit 2)', () => {
const result = runNode([DRIFT_SCRIPT, '--root', path.join(REPO_ROOT, 'package.json')]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 2);
});
});