Files
msd-core/tests/feat-3262-scan-phase-plans.test.cjs
Tom Boucher a28dcec981 chore(#597): replace count-based ratchet guards with AST lint + named-set allowlists (#603)
The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.

Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
  EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
  maxRetries (it owns no loop), so the test asserts the option contract
  (recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
  rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
  the helper, not approximated textually at every call site.

Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
  raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
  destructured and aliased forms; escape hatch is inline
  `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
  ~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
  error-swallowing or name-colliding local teardown helpers) keep the raw call
  with an inline eslint-disable + reason.

Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
  - assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
    STALE ids (a known offender was fixed but not pruned) — identity, not count,
    and a ratchet DOWN toward zero.
  - assertTightCeiling: a size/length budget whose ceiling must stay within a
    grace band of the high-water mark, so budgets may only tighten, never creep.

Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
  the remaining six patterns moved from integer baselines to named-set
  allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
  named filename sets (closes the swap-a-file-keep-the-count blind spot); a
  module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).

Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
  current high-water mark and an assertTightCeiling anti-creep check added per
  tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
  are intentionally left as-is — they are not grandfathered creeping budgets.

No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:43:49 -04:00

309 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('../get-shit-done/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);
});
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');
});
});