* refactor(#3180): one owner for completion ratio, a prompt-layer drift guard, and a written behavior contract The 2026-08-08 coverage audit on #3180 found the epic's copy counts were a lower bound for the third consecutive time, and that two derivation families had never been named at all. ADR-3180 gains Decision 7 — a normative behavior contract that says what the right answer IS for each derivation, not merely who owns it. A reviewer with no written rule can only ask "does this look like the others", which is how a fifth copy passes review. Decision 4 gains (d) scan surface is every authored surface and an owner FILE is never exempt, only its named functions; and (e) a surface that cannot be consolidated today ships ratcheted, never unguarded. Completion ratio: `clampPercent` sat exported and unused beside six hand-inlined copies of its own body across five modules. All six now route through it; `clampPercentFromFraction` is added for the one caller that already held a fraction. Every migration is behaviour-identical — clampPercent's first line IS the `total > 0 ? … : 0` ternary each copy carried. Guarded by lint-completion-ratio-drift.cjs, which reports zero re-derivations with no file-level exemption. Prompt layer: workflow markdown re-derives live-plan counting in raw shell (#1762), invisible to every `src/`-scoped guard. lint-planning-prompt-drift.cjs scans it with a shrink-only baseline of the 7 sites that exist today — new sites fail, and a baseline entry that stops firing fails too, so an acknowledgment can never outlive the thing it describes. lint-milestone-window-drift.cjs stops exempting its owner file wholesale; only the four named canonical functions are exempt now. The blanket exemption was pointed at the one file most likely to grow the next copy, and it had. Refs #3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3180): link Phases 6-8 sub-issues (#3216, #3217, #3218) from ADR-3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3180): address orthogonal review — consumer-output identity tests, count-keyed ratchet, property coverage Five findings from the two orthogonal review passes, all fixed. Decision 4(c) breach: the completion-ratio identity test asserted at the OWNER, which is exactly the bypass that decision exists to close — a consumer can call clampPercent and then post-process locally, leaving both the lint and an owner-level test green. It now drives `roadmap analyze`, `query progress` and `stats` and asserts on their own output, over a fixture containing a `status: superseded` plan so a consumer that re-counted raw files would report 60 where the owner reports 75. Decision 4(e) breach: ratchet entries named the epic (#3180) rather than the issue that removes them. They name Phase 8 (#3218) now. The ratchet keyed on (file, text) alone, so plan-phase.md's two byte-identical sites were one indistinguishable key and migrating either would have left the guard green with the other alive. Entries carry an occurrence count; fewer than acknowledged fails as a partial migration, more fails as a new copy. Adds the missing MAX_REGEX_LITERAL_LEN boundary coverage the sibling guard's test already had, and the fast-check property tests CONTRIBUTING requires for clamp/budget-limit functions. Refs #3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: stop wrapping a nested double-spawn in a 15s wall-clock budget (bug #641 probes) `tests/ci-test-scope.test.cjs`'s `bug #641` block spawned `run-tests.cjs` under PROBE_TIMEOUT_MS=15000; that child then spawned a nested `node --test`. A fixed wall-clock budget around a double spawn, running inside a container that is concurrently executing the full ~31k-test suite, fails by construction under load. Confirmed against three full matrix runs. Every failure was shaped `null !== 0` — the child was KILLED, never an assertion about the thing under test. One captured probe had already printed the correct resolution (`suite="all" files=2: a.test.cjs b.test.cjs`) and was killed anyway. It reproduces on `next` alone: 5 failures on linux-node22, 0 on linux-node24. The victim subset varies by run and by lane. What these tests are actually about is suite-token RESOLUTION — `unit` as a bare token in --files/--files-from. Executing the seeded trivial files is incidental and is the entire timeout surface, so the assertions move in-process against the same functions `main()` calls, in the same order. `parseArgs`, `selectExplicitFiles`, `selectFiles` and `walkTestFiles` are exported for that; no behavior, signature or logic changed. No coverage lost: `tests/run-tests-harness.test.cjs` already spawns the harness for real and asserts exit codes end to end, on a 120s budget. Pre-existing on `next`, fixed here rather than deferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: delete the three elapsed-time assertions CLAUDE.md forbids asserting on wall-clock time. Three assertions did, and all three are load-sensitive: on a saturated bench each can fail while the code under test is correct. In every case the load-bearing assertion sits on the line above and the timing line adds no discrimination. run-with-timeout: the stated worry — "was this 124 the cap firing or the 30s harness backstop?" — is already answered by the assertion above it. A backstop kills by signal, which surfaces as status null, never 124. Observed directly this session: three matrix runs produced exactly that null shape from killed children. normalize-test-command and context-predicates: both bounded a ReDoS check. A threshold only ever separates "fast" from "slightly slow", which is bench load, not correctness — catastrophic backtracking on 800 KB of input does not take 251ms, it does not finish at all. A real regression therefore shows up as the suite being killed on that test, which is louder and more reliable than a number. The structural assertions (returned unchanged; cleanly rejected) are what actually carry those tests, and they stay. The sweep now reports zero elapsed-time assertions in tests/. The remaining Date.now() uses are unique-path suffixes, barrier deadlines, fixture timestamps and fake mtimes — none of them assertions. Pre-existing on `next`, fixed here rather than deferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3180): backfill changeset PR number (#3223) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3180): key the prompt-drift ratchet on POSIX paths so it works on Windows The baseline keys on (file, trimmed text). `file` came from scanTree's `path.relative()`, which uses NATIVE separators, while the committed baseline stores POSIX. On Windows every violation was therefore unmatched — reported as FRESH — and every baseline entry matched nothing — reported as STALE. The guard failed 100% of the time there, on both CI shards: ✖ scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale + { file: 'gsd-core\\workflows\\execute-plan.md', ... } The remote runner this repo gates on is Linux-only and cannot see this class at all; the GitHub Actions Windows lane is what caught it. Normalization is unconditional — never gated on process.platform. A platform-conditional normalizer makes the POSIX path the special case and leaves the Windows branch unexercised on every other OS, which is the same blind spot in a different place. It is applied at one seam inside findPromptDrift, which builds `file` on every returned violation, so the baseline key, the --update writer, the stderr report and the tests all consume one normalized value. The regression tests drive a Windows-shaped relPath directly and run on every OS rather than skipping off-Windows — a test that only runs on the platform where the bug lives is why this escaped. They include a sanity check that un-normalized input does NOT match, so the assertion cannot pass vacuously. Audited the three sibling guards: none keys against a committed cross-platform baseline, and their exemption keys are path.join-built, so producer and consumer share the native convention. Left correct code alone rather than making them look alike. scripts/lib/drift-scan.cjs is untouched — normalizing there would break those three on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
327 lines
16 KiB
JavaScript
327 lines
16 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 REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
// ─── 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 exactly 6 entries (one row per distinct (file, text) pair)', () => {
|
|
// 7 total ACKNOWLEDGED occurrences across 6 distinct pairs: plan-phase.md's
|
|
// byte-identical DISK_PLANS site fires at two different lines and is
|
|
// recorded as ONE row carrying `count: 2` (the Finding-3 fix — a
|
|
// duplicated-row baseline made migrating only one of the two sites
|
|
// invisible to the ratchet).
|
|
const { entries, errors } = loadBaseline(REPO_ROOT);
|
|
assert.deepStrictEqual(errors, []);
|
|
assert.strictEqual(entries.length, 6);
|
|
const totalAcknowledgedOccurrences = entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
assert.strictEqual(totalAcknowledgedOccurrences, 7);
|
|
const planPhaseEntry = entries.find((e) => e.file === 'gsd-core/workflows/plan-phase.md');
|
|
assert.strictEqual(planPhaseEntry.count, 2);
|
|
});
|
|
|
|
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, []);
|
|
});
|