Files
msd-core/tests/planning-prompt-drift.test.cjs
Tom Boucher 8b4545f3c0 feat(#3218): the prompt layer asks the CLI for plan counts (#3327)
* feat(#3218): the prompt layer asks the CLI for plan counts

Seven sites across four workflows counted plans with ls and wc -l instead of
asking the CLI. A shell glob is not scanPhasePlans, so every fix that landed on
the owner missed all seven: they counted superseded plans as live, reported zero
for the nested plans layout, and missed loosely-named files. The 1762 figure of
30 plans and 24 summaries came from here.

phase find is extended rather than a verb added - 3218 is an enhancement whose
own checklist says it adds no new command, and CONTRIBUTING makes a new verb a
feature needing approved-feature. It gains plan_count and summary_count for the
live set and plan_count_all for the physical one, additively; the existing arrays
are untouched.

Both sets are exposed because the sites need different ones. Amendment 1 names
two cases; three of these sites ask a third - did the planner write files to disk
- and take the physical set, because a superseded plan is still a file the
planner wrote.

The progress.md dead route is fixed and was worse than the issue said. It read
.plans and .summaries arrays that roadmap.analyze has never emitted, so the
fallback always fired, both counts were always zero, and Route 0's
resume-incomplete-phase check had never fired at all.

The ratchet baseline is empty. Its own stale-entry check makes that
self-enforcing.

Verified on the remote runner.

* test(#3218): acknowledge the workflow growth and update the stale guard

The emitted-attribution gate named its own remedy, so it was followed rather
than pre-guessed: four workflow files grew between 200 and 770 bytes because each
replaced a shell glob with a find-phase call plus its jq extraction. plan-phase
grew most - two sites, and it takes the physical count for its did-the-planner-
write-files question. progress also carries the Route 0 dead-path fix.

plan-phase-drift-guard asserted the literal old ls shape. Updated rather than
deleted: what it protects is that a filesystem fallback exists and is reachable,
and that is intact. It is not a regression - gsd_run is already load-bearing
throughout plan-phase.md long before step 9, so the 9a and 11a fallback never
existed to survive gsd_run being unavailable; it guards against the planner
subagent's return hanging.

Three ack sources collided with the new fragment, which the gate treats as a hard
error rather than last-wins. Only the three colliding keys were removed, not the
421 spent entries, and two fragments left entryless were deleted per the
convention that an empty fragment signals nothing.

Verified on the remote runner.

* docs(#3218): document the live and physical plan counts

docs/CLI-TOOLS.md gains a find-phase counts section covering plan_count and
summary_count for the live set against plan_count_all for the physical one, plus
the null-not-zero not-found behavior. The live-versus-physical distinction is
spelled out because a caller picking the wrong one gets a plausible number, which
is the trap Amendment 1 records.

Changeset leads with what a user sees: progress and execute-plan stop counting
superseded plans as outstanding, a nested plans layout stops reporting zero, and
Route 0 resume routing starts working after never having worked.

No how-to. Nothing is enabled and nothing is sequenced - the user runs the same
command and the number is simply correct. The one new distinction is field
semantics, which is what a reference entry is for.

* chore(#3218): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3327 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-10 12:56:38 -04:00

360 lines
18 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, []);
});
// ─── E5 (test matrix): PROVE the guard, not just its pure functions, can
// still FAIL — an empty baseline that is green only because nothing
// exercises the fail path is exactly the "trusted on a green baseline it
// did not earn" failure this epic keeps recording. `main()` fixes its scan
// root to the real repo (`path.join(__dirname, '..')`), so this drives the
// actual CLI end-to-end against a real (temporary) file under a real
// SCAN_DIR — not a synthetic tree passed to the pure `scanRepo` — cleaned
// up in `t.after()` regardless of assertion outcome. ─────────────────────
describe('CLI end-to-end: the guard fails on a deliberate unacknowledged fixture', () => {
test('a fresh, unacknowledged plan-count re-derivation exits 1 and names itself in stderr', (t) => {
const fixturePath = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'zzz-e5-drift-fixture.md');
// helpers.cleanup() refuses any path outside the OS temp root; this
// fixture must live under a real SCAN_DIR (gsd-core/workflows/) because
// main() hardcodes its scan root to the real repo — see the file header
// above this describe block.
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- fixture lives outside the temp root helpers.cleanup() requires (see comment above)
t.after(() => fs.rmSync(fixturePath, { force: true }));
fs.writeFileSync(
fixturePath,
'FIXTURE_COUNT=$(ls .planning/phases/zzz/*-PLAN.md 2>/dev/null | wc -l)\n',
);
const result = runNode([DRIFT_SCRIPT]);
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 1);
assert.match(result.stderr, /NEW plan\/summary count re-derivation/);
assert.match(result.stderr, /gsd-core\/workflows\/zzz-e5-drift-fixture\.md/);
assert.match(result.stderr, /FIXTURE_COUNT=/);
});
});