* feat(#2790): add read-only planning.inspect schema-v1 snapshot query Adds a read-only query emitting a schema-versioned JSON projection of .planning/ so downstream harness UIs can consume planning state without parsing GSD's Markdown a second time. Composed strictly from the ADR-3180 section 7 owners plus parsePlanDocument, parseRequirements and parseUatItems; markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams. It declares its own flat external schema rather than serializing PlanningSnapshot, which is the diagnostic-rule subject and still growing. Extracts plan-document parsing out of cmdPhasePlanIndex into a shared leaf module so phase.plan-index and planning.inspect cannot drift, including the plan-id derivation both surfaces report. Also fixes parseRequirements dropping the separator delimiter used by the shipped requirements template, surfaced while wiring the requirement rows. * fix(#2790): close spec gaps and a raw-text test assertion found in review Review findings from the standards, spec and security passes: - phases[] rows carry goal and dependencies, the two per-phase elements the issue Summary names that had no corresponding field. Goal is bounded to the section's leading prose so the Depends-on line, the Plans checklist and the wave annotations are not duplicated into it. - requirement rows carry their own diagnostic codes, so a consumer no longer has to string-parse the global diagnostics subject to correlate. - roadmap_acceptance.checkbox is looked up through the phase-id key owners. It was compared raw against the on-disk directory name, so it read null for every real-world slugged phase directory and the evidence channel was inert. - the hostile-input test asserts the structured payload instead of matching the raw stdout string. The absence proof over raw stdout is kept deliberately. * fix(#2790): register planning in the runtime usage list and repair fixtures Remote runner reported 9 failures on 9b3f9aa. Two root causes, both fixed: - gsd-tools.cjs registered the planning family in HOST_COMMAND_ROUTERS but never added it to TOP_LEVEL_USAGE's Commands list. Those are two surfaces a parity test guards, and the top-of-file block comment is not the runtime help string. A real wiring gap that every local gate and three review passes missed. - the new suite's fixtures could not produce a resolvable phase set. STATE.md frontmatter omitted the milestone field, which ADR-3180 7.2 rule 1 makes the primary milestone selector, so the phase set scoped unscoped and every percentage was correctly withheld. Separately declarePhase returned a path without creating the directory, so a phase declared but never written to left phases empty. Both reproduced against the built module before fixing. No assertion was weakened. The withholding path is still exercised and still returns null when the roadmap is absent. * chore(#2790): backfill changeset pr number * test(#2790): cover every enumerated matrix row and contain a symlink escape Reverses a silent deferral. An earlier revision left 23 of the 78 enumerated matrix rows unimplemented and 7 more as one-off manual checks, with a paragraph in the artifact and the PR body describing the gap. CLAUDE.md is explicit that such a note is not a fix and is not surfacing. The rows are implemented instead and the manual-evidence bucket is gone: 49 test cases become 88, covering all 78. Writing the symlink row proved a real leak: a *-PLAN.md symlinked outside .planning/ had its content emitted into the payload, confirmed via a direct call and the spawned CLI. readDocument now resolves target and planning root with realpathSync and rejects an escape, returning the ordinary unreadable-document shape. Tested both ways, because a containment check that over-rejects is its own defect: an escaping symlink leaks nothing and degrades that plan alone, while a legitimately relocated .planning/ symlink stays fully readable. The three new modules are registered in the mutation COVERED registry, which had been reporting has_work false and skipping the Stryker gate entirely. Provisional non-binding floors so the shards run and report; raised to the measured value before merge, since the registry forbids calibrating from a local run. * fix(#2790): satisfy the mutation ratchet contract and scope the 1MB test Remote runner reported 16 failures on 8c451ed. Two causes. The COVERED registry has a paired contract the earlier commit violated: every module needs a matching RATCHET_BASELINE entry, and minScore must be between 50 and 100 with minScore === baseline. The provisional floor of 1 was illegal on both counts. All three modules now sit at 50 — the registry's own enforced minimum — with matching baselines. The score cannot be measured locally: the shard runs node --test, which this repo hard-blocks, so CI is the only source. Floors are raised to the measured value once this PR's shards report; a shard below 50 means the tests need strengthening, since the floor cannot go lower. The 1MB test was measuring the test harness rather than the product. The command handles the oversized payload correctly by spilling to a tmpfile and resolving it back, but the resolved stdout then exceeds runGsdTools' maxBuffer and the helper reports ENOBUFS. It now uses --pick so stdout stays one byte while the full 1MB document is still read and parsed end to end. * fix(#2790): wire containment across every document read this command drives An isolated security review of the containment control found the boundary logic sound but not comprehensively wired: two content reads reached the filesystem without it. An escaped phase DIRECTORY could enumerate external filenames into the file fields and diagnostic subjects. Both enumeration sites now containment-check the directory before reading. Worth recording that the leak was already prevented one layer earlier than the review claimed: Dirent#isDirectory() reports false for a directory symlink, so such a directory never becomes a phase row at all. The guard is defense-in-depth for a direct caller and for platforms where a reparse point reports as a directory. A *-VERIFICATION.md symlinked outside the root leaked one frontmatter value verbatim, because readVerificationStatus does its own read and copies an unrecognized status into the payload's next_action. Closed from the consumer side through that function's existing fs injection seam, so src/verification.cts keeps its signature and its other callers are untouched. The reviewer additionally rated a forged status: passed as an integrity bypass. It is not: anyone able to plant the symlink can plant a real VERIFICATION.md saying the same thing. The incremental risk is confidentiality, which is what these fixes close. src/plan-scan.cts is deliberately unchanged: isPlanSuperseded reads symlink-followed content but yields only a derived boolean, no document text. * test(#2790): give the mutation shards an in-process surface Two Stryker shards were CANCELLED at the 15-minute cap, not failed on score. CI log: 640 mutants instrumented, and the dry run reported 'Ran 1 tests in 20 seconds' because the shards pointed at the integration suite, where nearly every case spawns a gsd-tools subprocess and Stryker's command runner treats the whole test-runner invocation as a single test. 640 x 20s cannot finish in 15 minutes; at the kill it was 27/640 with an ETA over an hour. Every other COVERED module points at a property or unit file, and the workflow's own paths filter lists exactly those two patterns. In-process is the intended mutation surface; the shards were pointed at the wrong shape of test. Adds tests/planning-inspect.unit.test.cjs — 39 cases in 10 describes that spawn nothing and call the built modules directly. plan-document and the router need no filesystem at all, one being a pure content-to-object parser and the other taking an injected mock. The three shards now point here. The 91-case integration suite is untouched and still runs in the normal test job. * chore(#2790): ratchet mutation floors to the measured CI scores CI run 32392791843 measured all three shards, which is the only source the registry accepts — local runs count timeouts as kills and inflate badly. planning-command-router 95.65 -> floor 94 plan-document 76.58 -> floor 75 planning-inspect 57.03 -> floor 56 Applied the registry's own rule, floor(score) - 1, and updated RATCHET_BASELINE to match, since the ratchet test enforces equality. planning-inspect sits well below the file's target of 80 and is the obvious ratchet candidate as its tests improve. planning-command-router already exceeds the target. The placeholder comment about floors pending measurement is removed rather than left standing as a false statement. --------- Co-authored-by: sim <sim@local>
457 lines
18 KiB
JavaScript
457 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Characterization tests for T3 seam migration (#1390).
|
|
*
|
|
* These tests capture CURRENT behavior of the two gate-adjacent functions
|
|
* being migrated onto the markdown-sectionizer seam:
|
|
* 1. `extractPlanDesignatedSections` (check-command-router.cts)
|
|
* 2. `parseRequirements` checkbox-bullet path (gap-checker.cts)
|
|
*
|
|
* They are written BEFORE the migration (refactor pattern: tests go green
|
|
* before AND after). They act as the byte-identical contract:
|
|
* any migration that makes one of these fail is wrong.
|
|
*
|
|
* All tests are BEHAVIORAL (call the exported function, assert typed output).
|
|
* No source-grep.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
extractPlanDesignatedSections,
|
|
} = require('../gsd-core/bin/lib/check-command-router.cjs');
|
|
|
|
const {
|
|
parseRequirements,
|
|
} = require('../gsd-core/bin/lib/gap-checker.cjs');
|
|
|
|
// ─── extractPlanDesignatedSections ───────────────────────────────────────────
|
|
|
|
describe('extractPlanDesignatedSections — characterization (T3 pre-migration contract)', () => {
|
|
|
|
// ── HTML comment stripping (CALLER-SIDE: must survive migration) ──────────
|
|
|
|
test('strips HTML comments before scanning', () => {
|
|
const content = `# Plan\n<!-- D-01: this is a comment -->\n## Must Haves\n- D-01: implement\n`;
|
|
const result = extractPlanDesignatedSections(content);
|
|
// Comment is gone; designated section body is intact
|
|
assert.ok(!result.includes('D-01: this is a comment'), 'HTML comment content must be stripped');
|
|
});
|
|
|
|
test('strips multi-line HTML comment spanning lines', () => {
|
|
const content = `# Plan\n<!--\nhidden section\n-->\n## Objective\n- D-01: implement\n`;
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(!result.includes('hidden section'), 'Multi-line comment content must be stripped');
|
|
assert.ok(result.includes('D-01'), 'Objective section content must survive');
|
|
});
|
|
|
|
// ── Fenced code block stripping ───────────────────────────────────────────
|
|
|
|
test('strips backtick fenced code blocks', () => {
|
|
const content = [
|
|
'## Must Haves',
|
|
'- D-01: real requirement',
|
|
'```',
|
|
'## Must Haves',
|
|
'- D-99: fake in fence',
|
|
'```',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'Real D-01 outside fence must be included');
|
|
assert.ok(!result.includes('D-99'), 'D-99 inside fence must be stripped');
|
|
});
|
|
|
|
test('strips tilde fenced code blocks', () => {
|
|
const content = [
|
|
'## Truths',
|
|
'- D-01: real',
|
|
'~~~',
|
|
'- D-99: inside tilde fence',
|
|
'~~~',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'D-01 outside tilde fence must be included');
|
|
assert.ok(!result.includes('D-99'), 'D-99 inside tilde fence must be stripped');
|
|
});
|
|
|
|
// ── DESIGNATED_HEADINGS_RE matching ───────────────────────────────────────
|
|
|
|
test('collects body under ## Must Haves heading', () => {
|
|
const content = '# Plan\n\n## Must Haves\n\n- D-01: decision one\n- D-02: decision two\n\n## Other Heading\n\nsome text\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'Must Haves body must include D-01');
|
|
assert.ok(result.includes('D-02'), 'Must Haves body must include D-02');
|
|
assert.ok(!result.includes('some text'), 'Non-designated section must be excluded');
|
|
});
|
|
|
|
test('collects body under ## Truths heading', () => {
|
|
const content = '## Truths\n\n- D-03: truth one\n\n## Irrelevant\n\nignored\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-03'), 'Truths body must include D-03');
|
|
assert.ok(!result.includes('ignored'), 'Non-designated heading must be excluded');
|
|
});
|
|
|
|
test('collects body under ## Objective heading', () => {
|
|
const content = '## Objective\n\n- D-04: objective item\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-04'), 'Objective body must include D-04');
|
|
});
|
|
|
|
test('collects body under ## Tasks heading', () => {
|
|
const content = '## Tasks\n\n- D-05: task one\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-05'), 'Tasks body must include D-05');
|
|
});
|
|
|
|
test('collects body under ## Task (singular) heading', () => {
|
|
const content = '## Task\n\n- D-06: singular task\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-06'), 'Task (singular) body must include D-06');
|
|
});
|
|
|
|
test('collects body under ## Must Have (singular) heading', () => {
|
|
const content = '## Must Have\n\n- D-07: singular must have\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-07'), 'Must Have (singular) body must include D-07');
|
|
});
|
|
|
|
test('collects body under ## Truth (singular) heading', () => {
|
|
const content = '## Truth\n\n- D-08: singular truth\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-08'), 'Truth (singular) body must include D-08');
|
|
});
|
|
|
|
test('stops collecting when next heading is non-designated', () => {
|
|
const content = [
|
|
'## Must Haves',
|
|
'- D-01: item in must haves',
|
|
'## Implementation Plan',
|
|
'- D-99: item in non-designated',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'D-01 in designated section must be collected');
|
|
assert.ok(!result.includes('D-99'), 'D-99 in non-designated section must not be collected');
|
|
});
|
|
|
|
test('collects multiple designated sections separately', () => {
|
|
const content = [
|
|
'## Must Haves',
|
|
'- D-01: must have',
|
|
'## Objective',
|
|
'- D-02: objective',
|
|
'## Implementation',
|
|
'- D-99: not designated',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'D-01 from Must Haves must be collected');
|
|
assert.ok(result.includes('D-02'), 'D-02 from Objective must be collected');
|
|
assert.ok(!result.includes('D-99'), 'D-99 from Implementation must not be collected');
|
|
});
|
|
|
|
test('heading match is case-insensitive (## MUST HAVES)', () => {
|
|
const content = '## MUST HAVES\n\n- D-01: uppercase heading\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'Uppercase MUST HAVES heading must be matched');
|
|
});
|
|
|
|
test('heading match is case-insensitive (## TRUTHS)', () => {
|
|
const content = '## TRUTHS\n\n- D-02: uppercase truths\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-02'), 'Uppercase TRUTHS heading must be matched');
|
|
});
|
|
|
|
// ── YAML frontmatter extraction ───────────────────────────────────────────
|
|
|
|
test('extracts must_haves from YAML frontmatter', () => {
|
|
const content = [
|
|
'---',
|
|
'must_haves:',
|
|
' - D-01: jwt tokens',
|
|
' - D-02: redis',
|
|
'other_key: value',
|
|
'---',
|
|
'# Plan body',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'must_haves YAML value must be included');
|
|
assert.ok(result.includes('D-02'), 'must_haves YAML second value must be included');
|
|
});
|
|
|
|
test('extracts objective from YAML frontmatter', () => {
|
|
const content = [
|
|
'---',
|
|
'objective: Implement D-01 for auth',
|
|
'---',
|
|
'# Body',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'objective YAML value must be included');
|
|
});
|
|
|
|
test('extracts truths from YAML frontmatter', () => {
|
|
const content = [
|
|
'---',
|
|
'truths:',
|
|
' - D-03: the canonical truth',
|
|
'---',
|
|
'# Body',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-03'), 'truths YAML value must be included');
|
|
});
|
|
|
|
// ── XML tag bodies ─────────────────────────────────────────────────────────
|
|
|
|
test('extracts content from <objective> XML tags', () => {
|
|
const content = '<objective>D-01 is implemented here</objective>\n# Other section';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), '<objective> tag body must be extracted');
|
|
});
|
|
|
|
test('extracts content from <tasks> XML tags', () => {
|
|
const content = '<tasks>D-02 task body</tasks>';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-02'), '<tasks> tag body must be extracted');
|
|
});
|
|
|
|
test('extracts content from <action> XML tags', () => {
|
|
const content = '<action>D-03 action body</action>';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-03'), '<action> tag body must be extracted');
|
|
});
|
|
|
|
// ── Null / empty / missing input ─────────────────────────────────────────
|
|
|
|
test('returns empty string for null input', () => {
|
|
assert.strictEqual(extractPlanDesignatedSections(null), '');
|
|
});
|
|
|
|
test('returns empty string for undefined input', () => {
|
|
assert.strictEqual(extractPlanDesignatedSections(undefined), '');
|
|
});
|
|
|
|
test('returns empty string for empty string input', () => {
|
|
assert.strictEqual(extractPlanDesignatedSections(''), '');
|
|
});
|
|
|
|
// ── Byte-identical spot-check: full plan fixture ─────────────────────────
|
|
|
|
test('full plan fixture: exact output shape matches expected pattern', () => {
|
|
const content = [
|
|
'---',
|
|
'must_haves:',
|
|
' - D-01: use JWT',
|
|
'---',
|
|
'# Implementation Plan',
|
|
'',
|
|
'## Objective',
|
|
'',
|
|
'Implement D-02.',
|
|
'',
|
|
'## Must Haves',
|
|
'',
|
|
'- D-03: Redis session store',
|
|
'',
|
|
'## Background',
|
|
'',
|
|
'No decisions here.',
|
|
].join('\n');
|
|
const result = extractPlanDesignatedSections(content);
|
|
// All designated items present
|
|
assert.ok(result.includes('D-01'), 'D-01 from YAML must_haves present');
|
|
assert.ok(result.includes('D-02'), 'D-02 from Objective section present');
|
|
assert.ok(result.includes('D-03'), 'D-03 from Must Haves section present');
|
|
// Non-designated content absent
|
|
assert.ok(!result.includes('No decisions here'), 'Background section must be excluded');
|
|
});
|
|
|
|
test('CRLF content: designated sections collected correctly', () => {
|
|
const content = '## Must Haves\r\n\r\n- D-01: crlf item\r\n\r\n## Other\r\n\r\nnot here\r\n';
|
|
const result = extractPlanDesignatedSections(content);
|
|
assert.ok(result.includes('D-01'), 'CRLF content must be handled; D-01 must be collected');
|
|
assert.ok(!result.includes('not here'), 'Non-designated CRLF content must be excluded');
|
|
});
|
|
});
|
|
|
|
// ─── parseRequirements — checkbox-bullet path ─────────────────────────────────
|
|
|
|
describe('parseRequirements — checkbox-bullet characterization (T3 pre-migration contract)', () => {
|
|
|
|
// ── Basic checkbox parsing ────────────────────────────────────────────────
|
|
|
|
test('parses unchecked checkbox bullet - [ ] **REQ-01**', () => {
|
|
const md = '- [ ] **REQ-01** First requirement\n';
|
|
const items = parseRequirements(md);
|
|
assert.strictEqual(items.length, 1);
|
|
assert.strictEqual(items[0].id, 'REQ-01');
|
|
assert.strictEqual(items[0].text, 'First requirement');
|
|
});
|
|
|
|
test('parses checked checkbox bullet - [x] **REQ-01**', () => {
|
|
const md = '- [x] **REQ-01** Checked requirement\n';
|
|
const items = parseRequirements(md);
|
|
assert.strictEqual(items.length, 1);
|
|
assert.strictEqual(items[0].id, 'REQ-01');
|
|
assert.strictEqual(items[0].text, 'Checked requirement');
|
|
});
|
|
|
|
test('strips the separator colon used by the shipped requirements template', () => {
|
|
const md = '- [ ] **AUTH-01**: User can sign up\n';
|
|
const items = parseRequirements(md);
|
|
assert.strictEqual(items.length, 1);
|
|
assert.strictEqual(items[0].id, 'AUTH-01');
|
|
assert.strictEqual(items[0].text, 'User can sign up');
|
|
});
|
|
|
|
test('parses multiple checkbox bullets', () => {
|
|
const md = [
|
|
'- [ ] **REQ-01** First',
|
|
'- [x] **REQ-02** Second (checked)',
|
|
'- [ ] **REQ-03** Third',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
assert.deepStrictEqual(items.map(i => i.id), ['REQ-01', 'REQ-02', 'REQ-03']);
|
|
});
|
|
|
|
test('deduplicates repeated IDs (first occurrence wins)', () => {
|
|
const md = '- [ ] **REQ-01** First\n- [ ] **REQ-01** Duplicate\n';
|
|
const items = parseRequirements(md);
|
|
assert.strictEqual(items.length, 1, 'Duplicate IDs must be deduplicated');
|
|
assert.strictEqual(items[0].id, 'REQ-01');
|
|
assert.strictEqual(items[0].text, 'First');
|
|
});
|
|
|
|
test('parses non-REQ prefixes (TST-01, BACK-07, INSP-04)', () => {
|
|
const md = [
|
|
'- [ ] **TST-01** Test requirement',
|
|
'- [ ] **BACK-07** Backend requirement',
|
|
'- [ ] **INSP-04** Inspector requirement',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
const ids = items.map(i => i.id);
|
|
assert.ok(ids.includes('TST-01'), 'TST-01 must be parsed');
|
|
assert.ok(ids.includes('BACK-07'), 'BACK-07 must be parsed');
|
|
assert.ok(ids.includes('INSP-04'), 'INSP-04 must be parsed');
|
|
});
|
|
|
|
test('returns [] for empty string', () => {
|
|
assert.deepStrictEqual(parseRequirements(''), []);
|
|
});
|
|
|
|
test('returns [] for null', () => {
|
|
assert.deepStrictEqual(parseRequirements(null), []);
|
|
});
|
|
|
|
test('returns [] for undefined', () => {
|
|
assert.deepStrictEqual(parseRequirements(undefined), []);
|
|
});
|
|
|
|
test('returns [] for non-string (number)', () => {
|
|
assert.deepStrictEqual(parseRequirements(42), []);
|
|
});
|
|
|
|
// ── Table path (must remain caller-side after migration) ──────────────────
|
|
|
|
test('parses REQ-ID from table first-cell', () => {
|
|
const md = [
|
|
'| REQ-ID | Phase | Plan(s) |',
|
|
'|--------|-------|---------|',
|
|
'| TST-01 | Phase 01 | TBD |',
|
|
'| BACK-07 | Phase 01 | TBD |',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
const ids = items.map(i => i.id);
|
|
assert.ok(ids.includes('TST-01'), 'TST-01 must be parsed from table');
|
|
assert.ok(ids.includes('BACK-07'), 'BACK-07 must be parsed from table');
|
|
assert.ok(!ids.includes('REQ-ID'), 'Header token REQ-ID must not be parsed');
|
|
});
|
|
|
|
test('skips separator rows (|---|---|)', () => {
|
|
const md = [
|
|
'| REQ-ID | Phase |',
|
|
'|--------|-------|',
|
|
'| TST-01 | Phase 01 |',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
// Separator row itself must not produce a requirement
|
|
assert.ok(items.every(i => /^[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+$/.test(i.id)),
|
|
'All items must have valid ID format (not separator row content)');
|
|
});
|
|
|
|
test('skips header row immediately preceding separator', () => {
|
|
const md = [
|
|
'| REQ-ID | Phase |', // header row
|
|
'|--------|-------|', // separator row
|
|
'| REQ-01 | Phase 01 |',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
const ids = items.map(i => i.id);
|
|
assert.ok(!ids.includes('REQ-ID'), 'Header row token must be skipped');
|
|
assert.ok(ids.includes('REQ-01'), 'Data row must be parsed');
|
|
});
|
|
|
|
test('does not parse IDs from non-first table columns', () => {
|
|
const md = [
|
|
'| TST-01 | Phase 01 | PLAN-01 |',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
const ids = items.map(i => i.id);
|
|
assert.ok(ids.includes('TST-01'), 'First column ID must be parsed');
|
|
assert.ok(!ids.includes('PLAN-01'), 'Non-first column ID must NOT be parsed');
|
|
});
|
|
|
|
// ── Mixed checkbox + table ────────────────────────────────────────────────
|
|
|
|
test('parses both checkbox and table rows from same document', () => {
|
|
const md = [
|
|
'# Requirements',
|
|
'',
|
|
'| REQ-ID | Phase |',
|
|
'|--------|-------|',
|
|
'| TST-01 | Phase 01 |',
|
|
'',
|
|
'- [ ] **INSP-04** Inspector requirement',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
const ids = items.map(i => i.id);
|
|
assert.ok(ids.includes('TST-01'), 'Table row ID must be parsed');
|
|
assert.ok(ids.includes('INSP-04'), 'Checkbox row ID must be parsed');
|
|
});
|
|
|
|
test('deduplicates across checkbox and table forms (ID in both → one entry)', () => {
|
|
const md = [
|
|
'- [ ] **REQ-01** Checkbox form',
|
|
'',
|
|
'| REQ-01 | duplicate |',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
assert.strictEqual(items.filter(i => i.id === 'REQ-01').length, 1,
|
|
'REQ-01 appearing in both forms must appear once');
|
|
});
|
|
|
|
// ── Natural ordering (deterministic) ─────────────────────────────────────
|
|
|
|
test('returns items in document order (not sorted)', () => {
|
|
// parseRequirements preserves insertion order — sorting is sortRows()'s job
|
|
const md = [
|
|
'- [ ] **REQ-10** Tenth',
|
|
'- [ ] **REQ-02** Second',
|
|
'- [ ] **REQ-01** First',
|
|
].join('\n') + '\n';
|
|
const items = parseRequirements(md);
|
|
assert.deepStrictEqual(items.map(i => i.id), ['REQ-10', 'REQ-02', 'REQ-01'],
|
|
'Items must be returned in document order, not sorted');
|
|
});
|
|
|
|
// ── Indented checkbox bullets ─────────────────────────────────────────────
|
|
|
|
test('parses indented checkbox bullet (leading whitespace)', () => {
|
|
const md = ' - [ ] **REQ-01** Indented\n';
|
|
const items = parseRequirements(md);
|
|
assert.strictEqual(items.length, 1);
|
|
assert.strictEqual(items[0].id, 'REQ-01');
|
|
});
|
|
});
|