Batch 11 of the ad hoc timeout literal migration (epic #4445). Replaces every bare numeric timeout/timeoutMs object-literal property in tests/state-document.test.cjs, tests/phase.test.cjs, tests/commands.test.cjs, tests/pattern.test.cjs, tests/adr-612-bracket-coherence.test.cjs, tests/adr-612-bracket-read-tolerance.test.cjs, tests/milestone-lock.test.cjs, tests/init.test.cjs, tests/state-todos-render.test.cjs, tests/quick-batch.test.cjs, tests/graphify.test.cjs, and tests/effort-surface-axis.test.cjs with a named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes these 12 files from the rule's allowlist. Ground truth via eslint found 25 sites, not the issue's stated 24 (phase.test.cjs has 5, not 4) -- disclosed in the PR body. Reuses PROBE_TIMEOUT_MS, GIT_TIMEOUT_MS, and LOOP_HOOK_POINT_CLI_TIMEOUT_MS across 8 files. Adds two new shared constants to tests/helpers/timeouts.cjs (each independently arrived at by 2 files in this batch, crossing the promotion bar): PATHOLOGICAL_INPUT_TEST_TIMEOUT_MS (node:test's own per-test timeout option, not a subprocess bound) and GSD_TOOLS_CLI_MODERATE_TIMEOUT_MS (a single gsd-tools.cjs CLI subcommand spawn, distinct tier from PROBE_TIMEOUT_MS/LOOP_HOOK_POINT_CLI_TIMEOUT_MS). Adds 3 file-local constants for values used by only 1 file in this batch. No src/bin file touched, no numeric value changed anywhere. Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1579 lines
63 KiB
JavaScript
1579 lines
63 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* PR-2 (#2761 / epic #612) — the bracket-tolerant ROADMAP read path, at CLI level.
|
|
*
|
|
* Every reader here selects its heading grammar from the resolved
|
|
* `phase_id_convention`. The two things that buys, both pinned below:
|
|
*
|
|
* 1. A project that has not opted in reads exactly as it did before. The
|
|
* counterexample corpus is the one that falsified the earlier ungated
|
|
* design — `### [RFC.2119] 5:`, `### [v1.0] 2026-01-15:`, `### [ADR.612] 3:`,
|
|
* `### [rev.2] 9:`, `### [Cluster B] Phase 26:` — each of which was claimed
|
|
* as a phase and moved `phase_count` / `total_phases` / W006 on legacy
|
|
* repos. They are asserted against the CLI, not against a regex.
|
|
*
|
|
* 2. A project that HAS opted in gets its bracket headings read, including the
|
|
* sentinel exclusion that the widening would otherwise break: under
|
|
* READING-B the sentinel milestone lives in the bracket, so a filter that
|
|
* tests the phase token is blind to `### [GSD.999] 01:` and counts an
|
|
* icebox item as a real phase (#1445 / #1580) — quietly, since #1446 took
|
|
* total_phases out of the ratchet.
|
|
*
|
|
* Fixtures are raw markdown strings, never rendered through renderPhaseId/toDir.
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
const { PATHOLOGICAL_INPUT_TEST_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
let tmpDir;
|
|
|
|
const write = (roadmap, convention) => {
|
|
const planning = path.join(tmpDir, '.planning');
|
|
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8');
|
|
fs.writeFileSync(
|
|
path.join(planning, 'config.json'),
|
|
JSON.stringify(convention === undefined ? {} : { phase_id_convention: convention }),
|
|
'utf-8',
|
|
);
|
|
};
|
|
|
|
const analyze = () => {
|
|
const r = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
|
assert.ok(r.success, `roadmap analyze failed: ${r.error}`);
|
|
return JSON.parse(r.output);
|
|
};
|
|
|
|
// Upstream's shared diagnostic table returns coded IssueEntry objects; retain
|
|
// compatibility with the older string form so these assertions test messages,
|
|
// not the transport shape.
|
|
const consistencyMessages = (r) =>
|
|
(JSON.parse(r.output).warnings || []).map(w => (typeof w === 'string' ? w : w.message));
|
|
|
|
const BRACKET_ROADMAP = `# Roadmap
|
|
|
|
## [GSD.02] v2.0 — Foundation
|
|
|
|
- [x] **[GSD.02] 01: Setup**
|
|
- [ ] **[GSD.02] 05: Real work**
|
|
- [ ] **[GSD.02] 06: Follow-up**
|
|
|
|
### [GSD.02] 01: Setup
|
|
**Goal:** Lay the groundwork
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** Build the thing
|
|
|
|
### [GSD.02] 06: Follow-up
|
|
**Goal:** Polish it
|
|
`;
|
|
|
|
// ─── The legacy no-op guarantee (the whole point of gating) ────────────────
|
|
|
|
describe('#612 PR-2: a non-bracket repo is untouched by the bracket read path', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-legacy-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
// Each of these was claimed as a phase by the ungated design. `expect` is what
|
|
// the base build reports, so a regression here is a visible number change.
|
|
const COUNTEREXAMPLES = [
|
|
['### [RFC.2119] 5: Keyword definitions', 'RFC citation'],
|
|
['### [v1.0] 2026-01-15: Shipped release notes', 'version tag + date'],
|
|
['### [v1.0] 2024: Retrospective', 'version tag + year'],
|
|
['### [ADR.612] 3: Decisions to ratify', 'ADR citation'],
|
|
['### [rev.2] 9: Revision nine notes', 'lowercase tag'],
|
|
['### [ISO.8601] 2026: Dates', 'standard citation'],
|
|
['### [Cluster B] Phase 26: Clustered work', 'any-bracket + Phase label'],
|
|
['### [GSD] Phase 7: Bracketed legacy', 'project tag + Phase label'],
|
|
];
|
|
|
|
for (const [heading, label] of COUNTEREXAMPLES) {
|
|
for (const convention of [undefined, 'milestone-prefixed']) {
|
|
test(`${label} adds no phase (convention=${convention ?? 'unset'})`, () => {
|
|
write(`# Roadmap
|
|
|
|
## v1.0 — Foundation
|
|
|
|
### Phase 01: Setup
|
|
**Goal:** Groundwork
|
|
|
|
${heading}
|
|
**Goal:** Not a phase
|
|
`, convention);
|
|
const out = analyze();
|
|
// `[Cluster B] Phase 26` and `[GSD] Phase 7` DO match at base — the
|
|
// any-bracket tolerance is pre-existing — so they legitimately count.
|
|
const expected = /Phase \d/.test(heading) ? 2 : 1;
|
|
assert.equal(
|
|
out.phase_count, expected,
|
|
`phase_count moved; phases=${JSON.stringify(out.phases.map(p => p.number))}`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
test('a legacy roadmap reads its own headings, tags and checkboxes unchanged', () => {
|
|
write(`# Roadmap
|
|
|
|
## v1.0 — Foundation
|
|
|
|
## Phase Overview:
|
|
|
|
- [x] **Phase 1: Foundation**
|
|
- [ ] **Phase 2-01: API**
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Set up
|
|
|
|
### Phase 2-01 (INSERTED): API
|
|
**Goal:** Build it
|
|
|
|
#### Phase Details:
|
|
`, undefined);
|
|
const out = analyze();
|
|
assert.deepEqual(out.phases.map(p => [p.number, p.name]), [['1', 'Foundation'], ['2-01', 'API']]);
|
|
assert.deepEqual(out.phases.map(p => p.roadmap_complete), [true, false]);
|
|
});
|
|
|
|
test('a bracket roadmap on a NON-bracket repo is invisible, not miscounted', () => {
|
|
// Mid-migration disclosure: headings written in the new form before the
|
|
// config is switched are not read. Silent invisibility is the deliberate
|
|
// trade — the alternative is claiming phases on repos that never opted in.
|
|
write(BRACKET_ROADMAP, undefined);
|
|
const out = analyze();
|
|
assert.equal(out.phase_count, 0, 'no phases, and no phantoms either');
|
|
});
|
|
});
|
|
|
|
// ─── The bracket repo actually reads ───────────────────────────────────────
|
|
|
|
describe('#612 PR-2: a bracket repo reads its bracket headings', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-read-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('counts every phase and reads its NAME from the right capture group', () => {
|
|
write(BRACKET_ROADMAP, 'bracket');
|
|
const out = analyze();
|
|
assert.equal(out.phase_count, 3);
|
|
assert.deepEqual(
|
|
out.phases.map(p => [p.number, p.name]),
|
|
[['01', 'Setup'], ['05', 'Real work'], ['06', 'Follow-up']],
|
|
'number AND name — a group-offset error garbles the name first',
|
|
);
|
|
});
|
|
|
|
test('reads the Goal of each section (the heading is a section boundary)', () => {
|
|
write(BRACKET_ROADMAP, 'bracket');
|
|
assert.deepEqual(
|
|
analyze().phases.map(p => p.goal),
|
|
['Lay the groundwork', 'Build the thing', 'Polish it'],
|
|
);
|
|
});
|
|
|
|
test('reads summary-checkbox completion through a bracket bullet', () => {
|
|
write(BRACKET_ROADMAP, 'bracket');
|
|
assert.deepEqual(analyze().phases.map(p => p.roadmap_complete), [true, false, false]);
|
|
});
|
|
|
|
test('a legacy heading on a bracket repo still reads (migration window)', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 05: Bracket form
|
|
**Goal:** a
|
|
|
|
### Phase 6: Legacy form
|
|
**Goal:** b
|
|
`, 'bracket');
|
|
assert.deepEqual(analyze().phases.map(p => p.number), ['05', '6']);
|
|
});
|
|
|
|
test('get-phase resolves a bracket heading', () => {
|
|
write(BRACKET_ROADMAP, 'bracket');
|
|
const r = runGsdTools(['roadmap', 'get-phase', '05'], tmpDir);
|
|
assert.ok(r.success, r.error);
|
|
const out = JSON.parse(r.output);
|
|
assert.equal(out.found, true);
|
|
assert.equal(out.phase_name, 'Real work');
|
|
assert.equal(out.goal, 'Build the thing');
|
|
});
|
|
|
|
test('a checklist-only bracket phase reports malformed_roadmap', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
- [ ] **[GSD.02] 09: Summary only**
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
const out = JSON.parse(runGsdTools(['roadmap', 'get-phase', '09'], tmpDir).output);
|
|
assert.equal(out.found, false);
|
|
assert.equal(out.error, 'malformed_roadmap');
|
|
assert.equal(out.phase_name, 'Summary only');
|
|
});
|
|
});
|
|
|
|
// ─── Sentinels (READING-B) ─────────────────────────────────────────────────
|
|
|
|
describe('#612 PR-2: bracket sentinel milestones never count', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-sentinel-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('999 and 00 bracket milestones are excluded from phase_count', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.999] 01: Icebox item
|
|
**Goal:** Someday
|
|
|
|
### [GSD.00] 02: Pre-milestone groundwork
|
|
**Goal:** Before v1
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** Build it
|
|
`, 'bracket');
|
|
const out = analyze();
|
|
assert.deepEqual(out.phases.map(p => `${p.number}:${p.name}`), ['05:Real work']);
|
|
assert.equal(out.phase_count, 1);
|
|
});
|
|
|
|
test('a LOWERCASE sentinel bracket is excluded too', () => {
|
|
// Readers recognize `/i`; the identity helpers match `[A-Z]`. Without folding
|
|
// the captured id, `[gsd.999]` failed every sentinel test and the icebox item
|
|
// counted as a real phase.
|
|
write(`# Roadmap
|
|
|
|
## [gsd.02] v2.0
|
|
|
|
### [gsd.999] 07: Icebox
|
|
**Goal:** Someday
|
|
|
|
### [gsd.00] 08: Pre-milestone
|
|
**Goal:** Before
|
|
|
|
### [gsd.02] 01: Real
|
|
**Goal:** Build
|
|
`, 'bracket');
|
|
const out = analyze();
|
|
assert.deepEqual(out.phases.map(p => p.number), ['01'], 'lowercase sentinels excluded');
|
|
});
|
|
|
|
test('a 999 bracket CHECKLIST entry is not a missing-detail phantom', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
- [ ] **[GSD.999] 01: Icebox item**
|
|
- [ ] **[GSD.02] 07: Genuinely missing**
|
|
- [ ] **[GSD.02] 05: Real work**
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** Build it
|
|
`, 'bracket');
|
|
assert.deepEqual(analyze().missing_phase_details, ['07']);
|
|
});
|
|
|
|
// ─── #2761 M1: sentinel classification is PER-OCCURRENCE ─────────────────
|
|
//
|
|
// The checklist scan keyed its bracket-id map by the bare TOKEN, first-wins,
|
|
// and the detail set was keyed by the bare token too. Under READING-B the
|
|
// sentinel lives in the BRACKET, so two checklist entries sharing a token
|
|
// across different brackets — the icebox shape the ADR itself documents —
|
|
// had ONE classification between them, decided by document order.
|
|
|
|
for (const [label, checklist] of [
|
|
['sentinel first', ['- [ ] **[GSD.999] 01: Icebox item**', '- [ ] **[GSD.02] 01: Genuinely missing**']],
|
|
['sentinel last', ['- [ ] **[GSD.02] 01: Genuinely missing**', '- [ ] **[GSD.999] 01: Icebox item**']],
|
|
]) {
|
|
test(`a real phase sharing an icebox token is reported missing (${label})`, () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
${checklist.join('\n')}
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** Build it
|
|
`, 'bracket');
|
|
assert.deepEqual(
|
|
analyze().missing_phase_details, ['01'],
|
|
'[GSD.02] 01 has no detail heading and is not a sentinel — it is missing ' +
|
|
'in BOTH orders. Order-dependence here means the icebox entry\'s ' +
|
|
'classification was applied to the real phase.',
|
|
);
|
|
});
|
|
}
|
|
|
|
test('an icebox token is still suppressed when NO real phase shares it', () => {
|
|
// The other direction: per-occurrence classification must not turn the
|
|
// sentinel suppression itself into a false POSITIVE.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
- [ ] **[GSD.999] 01: Icebox item**
|
|
- [ ] **[GSD.00] 02: Pre-milestone**
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** Build it
|
|
`, 'bracket');
|
|
assert.equal(analyze().missing_phase_details, null, 'both entries are sentinels');
|
|
});
|
|
|
|
test('a detail heading does not satisfy a SAME-TOKEN entry from another bracket', () => {
|
|
// The order-INDEPENDENT half of the same defect: the detail set was keyed
|
|
// by bare token, so `[GSD.02] 01`'s heading marked token `01` present and
|
|
// masked `[GSD.03] 01`, which has no heading at all. No version strings and
|
|
// no STATE milestone, so the window is the whole document — the shape that
|
|
// puts two real brackets in one scan.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] Foundation
|
|
|
|
- [ ] **[GSD.02] 01: Has a heading**
|
|
|
|
### [GSD.02] 01: Has a heading
|
|
**Goal:** Build it
|
|
|
|
## [GSD.03] Second
|
|
|
|
- [ ] **[GSD.03] 01: Has no heading**
|
|
`, 'bracket');
|
|
assert.deepEqual(
|
|
analyze().missing_phase_details, ['01'],
|
|
'[GSD.03] 01 has no detail heading; [GSD.02] 01\'s heading must not cover it',
|
|
);
|
|
});
|
|
|
|
test('G5: a 999 token under a real milestone is STILL a backlog sentinel', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 999: Late work
|
|
**Goal:** Build it
|
|
`, 'bracket');
|
|
// READING-B adds the bracket rule; it does not repeal the engine-wide
|
|
// 0/999 backlog convention (#1445/#1580). A bracketed heading is a sentinel
|
|
// when EITHER its bracket milestone or its token is reserved.
|
|
assert.deepEqual(analyze().phases.map(p => p.number), []);
|
|
});
|
|
|
|
test('legacy sentinel filtering is unchanged on a legacy repo', () => {
|
|
write(`# Roadmap
|
|
|
|
## v2.0
|
|
|
|
### Phase 999.1: Icebox
|
|
**Goal:** a
|
|
|
|
### Phase 0: Pre-milestone
|
|
**Goal:** b
|
|
|
|
### Phase 5: Real
|
|
**Goal:** c
|
|
`, undefined);
|
|
assert.deepEqual(analyze().phases.map(p => p.number), ['5']);
|
|
});
|
|
});
|
|
|
|
// ─── validate.cts: the W006/W007 feeders and directory recognition ─────────
|
|
|
|
describe('#612 PR-2: validate.cts heading builders are convention-selected', () => {
|
|
const validate = require('../gsd-core/bin/lib/validate.cjs');
|
|
|
|
// The letter-tolerant `[\w][\w.-]*` capture lives here, so this is where an
|
|
// ungated widening does the most damage: a phantom becomes a W007.
|
|
const PHANTOM_HEADINGS = [
|
|
'### [RFC.2119] 5: Keyword definitions',
|
|
'### [v1.0] 2024: Retrospective',
|
|
'### [ADR.612] 3: Decisions to ratify',
|
|
'### [ISO.8601] 2026: Dates',
|
|
];
|
|
|
|
test('a non-bracket repo admits none of the phantom headings', () => {
|
|
const doc = ['### Phase 5: Real work', ...PHANTOM_HEADINGS].join('\n');
|
|
for (const convention of [undefined, null, 'milestone-prefixed', 'Bracket']) {
|
|
const { roadmapPhases } = validate.buildRoadmapPhaseVariants(doc, convention);
|
|
assert.deepEqual([...roadmapPhases], ['5'], `convention=${convention}`);
|
|
}
|
|
});
|
|
|
|
test('a bracket repo reads bracket headings', () => {
|
|
const { roadmapPhases } = validate.buildRoadmapPhaseVariants(
|
|
'### [GSD.02] 05: Real work\n### [GSD.02] 06: Follow-up\n', 'bracket',
|
|
);
|
|
assert.deepEqual([...roadmapPhases].sort(), ['05', '06']);
|
|
});
|
|
|
|
test('legacy headings and bullets are byte-identical either way', () => {
|
|
const doc = [
|
|
'### Phase 1: Foundation', '### Phase 2-01 (INSERTED): API', '### Phase 12A: Hotfix',
|
|
'#### Phase Details:', '- [x] **Phase 3: Done**', '- [ ] **Phase 4: Todo**',
|
|
].join('\n');
|
|
const legacy = [...validate.buildRoadmapPhaseVariants(doc).roadmapPhases].sort();
|
|
assert.deepEqual(legacy, ['1', '12A', '2-01', '3', '4', 'Details'].sort());
|
|
assert.deepEqual([...validate.buildRoadmapPhaseVariants(doc, 'bracket').roadmapPhases].sort(), legacy);
|
|
});
|
|
|
|
test('a label-only bullet site never gains any-bracket tolerance', () => {
|
|
const doc = '- [x] **[GSD] Phase 2-01: Legacy**\n- [ ] **[GSD.02] 07: Bracket**\n';
|
|
assert.deepEqual([...validate.buildRoadmapPhaseVariants(doc).roadmapPhases], []);
|
|
assert.deepEqual([...validate.buildRoadmapPhaseVariants(doc, 'bracket').roadmapPhases], ['07']);
|
|
});
|
|
|
|
test('the unchecked-bullet site keeps its live W006 on a legacy repo', () => {
|
|
// `[v1.0] Phase 05` in an unchecked bullet used to SUPPRESS a W006 that
|
|
// fires at base — a vanishing warning, worse than an added one.
|
|
const doc = '- [ ] **[v1.0] Phase 05: Thing**\n';
|
|
assert.deepEqual([...validate.buildNotStartedPhaseVariants(doc)], [],
|
|
'the bullet must not register phase 05 as not-started on a legacy repo');
|
|
// `[v1.0]` is no longer a bracket id at all: the milestone width is now the
|
|
// emit grammar's, and pad2 never produces a bare `0`. The residual this
|
|
// previously disclosed is gone rather than merely gated.
|
|
assert.deepEqual([...validate.buildNotStartedPhaseVariants(doc, 'bracket')], []);
|
|
});
|
|
|
|
test('a bracket repo picks up unchecked bracket bullets', () => {
|
|
const notStarted = validate.buildNotStartedPhaseVariants(
|
|
'- [ ] **[GSD.02] 05: Real work**\n- [x] **[GSD.02] 01: Done**\n', 'bracket');
|
|
assert.ok(notStarted.has('05'));
|
|
assert.ok(!notStarted.has('01'));
|
|
});
|
|
});
|
|
|
|
describe('#612 PR-2: directory recognition is convention-gated', () => {
|
|
const validate = require('../gsd-core/bin/lib/validate.cjs');
|
|
const core = require('../gsd-core/bin/lib/phase-id.cjs');
|
|
|
|
const LEGACY_DIRS = [
|
|
'02-01-setup', '01-setup', 'GSD-02-01-setup', '999.1-backlog', '14-2026-photos',
|
|
'02-04-01-deep', '12A-hotfix', 'not-a-phase', 'P0.34-56-name', 'P0.12-34-name',
|
|
'P0.3-2-tenant', 'P0.16-gate',
|
|
];
|
|
|
|
test('legacy dirs answer identically to the untouched constants', () => {
|
|
for (const d of LEGACY_DIRS) {
|
|
assert.equal(validate.isPhaseDirName(d), validate.phaseDirNameRe.test(d), d);
|
|
const viaConst = d.match(validate.PHASE_TOKEN_FROM_DIR_RE);
|
|
assert.equal(validate.phaseTokenFromDir(d), viaConst ? viaConst[1] : null, d);
|
|
}
|
|
});
|
|
|
|
test('the default-off invariant, extended to the directory side', () => {
|
|
for (const d of ['P0.34-56-name', 'P0.12-34-name']) {
|
|
for (const convention of [undefined, null, 'milestone-prefixed', 'BRACKET']) {
|
|
assert.equal(validate.isPhaseDirName(d, convention), false, `${d} / ${convention}`);
|
|
assert.equal(validate.phaseTokenFromDir(d, convention), null);
|
|
}
|
|
assert.equal(validate.isPhaseDirName(d, 'bracket'), true, `${d} opted in`);
|
|
}
|
|
});
|
|
|
|
test('bracket dirs resolve under the bracket convention', () => {
|
|
for (const [dir, token] of [
|
|
['GSD.02-05-feature', '05'], ['GSD.02-05.03-feature', '05.03'],
|
|
['GSD.02-05', '05'], ['CK.01-12.04-feature', '12.04'], ['GSD_X2.100-05-feature', '05'],
|
|
]) {
|
|
assert.equal(validate.isPhaseDirName(dir, 'bracket'), true, dir);
|
|
assert.equal(validate.phaseTokenFromDir(dir, 'bracket'), token, dir);
|
|
assert.equal(validate.isPhaseDirName(dir), false, `${dir} without the signal`);
|
|
}
|
|
});
|
|
|
|
test('shapes outside the emit grammar are not bracket dirs', () => {
|
|
// Admitting these would make the recognizer disagree with the resolver.
|
|
for (const d of ['GSD.02-12A-hotfix', 'GSD.02-05.03.07-x', 'GSD.2-05-x', 'not-a-phase', 'GSD.02']) {
|
|
assert.equal(validate.isPhaseDirName(d, 'bracket'), false, d);
|
|
}
|
|
});
|
|
|
|
test('the two bracket dir readers agree on ACCEPTED and REJECTED input alike', () => {
|
|
const corpus = [
|
|
'GSD.02-05-feature', 'GSD.02-05.03-feature', 'GSD.02-05', 'CK.01-12.04-f',
|
|
'GSD_X2.100-05-f', 'GSD.999-01-icebox', 'GSD.02-12A-hotfix', 'GSD.02-05.03.07-x',
|
|
'GSD.2-05-x', '02-01-setup', 'GSD-02-01-setup', 'not-a-phase', 'P0.34-56-name',
|
|
];
|
|
for (const dir of corpus) {
|
|
const shaped = validate.BRACKET_PHASE_DIR_RE.test(dir);
|
|
const viaOwner = core.extractPhaseToken(dir, 'bracket');
|
|
if (shaped) {
|
|
assert.equal(validate.phaseTokenFromDir(dir, 'bracket'), viaOwner, `accepted: ${dir}`);
|
|
} else {
|
|
// Rejected by the recognizer — the owner must NOT resolve it through the
|
|
// bracket branch either, or W005 calls a directory malformed in the same
|
|
// run that the milestone-complete check treats it as a real phase dir.
|
|
const legacyToken = core.extractPhaseToken(dir);
|
|
assert.equal(
|
|
viaOwner, legacyToken,
|
|
`rejected by the recognizer but bracket-resolved by the owner: ${dir}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('non-string input throws, matching the constants they wrap', () => {
|
|
for (const bad of [42, undefined, null, {}, [], true]) {
|
|
assert.throws(() => validate.isPhaseDirName(bad, 'bracket'), TypeError, String(bad));
|
|
assert.throws(() => validate.phaseTokenFromDir(bad, 'bracket'), TypeError, String(bad));
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── G6: validate consistency suppresses bracket sentinels ─────────────────
|
|
|
|
describe('#612 PR-2: bracket sentinels do not warn as missing directories', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-consist-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const consistencyWarnings = () => {
|
|
const r = runGsdTools(['validate', 'consistency'], tmpDir);
|
|
return consistencyMessages(r).filter(w => /no directory on disk/.test(w));
|
|
};
|
|
|
|
test('an icebox bracket phase is not reported as missing from disk', () => {
|
|
// validate health suppressed these via notStartedPhases while consistency
|
|
// did not, so the two verbs disagreed on the same repo.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [gsd.999] 07: Icebox
|
|
**Goal:** a
|
|
|
|
### [GSD.999] 08: Icebox
|
|
**Goal:** b
|
|
|
|
### [GSD.02] 01: Real
|
|
**Goal:** c
|
|
`, 'bracket');
|
|
assert.deepEqual(
|
|
consistencyWarnings().filter(w => /\b0[78]\b/.test(w)), [],
|
|
'sentinel phases legitimately have no directory',
|
|
);
|
|
});
|
|
|
|
test('a real bracket phase with no directory still warns', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 09: Real but absent
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
const w = consistencyWarnings();
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /Phase 09/);
|
|
});
|
|
|
|
// INVERTED by the merge of next @ 86101ee6. This case previously asserted the
|
|
// INHERITED WART — that a legacy `### Phase 999:` still warned here while
|
|
// `validate health` suppressed it — which this branch disclosed rather than
|
|
// fixed, because the legacy path was to stay byte-identical.
|
|
//
|
|
// ae7dc529 (#3225) fixed that wart upstream by adding the `isSentinelPhaseId`
|
|
// guard to this very loop, so the disagreement it pinned no longer exists and
|
|
// the old assertion inverted on the merge. The case is kept (not deleted) and
|
|
// flipped to assert the FIXED behaviour: it is the negative-space proof that
|
|
// this branch's `sentinelPhases` guard did not have to grow a legacy reading
|
|
// of its own, and it reds if a future resolution drops upstream's guard while
|
|
// keeping ours.
|
|
test('#3225 (merged): a legacy `### Phase 999:` no longer warns here', () => {
|
|
write(`# Roadmap
|
|
|
|
## v2.0
|
|
|
|
### Phase 999: Backlog
|
|
**Goal:** a
|
|
`, undefined);
|
|
assert.deepEqual(
|
|
consistencyWarnings(), [],
|
|
'the legacy leading-int sentinel rule suppresses this since #3225',
|
|
);
|
|
});
|
|
|
|
// Negative space for the case above: the #3225 guard is SENTINEL-scoped, not a
|
|
// blanket silencer. Without this, dropping the whole loop would also pass.
|
|
test('#3225 scope: a legacy NON-sentinel phase with no directory still warns', () => {
|
|
write(`# Roadmap
|
|
|
|
## v2.0
|
|
|
|
### Phase 09: Real but absent
|
|
**Goal:** a
|
|
`, undefined);
|
|
const w = consistencyWarnings();
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /Phase 09/);
|
|
});
|
|
});
|
|
|
|
// ─── #2761 B2: validate health and validate consistency must agree ─────────
|
|
//
|
|
// buildRoadmapPhaseVariants() surfaces `sentinelPhases` — tokens borne ONLY by
|
|
// a bracket-sentinel heading ([GSD.999] icebox / [GSD.00] pre-milestone) — so a
|
|
// backlog item's heading-only entry does not need a directory. cmdValidateConsistency
|
|
// consumes it (see the describe block above); cmdValidateHealth's W006 loop did
|
|
// not, so the SAME sentinel-only bracket ROADMAP produced a silent `validate
|
|
// consistency` and a false W006 from `validate health` — the two verbs
|
|
// contradicting each other about the same repo.
|
|
describe('#612 PR-2 B2: validate health and validate consistency agree on bracket sentinels', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-b2-agree-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const SENTINEL_ONLY = `# Roadmap
|
|
|
|
## [GSD.999] Icebox
|
|
|
|
### [GSD.999] 07: Someday
|
|
**Goal:** a
|
|
`;
|
|
|
|
const healthW006 = () => {
|
|
const r = runGsdTools(['validate', 'health'], tmpDir);
|
|
const out = JSON.parse(r.output);
|
|
return [...(out.errors || []), ...(out.warnings || [])]
|
|
.filter((i) => i.code === 'W006')
|
|
.map((i) => i.message);
|
|
};
|
|
|
|
const consistencyMissingDirWarnings = () => {
|
|
const r = runGsdTools(['validate', 'consistency'], tmpDir);
|
|
return consistencyMessages(r).filter((message) => /no directory on disk/.test(message));
|
|
};
|
|
|
|
test('a sentinel-only bracket roadmap: neither validator warns about the missing directory', () => {
|
|
write(SENTINEL_ONLY, 'bracket');
|
|
assert.deepEqual(
|
|
consistencyMissingDirWarnings(), [],
|
|
'validate consistency already excludes sentinel phases via sentinelPhases',
|
|
);
|
|
assert.deepEqual(
|
|
healthW006(), [],
|
|
'validate health must ALSO exclude sentinel phases — the two validators must agree on the same ROADMAP',
|
|
);
|
|
});
|
|
|
|
test('CONTROL: a real (non-sentinel) phase with no directory still warns on both validators', () => {
|
|
// The agreement above must not be achieved by suppressing W006 outright.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 09: Real but absent
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
const consistency = consistencyMissingDirWarnings();
|
|
const health = healthW006();
|
|
assert.equal(consistency.length, 1, JSON.stringify(consistency));
|
|
assert.equal(health.length, 1, JSON.stringify(health));
|
|
assert.match(consistency[0], /Phase 09/);
|
|
assert.match(health[0], /Phase 09/);
|
|
});
|
|
});
|
|
|
|
describe('#612 PR-2: sentinel suppression is occurrence-aware', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-occ-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const consistencyWarnings = () => {
|
|
const r = runGsdTools(['validate', 'consistency'], tmpDir);
|
|
return consistencyMessages(r).filter(w => /no directory on disk/.test(w));
|
|
};
|
|
|
|
test('a token borne ONLY by an icebox heading is suppressed', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.999] 07: Icebox only
|
|
**Goal:** a
|
|
|
|
### [GSD.02] 02: Real two
|
|
**Goal:** b
|
|
`, 'bracket');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'GSD.02-02-real-two'), { recursive: true });
|
|
assert.deepEqual(consistencyWarnings().filter(w => /\b07\b/.test(w)), []);
|
|
});
|
|
|
|
test('a token SHARED with a real heading still warns', () => {
|
|
// roadmapPhases is a token set, so `[GSD.999] 01` and `[GSD.02] 01` collapse
|
|
// to one entry. Keying suppression on the token alone let the icebox item
|
|
// silence a real phase that has no directory — a false negative worse than
|
|
// the warning it removed.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.999] 01: Icebox one
|
|
**Goal:** a
|
|
|
|
### [GSD.02] 01: REAL one, dir missing
|
|
**Goal:** b
|
|
|
|
### [GSD.02] 02: Real two
|
|
**Goal:** c
|
|
`, 'bracket');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'GSD.02-02-real-two'), { recursive: true });
|
|
const w = consistencyWarnings();
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /Phase 01/);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// R4-M1 — the DIRECTORY read inside `roadmap analyze`.
|
|
//
|
|
// `cmdRoadmapAnalyze` resolves the convention once and threads it into all four
|
|
// of its heading/checklist patterns, but the single `phaseTokenMatches` call
|
|
// that decides `disk_status` / `plan_count` / `summary_count` / `has_context` /
|
|
// `has_research` was left two-argument. Every canonical `{CODE}.{MM}-{PP}-slug`
|
|
// directory then read as `no_directory` with zero counts — while the SAME build
|
|
// resolved those same directories correctly in three other places on the same
|
|
// repo (W006/W007, `state json`, and the W021 milestone-complete read). Only the
|
|
// directory shape the convention exists to name failed; a mid-migration bracket
|
|
// repo carrying legacy `01-one` dirs resolved fine.
|
|
//
|
|
// Pinned the way the rest of this PR's gate is pinned: against the flat-legacy
|
|
// twin, computed in the same test run, PLUS exact literals so a shared wrong
|
|
// answer cannot pass. `grep disk_status tests/adr-612-*` was zero before this.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('#612 PR-2: roadmap analyze resolves bracket phase DIRECTORIES', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-analyze-dir-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const BRK = `# Roadmap
|
|
|
|
## [GSD.02] v2.0: Current
|
|
|
|
### [GSD.02] 01: One
|
|
**Goal:** a
|
|
|
|
### [GSD.02] 02: Two
|
|
**Goal:** b
|
|
`;
|
|
const LEG = `# Roadmap
|
|
|
|
## v2.0: Current
|
|
|
|
### Phase 01: One
|
|
**Goal:** a
|
|
|
|
### Phase 02: Two
|
|
**Goal:** b
|
|
`;
|
|
|
|
/**
|
|
* Phase 01 complete (PLAN + SUMMARY + a passing `*-VERIFICATION.md`),
|
|
* phase 02 planned (PLAN only).
|
|
*
|
|
* #3186 (ADR-3180 §7.4) made completion disk-strict: a fully summarized
|
|
* phase is still partial until its verification verdict passes. The legacy
|
|
* twin uses the same convention-agnostic completion predicate.
|
|
*/
|
|
const mkDirs = (specs) => {
|
|
for (const [dir, stem, complete] of specs) {
|
|
const q = path.join(tmpDir, '.planning', 'phases', dir);
|
|
fs.mkdirSync(q, { recursive: true });
|
|
fs.writeFileSync(path.join(q, `${stem}-01-PLAN.md`), '# plan\n', 'utf-8');
|
|
if (complete) {
|
|
fs.writeFileSync(path.join(q, `${stem}-01-SUMMARY.md`), '# summary\n', 'utf-8');
|
|
fs.writeFileSync(
|
|
path.join(q, `${stem}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8');
|
|
}
|
|
}
|
|
};
|
|
const diskShape = () => analyze().phases.map(
|
|
p => [p.number, p.disk_status, p.plan_count, p.summary_count]);
|
|
|
|
const BRK_DIRS = [['GSD.02-01-one', 'GSD.02-01', true], ['GSD.02-02-two', 'GSD.02-02', false]];
|
|
const LEG_DIRS = [['01-one', '01', true], ['02-two', '02', false]];
|
|
|
|
test('bracket dirs report complete/planned with real counts — not no_directory/0/0', () => {
|
|
write(BRK, 'bracket');
|
|
mkDirs(BRK_DIRS);
|
|
assert.deepEqual(diskShape(), [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]]);
|
|
});
|
|
|
|
test('and that shape equals its flat-legacy twin exactly', () => {
|
|
write(BRK, 'bracket');
|
|
mkDirs(BRK_DIRS);
|
|
const bracket = diskShape();
|
|
cleanup(tmpDir);
|
|
tmpDir = createTempProject('adr-612-analyze-dir-leg-');
|
|
write(LEG, undefined);
|
|
mkDirs(LEG_DIRS);
|
|
const legacy = diskShape();
|
|
assert.deepEqual(bracket, legacy, 'bracket must read the disk exactly as the legacy twin does');
|
|
assert.deepEqual(legacy, [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]],
|
|
'and the twin is the right answer, not a shared wrong one');
|
|
});
|
|
|
|
test('a bracket repo carrying LEGACY-shaped dirs still resolves (the read is additive)', () => {
|
|
// This shape resolved even with the two-argument call, which is why the bug
|
|
// was invisible: it failed ONLY for the canonical bracket directory name.
|
|
write(BRK, 'bracket');
|
|
mkDirs(LEG_DIRS);
|
|
assert.deepEqual(diskShape(), [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]]);
|
|
});
|
|
|
|
test('a NON-bracket repo is unaffected by the threaded convention', () => {
|
|
write(LEG, undefined);
|
|
mkDirs(LEG_DIRS);
|
|
assert.deepEqual(diskShape(), [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]]);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// R4-m1 — the CHECKLIST scan in buildRoadmapPhaseVariants.
|
|
//
|
|
// The heading scan is sentinel-aware; its checklist twin was compiled
|
|
// non-capturing and called every token REAL. The occurrence-aware un-suppression
|
|
// loop then deleted the icebox token the heading scan had correctly marked
|
|
// sentinel, and `validate consistency` warned that a bracket ICEBOX phase had no
|
|
// directory — in the HOUSE ROADMAP shape, where the icebox appears as both a
|
|
// bold bullet and a detail heading. `validate health` stayed silent on the same
|
|
// repo, so the two verbs disagreed.
|
|
//
|
|
// Both directions are pinned here: the icebox must be silent, AND a REAL phase
|
|
// carrying the same token must still warn. A fix that over-suppresses fails the
|
|
// second test.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('#612 PR-2: a bracket sentinel in the CHECKLIST index is suppressed too', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-checklist-sent-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const warnings = () => {
|
|
const r = runGsdTools(['validate', 'consistency'], tmpDir);
|
|
return consistencyMessages(r).filter(w => /no directory on disk/.test(w));
|
|
};
|
|
const realTwoOnDisk = () => fs.mkdirSync(
|
|
path.join(tmpDir, '.planning', 'phases', 'GSD.02-02-real-two'), { recursive: true });
|
|
|
|
test('house shape: icebox as BOTH a bold bullet and a heading is silent', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0: Current
|
|
|
|
- [ ] **[GSD.999] 01: Icebox item**
|
|
- [ ] **[GSD.02] 02: Real two**
|
|
|
|
### [GSD.999] 01: Icebox item
|
|
**Goal:** z
|
|
|
|
### [GSD.02] 02: Real two
|
|
**Goal:** b
|
|
`, 'bracket');
|
|
realTwoOnDisk();
|
|
assert.deepEqual(warnings(), [], 'an icebox phase legitimately has no directory');
|
|
});
|
|
|
|
test('CONTROL: the same ROADMAP without the icebox lines is also silent', () => {
|
|
// Makes the assertion above non-vacuous: silence must come from suppression,
|
|
// not from the repo having nothing to say.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0: Current
|
|
|
|
- [ ] **[GSD.02] 02: Real two**
|
|
|
|
### [GSD.02] 02: Real two
|
|
**Goal:** b
|
|
`, 'bracket');
|
|
realTwoOnDisk();
|
|
assert.deepEqual(warnings(), []);
|
|
});
|
|
|
|
test('a REAL phase sharing the sentinel token still warns (no over-suppression)', () => {
|
|
// The opposite direction. A checklist bullet for a REAL `[GSD.02] 01` must
|
|
// un-suppress the token that the `[GSD.999] 01` heading marked sentinel.
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0: Current
|
|
|
|
- [x] **[GSD.02] 01: Real one**
|
|
- [ ] **[GSD.02] 02: Real two**
|
|
|
|
### [GSD.999] 01: Icebox item
|
|
**Goal:** z
|
|
|
|
### [GSD.02] 02: Real two
|
|
**Goal:** b
|
|
`, 'bracket');
|
|
realTwoOnDisk();
|
|
const w = warnings();
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /Phase 01/);
|
|
});
|
|
});
|
|
|
|
// ─── Adversarial malformed bracket tokens ───────────────────────────────────
|
|
|
|
/**
|
|
* A tolerant reader's worst input is not a well-formed id it should reject —
|
|
* that is what the emit-grammar tests above cover — but a token that is
|
|
* STRUCTURALLY broken: the milestone is not a number, the bracket never closes,
|
|
* or a bracket is nested inside another. Each one is a plausible hand-edit or a
|
|
* half-finished migration, and each reaches every widened reader in this PR.
|
|
*
|
|
* The contract is the same for all three: no phantom phase, no throw, and the
|
|
* answer is IDENTICAL to what a non-opted-in repo gives, because none of these
|
|
* is in the emit grammar. A malformed bracket must not be "partly" read — a
|
|
* reader that recovers the `01` out of `[GSD.02] 01:` but not out of
|
|
* `[GSD.AB] 01:` is fine; one that recovers it from BOTH has invented a phase
|
|
* the ROADMAP does not declare.
|
|
*/
|
|
describe('#612 PR-2: malformed bracket tokens produce no phantom phase', () => {
|
|
const validate = require('../gsd-core/bin/lib/validate.cjs');
|
|
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-malformed-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const MALFORMED = [
|
|
['non-numeric milestone', '### [GSD.AB] 01: Broken'],
|
|
['unclosed bracket', '### [GSD.02 01: Broken'],
|
|
['nested bracket', '### [GSD.[02]] 01: Broken'],
|
|
['empty bracket', '### [] 01: Broken'],
|
|
['bracket with no dot', '### [GSD02] 01: Broken'],
|
|
['dot but empty milestone', '### [GSD.] 01: Broken'],
|
|
['milestone is a float', '### [GSD.0.2] 01: Broken'],
|
|
['negative milestone', '### [GSD.-2] 01: Broken'],
|
|
['whitespace milestone', '### [GSD. ] 01: Broken'],
|
|
['double dot', '### [GSD..02] 01: Broken'],
|
|
];
|
|
|
|
for (const [label, heading] of MALFORMED) {
|
|
test(`${label} is read as a phase by NO convention`, () => {
|
|
const doc = `### Phase 5: Real work\n${heading}\n`;
|
|
const opted = [...validate.buildRoadmapPhaseVariants(doc, 'bracket').roadmapPhases].sort();
|
|
const legacy = [...validate.buildRoadmapPhaseVariants(doc).roadmapPhases].sort();
|
|
assert.deepEqual(opted, ['5'], `${label}: bracket repo invented a phase — ${JSON.stringify(opted)}`);
|
|
assert.deepEqual(opted, legacy, `${label}: opted-in and legacy repos must agree`);
|
|
});
|
|
}
|
|
|
|
test('the malformed corpus reaches roadmap analyze without inventing a phase', () => {
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0: Current
|
|
|
|
### [GSD.02] 01: Real one
|
|
**Goal:** a
|
|
|
|
${MALFORMED.map(([, h]) => `${h}\n**Goal:** x\n`).join('\n')}`, 'bracket');
|
|
assert.deepEqual(analyze().phases.map(p => p.number), ['01']);
|
|
});
|
|
|
|
test('a malformed bracket DIRECTORY is not recognized, and never throws', () => {
|
|
const dirs = [
|
|
'GSD.AB-01-broken', 'GSD.02-01-ok', 'GSD.[02]-01-broken', 'GSD.-01-broken',
|
|
'GSD..02-01-broken', '.02-01-broken', 'GSD.02--01-broken', 'GSD.0.2-01-broken',
|
|
];
|
|
for (const dir of dirs) {
|
|
for (const convention of [undefined, null, 'milestone-prefixed', 'bracket']) {
|
|
assert.doesNotThrow(() => validate.isPhaseDirName(dir, convention), `${dir} / ${convention}`);
|
|
assert.doesNotThrow(() => validate.phaseTokenFromDir(dir, convention), `${dir} / ${convention}`);
|
|
}
|
|
if (dir === 'GSD.02-01-ok') continue;
|
|
assert.equal(validate.isPhaseDirName(dir, 'bracket'), false, `${dir} must not be a bracket dir`);
|
|
}
|
|
assert.equal(validate.isPhaseDirName('GSD.02-01-ok', 'bracket'), true, 'control');
|
|
});
|
|
|
|
test('the not-started variant builder agrees with the roadmap builder on the corpus', () => {
|
|
// Two builders, one grammar. If only one widens, `validate consistency`
|
|
// reports a phase the health check does not — the #3242 Bug B shape.
|
|
for (const [label, heading] of MALFORMED) {
|
|
const doc = `- [ ] **Phase 5: Real**\n${heading.replace('### ', '- [ ] **')}**\n`;
|
|
const a = [...validate.buildNotStartedPhaseVariants(doc, 'bracket')].sort();
|
|
const b = [...validate.buildNotStartedPhaseVariants(doc)].sort();
|
|
assert.deepEqual(a, b, `${label}: the two conventions disagreed — ${JSON.stringify([a, b])}`);
|
|
}
|
|
});
|
|
|
|
// #2761 M2 (trek-e review): this measured `process.hrtime.bigint()` against a
|
|
// 1s ceiling — an assertion about the host machine, not the SUT, and a flake
|
|
// on a loaded CI runner (RULESET.TESTS.no-timing-assertion). The property it
|
|
// guarded is kept and stated ALGORITHMICALLY instead: every widened pattern
|
|
// is built from BRACKET_ID_SRC, whose milestone field is a bounded
|
|
// alternation, so the classic ReDoS shape (nested quantifiers over a long
|
|
// unclosed bracket) must be LINEAR in input length. Running each attack at 1x
|
|
// and 4x and requiring byte-identical results tests exactly that — a
|
|
// catastrophically backtracking matcher cannot complete the 4x leg under any
|
|
// ceiling, whereas a bounded one is indifferent to the scaling. The `timeout`
|
|
// option is a hang backstop, not an assertion.
|
|
// Each attack states the phases its reading must name, as a function of n.
|
|
// Four are malformed and must name none; the fifth is well-formed but
|
|
// oversized, and must name exactly its (n-digit) token — a reading that is
|
|
// linear in n BY CONSTRUCTION, which is the property under test.
|
|
const ATTACKS = [
|
|
['unclosed bracket code', (n) => `### [${'A'.repeat(n)} 01: x`, () => []],
|
|
['unclosed bracket numeric', (n) => `### [GSD.${'0'.repeat(n)} 01: x`, () => []],
|
|
['nested open brackets', (n) => `### ${'['.repeat(Math.floor(n / 2.5))}GSD.02] 01: x`, () => []],
|
|
['repeated bracket group', (n) => `### [${'A.02] ['.repeat(Math.floor(n / 5))}A.02] 01: x`, () => []],
|
|
['oversized milestone + token', (n) => `### [GSD.${'9'.repeat(n)}] ${'1'.repeat(n)}: x`, (n) => ['1'.repeat(n)]],
|
|
];
|
|
const readAll = (doc) => {
|
|
const r = validate.buildRoadmapPhaseVariants(doc, 'bracket');
|
|
return {
|
|
phases: [...r.roadmapPhases].sort(),
|
|
variantCount: r.roadmapPhaseVariants.size,
|
|
sentinels: [...r.sentinelPhases].sort(),
|
|
notStarted: [...validate.buildNotStartedPhaseVariants(doc, 'bracket')].sort(),
|
|
isDir: validate.isPhaseDirName(doc, 'bracket'),
|
|
token: validate.phaseTokenFromDir(doc, 'bracket'),
|
|
};
|
|
};
|
|
|
|
test('pathological bracket input reads correctly at 1x and 4x length', { timeout: PATHOLOGICAL_INPUT_TEST_TIMEOUT_MS }, () => {
|
|
for (const [label, doc, expectedPhases] of ATTACKS) {
|
|
const readings = [5000, 20000].map((n) => [n, readAll(doc(n))]);
|
|
for (const [n, r] of readings) {
|
|
assert.deepEqual(r.phases, expectedPhases(n), `${label} @${n}: wrong phase set`);
|
|
assert.deepEqual(r.sentinels, [], `${label} @${n}: named a sentinel`);
|
|
assert.deepEqual(r.notStarted, [], `${label} @${n}: named a not-started phase`);
|
|
assert.equal(r.isDir, false, `${label} @${n}: a heading is not a phase directory`);
|
|
assert.equal(r.token, null, `${label} @${n}: a heading yields no directory token`);
|
|
}
|
|
// Scale invariance of the STRUCTURE: quadrupling the input may lengthen
|
|
// an extracted token (linear), but must not change how many things the
|
|
// reading names. A backtracking regression never reaches this line.
|
|
assert.equal(
|
|
readings[0][1].variantCount, readings[1][1].variantCount,
|
|
`${label}: quadrupling the input changed the variant cardinality`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('control: the same readers DO extract a well-formed bracket heading', () => {
|
|
// Non-vacuity guard — "names no phase" above must mean "the input is
|
|
// malformed", not "these readers are inert".
|
|
assert.deepEqual(readAll('### [GSD.02] 01: Real work').phases, ['01']);
|
|
assert.equal(validate.isPhaseDirName('GSD.02-01-real-work', 'bracket'), true);
|
|
assert.equal(validate.phaseTokenFromDir('GSD.02-01-real-work', 'bracket'), '01');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#612 PR-2: state validate resolves bracket phase DIRECTORIES', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-validate-dir-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
/**
|
|
* #3208 (merged to next as part of "resolve active state phase before drift
|
|
* scan") rewrote cmdStateValidate's directory lookup from a `startsWith`
|
|
* prefix test to the canonical `phaseKeyFromDir(...) === selectedPhaseKey`
|
|
* comparison. That is the correct surface — and it is exactly why the lookup
|
|
* now needs the resolved convention, which the rewrite does not pass.
|
|
*
|
|
* `phaseKeyFromDir` deliberately refuses to read a bracket directory without
|
|
* an explicit signal (ADR-2121: a bracket dir is string-indistinguishable
|
|
* from the legacy letter-prefixed-decimal family), so un-threaded it returns
|
|
* the whole dir name as the key — `GSD.02-05-real-work` -> `GSD.02-5-REAL-WORK`
|
|
* — while the STATE side is the bare `05` that `parsePhaseFromProse` yields.
|
|
* Both sides of one comparison derived under different conventions is #2562's
|
|
* defect class, and this file's other three `phaseKeyFromDir` call sites
|
|
* already thread against it.
|
|
*
|
|
* Observable symptom without the thread: a bracket repo whose phase directory
|
|
* plainly exists reports `valid: false` and "no phase directory matches phase
|
|
* 05" — wrong-and-confident on precisely the repos the convention supports,
|
|
* and drift detection (plan-count mismatch, verification status) never runs.
|
|
*
|
|
* The legacy twin below is the byte-identity control: threading a non-bracket
|
|
* convention must change nothing, since `extractPhaseToken` branches only on
|
|
* `=== 'bracket'`.
|
|
*/
|
|
const BRK = `# Roadmap
|
|
|
|
## [GSD.02] v2.0: Current
|
|
|
|
### [GSD.02] 05: Real work
|
|
**Goal:** a
|
|
`;
|
|
const LEG = `# Roadmap
|
|
|
|
## v2.0: Current
|
|
|
|
### Phase 05: Real work
|
|
**Goal:** a
|
|
`;
|
|
|
|
// STATE.md asserts 3 plans; disk carries 1. The drift warning is the PROOF
|
|
// the scan actually ran — a lookup that fails to find the directory returns
|
|
// before it, so "no plan_count drift reported" and "drift never ran" are
|
|
// distinguishable here rather than both reading as silence.
|
|
const STATE = `---
|
|
gsd_state_version: '1.0'
|
|
milestone: v2.0
|
|
current_phase: '05'
|
|
status: executing
|
|
total_plans_in_phase: 3
|
|
---
|
|
# Project State
|
|
|
|
## Current Position
|
|
**Phase:** 05 — Real work
|
|
`;
|
|
|
|
const seed = (roadmap, convention, dir) => {
|
|
write(roadmap, convention);
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), STATE, 'utf-8');
|
|
const q = path.join(tmpDir, '.planning', 'phases', dir);
|
|
fs.mkdirSync(q, { recursive: true });
|
|
fs.writeFileSync(path.join(q, '05-01-PLAN.md'), '# plan\n', 'utf-8');
|
|
};
|
|
|
|
// #3884 (e20744eac, "failure is a value") made argv strict: an unrecognized
|
|
// flag is now a hard `unknown flag ...; accepted: --strict` error on stderr
|
|
// with EMPTY stdout, where it was previously ignored. `state validate` never
|
|
// had a `--json` flag — JSON is its only output shape — so the token was a
|
|
// silent no-op that strict argv now rejects. Dropping it restores the same
|
|
// envelope this helper already parsed; the assertions below are unchanged.
|
|
const validateState = () => {
|
|
const r = runGsdTools(['state', 'validate'], tmpDir);
|
|
return JSON.parse(r.output);
|
|
};
|
|
|
|
// #3310 (ADR-3180 Phase 12) replaced `cmdStateValidate`'s `drift`
|
|
// object with coded `warnings: Diagnostic[]`: phase-directory misses are
|
|
// S004 and plan-count drift is S005. Keep these tests on their behavior-level
|
|
// subject while reading the current upstream envelope.
|
|
const codes = (out, code) =>
|
|
(out.warnings || []).filter((w) => w.code === code).map((w) => w.message);
|
|
const phaseDirWarnings = (out) =>
|
|
codes(out, 'S004').filter((m) => /no phase directory matches/.test(m));
|
|
const planCountWarnings = (out) => codes(out, 'S005');
|
|
|
|
test('a bracket phase directory is FOUND — no phantom "no phase directory matches"', () => {
|
|
seed(BRK, 'bracket', 'GSD.02-05-real-work');
|
|
const out = validateState();
|
|
assert.deepEqual(
|
|
phaseDirWarnings(out),
|
|
[],
|
|
`the directory exists and must resolve; an S004 phase-directory warning means the lookup missed it, got: ${JSON.stringify(out.warnings)}`,
|
|
);
|
|
});
|
|
|
|
test('and the drift scan actually RUNS — plan-count mismatch is reported', () => {
|
|
seed(BRK, 'bracket', 'GSD.02-05-real-work');
|
|
const out = validateState();
|
|
assert.deepEqual(
|
|
planCountWarnings(out),
|
|
['Plan count mismatch: STATE.md says 3 plans, disk has 1'],
|
|
'resolving the directory must let the plan-count drift check run',
|
|
);
|
|
});
|
|
|
|
test('and that answer equals its flat-legacy twin exactly', () => {
|
|
seed(BRK, 'bracket', 'GSD.02-05-real-work');
|
|
const bracket = validateState();
|
|
cleanup(tmpDir);
|
|
tmpDir = createTempProject('adr-612-validate-dir-leg-');
|
|
seed(LEG, undefined, '05-real-work');
|
|
const legacy = validateState();
|
|
assert.deepEqual(bracket.warnings, legacy.warnings,
|
|
'bracket must validate exactly as the legacy twin does');
|
|
assert.deepEqual(
|
|
planCountWarnings(legacy),
|
|
['Plan count mismatch: STATE.md says 3 plans, disk has 1'],
|
|
'and the twin is the right answer, not a shared wrong one',
|
|
);
|
|
});
|
|
|
|
test('a NON-bracket repo is unaffected by the threaded convention', () => {
|
|
seed(LEG, undefined, '05-real-work');
|
|
const out = validateState();
|
|
assert.deepEqual(phaseDirWarnings(out), []);
|
|
assert.deepEqual(
|
|
planCountWarnings(out),
|
|
['Plan count mismatch: STATE.md says 3 plans, disk has 1'],
|
|
);
|
|
});
|
|
|
|
test('a genuinely absent phase directory still reports not_found on bracket', () => {
|
|
// Non-vacuity guard: the thread must not make the lookup match ANYTHING.
|
|
seed(BRK, 'bracket', 'GSD.02-09-unrelated');
|
|
const out = validateState();
|
|
assert.equal(phaseDirWarnings(out).length, 1, JSON.stringify(out.warnings));
|
|
assert.equal(out.valid, false);
|
|
});
|
|
});
|
|
|
|
// ─── The #3309/#3310 re-homing: the reads that moved into the rule table ───
|
|
|
|
/**
|
|
* #3309/#3310 migrated `cmdValidateHealth` and `cmdValidateConsistency` onto the
|
|
* health-diagnostic rule table and deleted every helper this PR threaded —
|
|
* `collectDiskPhases`, `collectDiskPhaseEntries`, `collectArchivedPhaseDirNames`,
|
|
* `forEachArchivedPhaseToken`. Their reads now live in
|
|
* `src/planning-snapshot.cts` and the reads' CONSUMERS in
|
|
* `src/health-diagnostic-rules/*.cts`, so this PR's convention threading moved
|
|
* with them.
|
|
*
|
|
* Three of those re-homed sites turned out to be unfalsifiable by the rest of
|
|
* this suite once relocated: reverting the convention argument changed real CLI
|
|
* output and NOT ONE existing test went red. The pre-migration sites were
|
|
* covered indirectly, through helpers that no longer exist. Each case below
|
|
* pins one of them at the CLI, with its flat-legacy twin as the byte-identity
|
|
* control, so the threading is falsifiable in its new home rather than trusted.
|
|
*
|
|
* The fourth re-homed site — `buildValidPhaseSet`'s `extractPhaseToken(dir,
|
|
* convention)` in W002 (`state-consistency.cts`) — is deliberately NOT pinned
|
|
* here, because it is not observable: that rule unions the disk tokens with
|
|
* `roadmapDeclaredPhases` and `archivedPhaseTokens`, and any STATE.md reference
|
|
* a bracket disk token would rescue is already rescued by the ROADMAP half.
|
|
* It is threaded for provenance (it restores `collectDiskPhases(planBase,
|
|
* convention)`'s derivation exactly) and is disclosed as droppable rather than
|
|
* given a test that cannot fail for the right reason.
|
|
*/
|
|
describe('#612 PR-2: the re-homed W006/W007 reads still select by convention', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-rehome-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const healthCodes = (code) => {
|
|
const out = JSON.parse(runGsdTools(['validate', 'health'], tmpDir).output);
|
|
return [...(out.errors || []), ...(out.warnings || [])]
|
|
.filter(i => i.code === code)
|
|
.map(i => i.message);
|
|
};
|
|
const mkdir = (...parts) =>
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', ...parts), { recursive: true });
|
|
|
|
// ── archivedPhaseTokens (planning-snapshot.cts) ──────────────────────────
|
|
// Pre-migration this was `forEachArchivedPhaseToken(planBase, cb, convention)`
|
|
// feeding W006's existence check. `PHASE_TOKEN_FROM_DIR_RE` rejects
|
|
// `GSD.02-05-shipped` outright, so un-threaded the archive is invisible and a
|
|
// phase whose only directory was archived draws a W006 it should not.
|
|
|
|
test('an ARCHIVED bracket phase directory satisfies W006', () => {
|
|
mkdir('milestones', 'v1.0-phases', 'GSD.02-05-shipped');
|
|
mkdir('phases');
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 05: Shipped work
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
assert.deepEqual(healthCodes('W006'), [],
|
|
'the phase\'s directory lives in a milestone archive — that is not "no directory on disk"');
|
|
});
|
|
|
|
test('CONTROL: the flat-legacy twin of the same repo is equally silent', () => {
|
|
mkdir('milestones', 'v1.0-phases', '05-shipped');
|
|
mkdir('phases');
|
|
write(`# Roadmap
|
|
|
|
## v2.0
|
|
|
|
### Phase 05: Shipped work
|
|
**Goal:** a
|
|
`, undefined);
|
|
assert.deepEqual(healthCodes('W006'), []);
|
|
});
|
|
|
|
test('CONTROL: with the archive removed the same bracket repo DOES warn', () => {
|
|
// Non-vacuity: the silence above must come from the archive being read,
|
|
// not from W006 being unable to fire on this fixture at all.
|
|
mkdir('phases');
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 05: Shipped work
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
const w = healthCodes('W006');
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /Phase 05/);
|
|
});
|
|
|
|
// ── roadmapPhaseCheckboxes (planning-snapshot.cts) ───────────────────────
|
|
// Pre-migration this exclusion came from `buildNotStartedPhaseVariants(
|
|
// roadmapContent, convention)`; the rule table replaced that call with the
|
|
// `roadmapPhaseCheckboxes` field. Un-threaded, a bracket repo's `- [ ]`
|
|
// bullets never parse, so every not-yet-started bracket phase draws a W006.
|
|
|
|
test('an UNCHECKED bracket checklist bullet excludes its phase from W006', () => {
|
|
mkdir('phases');
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
- [ ] **[GSD.02] 09: Not started**
|
|
|
|
### [GSD.02] 09: Not started
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
assert.deepEqual(healthCodes('W006'), [],
|
|
'an unstarted phase legitimately has no directory yet');
|
|
});
|
|
|
|
test('CONTROL: the flat-legacy twin of the same repo is equally silent', () => {
|
|
mkdir('phases');
|
|
write(`# Roadmap
|
|
|
|
## v2.0
|
|
|
|
- [ ] **Phase 09: Not started**
|
|
|
|
### Phase 09: Not started
|
|
**Goal:** a
|
|
`, undefined);
|
|
assert.deepEqual(healthCodes('W006'), []);
|
|
});
|
|
|
|
test('CONTROL: a CHECKED bracket bullet is not excluded', () => {
|
|
// Non-vacuity in the direction that matters: the exclusion must read the
|
|
// checkbox STATE, not merely the presence of a bracket bullet.
|
|
mkdir('phases');
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
- [x] **[GSD.02] 09: Claimed complete**
|
|
|
|
### [GSD.02] 09: Claimed complete
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
const w = healthCodes('W006');
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /Phase 09/);
|
|
});
|
|
|
|
// ── W007's token (roadmap-disk-consistency.cts) ──────────────────────────
|
|
// Pre-migration W007 iterated `collectDiskPhaseEntries`' keys, which were
|
|
// built by the convention-aware extractor; the migrated rule iterates
|
|
// directory NAMES and tokenizes per entry. Un-threaded it reports the whole
|
|
// directory name as the phase id.
|
|
|
|
test('an ORPHAN bracket directory is reported by its phase token, not its dir name', () => {
|
|
mkdir('phases', 'GSD.02-77-orphan');
|
|
write(`# Roadmap
|
|
|
|
## [GSD.02] v2.0
|
|
|
|
### [GSD.02] 05: Real
|
|
**Goal:** a
|
|
`, 'bracket');
|
|
const w = healthCodes('W007');
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /^Phase 77 exists on disk/,
|
|
'un-threaded this reads "Phase GSD.02-77-orphan exists on disk"');
|
|
});
|
|
|
|
test('CONTROL: the flat-legacy twin names its token the same way', () => {
|
|
mkdir('phases', '77-orphan');
|
|
write(`# Roadmap
|
|
|
|
## v2.0
|
|
|
|
### Phase 05: Real
|
|
**Goal:** a
|
|
`, undefined);
|
|
const w = healthCodes('W007');
|
|
assert.equal(w.length, 1, JSON.stringify(w));
|
|
assert.match(w[0], /^Phase 77 exists on disk/);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// The #3165 TRUNCATED-WINDOW FALLBACK — `roadmap analyze`'s second reading pass.
|
|
//
|
|
// #3428 (merged in at next @ 483083ae) gave `cmdRoadmapAnalyze` a recovery path:
|
|
// when the scoped milestone window is suspect (non-COMPLETE scope), found zero
|
|
// phases, and phase directories exist on disk, it re-scans the
|
|
// shipped-milestone-stripped document through the SAME extracted enrichment
|
|
// (`collectAnalyzePhases`). That extraction turned every seam this PR threads
|
|
// into a TWO-call-site seam, and the second site was invisible to the suite:
|
|
// passing a null convention there — while the scoped site stayed threaded —
|
|
// was green across all 518 tests of the bracket, roadmap and milestone-window
|
|
// files. Every pre-existing bracket assertion reaches the enrichment through
|
|
// the SCOPED window, so none of them can see the fallback's reading at all.
|
|
//
|
|
// The shape below is the one that reaches it on a bracket repo: a MID-MIGRATION
|
|
// ROADMAP whose ACTIVE milestone is bracketed, whose phase-detail sections sit
|
|
// after an intervening CLOSED legacy milestone (so the window closes over prose
|
|
// only), and which still carries one legacy `### Phase N:` section of its own
|
|
// (so `hasPhaseEntries` — a convention-BLIND reader, see the disclosure below —
|
|
// sees the document as containing phases and the window as not, which is what
|
|
// classifies the window TRUNCATED).
|
|
//
|
|
// DISCLOSURE, measured and deliberately NOT fixed here: `hasPhaseEntries`
|
|
// (src/roadmap-parser.cts) is one of the convention-less readers this PR does
|
|
// not widen. On a PURE-bracket ROADMAP it finds no phase entries anywhere, so
|
|
// `classifyMilestoneWindow`'s row 8 cannot fire and the window classifies
|
|
// COMPLETE. The consequence is larger than a scope label: on the same layout
|
|
// as below but with no legacy section — bracket milestone, bracket phase
|
|
// headings after a closed milestone, canonical bracket directories on disk —
|
|
// `roadmap analyze` returns
|
|
// `{"scope":"complete","phase_count":0,"next_phase":null,"phases":[]}` where
|
|
// the flat-legacy twin of that document returns
|
|
// `{"scope":"truncated","phase_count":2,...}`. `scope: "complete"` with
|
|
// `phase_count: 0` is the "genuinely empty milestone" answer — precisely the
|
|
// indistinguishability #3184 introduced `scope` to remove — and
|
|
// `workflows/next.md` Route 0 iterates `.phases[]`, so the safety invariant
|
|
// #3165 exists to protect silently does not run, on the convention this epic
|
|
// is for.
|
|
//
|
|
// Not fixed in a merge round: widening `hasPhaseEntries` changes the value of
|
|
// the upstream-owned `scope` FIELD on bracket repos, which is a behaviour
|
|
// slice (the PR-2.5/PR-4 convention-less-readers question), not a conflict
|
|
// resolution. The mid-migration shape below is the reachable half — and it is
|
|
// the half a repo actually mid-migration has.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('#612 PR-2: the #3165 truncated-window fallback reads bracket phases too', () => {
|
|
beforeEach(() => { tmpDir = createTempProject('adr-612-analyze-fallback-'); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
const writeWithMilestone = (roadmap, convention) => {
|
|
write(roadmap, convention);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v2.0\n---\n', 'utf-8');
|
|
};
|
|
|
|
// The checklist bullets sit BESIDE the detail headings, past the closed
|
|
// milestone heading, deliberately: inside the window they would give the
|
|
// window phase entries of its own, `classifyMilestoneWindow` would call it
|
|
// COMPLETE, and the recovery path this block is about would never fire.
|
|
const BRK = `# Roadmap
|
|
|
|
## [GSD.02] v2.0 Current 🚧
|
|
|
|
Active milestone prose.
|
|
|
|
## v1.0 Old ✅ SHIPPED
|
|
|
|
### Phase 99: Legacy leftover
|
|
**Goal:** z
|
|
|
|
- [ ] **[GSD.02] 01: One**
|
|
- [ ] **[GSD.02] 02: Two**
|
|
|
|
### [GSD.02] 01: One
|
|
**Goal:** a
|
|
|
|
### [GSD.02] 02: Two
|
|
**Goal:** b
|
|
`;
|
|
const LEG = `# Roadmap
|
|
|
|
## v2.0 Current 🚧
|
|
|
|
Active milestone prose.
|
|
|
|
## v1.0 Old ✅ SHIPPED
|
|
|
|
### Phase 99: Legacy leftover
|
|
**Goal:** z
|
|
|
|
- [ ] **Phase 01: One**
|
|
- [ ] **Phase 02: Two**
|
|
|
|
### Phase 01: One
|
|
**Goal:** a
|
|
|
|
### Phase 02: Two
|
|
**Goal:** b
|
|
`;
|
|
|
|
const mkDirs = (specs) => {
|
|
for (const [dir, stem, complete] of specs) {
|
|
const q = path.join(tmpDir, '.planning', 'phases', dir);
|
|
fs.mkdirSync(q, { recursive: true });
|
|
fs.writeFileSync(path.join(q, `${stem}-01-PLAN.md`), '# plan\n', 'utf-8');
|
|
if (complete) {
|
|
fs.writeFileSync(path.join(q, `${stem}-01-SUMMARY.md`), '# summary\n', 'utf-8');
|
|
fs.writeFileSync(
|
|
path.join(q, `${stem}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8');
|
|
}
|
|
}
|
|
};
|
|
const BRK_DIRS = [['GSD.02-01-one', 'GSD.02-01', true], ['GSD.02-02-two', 'GSD.02-02', false]];
|
|
const LEG_DIRS = [['01-one', '01', true], ['02-two', '02', false]];
|
|
const diskShape = (a) => a.phases.map(p => [p.number, p.disk_status, p.plan_count, p.summary_count]);
|
|
|
|
const EXPECTED = [
|
|
['99', 'no_directory', 0, 0],
|
|
['01', 'complete', 1, 1],
|
|
['02', 'planned', 1, 0],
|
|
];
|
|
|
|
test('the fallback is what supplies the phases here — without dirs on disk it stays gated off', () => {
|
|
// Non-vacuity: the SCOPED window contributes nothing on this document. With
|
|
// the on-disk evidence removed, #3428's own precondition fails and the
|
|
// result is the empty one the recovery path exists to replace. Any later
|
|
// assertion about `disk_status` below is therefore an assertion about the
|
|
// FALLBACK's reading, not the scoped scan's.
|
|
writeWithMilestone(BRK, 'bracket');
|
|
const a = analyze();
|
|
assert.equal(a.scope, 'truncated', 'the window must be suspect or nothing recovers');
|
|
assert.equal(a.phase_count, 0, 'the scoped window genuinely finds no phases');
|
|
});
|
|
|
|
test('bracket phases recovered by the fallback resolve their DIRECTORIES', () => {
|
|
writeWithMilestone(BRK, 'bracket');
|
|
mkDirs(BRK_DIRS);
|
|
const a = analyze();
|
|
assert.equal(a.scope, 'truncated', 'still flagged best-effort, per #3165');
|
|
assert.deepEqual(diskShape(a), EXPECTED,
|
|
'un-threaded, the fallback does not match the bracket headings at all: phase_count 1');
|
|
});
|
|
|
|
test('and the recovered phases are not then reported as missing detail sections', () => {
|
|
// #2761 M1 x #3165: `detailKeys` — the occurrence index the checklist scan
|
|
// is filtered against — must be swapped for the FALLBACK scan's, in the
|
|
// same breath as `phases` and `effectiveContent`. Left holding the scoped
|
|
// scan's (empty) index while the checklist scan reads the fallback
|
|
// document, every phase the recovery just found is reported as a checklist
|
|
// entry with no detail section: `missing_phase_details: ["01","02"]`,
|
|
// wrong-and-confident on exactly the output #3165 exists to populate.
|
|
writeWithMilestone(BRK, 'bracket');
|
|
mkDirs(BRK_DIRS);
|
|
const a = analyze();
|
|
assert.equal(a.phase_count, 3, 'guard: the recovery fired, so this is the fallback index');
|
|
assert.equal(a.missing_phase_details, null,
|
|
'every checklist bullet has a detail heading in the same document');
|
|
});
|
|
|
|
test('CONTROL: a checklist entry with NO detail heading is still reported', () => {
|
|
// The companion direction: a fix that simply suppressed the report would
|
|
// pass the test above. A bullet with no heading anywhere must still surface.
|
|
writeWithMilestone(BRK.replace(
|
|
'- [ ] **[GSD.02] 02: Two**',
|
|
'- [ ] **[GSD.02] 02: Two**\n- [ ] **[GSD.02] 07: Never written**'), 'bracket');
|
|
mkDirs(BRK_DIRS);
|
|
assert.deepEqual(analyze().missing_phase_details, ['07']);
|
|
});
|
|
|
|
test('CONTROL: and that shape equals its flat-legacy twin exactly', () => {
|
|
writeWithMilestone(BRK, 'bracket');
|
|
mkDirs(BRK_DIRS);
|
|
const bracket = diskShape(analyze());
|
|
cleanup(tmpDir);
|
|
tmpDir = createTempProject('adr-612-analyze-fallback-leg-');
|
|
writeWithMilestone(LEG, undefined);
|
|
mkDirs(LEG_DIRS);
|
|
const legacy = diskShape(analyze());
|
|
assert.deepEqual(bracket, legacy,
|
|
'the fallback must read a bracket document exactly as it reads the legacy twin');
|
|
assert.deepEqual(legacy, EXPECTED, 'and the twin is the right answer, not a shared wrong one');
|
|
});
|
|
|
|
test('CONTROL: a NON-bracket repo takes the same path and is unaffected', () => {
|
|
writeWithMilestone(LEG, undefined);
|
|
mkDirs(LEG_DIRS);
|
|
assert.deepEqual(diskShape(analyze()), EXPECTED);
|
|
});
|
|
});
|