This commit is contained in:
@@ -738,6 +738,103 @@ describe('bug #2836: audit-open quick-task summary filename + UAT terminal statu
|
||||
}
|
||||
});
|
||||
|
||||
test('#3511: scanUatGaps excludes a cross-phase stray UAT file sitting in the same phase dir; this phase\'s own open gap still reports', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// This phase's own open UAT gap.
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '03-UAT.md'),
|
||||
'---\nstatus: pending\n---\nresult: pending\n',
|
||||
'utf-8',
|
||||
);
|
||||
// Cross-phase stray in the SAME directory — token "04", not "03".
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '04-UAT.md'),
|
||||
'---\nstatus: pending\n---\nresult: pending\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
||||
|
||||
assert.equal(realUatGaps.length, 1,
|
||||
`only this phase's own gap must report; got: ${JSON.stringify(realUatGaps)}`);
|
||||
assert.equal(realUatGaps[0].file, '03-UAT.md');
|
||||
assert.equal(realUatGaps[0].phase, '03');
|
||||
assert.ok(!realUatGaps.some(i => i.file === '04-UAT.md'),
|
||||
'the cross-phase stray must not appear in uat_gaps');
|
||||
assert.equal(result.counts.uat_gaps, 1);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('#3511: scanVerificationGaps excludes a cross-phase stray VERIFICATION file sitting in the same phase dir; this phase\'s own open gap still reports', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// This phase's own open VERIFICATION gap.
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '03-VERIFICATION.md'),
|
||||
'---\nstatus: gaps_found\n---\n# Verification\n',
|
||||
'utf-8',
|
||||
);
|
||||
// Cross-phase stray in the SAME directory — token "04", not "03".
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '04-VERIFICATION.md'),
|
||||
'---\nstatus: human_needed\n---\n# Verification\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realVerificationGaps = result.items.verification_gaps.filter(i => !i.scan_error);
|
||||
|
||||
assert.equal(realVerificationGaps.length, 1,
|
||||
`only this phase's own gap must report; got: ${JSON.stringify(realVerificationGaps)}`);
|
||||
assert.equal(realVerificationGaps[0].file, '03-VERIFICATION.md');
|
||||
assert.equal(realVerificationGaps[0].phase, '03');
|
||||
assert.ok(!realVerificationGaps.some(i => i.file === '04-VERIFICATION.md'),
|
||||
'the cross-phase stray must not appear in verification_gaps');
|
||||
assert.equal(result.counts.verification_gaps, 1);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('#3511 follow-up: own gap still reports from a NON-canonical dir shape (over-exclusion check)', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
// "1-unpadded" tokenizes to literal "1", but scaffold writes the PADDED
|
||||
// "01-…" form — a literal token compare excluded the phase's own file.
|
||||
const phaseDir = path.join(cwd, '.planning', 'phases', '1-unpadded');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '01-UAT.md'),
|
||||
'---\nstatus: partial\n---\n\n## Tests\n\n### 1. Test\nexpected: works\nresult: pending\n',
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, '01-VERIFICATION.md'),
|
||||
'---\nstatus: gaps_found\n---\n# Verification\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = auditOpenArtifacts(cwd);
|
||||
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
||||
const realVerificationGaps = result.items.verification_gaps.filter(i => !i.scan_error);
|
||||
|
||||
assert.equal(realUatGaps.length, 1,
|
||||
`own UAT gap in an unpadded-dir phase must still report; got: ${JSON.stringify(realUatGaps)}`);
|
||||
assert.equal(realVerificationGaps.length, 1,
|
||||
`own VERIFICATION gap in an unpadded-dir phase must still report; got: ${JSON.stringify(realVerificationGaps)}`);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('quick task without any SUMMARY file is still flagged as missing', () => {
|
||||
const cwd = mkTmp();
|
||||
try {
|
||||
|
||||
@@ -2240,6 +2240,27 @@ describe('stats command', () => {
|
||||
assert.strictEqual(phase.status, 'Complete', 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet');
|
||||
});
|
||||
|
||||
// #3511 BLOCKER-2 regression: a cross-phase stray VERIFICATION.md must not
|
||||
// resolve as THIS phase's report. Phase 03's directory holds only a
|
||||
// '04-VERIFICATION.md' (belongs to phase 04); an unscoped resolver would
|
||||
// pick it up as phase 03's own report and read 'Complete'.
|
||||
test('#3511: phase status is not Complete off a cross-phase stray VERIFICATION.md', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '03-test');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '03-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '03-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, '04-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
const phase = stats.phases.find(p => p.number === '03');
|
||||
assert.ok(phase, 'phase 03 must be present in stats output');
|
||||
assert.notStrictEqual(phase.status, 'Complete',
|
||||
`phase 03 must not report Complete off phase 04's report; got: ${phase.status}`);
|
||||
});
|
||||
|
||||
test('counts requirements from REQUIREMENTS.md', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
||||
|
||||
@@ -485,6 +485,22 @@ describe('getPhaseFileStats', () => {
|
||||
const stats = coreUtils.getPhaseFileStats(tmpDir);
|
||||
assert.deepEqual(stats.plans, ['plans/PLAN-01.md']);
|
||||
});
|
||||
|
||||
test('#3511 BLOCKER-2 regression: a cross-phase stray VERIFICATION.md is scoped out of hasVerification', () => {
|
||||
// Dir is phase 03's own directory ("03-test" → token "03"); the only
|
||||
// VERIFICATION.md present belongs to phase 04. scopeToPhase must exclude
|
||||
// it, so hasVerification reads false — an unscoped implementation would
|
||||
// wrongly report phase 03 as verified off phase 04's report.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-'));
|
||||
const phaseDir = path.join(tmpDir, '03-test');
|
||||
fs.mkdirSync(phaseDir);
|
||||
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), '---\nstatus: passed\n---\n');
|
||||
const stats = coreUtils.getPhaseFileStats(phaseDir);
|
||||
assert.strictEqual(stats.hasVerification, false,
|
||||
`hasVerification must be false — the only VERIFICATION.md belongs to phase 04, not 03; got scopedFiles-derived: ${stats.hasVerification}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractOneLinerFromBody ──────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,8 @@ const {
|
||||
parseMustHavesBlock,
|
||||
} = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
|
||||
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
|
||||
// ─── extractFrontmatter ─────────────────────────────────────────────────────
|
||||
|
||||
describe('extractFrontmatter', () => {
|
||||
@@ -1236,11 +1238,15 @@ function buildRoadmap(numPhases) {
|
||||
*/
|
||||
function createPhaseDirs(phasesDir, count) {
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
||||
const dirName = String(i).padStart(2, '0');
|
||||
const dir = path.join(phasesDir, dirName);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`);
|
||||
fs.writeFileSync(path.join(dir, `01-SUMMARY.md`), `# Summary\n`);
|
||||
fs.writeFileSync(path.join(dir, `01-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${normalizePhaseName(dirName)}-VERIFICATION.md`),
|
||||
'---\nstatus: passed\n---\n# Verification\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,91 @@ describe('init commands', () => {
|
||||
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
|
||||
});
|
||||
|
||||
test('#3511-class: init manager has_context/has_research ignore another phase\'s misplaced artifact', () => {
|
||||
writePlanningDocs(tmpDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'- [ ] **Phase 1: Setup**',
|
||||
'- [ ] **Phase 2: API**',
|
||||
'',
|
||||
'### Phase 1: Setup',
|
||||
'',
|
||||
'**Goal:** Build the foundation.',
|
||||
'',
|
||||
'### Phase 2: API',
|
||||
'',
|
||||
'**Goal:** Build the API.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
seedPhase(tmpDir, '01-setup', {});
|
||||
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
||||
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
||||
seedPhase(tmpDir, '02-api', {
|
||||
'01-RESEARCH.md': '# Research for phase 01',
|
||||
'01-CONTEXT.md': '# Context for phase 01',
|
||||
});
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const p2 = output.phases.find((p) => p.number === '2');
|
||||
assert.strictEqual(p2.has_research, false,
|
||||
'phase 2 must not report has_research from a file that belongs to phase 1');
|
||||
assert.strictEqual(p2.has_context, false,
|
||||
'phase 2 must not report has_context from a file that belongs to phase 1');
|
||||
});
|
||||
|
||||
test('#3511-class: init verify-work ui_phase_active ignores another phase\'s misplaced UI-SPEC file', () => {
|
||||
writePlanningDocs(tmpDir);
|
||||
// ui_phase_active is `hasActiveUiStep || hasUiSpecFile` (detectUiPhaseActive,
|
||||
// src/init.cts) — the `ui` capability's `workflow.ui_phase` config key
|
||||
// defaults to `true` (capabilities/ui/capability.json), which alone would
|
||||
// make `hasActiveUiStep` (and therefore the whole OR) true regardless of
|
||||
// which file the phase directory holds. Disabling it here isolates the
|
||||
// signal this test actually exercises: the misplaced-file half of the OR.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { ui_phase: false } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: Setup',
|
||||
'',
|
||||
'**Goal:** Build the foundation.',
|
||||
'',
|
||||
'### Phase 2: API',
|
||||
'',
|
||||
'**Goal:** Build the API.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
seedPhase(tmpDir, '01-setup', {});
|
||||
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
||||
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
||||
seedPhase(tmpDir, '02-api', {
|
||||
'01-UI-SPEC.md': '# UI Spec for phase 01',
|
||||
});
|
||||
|
||||
const result = runGsdTools('init verify-work 2', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.ui_phase_active, false,
|
||||
'phase 2 must not report ui_phase_active from a UI-SPEC file that belongs to phase 1');
|
||||
});
|
||||
|
||||
// #3473 F2 (companion to #3357): init plan-phase's verification_path
|
||||
// projector now resolves via the shared resolveVerificationFile resolver
|
||||
// instead of a hand-rolled `.find()` over unsorted readdir() order. The
|
||||
@@ -1717,6 +1802,27 @@ describe('cmdInitProgress', () => {
|
||||
assert.strictEqual(output.next_phase, null);
|
||||
});
|
||||
|
||||
test('#3511-class: has_research ignores a misplaced RESEARCH.md that belongs to another phase', () => {
|
||||
// Phase 01 has no artifacts of its own.
|
||||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
|
||||
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
||||
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
||||
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||||
fs.mkdirSync(phase2, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase2, '01-RESEARCH.md'), '# Research for phase 01');
|
||||
|
||||
const result = runGsdTools('init progress', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const p2 = output.phases.find(p => p.number === '02');
|
||||
assert.strictEqual(p2.has_research, false,
|
||||
'phase 02 must not report has_research from a file that belongs to phase 01');
|
||||
assert.strictEqual(p2.status, 'pending');
|
||||
});
|
||||
|
||||
test('implementation-complete phase without passed verification remains current work', () => {
|
||||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
@@ -3465,6 +3571,25 @@ describe('init section manifest', () => {
|
||||
'gsd-core/workflows/execute-phase/steps/partial-wave.md',
|
||||
]);
|
||||
});
|
||||
|
||||
test('#3511-class: regression-gate (state:has-prior-phases) ignores a misplaced VERIFICATION.md that belongs to another phase', (t) => {
|
||||
const dir = createTempProject('gsd-3511-hasprior-');
|
||||
t.after(() => cleanup(dir));
|
||||
// Phase 01 is the one being executed.
|
||||
seedPhase(dir, '01-widgets', {});
|
||||
// Phase 02's directory holds ONLY a stray artifact whose filename
|
||||
// token ("05-") belongs to phase 05, not to this directory's own
|
||||
// phase (02) — it must not make phase 02 look like it has its own
|
||||
// verification report.
|
||||
seedPhase(dir, '02-other', { '05-VERIFICATION.md': '# Verification for phase 05' });
|
||||
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir), 'stray-verification');
|
||||
|
||||
assert.ok(body.section_manifest, 'section_manifest must be present');
|
||||
assert.ok(!body.section_manifest.included.includes('regression-gate'),
|
||||
'regression-gate must not be included when no OTHER phase has its own verification');
|
||||
assert.ok(body.section_manifest.excluded.includes('regression-gate'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── E44-46: phase argument boundary ────────────────────────────────────
|
||||
|
||||
@@ -263,6 +263,268 @@ describe('extractPhaseToken', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isPhaseArtifact (#3511) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Predicate for AGGREGATE phase-directory scans (uat-predicate.cts, phase.cts,
|
||||
// state.cts, uat.cts, audit.cts) — answers "does fileName belong to THIS
|
||||
// phase" so a stray, cross-phase, or ad-hoc file cannot contribute its status
|
||||
// to a phase it does not belong to. See src/phase-id.cts's isPhaseArtifact
|
||||
// docblock for the full contract and fail-safe rationale.
|
||||
|
||||
describe('isPhaseArtifact (#3511)', () => {
|
||||
test('plain: file matches its own phase dir, not a cross-phase dir', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03-VERIFICATION.md', '03-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('04-VERIFICATION.md', '03-foo'), false);
|
||||
});
|
||||
|
||||
test('ad-hoc worksheet: 03-CORRECTION-VERIFICATION.md belongs to 03-foo (it is the phase\'s own file)', () => {
|
||||
// Over-exclusion here would be a worse bug than #3511 itself — a phase's
|
||||
// own ad-hoc worksheet must never be treated as a stray.
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03-CORRECTION-VERIFICATION.md', '03-foo'), true);
|
||||
});
|
||||
|
||||
test('letter suffix: token must match exactly, in both directions', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03A-VERIFICATION.md', '03A-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03-VERIFICATION.md', '03A-foo'), false,
|
||||
'a bare "03-" file must not belong to letter-suffixed phase dir "03A-foo"');
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03A-VERIFICATION.md', '03-foo'), false,
|
||||
'a letter-suffixed "03A-" file must not belong to bare phase dir "03-foo"');
|
||||
});
|
||||
|
||||
test('decimal sub-phase: token must match exactly, not as a numeric prefix', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('35.1-VERIFICATION.md', '35.1-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('35.10-VERIFICATION.md', '35.1-foo'), false,
|
||||
'35.10-… must not match phase dir 35.1-foo despite the shared numeric prefix');
|
||||
});
|
||||
|
||||
test('plan/summary shapes belong to their phase the same way VERIFICATION/UAT do', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03-01-SUMMARY.md', '03-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03-01-PLAN.md', '03-foo'), true);
|
||||
});
|
||||
|
||||
test('project-code-prefixed dirs: both the prefixed and the project-code-STRIPPED reading belong (#3511 blocker 1)', () => {
|
||||
// Files are named by normalizePhaseName, which STRIPS the project code
|
||||
// (cmdScaffold, src/commands.cts) — files never carry the code, only the
|
||||
// directory does. `01-VERIFICATION.md` IS the real report cmdScaffold
|
||||
// writes into `CK-01-foundation`; excluding it locked in the #3511
|
||||
// follow-up defect (this assertion's polarity was inverted pre-fix).
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('CK-01-VERIFICATION.md', 'CK-01-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('01-VERIFICATION.md', 'CK-01-foo'), true,
|
||||
'the project-code-STRIPPED file is the real scaffold output and must belong to its own project-code-prefixed dir');
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('02-VERIFICATION.md', 'CK-01-foo'), false,
|
||||
'a different phase number must still be excluded, project code aside');
|
||||
});
|
||||
|
||||
test('#3511 blocker 2: unpadded dir "1-unpadded" — its own padded file belongs', () => {
|
||||
// #3511 blocker: dir "1-unpadded" has literal token "1"; cmdScaffold
|
||||
// writes the PADDED "01-VERIFICATION.md" into it via normalizePhaseName.
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('01-VERIFICATION.md', '1-unpadded'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('02-VERIFICATION.md', '1-unpadded'), false);
|
||||
});
|
||||
|
||||
test('#3511 blocker 3: digit-leading-slug family — own file uses the LEADING digit run, not the mis-absorbed token', () => {
|
||||
// "05-80-20-cleanup" tokenizes to "05-80-20" (#2528 mis-absorption), but
|
||||
// cmdScaffold writes "05-UAT.md"/"05-VERIFICATION.md" (leading digit run
|
||||
// only) — the same reading matchPhaseDirs' bare-integer fallback uses.
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('05-UAT.md', '05-80-20-cleanup'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('05-VERIFICATION.md', '05-80-20-cleanup'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('80-VERIFICATION.md', '05-80-20-cleanup'), false,
|
||||
'the slug word "80" must not be mistaken for a real phase number');
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('10-UAT.md', '10-24-7-autonomy'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('24-UAT.md', '10-24-7-autonomy'), false);
|
||||
});
|
||||
|
||||
test('case-insensitive letter suffix (review item 8): "03A-VERIFICATION.md" belongs to lowercase-suffixed "03a-foo"', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03A-VERIFICATION.md', '03a-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('03a-VERIFICATION.md', '03A-foo'), true);
|
||||
});
|
||||
|
||||
// WARNING-2 (#3511 review): the `firstLetterPrefixed` include-everything
|
||||
// branch (bracket-convention ambiguity fail-safe, docblock "BRACKET
|
||||
// CONVENTION" section) was untested on its own — only the ZERO-SEGMENT
|
||||
// fail-safe below had direct coverage. Pinned here so a later flip to
|
||||
// `false` for this family is caught rather than silently shipped.
|
||||
test('#3511 WARNING-2: bracket-ambiguous letter-prefixed-decimal dirs (firstLetterPrefixed) are the deliberate include-everything fail-safe', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('99-VERIFICATION.md', 'P0.3-2-slug'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('07-UAT.md', 'v2-migration'), true);
|
||||
});
|
||||
|
||||
// WARNING-5 (#3511 review): pin actual behavior for a decimal sub-phase
|
||||
// token compared against a same-leading-number-but-different-sub-phase
|
||||
// artifact — the exact-token-match rule (not a numeric-prefix match).
|
||||
test('#3511 WARNING-5: "35-VERIFICATION.md" (bare, no sub-phase) does not belong to decimal sub-phase dir "35.1-slug"', () => {
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('35-VERIFICATION.md', '35.1-slug'), false);
|
||||
});
|
||||
|
||||
test('fail-safe: a dir name with no derivable token includes EVERY file (never exclude)', () => {
|
||||
// derivePhaseTokenSegments finds zero segments for these dir names (same
|
||||
// condition extractPhaseToken treats as "return dirName unchanged" —
|
||||
// see the 'returns the full dirName when no numeric token found' test
|
||||
// above). Excluding on an unreliable token would make an aggregate gate
|
||||
// silently permissive in the wrong direction (dropping the phase's own
|
||||
// real blockers) — worse than the cross-phase-contamination bug #3511
|
||||
// fixes. Every file must be treated as belonging to the phase instead.
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('04-VERIFICATION.md', 'no-numeric'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('anything-at-all.md', 'alpha'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('99-UAT.md', 'phase-name-01'), true);
|
||||
});
|
||||
|
||||
test('#3511 Fix 2: a bare "VERIFICATION.md"/"UAT.md" (no dash, no token of its own) belongs by containment', () => {
|
||||
// src/state.cts:3740's S006 filter matches `f.includes('VERIFICATION')`
|
||||
// with no dash requirement, so a bare `VERIFICATION.md` (a form
|
||||
// core-utils.cts, init.cts and verification.cts all treat as valid) was
|
||||
// silently excluded pre-fix — S006 drift detection lost, S007 wrongly
|
||||
// flipped on. Directory containment is the only signal for a token-less
|
||||
// file, and it is sufficient.
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('VERIFICATION.md', '03-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('UAT.md', '03-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('VERIFICATION.md', 'CK-01-foo'), true);
|
||||
assert.strictEqual(phaseId.isPhaseArtifact('VERIFICATION.md', '1-unpadded'), true);
|
||||
});
|
||||
|
||||
// ── Property: over-exclusion (#3511 follow-up) ───────────────────────────────
|
||||
//
|
||||
// Every "own file still contributes" assertion above uses a HAND-PICKED
|
||||
// fixture. This property generates across the real dirName shapes (padded /
|
||||
// unpadded, project-code-prefixed, digit-leading slugs, decimals, letter
|
||||
// suffixes) and asserts, for EACH, that the artifact name `cmdScaffold`
|
||||
// (src/commands.cts, via `normalizePhaseName`) would actually write into
|
||||
// that directory is a member — the exact invariant all three #3511
|
||||
// follow-up blockers violated, and the one no hand-picked fixture pins on
|
||||
// its own.
|
||||
test('property: the file cmdScaffold would write into a phase dir is always isPhaseArtifact-true for that dir', () => {
|
||||
const artifactType = fc.constantFrom('UAT', 'VERIFICATION', 'CONTEXT');
|
||||
|
||||
// dirName shape generators mirroring the real on-disk families this
|
||||
// module's own docblocks (extractPhaseToken, matchPhaseDirs) enumerate.
|
||||
const letterSuffix = fc.constantFrom('', 'A', 'B', 'C');
|
||||
const projectCode = fc.constantFrom(null, 'CK', 'PROJ', 'APP1');
|
||||
const slugWord = fc.constantFrom('foo', 'cleanup', 'autonomy', 'follow-up');
|
||||
// Digit-leading-slug family (#2528): a second all-digit slug SEGMENT that
|
||||
// is NOT a genuine sub-phase — 2-3 digit words like "80", "100".
|
||||
const digitSlugSegment = fc.constantFrom(null, '80', '20', '100');
|
||||
|
||||
const plainDirNameGen = fc.record({
|
||||
code: projectCode,
|
||||
padded: fc.boolean(),
|
||||
num: fc.integer({ min: 1, max: 99 }),
|
||||
letter: letterSuffix,
|
||||
digitSlug: digitSlugSegment,
|
||||
slug: slugWord,
|
||||
}).map(({ code, padded, num, letter, digitSlug, slug }) => {
|
||||
const numStr = padded ? String(num).padStart(2, '0') : String(num);
|
||||
const token = `${numStr}${letter}`;
|
||||
const codePrefix = code ? `${code}-` : '';
|
||||
const digitTail = digitSlug ? `-${digitSlug}` : '';
|
||||
return { dirName: `${codePrefix}${token}${digitTail}-${slug}`, phase: token };
|
||||
});
|
||||
|
||||
// INFO-2 (#3511 review): a genuine decimal sub-phase dir (`05.3-slug`) —
|
||||
// the exact-token-match branch, not the digit-leading-slug fallback.
|
||||
const decimalDirNameGen = fc.record({
|
||||
code: projectCode,
|
||||
padded: fc.boolean(),
|
||||
major: fc.integer({ min: 1, max: 99 }),
|
||||
sub: fc.integer({ min: 1, max: 99 }),
|
||||
slug: slugWord,
|
||||
}).map(({ code, padded, major, sub, slug }) => {
|
||||
const majorStr = padded ? String(major).padStart(2, '0') : String(major);
|
||||
const token = `${majorStr}.${sub}`;
|
||||
const codePrefix = code ? `${code}-` : '';
|
||||
return { dirName: `${codePrefix}${token}-${slug}`, phase: token };
|
||||
});
|
||||
|
||||
// INFO-2 (#3511 review): the letter-prefixed-decimal family (`P0.3-2-slug`)
|
||||
// — string-indistinguishable from a bracket-dir token without an explicit
|
||||
// convention signal, so `isPhaseArtifact` treats it as the deliberate
|
||||
// `firstLetterPrefixed` include-everything fail-safe (see WARNING-2 test
|
||||
// above): every candidate file belongs, by construction.
|
||||
const letterPrefixedDecimalDirNameGen = fc.record({
|
||||
prefix: fc.constantFrom('P0', 'M1', 'A2'),
|
||||
major: fc.integer({ min: 1, max: 20 }),
|
||||
sub: fc.integer({ min: 1, max: 20 }),
|
||||
slug: slugWord,
|
||||
}).map(({ prefix, major, sub, slug }) => ({
|
||||
dirName: `${prefix}.${major}-${sub}-${slug}`,
|
||||
phase: `${major}-${sub}`,
|
||||
}));
|
||||
|
||||
const dirNameGen = fc.oneof(plainDirNameGen, decimalDirNameGen, letterPrefixedDecimalDirNameGen);
|
||||
|
||||
fc.assert(
|
||||
fc.property(dirNameGen, artifactType, ({ dirName, phase }, type) => {
|
||||
const padded = phaseId.normalizePhaseName(phase);
|
||||
const writtenFile = `${padded}-${type}.md`;
|
||||
return phaseId.isPhaseArtifact(writtenFile, dirName);
|
||||
}),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── scopeToPhase (#3511) ───────────────────────────────────────────────────
|
||||
//
|
||||
// scopeToPhase is a plain filter over isPhaseArtifact: `fileNames.filter(f =>
|
||||
// isPhaseArtifact(f, phaseDirName))`. An earlier follow-up ("WARNING 4") added
|
||||
// a rule that fell back to the unfiltered input whenever scoping would empty
|
||||
// a non-empty candidate set — but that defeated the actual #3511 fix: a phase
|
||||
// dir holding only a misfiled cross-phase report would publish that other
|
||||
// phase's status as its own. The fallback rule has been REMOVED; an empty
|
||||
// result is now the honest answer for "this phase has none of its own".
|
||||
describe('scopeToPhase (#3511)', () => {
|
||||
test('mixed set: own file kept, stray dropped', () => {
|
||||
assert.deepStrictEqual(
|
||||
phaseId.scopeToPhase(['03-VERIFICATION.md', '04-VERIFICATION.md'], '03-foo'),
|
||||
['03-VERIFICATION.md'],
|
||||
);
|
||||
});
|
||||
|
||||
test('empty input: returns empty', () => {
|
||||
assert.deepStrictEqual(phaseId.scopeToPhase([], '03-foo'), []);
|
||||
});
|
||||
|
||||
test('underivable dir token: every file passes through unchanged (isPhaseArtifact fail-safe)', () => {
|
||||
assert.deepStrictEqual(
|
||||
phaseId.scopeToPhase(['04-VERIFICATION.md', 'anything-at-all.md'], 'no-numeric'),
|
||||
['04-VERIFICATION.md', 'anything-at-all.md'],
|
||||
);
|
||||
});
|
||||
|
||||
test('#3511: a phase dir holding ONLY another phase\'s report scopes to empty — ' +
|
||||
'an empty result is the honest answer, not a reason to fall back to the stray', () => {
|
||||
// Removing this behavior is the whole point of #3511: returning
|
||||
// 04-VERIFICATION.md here would publish phase 04's status as phase 03's.
|
||||
assert.deepStrictEqual(
|
||||
phaseId.scopeToPhase(['04-VERIFICATION.md'], '03-foo'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('property: a phase dir never scopes away its own canonically-named artifact', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
fc.stringMatching(/^[a-z]{1,8}$/),
|
||||
(num, slug) => {
|
||||
const padded = String(num).padStart(2, '0');
|
||||
for (const dirNum of new Set([padded, String(num)])) {
|
||||
const dirName = `${dirNum}-${slug}`;
|
||||
for (const fileNum of new Set([padded, String(num)])) {
|
||||
const own = `${fileNum}-VERIFICATION.md`;
|
||||
assert.deepStrictEqual(
|
||||
phaseId.scopeToPhase([own], dirName),
|
||||
[own],
|
||||
`${own} is phase ${dirNum}'s own artifact in ${dirName} and must survive scoping`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 300 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── phaseTokenMatches ────────────────────────────────────────────────────────
|
||||
|
||||
describe('phaseTokenMatches', () => {
|
||||
|
||||
@@ -2473,6 +2473,119 @@ describe('phase remove command', () => {
|
||||
assert.ok(roadmap.includes('Phase 2: Features'), 'phase 3 should be renumbered to 2');
|
||||
});
|
||||
|
||||
// WARNING-3 (#3511 review): renameIntegerPhases renames a phase directory
|
||||
// whose leading number is UNPADDED (dir regex accepts bare `\d+`), but
|
||||
// renamed its artifact files only against the 2-PADDED prefix, so an
|
||||
// unpadded-numbered artifact desynced from its now-renamed directory and
|
||||
// the phase read `missing` afterward.
|
||||
test('#3511 WARNING-3: renames an unpadded-numbered artifact alongside its unpadded dir', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 9: B\n**Goal:** B\n`
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||||
const p9 = path.join(tmpDir, '.planning', 'phases', '9-slug');
|
||||
fs.mkdirSync(p9, { recursive: true });
|
||||
// Unpadded artifact filename, paired with the unpadded dir number.
|
||||
fs.writeFileSync(path.join(p9, '9-VERIFICATION.md'), '---\nstatus: passed\n---\n\nVerified OK.');
|
||||
|
||||
const result = runGsdTools('phase remove 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const newDir = path.join(tmpDir, '.planning', 'phases', '08-slug');
|
||||
assert.ok(fs.existsSync(newDir), 'phase 9 should be renumbered to 08-slug');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(newDir, '08-VERIFICATION.md')),
|
||||
'the unpadded 9-VERIFICATION.md should be renamed to 08-VERIFICATION.md alongside the dir'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(newDir, '9-VERIFICATION.md')),
|
||||
'the stale unpadded-prefix file should no longer exist'
|
||||
);
|
||||
|
||||
const findResult = runGsdTools('find-phase 8', tmpDir);
|
||||
assert.ok(findResult.success, `find-phase failed: ${findResult.error}`);
|
||||
const findOutput = JSON.parse(findResult.output);
|
||||
assert.strictEqual(findOutput.found, true, 'renumbered phase 8 should resolve');
|
||||
});
|
||||
|
||||
// #3511 BLOCKER-2 follow-up (security-review regression): the collision
|
||||
// guard added to stop the original overwrite-and-lose-data bug (BLOCKER-1)
|
||||
// introduced a NEW wrong-answer regression — skipping the rename let a
|
||||
// STRAY cross-phase file outrank the phase's own report at the canonical
|
||||
// name. Phase 9's directory holds its OWN `09-VERIFICATION.md` (gaps_found)
|
||||
// and a stray `08-VERIFICATION.md` (passed) that actually belongs to phase
|
||||
// 8. Removing phase 8 renumbers phase 9 -> phase 8 and must displace the
|
||||
// stray (never overwrite it, never let it win) so the phase's own report
|
||||
// lands at the canonical name.
|
||||
test('#3511 BLOCKER-2 follow-up: collision displaces the occupying file instead of skip-or-overwrite', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n### Phase 8: A\n**Goal:** A\n### Phase 9: B\n**Goal:** B\n`
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '08-a'), { recursive: true });
|
||||
const p9 = path.join(tmpDir, '.planning', 'phases', '09-foo');
|
||||
fs.mkdirSync(p9, { recursive: true });
|
||||
// The phase's OWN report.
|
||||
fs.writeFileSync(
|
||||
path.join(p9, '09-VERIFICATION.md'),
|
||||
'---\nstatus: gaps_found\n---\n\nOwn report for phase 9.'
|
||||
);
|
||||
// A STRAY report belonging to phase 8, sitting inside phase 9's directory.
|
||||
fs.writeFileSync(
|
||||
path.join(p9, '08-VERIFICATION.md'),
|
||||
'---\nstatus: passed\n---\n\nStray report belonging to phase 8.'
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase remove 8', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
const newDir = path.join(tmpDir, '.planning', 'phases', '08-foo');
|
||||
assert.ok(fs.existsSync(newDir), 'phase 9 should be renumbered to 08-foo');
|
||||
|
||||
// (a) no file is lost — both original contents still exist on disk.
|
||||
const canonicalPath = path.join(newDir, '08-VERIFICATION.md');
|
||||
const displacedPath = path.join(newDir, '08-VERIFICATION.md.orphaned');
|
||||
assert.ok(fs.existsSync(canonicalPath), 'canonical 08-VERIFICATION.md must exist');
|
||||
assert.ok(fs.existsSync(displacedPath), 'the displaced stray file must still exist on disk');
|
||||
const canonical = fs.readFileSync(canonicalPath, 'utf-8');
|
||||
const displaced = fs.readFileSync(displacedPath, 'utf-8');
|
||||
assert.ok(
|
||||
displaced.includes('Stray report belonging to phase 8'),
|
||||
'displaced file must retain the stray content'
|
||||
);
|
||||
|
||||
// (b) 08-VERIFICATION.md in the renamed dir is the phase's OWN former
|
||||
// 09-VERIFICATION.md — assert on its body/status, not just its name.
|
||||
assert.ok(
|
||||
canonical.includes('status: gaps_found'),
|
||||
`canonical 08-VERIFICATION.md must be the phase's own report (gaps_found), ` +
|
||||
`not the stray (passed). Got: ${canonical}`
|
||||
);
|
||||
assert.ok(
|
||||
canonical.includes('Own report for phase 9'),
|
||||
'canonical file body must be the phase\'s own former 09-VERIFICATION.md content'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(newDir, '09-VERIFICATION.md')),
|
||||
'the stale 09-VERIFICATION.md name should no longer exist'
|
||||
);
|
||||
|
||||
// (c) the displacement is reported in the command output.
|
||||
assert.ok(
|
||||
Array.isArray(output.renamed_file_collisions),
|
||||
'renamed_file_collisions must be present in the output'
|
||||
);
|
||||
const entry = output.renamed_file_collisions.find((c) => c.to === '08-VERIFICATION.md');
|
||||
assert.ok(
|
||||
entry,
|
||||
`expected a collision entry for 08-VERIFICATION.md, got: ${JSON.stringify(output.renamed_file_collisions)}`
|
||||
);
|
||||
assert.strictEqual(entry.from, '09-VERIFICATION.md');
|
||||
assert.strictEqual(entry.displaced_to, '08-VERIFICATION.md.orphaned');
|
||||
});
|
||||
|
||||
test('rejects removal of phase with summaries unless --force', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
@@ -8777,7 +8890,7 @@ function writePassedVerificationFile(phaseDir, phase = '01') {
|
||||
* - Phase 01 directory with one plan+summary (to satisfy phase complete guard)
|
||||
* - Phase 02 directory (next phase)
|
||||
*/
|
||||
function createFixture(prefix = 'gsd-4-regression-') {
|
||||
function createFixture(prefix = 'gsd-4-regression-', phase01DirName = '01-foundation') {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
@@ -8845,7 +8958,7 @@ function createFixture(prefix = 'gsd-4-regression-') {
|
||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
|
||||
|
||||
// Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes
|
||||
const phase01Dir = path.join(phasesDir, '01-foundation');
|
||||
const phase01Dir = path.join(phasesDir, phase01DirName);
|
||||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
||||
@@ -9231,6 +9344,87 @@ describe('#3057 B3: cmdPhaseComplete — verification staleness-check indetermin
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3511: cmdPhaseComplete — UAT/VERIFICATION advisory pre-scan is phase-scoped
|
||||
//
|
||||
// A cross-phase, stray, or ad-hoc file sitting in this phase's directory must
|
||||
// not name an advisory warning against this phase — and this phase's own
|
||||
// UAT/VERIFICATION files must keep warning exactly as before (non-stray case
|
||||
// unchanged). Covers BOTH loops scoped by #3511 (the UAT loop and the
|
||||
// VERIFICATION loop).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3511: cmdPhaseComplete — advisory pre-scan warnings are phase-scoped', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('a cross-phase stray UAT/VERIFICATION file does not contribute a warning; this phase\'s own files still do', (t) => {
|
||||
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
|
||||
// This phase's own UAT file — must still produce its usual warning.
|
||||
fs.writeFileSync(path.join(phase01Dir, '01-UAT.md'), [
|
||||
'---', 'status: partial', '---', '',
|
||||
'### 1. Test A', 'expected: A', 'result: pending', '',
|
||||
].join('\n'));
|
||||
|
||||
// Cross-phase strays sitting in phase 01's directory — token "02", not
|
||||
// "01" — must NOT contribute a warning to phase 01's completion.
|
||||
fs.writeFileSync(path.join(phase01Dir, '02-UAT.md'), [
|
||||
'---', 'status: partial', '---', '',
|
||||
'### 1. Test B', 'expected: B', 'result: blocked', '',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(phase01Dir, '02-VERIFICATION.md'), [
|
||||
'---', 'status: human_needed', '---', '',
|
||||
'# Verification', '',
|
||||
].join('\n'));
|
||||
|
||||
const output = JSON.parse(capturePhaseComplete(t, tmpDir, '1'));
|
||||
|
||||
assert.strictEqual(output.completed_phase, '1');
|
||||
assert.ok(
|
||||
output.warnings.some((w) => w.includes('01-UAT.md') && w.includes('has pending tests')),
|
||||
`own UAT file must still warn; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!output.warnings.some((w) => w.includes('02-UAT.md')),
|
||||
`stray UAT file must not contribute a warning; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!output.warnings.some((w) => w.includes('02-VERIFICATION.md') || /needs human verification/.test(w)),
|
||||
`stray VERIFICATION file must not contribute a warning; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3511 follow-up: own UAT file still warns from a NON-canonical dir shape "1-unpadded" (over-exclusion check)', (t) => {
|
||||
const unpaddedTmpDir = createFixture('gsd-4-regression-unpadded-', '1-unpadded');
|
||||
try {
|
||||
const phaseDir = path.join(unpaddedTmpDir, '.planning', 'phases', '1-unpadded');
|
||||
// "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED
|
||||
// "01-…" form. A literal token compare excluded the phase's own file.
|
||||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||||
'---', 'status: partial', '---', '',
|
||||
'### 1. Test A', 'expected: A', 'result: pending', '',
|
||||
].join('\n'));
|
||||
|
||||
const output = JSON.parse(capturePhaseComplete(t, unpaddedTmpDir, '1'));
|
||||
|
||||
assert.ok(
|
||||
output.warnings.some((w) => w.includes('01-UAT.md') && w.includes('has pending tests')),
|
||||
`own UAT file in an unpadded-dir phase must still warn; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(unpaddedTmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Regressions: phase complete preserves completion date (#1161)
|
||||
// Tests drive the REAL handler (cmdPhaseComplete) via the CLI entry point
|
||||
|
||||
@@ -43,6 +43,7 @@ const { worstScope } = planningSnapshotLib;
|
||||
|
||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||
const { _unusableInputEmissionCountForTests } = require('../gsd-core/bin/lib/unusable-input.cjs');
|
||||
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
|
||||
// Phase 11 (#3309) additions — agent-install fixture helper mirrors
|
||||
// tests/agent-install-check.test.cjs's own EXPECTED_AGENTS/createCompleteAgents
|
||||
@@ -107,9 +108,10 @@ function makeDirUnreadableAsFile(fullPath) {
|
||||
// on a "healthy" phase (Phase 12, #3310) get a genuinely clean baseline
|
||||
// rather than a false positive from a plan that predates the `wave:` field.
|
||||
function makeCompletePhaseDir(cwd, relPhaseDir) {
|
||||
const phaseNum = normalizePhaseName(path.basename(relPhaseDir));
|
||||
writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '---\nwave: 1\n---\n\n# Plan\n');
|
||||
writeFile(cwd, `${relPhaseDir}/01-01-SUMMARY.md`, '# Summary\n');
|
||||
writeFile(cwd, `${relPhaseDir}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n');
|
||||
writeFile(cwd, `${relPhaseDir}/${phaseNum}-VERIFICATION.md`, '---\nstatus: passed\n---\n');
|
||||
}
|
||||
|
||||
function buildHealthyTwoPhaseFixture(cwd) {
|
||||
@@ -955,6 +957,24 @@ describe('researchValidationStatus field (Phase 11, #3309)', () => {
|
||||
assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false });
|
||||
});
|
||||
|
||||
test('#3511-class: a phase dir holding only ANOTHER phase\'s -RESEARCH.md/-VALIDATION.md does not set this phase\'s flags', (t) => {
|
||||
const cwd = createTempDir('gsd-3511-rvs4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
|
||||
// Phase 01's directory is empty. Phase 02's directory holds ONLY stray
|
||||
// artifacts whose filename token ("01-") belongs to phase 01, not to
|
||||
// this directory's own phase (02).
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
|
||||
writeFile(cwd, '.planning/phases/02-bar/01-RESEARCH.md', '# Research\n\n## Validation Architecture\n\ntext\n');
|
||||
writeFile(cwd, '.planning/phases/02-bar/01-VALIDATION.md', '# Validation\n');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entry = snap.researchValidationStatus.value.find((r) => r.dir === '02-bar');
|
||||
assert.deepStrictEqual(entry, { dir: '02-bar', hasValidationArchitecture: false, hasValidationMd: false },
|
||||
'phase 2 must not report validation status from a file that belongs to phase 1');
|
||||
});
|
||||
|
||||
test('hostile: an unreadable phase directory degrades that entry to false/false without throwing', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-rvs3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
|
||||
@@ -370,7 +370,7 @@ describe('withPlanningLock PID-liveness staleness + EEXIST safety (audit M1+M2)'
|
||||
__foldDescribe("folded:bug-3739-gap-checker-padded-prefix-context (consolidation epic #1969 B3 #1972)", () => {
|
||||
/**
|
||||
* Bug #3739: gap-analysis silently skips CONTEXT.md decisions when the file
|
||||
* uses the padded-prefix convention (e.g. 01-CONTEXT.md, 02.1-CONTEXT.md).
|
||||
* uses the padded-prefix convention (e.g. 01-CONTEXT.md, 01.1-CONTEXT.md).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Padded-prefix CONTEXT.md (NN-CONTEXT.md) decisions ARE included in the
|
||||
@@ -481,10 +481,10 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
|
||||
assert.strictEqual(d05.status, 'Covered', 'D-05 must be Covered');
|
||||
});
|
||||
|
||||
// ── Test 4: deeper padded prefix (02.1-CONTEXT.md) ───────────────────────
|
||||
// ── Test 4: deeper padded prefix (01.1-CONTEXT.md) ───────────────────────
|
||||
|
||||
test('multi-segment padded prefix (02.1-CONTEXT.md) decisions appear in gap report', () => {
|
||||
writeContextAs('02.1-CONTEXT.md', [
|
||||
test('multi-segment padded prefix (01.1-CONTEXT.md) decisions appear in gap report', () => {
|
||||
writeContextAs('01.1-CONTEXT.md', [
|
||||
{ id: 'D-03', text: 'Use postgres' },
|
||||
]);
|
||||
writePlan('01', '# Plan\n\nImplements D-03.\n');
|
||||
@@ -494,7 +494,7 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
|
||||
const out = JSON.parse(r.output);
|
||||
|
||||
const d03 = out.rows.find(x => x.item === 'D-03');
|
||||
assert.ok(d03, 'D-03 must appear from 02.1-CONTEXT.md');
|
||||
assert.ok(d03, 'D-03 must appear from 01.1-CONTEXT.md');
|
||||
assert.strictEqual(d03.status, 'Covered');
|
||||
});
|
||||
|
||||
|
||||
@@ -312,6 +312,26 @@ describe('gap-analysis CLI (#2493)', () => {
|
||||
assert.strictEqual(out.rows[0].source, 'REQUIREMENTS.md');
|
||||
});
|
||||
|
||||
test('#3511-class: a phase dir holding only ANOTHER phase\'s -CONTEXT.md is treated as CONTEXT-missing', () => {
|
||||
// phaseDir is '01-test' for this describe block's own scenarios; use a
|
||||
// SEPARATE phase 02 directory that holds ONLY a stray artifact whose
|
||||
// filename token ("01-") belongs to phase 01, not to this directory's
|
||||
// own phase (02).
|
||||
const otherPhaseDir = path.join(tmpDir, '.planning', 'phases', '02-other');
|
||||
fs.mkdirSync(otherPhaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(otherPhaseDir, '01-CONTEXT.md'),
|
||||
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** foo\n</decisions>\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(otherPhaseDir, '02-PLAN.md'), '# Plan mentioning D-01\n');
|
||||
|
||||
const r = runGsdTools(['gap-analysis', '--phase-dir', otherPhaseDir], tmpDir);
|
||||
assert.ok(r.success, r.error);
|
||||
const out = JSON.parse(r.output);
|
||||
assert.deepStrictEqual(out.rows, [],
|
||||
'phase 02 must not report a CONTEXT.md decision that belongs to phase 01\'s file');
|
||||
});
|
||||
|
||||
test('both REQUIREMENTS.md and CONTEXT.md missing → no error, empty rows', () => {
|
||||
writePlan('01', '# Plan\n');
|
||||
const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir);
|
||||
|
||||
@@ -401,6 +401,39 @@ describe('roadmap analyze disk status variants', () => {
|
||||
assert.strictEqual(output.phases[0].has_research, true, 'has_research should be true');
|
||||
});
|
||||
|
||||
test('#3511-class: roadmap analyze has_research/has_context ignore another phase\'s misplaced artifact', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
### Phase 1: Setup
|
||||
**Goal:** Build the foundation
|
||||
|
||||
### Phase 2: API
|
||||
**Goal:** Build the API
|
||||
`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
||||
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
||||
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||||
fs.mkdirSync(p2, { recursive: true });
|
||||
fs.writeFileSync(path.join(p2, '01-RESEARCH.md'), '# Research for phase 01');
|
||||
fs.writeFileSync(path.join(p2, '01-CONTEXT.md'), '# Context for phase 01');
|
||||
|
||||
const result = runGsdTools('roadmap analyze', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const phase2 = output.phases.find((p) => p.number === '2');
|
||||
assert.strictEqual(phase2.has_research, false,
|
||||
'phase 2 must not report has_research from a file that belongs to phase 1');
|
||||
assert.strictEqual(phase2.has_context, false,
|
||||
'phase 2 must not report has_context from a file that belongs to phase 1');
|
||||
});
|
||||
|
||||
test('returns discussed status for phase dir with only CONTEXT.md', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
|
||||
@@ -4135,6 +4135,122 @@ describe('#3310 state validate — S0NN coded diagnostics', () => {
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('#3511: S006 does not fire from a cross-phase stray VERIFICATION file; this phase\'s own file still fires it', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// No summary — isolates this test from the S007 "all plans summarized" path.
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
// Cross-phase stray sitting in phase 01's directory — token "02", not "01".
|
||||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s006Warnings = (output.warnings || []).filter((w) => w.code === 'S006');
|
||||
assert.strictEqual(s006Warnings.length, 1,
|
||||
`exactly one S006 must fire (this phase's own file only); got: ${JSON.stringify(s006Warnings)}`);
|
||||
assert.ok(s006Warnings[0].message.includes('01-VERIFICATION.md'),
|
||||
`S006 must name this phase's own file; got: ${s006Warnings[0].message}`);
|
||||
assert.ok(!s006Warnings[0].message.includes('02-VERIFICATION.md'),
|
||||
`S006 must not name the cross-phase stray; got: ${s006Warnings[0].message}`);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('#3511: S007 fires when the only VERIFICATION.md present is a cross-phase stray (unscoped counting would have wrongly suppressed it)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
|
||||
// No own VERIFICATION.md — only a cross-phase stray (token "02"). Scoped
|
||||
// counting must treat this phase as having NO verification file, so S007
|
||||
// ("all plans summarized but still executing") must still fire.
|
||||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s007 = findWarning(output, 'S007');
|
||||
assert.ok(s007,
|
||||
'S007 must fire: the only VERIFICATION.md present belongs to a different phase, so this phase has none of its own');
|
||||
assert.ok(!findWarning(output, 'S006'), 'the cross-phase stray must not fire S006 either');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('#3511 follow-up: S006 still fires from a NON-canonical dir shape "1-unpadded" (over-exclusion check)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
|
||||
);
|
||||
// "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED
|
||||
// "01-VERIFICATION.md" form. A literal token compare excluded it.
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '1-unpadded');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, `S006 must still fire for the phase's own file in a non-canonical dir; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.ok(s006.message.includes('01-VERIFICATION.md'));
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('#3511 Fix 2: S006 fires from a bare "VERIFICATION.md" (no dash, no token of its own)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
// Bare form (no dash) — core-utils.cts, init.cts and verification.cts all
|
||||
// treat this as a valid report; a literal `<token>-` prefix check can
|
||||
// never match it, silently losing S006 drift detection.
|
||||
fs.writeFileSync(path.join(phaseDir, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, `S006 must fire for a bare VERIFICATION.md; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.ok(s006.message.includes('VERIFICATION.md'));
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('#3511 WARNING-4: an underscore-separated "03_VERIFICATION.md" (broader .includes(\'VERIFICATION\') grammar) still fires S006 in its own phase dir', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 3\n**Current Phase:** 3\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
|
||||
);
|
||||
// state.cts's own pre-filter (`.includes('VERIFICATION')`, no dash) is
|
||||
// deliberately broader than the `-VERIFICATION.md` grammar every other
|
||||
// #3511 site uses, so this underscore-separated name passes it.
|
||||
// `scopeToPhase`/`isPhaseArtifact` (`phase-id.cts`) accept `_` alongside
|
||||
// `-` and `.` as a candidate-boundary separator specifically so this
|
||||
// broader pre-filter's own-phase matches aren't dropped after the fact —
|
||||
// "03_VERIFICATION.md" in phase 03's directory IS this phase's own file,
|
||||
// and the aggregate scan must not report it as absent. S006 fires here.
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-foo');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '03_VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, `S006 must fire for "03_VERIFICATION.md" in its own phase dir; got: ${JSON.stringify(output.warnings)}`);
|
||||
const s007 = findWarning(output, 'S007');
|
||||
assert.ok(!s007, `S007 must not fire once S006 covers the passed own-phase verification; got: ${JSON.stringify(output.warnings)}`);
|
||||
});
|
||||
|
||||
test('output never carries a drift key, across clean/warning/error shapes (breaking-change proof, test matrix row 18)', () => {
|
||||
// Clean shape.
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -1011,6 +1011,83 @@ describe('evaluateUatPassed — output shape (Hyrum\'s Law contract)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3511: phase-scoped UAT/VERIFICATION scanning — the transition gate ─────
|
||||
//
|
||||
// evaluateUatPassed is the CRITICAL anchor for #3511: its `passed`/`blockers`
|
||||
// fields directly gate a phase transition. A cross-phase stray file sitting
|
||||
// in a phase directory must never contribute a blocker to a phase it does not
|
||||
// belong to, and the phase's own artifacts must keep behaving exactly as
|
||||
// before.
|
||||
|
||||
describe('#3511: evaluateUatPassed — cross-phase stray files do not contribute blockers', () => {
|
||||
let baseDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-uat-pred-'));
|
||||
// Phase-shaped basename ("03-…") so isPhaseArtifact actually scopes —
|
||||
// extractPhaseToken('03-uat-predicate') derives token "03".
|
||||
phaseDir = path.join(baseDir, '03-uat-predicate');
|
||||
fs.mkdirSync(phaseDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmDir(baseDir);
|
||||
});
|
||||
|
||||
test('passed:true with a passing own UAT + own VERIFICATION, despite a blocking cross-phase stray VERIFICATION', () => {
|
||||
writeFile(phaseDir, '03-UAT.md', makePassingUat(1));
|
||||
writeFile(phaseDir, '03-VERIFICATION.md', '---\nstatus: passed\n---\n\nOK.');
|
||||
// Cross-phase stray: a "04" VERIFICATION file sitting in phase 03's
|
||||
// directory, with a BLOCKING status. Pre-#3511, this unscoped scan would
|
||||
// have picked it up and blocked phase 03's transition.
|
||||
writeFile(phaseDir, '04-VERIFICATION.md', '---\nstatus: human_needed\n---\n\nNeeds human check.');
|
||||
|
||||
const report = evaluateUatPassed(phaseDir);
|
||||
assert.strictEqual(report.passed, true,
|
||||
'a cross-phase stray VERIFICATION file must not block this phase\'s transition');
|
||||
assert.ok(
|
||||
!report.blockers.some(b => /04-VERIFICATION\.md/.test(b) || /human_needed/i.test(b)),
|
||||
`blockers must not name the stray file; got: ${JSON.stringify(report.blockers)}`,
|
||||
);
|
||||
assert.strictEqual(report.verification_files.includes('04-VERIFICATION.md'), false,
|
||||
'the stray must not even be counted as a verification_files entry for this phase');
|
||||
});
|
||||
|
||||
test('passed:false with own report still failing, unaffected by an unrelated passing cross-phase stray (non-stray case unchanged)', () => {
|
||||
writeFile(phaseDir, '03-UAT.md', makePassingUat(1));
|
||||
// This phase's own VERIFICATION is blocking.
|
||||
writeFile(phaseDir, '03-VERIFICATION.md', '---\nstatus: gaps_found\n---\n\nHas gaps.');
|
||||
// A cross-phase stray that is itself passing must not paper over the
|
||||
// phase's own real failure either — over-exclusion is as dangerous as
|
||||
// under-exclusion here.
|
||||
writeFile(phaseDir, '99-VERIFICATION.md', '---\nstatus: passed\n---\n\nOK.');
|
||||
|
||||
const report = evaluateUatPassed(phaseDir);
|
||||
assert.strictEqual(report.passed, false,
|
||||
'the phase\'s own gaps_found VERIFICATION must still block, exactly as before #3511');
|
||||
assert.ok(report.blockers.some(b => /gaps_found/i.test(b)),
|
||||
`blockers must still name this phase's own gaps_found status; got: ${JSON.stringify(report.blockers)}`);
|
||||
});
|
||||
|
||||
test('#3511 follow-up: passed:true from a NON-canonical dir shape "1-unpadded" (over-exclusion / no_uat_artifacts check)', () => {
|
||||
// "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED
|
||||
// "01-…" form (normalizePhaseName). A literal token compare excluded the
|
||||
// phase's own artifacts here, flipping `no_uat_artifacts: true` and
|
||||
// false-blocking the transition gate this predicate feeds.
|
||||
const unpaddedDir = path.join(baseDir, '1-unpadded');
|
||||
fs.mkdirSync(unpaddedDir);
|
||||
writeFile(unpaddedDir, '01-UAT.md', makePassingUat(1));
|
||||
writeFile(unpaddedDir, '01-VERIFICATION.md', '---\nstatus: passed\n---\n\nOK.');
|
||||
|
||||
const report = evaluateUatPassed(unpaddedDir);
|
||||
assert.strictEqual(report.no_uat_artifacts, false,
|
||||
`own UAT/VERIFICATION files in an unpadded-dir phase must be found; got: ${JSON.stringify(report)}`);
|
||||
assert.strictEqual(report.passed, true,
|
||||
`the phase's own passing files in a non-canonical dir must pass the gate; got: ${JSON.stringify(report)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX A regression: nested-fence (~~~ inside ```) ─────────────────────────
|
||||
|
||||
describe('FIX A — nested fence: ~~~ inside ``` does not prematurely close outer fence', () => {
|
||||
|
||||
@@ -433,6 +433,78 @@ All checks passed.
|
||||
assert.strictEqual(output.summary.total_files, 0);
|
||||
});
|
||||
|
||||
// #3511: a cross-phase, stray, or ad-hoc UAT/VERIFICATION file sitting in
|
||||
// this phase's directory must not surface under this phase's audit-uat
|
||||
// entry; this phase's own UAT/VERIFICATION artifacts must keep reporting
|
||||
// exactly as before (non-stray case unchanged).
|
||||
test('#3511: cross-phase stray UAT/VERIFICATION files in the same dir do not surface; own artifacts still do', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-foo');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
// This phase's own UAT — must still report its pending item.
|
||||
fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), [
|
||||
'---', 'status: partial', '---', '',
|
||||
'## Tests', '',
|
||||
'### 1. Own Test', 'expected: Works', 'result: pending', '',
|
||||
].join('\n'));
|
||||
// This phase's own VERIFICATION — must still report its human-needed item.
|
||||
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), [
|
||||
'---', 'status: human_needed', 'phase: 03-foo', '---', '',
|
||||
'## Human Verification', '',
|
||||
'1. Own human check',
|
||||
].join('\n'));
|
||||
|
||||
// Cross-phase strays sitting in the SAME directory — token "04", not "03".
|
||||
fs.writeFileSync(path.join(phaseDir, '04-UAT.md'), [
|
||||
'---', 'status: partial', '---', '',
|
||||
'## Tests', '',
|
||||
'### 1. Stray Test', 'expected: Works', 'result: pending', '',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
|
||||
'---', 'status: human_needed', 'phase: 04-bar', '---', '',
|
||||
'## Human Verification', '',
|
||||
'1. Stray human check',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.summary.total_files, 2,
|
||||
`only this phase's own 2 files must be scanned; got: ${JSON.stringify(output.results.map(r => r.file))}`);
|
||||
assert.strictEqual(output.summary.total_items, 2,
|
||||
`1 own UAT item + 1 own VERIFICATION item, strays excluded; got: ${output.summary.total_items}`);
|
||||
assert.strictEqual(output.summary.by_phase['03'], 2, 'own phase must be credited both items');
|
||||
assert.ok(!('04' in output.summary.by_phase), 'the cross-phase stray must not appear in by_phase at all');
|
||||
assert.ok(!result.output.includes('04-UAT.md'), 'stray UAT filename must never surface in the output');
|
||||
assert.ok(!result.output.includes('04-VERIFICATION.md'), 'stray VERIFICATION filename must never surface in the output');
|
||||
assert.ok(output.results.some(r => r.file === '03-UAT.md' && r.items.some(i => i.name === 'Own Test')));
|
||||
assert.ok(output.results.some(r => r.file === '03-VERIFICATION.md' && r.items.some(i => i.name === 'Own human check')));
|
||||
});
|
||||
|
||||
// #3511 follow-up: over-exclusion check on the #2528 digit-leading-slug
|
||||
// family. "05-80-20-cleanup" tokenizes to "05-80-20" (mis-absorbed past
|
||||
// the digit run scaffold actually writes into), so a literal token compare
|
||||
// excluded the phase's own report — audit-uat reported total_files: 0.
|
||||
test('#3511 follow-up: own UAT file still surfaces from the digit-leading-slug dir "05-80-20-cleanup" (over-exclusion check)', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-80-20-cleanup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, '05-UAT.md'), [
|
||||
'---', 'status: partial', '---', '',
|
||||
'## Tests', '',
|
||||
'### 1. Own Test', 'expected: Works', 'result: pending', '',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
assert.strictEqual(output.summary.total_files, 1,
|
||||
`own UAT file in a digit-leading-slug dir must still surface; got: ${JSON.stringify(output)}`);
|
||||
assert.strictEqual(output.summary.by_phase['05'], 1);
|
||||
});
|
||||
|
||||
// Regression: #2286 — parseUatItems never scanned a `## Gaps` section, so a
|
||||
// *-UAT.md file recording its only outstanding findings there returned
|
||||
// total_items: 0 (false-clean). Boundary: 0 / 1 / 2+ unresolved entries.
|
||||
|
||||
@@ -59,11 +59,18 @@ const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a temporary phase directory under os.tmpdir().
|
||||
* Returns the absolute path; caller must clean up.
|
||||
* Create a temporary phase directory named like a real one (`NN-slug`),
|
||||
* inside a throwaway parent. #3511: a phase directory's own name determines
|
||||
* which files count as ITS artifacts, so a fixture whose basename does not
|
||||
* name the same phase as the files written into it is not a valid phase dir.
|
||||
* @param {string} suffix - test-distinguishing suffix for the parent
|
||||
* @param {string} phaseDirName - basename of the phase dir (default '01-foo')
|
||||
*/
|
||||
function mkPhaseDir(suffix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
|
||||
function mkPhaseDir(suffix, phaseDirName = '01-foo') {
|
||||
const parent = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
|
||||
const phaseDir = path.join(parent, phaseDirName);
|
||||
fs.mkdirSync(phaseDir);
|
||||
return phaseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +106,7 @@ describe('verification-status', () => {
|
||||
assert.equal(result.next_command, '', 'next_command must be empty for passed');
|
||||
assert.ok(result.next_action.length > 0, 'next_action must be non-empty');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,7 +136,14 @@ describe('verification-status', () => {
|
||||
|
||||
// ── Case 3: human_needed ──────────────────────────────────────────────────
|
||||
test('status: human_needed → status human_needed, next_command is empty', () => {
|
||||
const dir = mkPhaseDir('human-needed');
|
||||
// Deliberately non-numeric dir basename ("human-needed" has no digits at
|
||||
// all) — extractPhaseToken has no derivable token, so (a) isPhaseArtifact's
|
||||
// fail-safe still includes 01-hn-VERIFICATION.md as this "phase"'s own
|
||||
// report, and (b) the next_command number-append check (which requires a
|
||||
// PURELY numeric token) never fires. This is what this test is actually
|
||||
// pinning — see the comment below — so the dir name must stay non-numeric,
|
||||
// not the realistic 'NN-slug' default.
|
||||
const dir = mkPhaseDir('human-needed', 'human-needed');
|
||||
try {
|
||||
writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed');
|
||||
const result = readVerificationStatus(dir);
|
||||
@@ -139,13 +153,16 @@ describe('verification-status', () => {
|
||||
assert.equal(result.next_command, '/gsd-verify-work');
|
||||
assert.ok(result.next_action.length > 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 4: no *-VERIFICATION.md → missing ────────────────────────────────
|
||||
test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => {
|
||||
const dir = mkPhaseDir('missing');
|
||||
// Non-numeric dir basename: next_command asserts no phase-number argument
|
||||
// is appended, which requires extractPhaseToken(dirName) to not be purely
|
||||
// numeric — see the human_needed test above for the same rationale.
|
||||
const dir = mkPhaseDir('missing', 'missing');
|
||||
try {
|
||||
// write a non-matching file to confirm it is ignored
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# phase');
|
||||
@@ -158,13 +175,15 @@ describe('verification-status', () => {
|
||||
`next_action must reassure the user execute-phase will not redo work (#1762); got: ${result.next_action}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 5: unknown frontmatter status value ──────────────────────────────
|
||||
test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => {
|
||||
const dir = mkPhaseDir('unknown');
|
||||
// Non-numeric dir basename: next_command asserts no phase-number argument
|
||||
// is appended — see the human_needed test above for the same rationale.
|
||||
const dir = mkPhaseDir('unknown', 'unknown');
|
||||
try {
|
||||
writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus');
|
||||
const result = readVerificationStatus(dir);
|
||||
@@ -179,7 +198,7 @@ describe('verification-status', () => {
|
||||
`next_action must acknowledge an unrecognized status may be an intentional marker (#1762); got: ${result.next_action}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -225,7 +244,7 @@ describe('verification-status', () => {
|
||||
);
|
||||
assert.equal(result.next_command, '', 'next_command must be empty for passed');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -298,7 +317,7 @@ describe('verification-status', () => {
|
||||
assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed');
|
||||
assert.equal(result.next_command, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -316,7 +335,7 @@ describe('verification-status', () => {
|
||||
"A body-only status: line must NOT be read — result should be 'missing'",
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -333,10 +352,14 @@ describe('verification-status', () => {
|
||||
// of the #3492 phase-pinned rule, not the contract itself — see the
|
||||
// `#3357/#3492` describe block below for the primary, phase-pinned tier
|
||||
// (resolveVerificationFile unit tests are the reliable anchors there).
|
||||
// mkPhaseDir's random-suffixed basename never matches "01"/"02", so this
|
||||
// exercises the fallback by construction.
|
||||
// The dir basename ('multi', no digits) has no derivable phase token, so
|
||||
// scopeToPhase's isPhaseArtifact fail-safe passes BOTH candidates through
|
||||
// unfiltered (#3511: scopeToPhase is a plain filter with no other
|
||||
// fallback — a derivable token that matched neither file would empty the
|
||||
// set and this test would read 'missing', not exercise the alphabetical
|
||||
// tiebreak at all).
|
||||
test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => {
|
||||
const dir = mkPhaseDir('multi');
|
||||
const dir = mkPhaseDir('multi', 'multi');
|
||||
try {
|
||||
// Write two files: alphabetically "01-a" comes before "02-b"
|
||||
// "01-a" has passed; "02-b" has gaps_found — first by sort must win
|
||||
@@ -350,7 +373,7 @@ describe('verification-status', () => {
|
||||
'With no exact phase-token match, the first by lexicographic sort must be used',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
cleanup(path.dirname(dir));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -986,6 +1009,106 @@ describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple c
|
||||
);
|
||||
});
|
||||
|
||||
// #3511 reconciliation: resolveVerificationFile's fallback now scopes to
|
||||
// isPhaseArtifact(fileName, phaseDirName), so a stray cross-phase file can
|
||||
// no longer win the alphabetical-first fallback tier either — closing the
|
||||
// gap isPhaseArtifact's own docblock (src/phase-id.cts) used to flag as
|
||||
// open. The two pure cases below are the reliable anchors; the behavioral
|
||||
// test after them pins the same contract through the real CLI-facing
|
||||
// readVerificationStatus call path.
|
||||
test('#3511: a cross-phase stray is excluded from the fallback → null, not the stray', () => {
|
||||
assert.equal(
|
||||
resolveVerificationFile(['04-VERIFICATION.md'], { phaseDirName: '03-foo' }),
|
||||
null,
|
||||
'04-VERIFICATION.md belongs to phase 04, not the "03-foo" directory\'s phase 03 — must not be returned',
|
||||
);
|
||||
});
|
||||
|
||||
test('#3511: a non-canonically-named report OF THIS phase still wins the fallback (the #3357 guarantee survives)', () => {
|
||||
// The more important of the two #3511 cases: isPhaseArtifact scopes by
|
||||
// phase-number membership, not by canonical shape, so this file still
|
||||
// passes and the #3357 "non-canonical report still resolves" guarantee
|
||||
// is not disturbed by the #3511 scoping.
|
||||
assert.equal(
|
||||
resolveVerificationFile(['03-CORRECTION-VERIFICATION.md'], { phaseDirName: '03-foo' }),
|
||||
'03-CORRECTION-VERIFICATION.md',
|
||||
'03-CORRECTION-VERIFICATION.md names phase 03, same as directory "03-foo" — must still resolve',
|
||||
);
|
||||
});
|
||||
|
||||
test('#3511: cross-phase stray alongside this phase\'s own non-canonical report → own report wins, stray excluded (not merely outsorted)', () => {
|
||||
// Distinguishes "excluded from the fallback" from "just happens to sort
|
||||
// after" — candidates are sorted at verification.cts's own call site
|
||||
// before reaching resolveVerificationFile, and '01-VERIFICATION.md'
|
||||
// sorts BEFORE '03-CORRECTION-VERIFICATION.md' alphabetically, so an
|
||||
// UNSCOPED (alphabetical-first) fallback would wrongly pick the stray
|
||||
// here. Scoping must actively exclude it for '03-CORRECTION-…' to win.
|
||||
assert.equal(
|
||||
resolveVerificationFile(
|
||||
['01-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'],
|
||||
{ phaseDirName: '03-foo' },
|
||||
),
|
||||
'03-CORRECTION-VERIFICATION.md',
|
||||
);
|
||||
});
|
||||
|
||||
// WARNING-2/5/INFO-2 note (#3511 review): the only fallback test above uses
|
||||
// a token-LESS dir ("03-foo" isn't token-less — this refers to the earlier
|
||||
// `multiple *-VERIFICATION.md files, none matching the phase token` test,
|
||||
// which passes no derivable-token distinguishing fixture and so passes
|
||||
// identically pre-#3511-fix). This test uses a dir WITH a derivable token
|
||||
// (`03-foo` → token "03") and TWO candidates that BOTH belong to that same
|
||||
// phase (`03-a-…`/`03-b-…`, no exact `03-VERIFICATION.md`), so scoping
|
||||
// excludes nothing and the alphabetical-first tie-break still decides —
|
||||
// pinning that scoping does not disturb the ordinary same-phase-multi-file
|
||||
// case.
|
||||
test('#3511: alphabetical fallback when BOTH candidates are this phase\'s own (derivable token, no exact match)', () => {
|
||||
assert.equal(
|
||||
resolveVerificationFile(['03-a-VERIFICATION.md', '03-b-VERIFICATION.md'], { phaseDirName: '03-foo' }),
|
||||
'03-a-VERIFICATION.md',
|
||||
'both candidates belong to phase 03 (same as dir "03-foo"); alphabetically-first must still win',
|
||||
);
|
||||
});
|
||||
|
||||
test('behavioral (readVerificationStatus): a phase dir holding only a cross-phase stray reports missing, not the stray\'s status (#3511)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-stray-only-'));
|
||||
const dir = path.join(baseDir, '03-foo');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
// Only a stray belonging to phase 04 sits in phase 03's directory. Give
|
||||
// it a status that would NOT read as missing if it were (wrongly) picked,
|
||||
// so a regression here is loud rather than accidentally matching.
|
||||
writeVerificationMd(dir, '04-VERIFICATION.md', 'passed');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'missing',
|
||||
'a phase dir holding only another phase\'s report must report missing, not passed',
|
||||
);
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('behavioral (readVerificationStatus): a phase dir holding only its own non-canonically-named report still resolves it (#3357 guarantee survives #3511 scoping)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-own-noncanon-'));
|
||||
const dir = path.join(baseDir, '03-foo');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'passed',
|
||||
'the phase\'s own non-canonically-named report must still resolve, not read as missing',
|
||||
);
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('behavioral (readVerificationStatus): a phase with both its own report and a cross-phase stray reports the OWN report\'s status, not the stray\'s', () => {
|
||||
// The directory basename is "03-canonical-test" so extractPhaseToken
|
||||
// derives token "03" — the exact same derivation readVerificationStatus
|
||||
@@ -1123,6 +1246,21 @@ describe('#3473 F2: resolveVerificationFile allowBare option', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// #3511: allowBare must still fall through to the bare match when the ONLY
|
||||
// dashed candidate is excluded by phaseDirName scoping (a cross-phase
|
||||
// stray) — the fallback tier finding nothing phase-owned is the same
|
||||
// "no dashed candidate at all" case allowBare was always reached from.
|
||||
test('#3511: allowBare:true, bare + a cross-phase dashed stray scoped out by phaseDirName → the bare file wins', () => {
|
||||
assert.equal(
|
||||
resolveVerificationFile(
|
||||
['VERIFICATION.md', '04-VERIFICATION.md'],
|
||||
{ allowBare: true, phaseDirName: '03-foo' },
|
||||
),
|
||||
'VERIFICATION.md',
|
||||
'04-VERIFICATION.md belongs to a different phase and is excluded, so bare VERIFICATION.md is the only remaining candidate',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── #3518: resolveUatFile — phase-pinned, deterministic *-UAT.md pick ───────
|
||||
|
||||
@@ -14,7 +14,7 @@ const { createFixture, seedWorkstream } = require('./fixtures/index.cjs');
|
||||
const { buildWorkstreamInventory, isCompletedInventory, pickRollupWinners } = require('../gsd-core/bin/lib/workstream-inventory-builder.cjs');
|
||||
const { inspectWorkstream } = require('../gsd-core/bin/lib/workstream-inventory.cjs');
|
||||
const { VERIFIER_STATUSES } = require('../gsd-core/bin/lib/verification.cjs');
|
||||
const { phaseKeyFromDir, phaseKeyFromProse, phaseKeyFromToken } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const { phaseKeyFromDir, phaseKeyFromProse, phaseKeyFromToken, normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const STALE_STATE = 'status: executing\n';
|
||||
@@ -235,7 +235,7 @@ describe('#2562 — progress/status scoped to the current milestone (derived fro
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
|
||||
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
|
||||
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
|
||||
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
|
||||
}
|
||||
|
||||
const MS_STATE = 'milestone: v2.0\nstatus: executing\n';
|
||||
@@ -361,7 +361,7 @@ describe('#2562 — milestone scoping boundaries (one phase-key derivation)', ()
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
|
||||
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
|
||||
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
|
||||
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
|
||||
}
|
||||
|
||||
function roadmapWithRows(rows) {
|
||||
@@ -926,7 +926,7 @@ describe('#2562 — a shipped marker its own artifacts contradict is not asserte
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
|
||||
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
|
||||
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
|
||||
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
|
||||
}
|
||||
|
||||
function writeSnapshot(wsDir) {
|
||||
@@ -1054,7 +1054,7 @@ describe('#2645 — deleting a verification report must not raise completeness',
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
|
||||
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
|
||||
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
|
||||
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user