* fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
/**
|
|
* Regression tests for bug #3537
|
|
*
|
|
* Phase state verbs must match a canonical phase id against ROADMAP.md prose
|
|
* regardless of zero-padding on either side: the skills pass the padded form
|
|
* (`02.7`) after resolving the phase directory, but human-authored ROADMAP
|
|
* prose is conventionally un-padded (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`).
|
|
*
|
|
* v1.42.1 added `phaseMarkdownRegexSource()` which renders `0*<integer><...>`
|
|
* — padding-tolerant on both sides — but wired it into only 1 of 8 call sites.
|
|
* The other 7 used raw `escapeRegex(phaseNum)` or `0*${escapeRegex(...)}`
|
|
* (tolerated extra padding, not missing), so passing the padded form silently
|
|
* no-op'd and the verbs returned success while ROADMAP.md was unchanged.
|
|
*
|
|
* Parity assertion (per CONTEXT.md DEFECT.GENERATIVE-FIX): for each verb,
|
|
* running with the padded form must produce the same ROADMAP.md as running
|
|
* with the un-padded form against an identical fixture. Per-site fixes
|
|
* without a parity test let the next call-site drift back undetected.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const gsdTools = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
function run(args, cwd) {
|
|
try {
|
|
return {
|
|
stdout: execFileSync('node', [gsdTools, ...args], {
|
|
cwd,
|
|
timeout: 15000,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}),
|
|
ok: true,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
stdout: (e.stdout && e.stdout.toString()) || '',
|
|
stderr: (e.stderr && e.stderr.toString()) || '',
|
|
ok: false,
|
|
code: e.status,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a planning fixture with project_code='CK', padded phase directory
|
|
* (`CK-02.7-meta-lead-ads/`), and un-padded ROADMAP prose (`Phase 2.7`).
|
|
* This mirrors the reporter's environment in #3537 exactly.
|
|
*/
|
|
function setupFixture(tmpDir, opts = {}) {
|
|
const {
|
|
projectCode = 'CK',
|
|
paddedId = '02.7',
|
|
unpaddedId = '2.7',
|
|
extraPhases = [],
|
|
} = opts;
|
|
|
|
const planningDir = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ project_code: projectCode })
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'STATE.md'),
|
|
`---\ncurrent_phase: ${unpaddedId}\nstatus: executing\n---\n# State\n`
|
|
);
|
|
|
|
// Padded phase directory with one plan + matching summary so the phase
|
|
// is "complete" for phase-complete and update-plan-progress verbs.
|
|
const phaseDirName = `${projectCode}-${paddedId}-meta-lead-ads`;
|
|
const phaseDir = path.join(planningDir, 'phases', phaseDirName);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, `${paddedId}-01-PLAN.md`),
|
|
`---\nphase: ${unpaddedId}\nplan: 1\nwave: 1\n---\n# Plan 1\n`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, `${paddedId}-01-SUMMARY.md`),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, `${paddedId}-VERIFICATION.md`),
|
|
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n'
|
|
);
|
|
|
|
const extra = extraPhases
|
|
.map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`)
|
|
.join('\n');
|
|
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
'## v1.0 Milestone',
|
|
'',
|
|
`- [ ] **Phase ${unpaddedId}: Meta Lead Ads**`,
|
|
extra,
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
`| ${unpaddedId} Meta Lead Ads | 0/1 | Planned | - |`,
|
|
'',
|
|
`### Phase ${unpaddedId}: Meta Lead Ads`,
|
|
'',
|
|
'**Goal:** ship the thing',
|
|
'**Plans:** 0 plans',
|
|
'',
|
|
'Plans:',
|
|
`- [ ] ${paddedId}-01-PLAN.md`,
|
|
'',
|
|
...extraPhases.flatMap((p) => [
|
|
`### Phase ${p.id}: ${p.name}`,
|
|
'',
|
|
'**Goal:** stub',
|
|
'**Plans:** 0 plans',
|
|
'',
|
|
'Plans:',
|
|
`- [ ] ${p.id}-01-PLAN.md`,
|
|
'',
|
|
]),
|
|
]
|
|
.filter((l) => l !== '')
|
|
.join('\n') + '\n';
|
|
|
|
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
|
|
|
|
return {
|
|
planningDir,
|
|
roadmapPath: path.join(planningDir, 'ROADMAP.md'),
|
|
phaseDir,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run a verb in two parallel fixtures — one passing the padded form, one
|
|
* passing the un-padded form — then compare the resulting ROADMAP.md bytes.
|
|
* Any divergence means the verb's regex did not tolerate padding on at least
|
|
* one side.
|
|
*/
|
|
function expectParity({ verbWithPadded, verbWithUnpadded, fixtureOpts }) {
|
|
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-A-'));
|
|
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-B-'));
|
|
try {
|
|
const a = setupFixture(tmpA, fixtureOpts);
|
|
const b = setupFixture(tmpB, fixtureOpts);
|
|
|
|
const ra = verbWithPadded(tmpA);
|
|
const rb = verbWithUnpadded(tmpB);
|
|
|
|
const aRoadmap = fs.readFileSync(a.roadmapPath, 'utf-8');
|
|
const bRoadmap = fs.readFileSync(b.roadmapPath, 'utf-8');
|
|
|
|
return { aRoadmap, bRoadmap, ra, rb };
|
|
} finally {
|
|
cleanup(tmpA);
|
|
cleanup(tmpB);
|
|
}
|
|
}
|
|
|
|
describe('bug #3537: phase verbs accept padded ids against un-padded ROADMAP prose', () => {
|
|
test('phase complete: padded 02.7 and un-padded 2.7 produce identical ROADMAP', () => {
|
|
const { aRoadmap, bRoadmap } = expectParity({
|
|
fixtureOpts: {},
|
|
verbWithPadded: (cwd) => run(['phase', 'complete', '02.7'], cwd),
|
|
verbWithUnpadded: (cwd) => run(['phase', 'complete', '2.7'], cwd),
|
|
});
|
|
|
|
assert.equal(
|
|
aRoadmap,
|
|
bRoadmap,
|
|
'padded `02.7` must mutate ROADMAP identically to un-padded `2.7`'
|
|
);
|
|
// And the canonical mutation must have actually happened (otherwise
|
|
// both forms could be silently no-op'ing and still produce identical
|
|
// output — a vacuous parity pass).
|
|
assert.match(
|
|
aRoadmap,
|
|
/- \[x\] \*\*Phase 2\.7:/,
|
|
'overview checkbox should be flipped under both invocations'
|
|
);
|
|
});
|
|
|
|
test('roadmap get-phase: padded 02.7 returns the same section as un-padded 2.7', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-get-'));
|
|
try {
|
|
setupFixture(tmp, {});
|
|
const padded = run(['roadmap', 'get-phase', '02.7', '--raw'], tmp);
|
|
const unpadded = run(['roadmap', 'get-phase', '2.7', '--raw'], tmp);
|
|
|
|
assert.equal(
|
|
padded.stdout,
|
|
unpadded.stdout,
|
|
'padded and un-padded ids must return identical sections'
|
|
);
|
|
// Non-vacuous guard: both forms must have actually returned a section
|
|
// (the bug we're fixing was that the padded form returned an empty
|
|
// string while reporting success).
|
|
assert.ok(
|
|
padded.stdout.trim().length > 0,
|
|
'verb must return non-empty section under both invocations'
|
|
);
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('phase next-decimal: padded 02 finds decimals in un-padded ROADMAP', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-nd-'));
|
|
try {
|
|
setupFixture(tmp, {
|
|
paddedId: '02.7',
|
|
unpaddedId: '2.7',
|
|
});
|
|
|
|
// Padded base `02` must discover the existing decimal `2.7` from the
|
|
// un-padded heading and propose `2.8` (or higher) as next.
|
|
const padded = run(['phase', 'next-decimal', '02', '--raw'], tmp);
|
|
const unpadded = run(['phase', 'next-decimal', '2', '--raw'], tmp);
|
|
|
|
assert.equal(
|
|
padded.stdout,
|
|
unpadded.stdout,
|
|
'next-decimal must produce identical JSON for padded and un-padded base'
|
|
);
|
|
|
|
// Sanity: the existing 2.7 must be reflected. If the prose-scan regex
|
|
// silently failed to match `Phase 2.7`, the result would skip 2.7 and
|
|
// wrongly propose 2.1 as next. `phase next-decimal --raw` emits the
|
|
// next id as plain text (`02.8`), so trimmed string equality is the
|
|
// typed assertion shape (no raw-text regex matching — lint policy).
|
|
const nextDecimalPadded = padded.stdout.trim();
|
|
assert.notEqual(
|
|
nextDecimalPadded,
|
|
'02.1',
|
|
'must not propose 02.1 when 2.7 already exists in ROADMAP'
|
|
);
|
|
assert.notEqual(
|
|
nextDecimalPadded,
|
|
'2.1',
|
|
'must not propose 2.1 when 2.7 already exists in ROADMAP'
|
|
);
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('phase insert: padded base 02 finds anchor in un-padded ROADMAP', () => {
|
|
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-A-'));
|
|
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-B-'));
|
|
try {
|
|
// Use a phase 2 (no decimal) base so insert proposes 2.1.
|
|
const optsA = {
|
|
paddedId: '02',
|
|
unpaddedId: '2',
|
|
};
|
|
setupFixture(tmpA, optsA);
|
|
setupFixture(tmpB, optsA);
|
|
|
|
const padded = run(['phase', 'insert', '02', 'urgent extension'], tmpA);
|
|
const unpadded = run(['phase', 'insert', '2', 'urgent extension'], tmpB);
|
|
|
|
// Both invocations should succeed (exit 0) — passing the padded base
|
|
// against un-padded prose used to error "Phase 02 not found".
|
|
assert.ok(
|
|
padded.ok,
|
|
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
|
|
);
|
|
assert.ok(
|
|
unpadded.ok,
|
|
`un-padded form must succeed, got code=${unpadded.code}`
|
|
);
|
|
|
|
const aRoadmap = fs.readFileSync(
|
|
path.join(tmpA, '.planning', 'ROADMAP.md'),
|
|
'utf-8'
|
|
);
|
|
|
|
// The new header may be rendered as `Phase 02.1` or `Phase 2.1`
|
|
// (normalizePhaseName pads to 2 digits today; that is pre-existing
|
|
// behavior, not the subject of #3537). The critical assertion for
|
|
// this verb is "padded form found the anchor and the insertion
|
|
// happened" — full byte-parity is gated by an unrelated `Depends on:
|
|
// Phase ${afterPhase}` echo bug that lies outside #3537's scope.
|
|
assert.match(
|
|
aRoadmap,
|
|
/### Phase 0?2\.1: urgent extension/,
|
|
'padded form must insert the new decimal phase header'
|
|
);
|
|
// Reference `tmpB` to ensure cleanup runs and keep it alive in the
|
|
// closure — also a smoke-check that the un-padded sibling did not
|
|
// crash mid-run.
|
|
assert.ok(fs.existsSync(path.join(tmpB, '.planning', 'ROADMAP.md')));
|
|
} finally {
|
|
cleanup(tmpA);
|
|
cleanup(tmpB);
|
|
}
|
|
});
|
|
|
|
test('roadmap annotate-dependencies: padded 02.7 finds phase section', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann-'));
|
|
try {
|
|
const { roadmapPath } = setupFixture(tmp, {});
|
|
const before = fs.readFileSync(roadmapPath, 'utf-8');
|
|
|
|
const padded = run(
|
|
['roadmap', 'annotate-dependencies', '02.7'],
|
|
tmp
|
|
);
|
|
assert.ok(
|
|
padded.ok,
|
|
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
|
|
);
|
|
|
|
const after = fs.readFileSync(roadmapPath, 'utf-8');
|
|
// The annotation may be a no-op if there's only one wave and no
|
|
// cross-cutting truths, but the verb must have reached the phase
|
|
// section. Confirm by running parity against un-padded form on a
|
|
// separate fixture and asserting equality.
|
|
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann2-'));
|
|
try {
|
|
const { roadmapPath: rp2 } = setupFixture(tmp2, {});
|
|
run(['roadmap', 'annotate-dependencies', '2.7'], tmp2);
|
|
const unpadded = fs.readFileSync(rp2, 'utf-8');
|
|
assert.equal(
|
|
after,
|
|
unpadded,
|
|
'annotate-dependencies must produce identical output for padded and un-padded ids'
|
|
);
|
|
// And the verb must not have silently destroyed the file (sanity).
|
|
assert.match(after, /### Phase 2\.7:/, 'phase header must survive');
|
|
// Reference `before` to keep it from being dead-binding-flagged
|
|
// and to assert the run did not corrupt the rest of the file.
|
|
assert.ok(before.length > 0);
|
|
} finally {
|
|
cleanup(tmp2);
|
|
}
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('roadmap update-plan-progress: control case — already wired in 1.42.1', () => {
|
|
// This is the one site already using phaseMarkdownRegexSource. Including
|
|
// it as a control proves the parity assertion is a meaningful signal
|
|
// (this test should pass on main, while the others fail).
|
|
const { aRoadmap, bRoadmap } = expectParity({
|
|
fixtureOpts: {},
|
|
verbWithPadded: (cwd) =>
|
|
run(['roadmap', 'update-plan-progress', '02.7'], cwd),
|
|
verbWithUnpadded: (cwd) =>
|
|
run(['roadmap', 'update-plan-progress', '2.7'], cwd),
|
|
});
|
|
|
|
assert.equal(
|
|
aRoadmap,
|
|
bRoadmap,
|
|
'control verb must already produce identical output (wired in 1.42.1)'
|
|
);
|
|
assert.match(
|
|
aRoadmap,
|
|
/- \[x\] \*\*Phase 2\.7:/,
|
|
'control verb must flip checkbox under both invocations'
|
|
);
|
|
});
|
|
});
|