Files
msd-core/tests/feat-3262-scan-phase-plans.test.cjs
Jeremy McSpadden 77c7b4fc9d fix(#1522): enforce canonical verification before phase transition (#1548)
* fix: require fresh phase verification before transition

* no-mistakes(review): Fix canonical verification closeout gates

* no-mistakes(review): Fix verify-work frontmatter promotion command

* no-mistakes(review): Fix stale verification gates

* no-mistakes(review): Fix canonical verification routing gates

* no-mistakes(review): Fix verification dependency and runtime routing gates

* no-mistakes(review): Block stale verification bypasses

* fix: handle large init manager outputs in verification workflows

* chore: update changeset pr number

* fix(verify-work): use fresh verification.status for stale gate

The stale check after UAT used phase_completion.verification_status from
session-start INIT while human_needed promotion already queried fresh
verification.status. Align the stale gate with the canonical query so
mid-session verification refresh is not ignored.

* fix(init): skip roadmap-checked phases when selecting next_phase

Roadmap-only phases without a disk directory were still promoted to
next_phase when their checkbox was already checked. Exclude
checkboxComplete phases so progress routing does not point at work the
roadmap already marks done.

* fix: gaps_found not overridden by stale, transition uses canonical verification

- verification.cts: check gaps_found before stale so gap-closure routing
  is not masked by a newer summary mtime
- phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus
  already handles stale detection
- transition.md: replace raw grep on file content with verification.status query
  to avoid false-positive blocks from body text matching

* ci: retrigger tests after rebase

* fix(transition): replace gsd_run advisory check with awk frontmatter extraction

The runtime launcher is not defined until the update_roadmap_and_state step
bash block (~line 165). The early verify_completion block used gsd_run to
query verification.status, which violated the runtime-launcher-parity test:
'preamble appears AFTER the first gsd_run reference'.

Replace the gsd_run call with an awk-based frontmatter extractor that reads
only the status: field between the two --- fences. This avoids both the
preamble-ordering constraint and the original false-positive grep bug where
body text like 'previous_status: gaps_found' would match a full-text regex.

The phase.complete gate at update_roadmap_and_state is the canonical
enforcement point; this early check is advisory only.

Also update workflow-size-baseline.json for the updated transition.md size.

Fixes: runtime-launcher-parity test (B)

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* fix: re-check verification under planning lock in phase complete

Move readVerificationStatus into withPlanningLock so stale verification
cannot slip through when a SUMMARY.md is written between the gate and
the roadmap/state mutation. Return the blocked status from the lock
callback and emit the error after release to avoid leaving .lock behind.

* fix(transition): gate on canonical verification.status including stale

Replace awk frontmatter read with verification.status query so transition
blocks when summaries are newer than VERIFICATION.md, matching phase.complete
and other workflows (autonomous, progress, verify-work).

* Fix workflow verification gates for yolo transition and stale routing

Require VERIFY_STATUS passed before yolo/interactive transition advance.
Route stale verification recovery to verify-work, matching canonical projection.

* fix(transition): use verification.status query for stale-aware advisory check

The awk-based check read raw frontmatter status: passed, which misses the
stale case where summaries are newer than the VERIFICATION.md file even
though the frontmatter still says passed. The stale status is computed from
file modification times, not stored in frontmatter.

Move the preamble to the verify_completion bash block (the first block with
a gsd_run call) so gsd_run query verification.status can be used for the
advisory check. This gives the full readVerificationStatus logic including
mtime-based staleness detection, matching the enforcement gate at phase.complete.

Capture full JSON (VERIFY_JSON) so next_action can be included in the
advisory output alongside the status.

Also update workflow-size-baseline.json for the updated transition.md size.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* ci: trigger test matrix for 525b946

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* fix(transition): restore awk frontmatter extraction for pre-shim verification check

The gsd_run launcher shim is not defined until line ~163 of transition.md,
so the verification debt check at line ~80 cannot use gsd_run. Restore the
awk-based frontmatter extraction that correctly reads status without needing
the runtime, and restore the shim at its proper location before
phase.complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(#1522): clarify transition verification gate wording

* fix(#1522): update transition workflow size baseline

* fix(#1522): update workflow-size-baseline after rebase onto next

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

* fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review)

Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync
and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections,
so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any
FS error threw uncaught into callers NOT under the planning lock (init.manager /
init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and
thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller)
for parity with readVerificationStatus's no-throw contract and testability. Also adds the
Verification Module glossary entry to CONTEXT.md (review B3).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-24 13:19:10 -04:00

311 lines
12 KiB
JavaScript

/**
* Tests for the shared scanPhasePlans() helper (k014).
*
* Covers:
* - Top-level plans only (flat layout)
* - Top-level + nested layout (post-#3139)
* - Completed-summary detection (summaries >= plans)
* - Ignored files (OUTLINE, pre-bounce, CONTEXT, RESEARCH)
* - Empty phase dir → { planCount: 0, summaryCount: 0 }
* - Parity: helper produces correct counts for mixed flat+nested fixture tree
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
// Helper under test — must exist at this path (GREEN phase wires it up)
const scanPhasePlans = require('../gsd-core/bin/lib/plan-scan.cjs');
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
let tmpDir;
function phaseDir(name = 'phase') {
const d = path.join(tmpDir, name);
fs.mkdirSync(d, { recursive: true });
return d;
}
function touch(dir, ...filenames) {
for (const f of filenames) {
fs.writeFileSync(path.join(dir, f), '');
}
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-'));
});
afterEach(() => {
cleanup(tmpDir);
});
// ---------------------------------------------------------------------------
// Basic shapes
// ---------------------------------------------------------------------------
describe('scanPhasePlans — flat layout', () => {
test('empty directory → zero counts', () => {
const dir = phaseDir();
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 0, 'planCount');
assert.strictEqual(result.summaryCount, 0, 'summaryCount');
});
test('bare PLAN.md counts as one plan', () => {
const dir = phaseDir();
touch(dir, 'PLAN.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'planCount');
assert.strictEqual(result.summaryCount, 0, 'summaryCount');
});
test('canonical padded plan file (01-01-PLAN.md)', () => {
const dir = phaseDir();
touch(dir, '01-01-PLAN.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'planCount');
});
test('canonical padded plan + matching summary → completed', () => {
const dir = phaseDir();
touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1);
assert.strictEqual(result.summaryCount, 1);
assert.strictEqual(result.completed, true, 'phase should be complete when summaries >= plans');
});
test('plan without summary → not completed', () => {
const dir = phaseDir();
touch(dir, '01-01-PLAN.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.completed, false);
});
test('multiple plans all summarized → completed', () => {
const dir = phaseDir();
touch(dir, '01-01-PLAN.md', '01-02-PLAN.md', '01-01-SUMMARY.md', '01-02-SUMMARY.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 2);
assert.strictEqual(result.summaryCount, 2);
assert.strictEqual(result.completed, true);
});
test('bare SUMMARY.md counts as one summary', () => {
const dir = phaseDir();
touch(dir, 'PLAN.md', 'SUMMARY.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1);
assert.strictEqual(result.summaryCount, 1);
});
test('extended-layout root file (5-PLAN-01-setup.md style)', () => {
// roadmap.cjs isPlanFile explicitly matches any .md with PLAN in name at root
// (not just ending with -PLAN.md). The canonical helper must too.
// e.g. gsd-plan-phase writes "5-PLAN-01-setup.md".
const dir = phaseDir();
// The summary for this file follows the canonical *-SUMMARY.md suffix convention.
touch(dir, '3-PLAN-01-setup.md', '3-01-SUMMARY.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'extended-layout root plan counted');
assert.strictEqual(result.summaryCount, 1, 'extended-layout root summary counted');
});
});
// ---------------------------------------------------------------------------
// Ignored files
// ---------------------------------------------------------------------------
describe('scanPhasePlans — ignored files', () => {
test('PLAN-OUTLINE file is ignored (flat)', () => {
const dir = phaseDir();
touch(dir, '01-01-PLAN.md', '01-01-PLAN-OUTLINE.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'OUTLINE should not count as a plan');
});
test('pre-bounce file is ignored (flat)', () => {
const dir = phaseDir();
touch(dir, '01-01-PLAN.md', '01-01-PLAN.pre-bounce.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'pre-bounce should not count as a plan');
});
test('CONTEXT.md is not counted as a plan', () => {
const dir = phaseDir();
touch(dir, 'PLAN.md', 'CONTEXT.md', '01-01-CONTEXT.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'CONTEXT files should not be plans');
});
test('RESEARCH.md is not counted as a plan', () => {
const dir = phaseDir();
touch(dir, 'PLAN.md', 'RESEARCH.md', '01-01-RESEARCH.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'RESEARCH files should not be plans');
});
test('VERIFICATION.md is not counted as a plan', () => {
const dir = phaseDir();
touch(dir, 'PLAN.md', 'VERIFICATION.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'VERIFICATION files should not be plans');
});
});
// ---------------------------------------------------------------------------
// Nested layout (post-#3139)
// ---------------------------------------------------------------------------
describe('scanPhasePlans — nested layout', () => {
test('nested PLAN-NN-slug.md files counted', () => {
const dir = phaseDir();
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 2, 'nested plans counted');
assert.strictEqual(result.hasNestedPlans, true, 'hasNestedPlans flag set');
});
test('nested SUMMARY-NN-slug.md files counted', () => {
const dir = phaseDir();
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1);
assert.strictEqual(result.summaryCount, 1);
assert.strictEqual(result.completed, true);
assert.deepStrictEqual(result.planFiles, ['plans/PLAN-01-setup.md']);
assert.deepStrictEqual(result.summaryFiles, ['plans/SUMMARY-01-setup.md']);
});
test('flat root + nested plans combined', () => {
const dir = phaseDir();
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
// root: 1 plan, 1 summary
touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md');
// nested: 2 plans, 1 summary
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md', 'SUMMARY-01-setup.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 3, 'root + nested plans');
assert.strictEqual(result.summaryCount, 2, 'root + nested summaries');
assert.strictEqual(result.completed, false, 'not all plans have summaries');
});
test('hasNestedPlans is false when plans/ directory absent', () => {
const dir = phaseDir();
touch(dir, 'PLAN.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.hasNestedPlans, false);
});
test('nested OUTLINE files are ignored', () => {
const dir = phaseDir();
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01-OUTLINE.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'OUTLINE excluded in nested');
});
test('nested pre-bounce files are ignored', () => {
const dir = phaseDir();
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01.pre-bounce.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1, 'pre-bounce excluded in nested');
});
test('plans/ that is not readable as directory does not throw', () => {
const dir = phaseDir();
// Create plans/ as a file (unreadable as directory)
fs.writeFileSync(path.join(dir, 'plans'), 'not-a-directory');
touch(dir, 'PLAN.md');
// Should not throw
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 1);
assert.strictEqual(result.hasNestedPlans, false);
});
});
// ---------------------------------------------------------------------------
// Parity: helper output shape and mixed fixture
// ---------------------------------------------------------------------------
describe('scanPhasePlans — call-site parity on mixed fixture', () => {
// Build a fixture tree that exercises both flat and nested layout:
// 01-foundation/
// 01-01-PLAN.md
// 01-01-SUMMARY.md
// 01-01-PLAN-OUTLINE.md (should be ignored)
// 01-02-PLAN.md
// plans/
// PLAN-01-setup.md
// SUMMARY-01-setup.md
function buildMixedPhase() {
const dir = phaseDir('01-foundation');
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md', '01-01-PLAN-OUTLINE.md', '01-02-PLAN.md');
touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md');
return dir;
}
test('scanPhasePlans() counts match expected values for mixed fixture', () => {
const dir = buildMixedPhase();
const result = scanPhasePlans(dir);
// flat: 01-01-PLAN.md + 01-02-PLAN.md = 2 (OUTLINE ignored)
// nested: PLAN-01-setup.md = 1
assert.strictEqual(result.planCount, 3, 'planCount should be 3');
// flat: 01-01-SUMMARY.md = 1; nested: SUMMARY-01-setup.md = 1
assert.strictEqual(result.summaryCount, 2, 'summaryCount should be 2');
assert.strictEqual(result.completed, false, 'not all plans have summaries');
assert.strictEqual(result.hasNestedPlans, true, 'nested layout present');
});
test('scanPhasePlans() output shape has required fields', () => {
const dir = buildMixedPhase();
const result = scanPhasePlans(dir);
assert.ok('planCount' in result, 'planCount field present');
assert.ok('summaryCount' in result, 'summaryCount field present');
assert.ok('completed' in result, 'completed field present');
assert.ok('hasNestedPlans' in result, 'hasNestedPlans field present');
assert.ok('planFiles' in result, 'planFiles field present');
assert.ok('summaryFiles' in result, 'summaryFiles field present');
assert.ok(Array.isArray(result.planFiles), 'planFiles is array');
assert.ok(Array.isArray(result.summaryFiles), 'summaryFiles is array');
});
test('parity baseline: 2 flat + 1 nested plans across all call sites', () => {
// This test documents the exact expected counts for a representative fixture.
// After the GREEN phase ports roadmap.cjs/state.cjs/init.cjs to use
// scanPhasePlans, those call sites delegate here and this assertion is
// the single contract all of them must satisfy.
const dir = phaseDir('02-api');
touch(dir, '02-01-PLAN.md', '02-02-PLAN.md', '02-01-SUMMARY.md');
const plansDir = path.join(dir, 'plans');
fs.mkdirSync(plansDir);
touch(plansDir, 'PLAN-01-impl.md', 'SUMMARY-01-impl.md');
const result = scanPhasePlans(dir);
assert.strictEqual(result.planCount, 3, 'helper: 2 flat + 1 nested');
assert.strictEqual(result.summaryCount, 2, 'helper: 1 flat + 1 nested');
assert.strictEqual(result.completed, false, '2 summaries < 3 plans');
assert.strictEqual(result.hasNestedPlans, true, 'plans/ dir exists with plans');
});
});