* chore(#2143): prohibition-with-teeth + migrate remaining table sites — Phase 4 Phase 4 of epic #2143 (ADR-2143 §7). Completes the markdown table/mutation consolidation by (a) giving the ad-hoc-parsing prohibition teeth and (b) migrating the last ad-hoc table sites onto the shared seam. - src/markdown-table.cts: new formatting-preserving `updateTableCell` primitive (self-contained, ragged-row-tolerant header/delimiter/cell-range scan; splices only the target cell's raw span, preserving all other bytes incl. padding/CRLF; no-op-preserves-padding when a transformer returns the current value). Exports splitTableRow/isDelimiterRow/findTableStartOffset for tolerant reuse. - eslint-rules/no-adhoc-markdown-parsing.cjs: TABLE-REGEX detector extended to `new RegExp(<literal|static-template>)`; new `.replace()`-mutation detector for roadmap/state/content receivers with a table/section-shaped pattern. - scripts/lint-table-schema-drift.cjs (wired into lint:ci): fails if a TABLE_SCHEMA header drifts from its authored table; tests import its logic (single source). - Migrated onto the seam (behaviour-preserving vs pre-Phase-4 HEAD, verified byte-diff old-vs-new): roadmap.cts cmdRoadmapUpdatePlanProgress, phase.cts cmdPhaseComplete + traceability, milestone.cts cmdRequirementsMarkComplete, uat.cts read path, state.cts metrics/decisions/By-Phase. - Incidental correctness gains from the migration: a decoy table can no longer swallow a phase-progress update (## Progress scoping); a ragged neighbouring row no longer silently aborts an edit; completing integer phase N no longer touches a decimal sub-phase N.x row; record-metric no longer drops trailing section content or duplicates the ## Performance Metrics section. - Kept justified allow-adhoc-markdown markers only where genuinely not a table (security.cts <|role|> token) or a loose non-GFM section (uat human-verify). Two orthogonal isolated reviews (correctness/adversarial + security) passed; correctness found 4 behaviour regressions in the first migration pass, all fixed and re-verified byte-identical-or-better vs OLD. Surfaced for maintainer (pre-existing, ambiguous domain logic, NOT changed here): templates/state.md places a By-Phase table under ## Performance Metrics while cmdStateRecordMetric assumes a Plan|Duration|Tasks|Files table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): match traceability row by first-cell value, not Requirement header Phase 4's migration matched the REQUIREMENTS.md traceability row by a column literally named `Requirement` (`row['Requirement']`), but real tables head that column `REQ-ID`. The by-name lookup found nothing, so `phase complete` and `requirements mark-complete` left the Status cell `Pending` (regressed #2769 / #2203, caught by gsd-test — 8 failures, both node 22/24). - src/phase.cts, src/milestone.cts: match the row by its FIRST cell's value (the requirement-ID column) regardless of that column's HEADER name, via `Object.values(row)[0]` (updateTableCell builds the record in header order). This mirrors OLD's first-cell `\|\s*<id>\s*\|` anchor, restoring header-name independence while keeping the seam. - src/milestone.cts hasTable: broadened from `Requirement`-only to also recognize `Requirement ID` / `REQ-ID` / `REQ ID` headers, kept in sync with the now-positional rowMatch/hasRow so a REQ-ID-headed table participates in the ADR-2143 §6 write-set and the #2140 table_unmatched drift check (it was silently omitted before — a checkbox-only partial reconcile against a REQ-ID table could report as fully reconciled). The `Requirement`-headed path is byte-identical to OLD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#2143): replace stale structural milestone guards with behavioural suite The `milestone.cjs regex global state fix` block was a source-structure guard (allow-test-rule: structural-regression-guard) — it readFileSync'd the compiled milestone.cjs and asserted removed regex idioms (`tablePattern.test`, `afterTable !== reqContent`, `doneTable = new RegExp(...)`). Phase 4's migration deleted those regexes (table update is now updateTableCell), making the assertions obsolete. Per the Test Cleanup rule, replace them in-PR with a behavioural suite driving the compiled CLI: - multi-ID mark-complete flips all IDs (guards the lastIndex/global-state class), - Pending->Complete flip under both `REQ-ID` and `Requirement` headers (#2769), - idempotent already_complete detection with no corruption, - REQ-ID-headed table participates in write_set (traceability entry, applied), - REQ-ID-headed table trips #2140 table_unmatched drift on a missing row. Pruned the now-nonexistent structural-regression-guard entry from the lint-allow-test-rule-refs allowlist (the source-text-is-the-product entry for the same file remains valid). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): backfill PR number 2253 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): record-metric targets its own metrics table, not By-Phase velocity `state record-metric` appended its per-plan row (`| Phase 1 P1 | 5min | 3 tasks | 4 files |`) into the FIRST table under `## Performance Metrics` — which on a real template-derived STATE.md is the By-Phase velocity table `| Phase | Plans | Total | Avg/Plan |`, polluting it on EVERY plan completion (execute-plan.md:414 is a per-plan call). The command's own metrics table is `| Plan | Duration | Tasks | Files |`, which the template does not ship, so the row never reached it; the scaffold branch also emitted a wrong `| Phase | Plan | Duration | Notes |` header matching neither the row nor the canonical table. Pre-existing (predates Phase 4); surfaced while migrating this site and fixed here per no-defer, on the user's explicit go-ahead. - src/state.cts cmdStateRecordMetric: locate the metrics table by its own header shape (`Plan|Duration|Tasks|Files`, via splitTableRow/isDelimiterRow) rather than "first table in the section". When the section exists but has no metrics table (only the By-Phase table), self-heal by appending a fresh **Per-Plan Metrics:** table to the END of the section body — By-Phase table, Recent Trend and footer preserved verbatim, no duplicate `## Performance Metrics` heading, created stays false. Absent-section scaffold header corrected to the canonical `| Plan | Duration | Tasks | Files |`. Ragged-tolerance + None-yet preserved. - Not touching templates/state.md (golden-install-parity hashed) — record-metric self-creates the table on first use instead. Failing-first regression test (tests/state.test.cjs) demonstrates the By-Phase pollution on the pre-fix build, then green after. Verified: no pollution, self- heal idempotency, both-tables isolation, content/heading preservation, flags, None-yet, corrected scaffold header (23-check adversarial harness + all existing record-metric scenarios). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#2143): deleteSection seam primitive (level-bounded whole-section removal) ADR-2143 §4 shipped withSection/collectSection (replace a section BODY) but no way to DELETE a section (heading + body). Phase 4 suppressed the phase-remove section delete instead of building it. deleteSection(content, predicate, opts) locates the section via the collectSection machinery and splices out from the heading's start offset to the next same-or-higher-level heading — so a level-3 `### Phase N` delete stops at a following level-2 `## Progress`, never past it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): phase remove no longer deletes ## Progress on last-phase removal updateRoadmapAfterPhaseRemoval deleted a `### Phase N` detail section with a greedy raw regex whose lazy scan, on the LAST phase, ran to EOF and destroyed the following `## Progress` heading and its entire tracking table — silent data loss, uncovered by tests (removal tests only exercised a middle phase). Migrated onto the new deleteSection seam (level-bounded, stops at `## Progress`); dropped the allow-adhoc-markdown SECTION-DELETION suppression. Failing-first regression (tests/phase.test.cjs) removes the LAST phase and asserts the ## Progress heading + table survive; middle-phase removal is byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#2143): deleteTableRow seam primitive (row removal, ragged-tolerant) Sibling of updateTableCell: locates the first GFM table, matches a DATA row by predicate (ragged-tolerant record build, header order), and splices out that row's whole line preserving every other byte. Returns {ok:false,reason} on no table / no match. Enables migrating the phase-remove Progress-table row delete off its ad-hoc regex (ADR-2143 §7 — the "future row-delete seam" Phase 4 punted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): phase remove deletes the Progress row via deleteTableRow The Progress-table row delete used a whole-document regex with two defects: (a) `\.?\s` required whitespace after the phase number, so a COMPACT row `|2|Beta|` was never deleted (stale row left behind); (b) unscoped — it could strike a row in a different table (e.g. an earlier `| Phase | Requirements |` table). Migrated onto deleteTableRow, scoped to the `## Progress` section (mirrors deriveProgressFromRoadmap), matching the row by first-cell phase number (integer zero-pad-insensitive; decimal exact; removing `2` never touches `2.5`). Both allow-adhoc-markdown suppressions removed. New behavioural tests: compact unpadded row deleted; padded byte-parity on the surviving rows (their ordinal correctly renumbers via the pre-existing renumber block). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): deleteTableRow leaves no dangling newline on last EOL-less row Deleting the final row of a table with no trailing EOL sliced from the row's start to end-of-string, stranding the newline that terminated the previous line. Back rowStart over the preceding \r?\n in that branch so the table ends cleanly. (Caught by the primitive's own unit test on gsd-test; local scenario checks missed the no-trailing-EOL edge.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): migrate read-only section-collects onto collectSection Six hand-rolled `## Section` read-extract regexes replaced by the collectSection seam (behaviour-preserving; extracted bodies feed the same downstream parsers): state.cts matchSessionSection (## Session / ## Session Continuity) + ## Blockers, smart-entry.cts ## Blockers, audit.cts ## Current Focus + ## Open Questions. Removes 6 allow-adhoc-markdown "pending #1372" suppressions. Incidental fix: the old Session regex `## Session[ \t]*\n` silently failed on a CRLF `## Session\r\n` heading (Windows STATE.md), nulling all session fields; collectSection is CRLF-safe, so session state now resolves on Windows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): fence-safe state-transition section writes + dedup stripFrontmatter - milestoneCompleteCore's `## Current Position` and `## Operator Next Steps` section resets used fence-blind raw regexes that a fenced `##` inside the body could truncate/mis-target (#2130/#2067/#2080 class). Migrated onto a fence-aware tokenizeHeadings-based helper (resetSectionVerbatim) that is byte-identical to the old output on the canonical path (9/9 fixtures) and correctly ignores a fenced fake heading (proven robustness gain). - mutateCurrentPositionFirstTime: hand-rolled locate+splice → collectSection + replaceSection (byte-parity). - stripFrontmatter was inlined byte-identically in state.cts AND state-transition.cts; hoisted the single canonical copy into frontmatter.cts (both call sites now import it) + unit tests — eliminates the divergence risk per CLAUDE.md "Generative Fix Divergence". Removes 3 allow-adhoc-markdown / #1372 markers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): name-address By-Phase sum + uat parse, eslint recall hole, catches - state.cts By-Phase "Total plans completed" sum: positional 2nd-cell regex → name-addressed splitTableRow read (correct on a reordered header, where the old code silently summed the wrong column). Marker removed. - uat.cts parseVerificationItems: loose pipe regex → splitTableRow within the existing table/numbered/bullet union scan (item list byte-identical; does NOT reintroduce the reverted strict-parseMarkdownTable item-drop). Marker removed. - eslint no-adhoc-markdown-parsing: close the `new RegExp(identifier)` recall hole — resolve a const-declared table-shaped regex identifier (mirrors the .replace() detector) + RuleTester cases; param/call args stay out (boundary). - commands.cts: delete a lying comment that claimed the scaffold date "stays on raw UTC / deferred" — #2136 already moved it to realClock.localToday(). - Empty catches (classified, not blind-swept): removed 4 dead try/catch; fixed 3 error-hiding (phase-insert decimal-dir I/O collision now fails loud; phase-remove rename partial-failure surfaced; milestone-archive true count via finally); left best-effort swallows with justification comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): extractFencedBlock seam + migrate api-coverage named fence parseCoverageMatrix extracted its ```coverage fenced block with an ad-hoc regex (the last real allow-adhoc-markdown suppression). Added extractFencedBlock to the markdown-sectionizer seam (reuses stripFencedCode's CommonMark fence engine — info-string match, ~~~/backtick, nesting, indent) and migrated onto it; byte- parity on the parsed CoverageMatrix across 8 fixtures. Only security.cts:367 (a genuine `<|role|>` protocol-token false-positive, not a GFM table) remains marked in src/ — the "prohibition with teeth" goal (nothing grandfathered but a true FP) is met. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): By-Phase row insert is name-addressed (insertTableRow seam) updatePerformanceMetricsSection's INSERT-new-row branch located the By-Phase table with a canonical-column-order-only regex + a hardcoded positional row literal, so on a reordered header it silently inserted nothing — inconsistent with the now name-addressed UPDATE and SUM halves of the same function. Added insertTableRow (markdown-table seam sibling of updateTableCell/deleteTableRow: name-addressed, header-order-agnostic, EOL-preserving) and migrated the branch onto it, mapping By-Phase values by column NAME. Canonical-order output is byte-identical; a reordered header now inserts a correctly-mapped row; a pre-existing CRLF mixed-EOL splice glitch is incidentally fixed. Retired the now-dead byPhaseTablePattern const. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): phase-list checkbox flip via updateBullet seam Added updateBullet (markdown-sectionizer): a fence-aware, offset-tracked single-bullet write primitive (GFM 1–4-space marker tolerance) — the write counterpart to read-only iterateBullets. Migrated mutateMilestonePhase's phase-list checkbox flip (`- [ ] Phase N …` → `- [x] … (completed <date>)`) off its whole-slice regex onto it, same milestone-slice scope + clock seam. Byte-identical across simple / idempotent / metachar-title / double-space / CRLF scenarios. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): scope the Progress-ordinal renumber to ## Progress via seam phase remove's integer-renumber decremented Progress-table phase ordinals with a whole-document `content.replace(/(\|\s*)(\d+)(\.\s)/g, …)` — unscoped, so it also rewrote any `| N. …` cell in an unrelated/decoy table (same class as the batch-2 row-delete scoping bug). Migrated onto updateTableCell, scoped to the ## Progress section, decrementing each affected row's leading phase ordinal by column name. Byte-identical on canonical Progress tables + multi-row + decimal-sibling cases; a decoy `| 3. … |` row before ## Progress is now correctly left untouched. The sibling heading / checkbox-bullet / PLAN.md-filename / Depends-on-prose renumbers are not GFM-table mutations (outside ADR-2143's table/section mandate) — left as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): review fixes — scope traceability write, restore Current Position H3-stop Adversarial review of the remediation (BLOCK verdict) — all 9 findings fixed: - F1 (BLOCKER): requirements mark-complete / phase complete flipped the checkbox but NOT the traceability row on the shipped template, because updateTableCell bound to the FIRST table (## Out of Scope, no Status column) instead of the ## Traceability table — the #2140 silent-divergence class, re-introduced by the seam migration and missed by tests (fixtures had Traceability first). Scoped the write + hasRow probe to the ## Traceability section slice (updateTraceability Cell helper) in milestone.cts + phase.cts. Failing-first tests on the Out-of-Scope-before-Traceability layout; the #2769 first-cell match preserved. - F2 (MAJOR): mutateCurrentPositionFirstTime restored to locateCurrentPosition (STOP_H2_PLUS) — collectSection's default H2-stop swallowed a level-3 subsection and the field regexes clobbered it (#2130 class). - F3/F8: Progress-ordinal renumber re-escapes via escapeCell + keys padding recovery by row index (was de-escaping `\|` and losing padding on dup values). - F4: insertTableRow escapes cell values internally. - F5: updateBullet accepts a tab after the marker (`[ \t]{1,4}`). - F7: resetSectionVerbatim consumes CRLF blank lines (byte-parity on CRLF). - F6/F9: corrected two misleading comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): data-loss + CRLF-session user-facing fixes (#2253) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#2143): de-flake the G10 windsurf ReDoS-guard wall-clock assertion The G10 test asserted `elapsedMs < 1000` for a 200k-char payload — a wall-clock assertion (CLAUDE.md: never assert on wall-clock time) that flaked on a loaded node24 bench at ~1.1s. It was redundant: runHook's spawnSync `timeout: 10000` already SIGKILLs a catastrophic-backtracking hook, so the exit-0 assertion is the real ReDoS guard. Removed the timing assertion; kept exit-0 + documented the subprocess-timeout mechanism. Surfaced (not caused) by this branch's gsd-test runs loading the bench; unrelated to the markdown-parsing changes but fixed in place per the no-flaky-tests rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1764 lines
70 KiB
JavaScript
1764 lines
70 KiB
JavaScript
/**
|
||
* GSD Tools Tests - frontmatter.cjs
|
||
*
|
||
* Tests for the hand-rolled YAML parser's pure function exports:
|
||
* extractFrontmatter, reconstructFrontmatter, spliceFrontmatter,
|
||
* parseMustHavesBlock, and FRONTMATTER_SCHEMAS.
|
||
*
|
||
* Includes REG-04 regression: quoted comma inline array edge case.
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const {
|
||
extractFrontmatter,
|
||
reconstructFrontmatter,
|
||
spliceFrontmatter,
|
||
stripFrontmatter,
|
||
parseMustHavesBlock,
|
||
} = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
// ─── extractFrontmatter ─────────────────────────────────────────────────────
|
||
|
||
describe('extractFrontmatter', () => {
|
||
test('parses simple key-value pairs', () => {
|
||
const content = '---\nname: foo\ntype: execute\n---\nbody';
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.name, 'foo');
|
||
assert.strictEqual(result.type, 'execute');
|
||
});
|
||
|
||
test('strips quotes from values', () => {
|
||
const doubleQuoted = '---\nname: "foo"\n---\n';
|
||
const singleQuoted = '---\nname: \'foo\'\n---\n';
|
||
assert.strictEqual(extractFrontmatter(doubleQuoted).name, 'foo');
|
||
assert.strictEqual(extractFrontmatter(singleQuoted).name, 'foo');
|
||
});
|
||
|
||
test('parses nested objects', () => {
|
||
const content = '---\ntechstack:\n added: prisma\n patterns: repository\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result.techstack, { added: 'prisma', patterns: 'repository' });
|
||
});
|
||
|
||
test('parses block arrays', () => {
|
||
const content = '---\nitems:\n - alpha\n - beta\n - gamma\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result.items, ['alpha', 'beta', 'gamma']);
|
||
});
|
||
|
||
test('parses inline arrays', () => {
|
||
const content = '---\nkey: [a, b, c]\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result.key, ['a', 'b', 'c']);
|
||
});
|
||
|
||
test('handles quoted commas in inline arrays — REG-04 fixed', () => {
|
||
const content = '---\nkey: ["a, b", c]\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result.key, ['a, b', 'c']);
|
||
});
|
||
|
||
test('handles single-quoted commas in inline arrays', () => {
|
||
const content = "---\nkey: ['x, y', z]\n---\n";
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result.key, ['x, y', 'z']);
|
||
});
|
||
|
||
test('handles mixed quotes in inline arrays', () => {
|
||
const content = '---\nkey: ["a, b", \'c, d\', e]\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result.key, ['a, b', 'c, d', 'e']);
|
||
});
|
||
|
||
test('returns empty object for no frontmatter', () => {
|
||
const content = 'Just plain content, no frontmatter.';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result, {});
|
||
});
|
||
|
||
test('returns empty object for empty frontmatter', () => {
|
||
const content = '---\n---\nBody text.';
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result, {});
|
||
});
|
||
|
||
test('parses frontmatter-only content', () => {
|
||
const content = '---\nkey: val\n---';
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.key, 'val');
|
||
});
|
||
|
||
test('handles emoji and non-ASCII in values', () => {
|
||
const content = '---\nname: "Hello World"\nlabel: "cafe"\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.name, 'Hello World');
|
||
assert.strictEqual(result.label, 'cafe');
|
||
});
|
||
|
||
test('converts empty-object placeholders to arrays when dash items follow', () => {
|
||
// When a key has no value, it gets an empty {} placeholder.
|
||
// When "- item" lines follow, the parser converts {} to [].
|
||
const content = '---\nrequirements:\n - REQ-01\n - REQ-02\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.ok(Array.isArray(result.requirements), 'should convert placeholder object to array');
|
||
assert.deepStrictEqual(result.requirements, ['REQ-01', 'REQ-02']);
|
||
});
|
||
|
||
test('skips empty lines in YAML body', () => {
|
||
const content = '---\nfirst: one\n\nsecond: two\n\nthird: three\n---\n';
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.first, 'one');
|
||
assert.strictEqual(result.second, 'two');
|
||
assert.strictEqual(result.third, 'three');
|
||
});
|
||
|
||
// ─── Bug #2130: body --- sequence mis-parse ──────────────────────────────
|
||
|
||
test('#2130: frontmatter at top with YAML example block in body — returns top frontmatter', () => {
|
||
const content = [
|
||
'---',
|
||
'name: my-agent',
|
||
'type: execute',
|
||
'---',
|
||
'',
|
||
'# Documentation',
|
||
'',
|
||
'Here is a YAML example:',
|
||
'',
|
||
'```yaml',
|
||
'---',
|
||
'key: value',
|
||
'other: stuff',
|
||
'---',
|
||
'```',
|
||
'',
|
||
'End of doc.',
|
||
].join('\n');
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.name, 'my-agent', 'should extract name from TOP frontmatter');
|
||
assert.strictEqual(result.type, 'execute', 'should extract type from TOP frontmatter');
|
||
assert.strictEqual(result.key, undefined, 'should NOT extract key from body YAML block');
|
||
assert.strictEqual(result.other, undefined, 'should NOT extract other from body YAML block');
|
||
});
|
||
|
||
test('#2130: frontmatter at top with horizontal rules in body — returns top frontmatter', () => {
|
||
const content = [
|
||
'---',
|
||
'title: My Doc',
|
||
'status: active',
|
||
'---',
|
||
'',
|
||
'# Section One',
|
||
'',
|
||
'Some text.',
|
||
'',
|
||
'---',
|
||
'',
|
||
'# Section Two',
|
||
'',
|
||
'More text.',
|
||
'',
|
||
'---',
|
||
'',
|
||
'# Section Three',
|
||
].join('\n');
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.title, 'My Doc', 'should extract title from TOP frontmatter');
|
||
assert.strictEqual(result.status, 'active', 'should extract status from TOP frontmatter');
|
||
});
|
||
|
||
test('#2130: body-only --- block with no frontmatter at byte 0 — returns empty', () => {
|
||
const content = [
|
||
'# My Document',
|
||
'',
|
||
'Some intro text.',
|
||
'',
|
||
'---',
|
||
'key: value',
|
||
'other: stuff',
|
||
'---',
|
||
'',
|
||
'End of doc.',
|
||
].join('\n');
|
||
const result = extractFrontmatter(content);
|
||
assert.deepStrictEqual(result, {}, 'should return empty object when --- block is not at byte 0');
|
||
});
|
||
|
||
test('#2130: valid frontmatter at byte 0 still works (regression guard)', () => {
|
||
const content = [
|
||
'---',
|
||
'phase: 01',
|
||
'plan: 03',
|
||
'type: execute',
|
||
'wave: 1',
|
||
'depends_on: ["01-01", "01-02"]',
|
||
'files_modified:',
|
||
' - src/auth.ts',
|
||
' - src/middleware.ts',
|
||
'autonomous: true',
|
||
'---',
|
||
'',
|
||
'# Plan body here',
|
||
].join('\n');
|
||
const result = extractFrontmatter(content);
|
||
assert.strictEqual(result.phase, '01');
|
||
assert.strictEqual(result.plan, '03');
|
||
assert.strictEqual(result.type, 'execute');
|
||
assert.strictEqual(result.wave, '1');
|
||
assert.deepStrictEqual(result.depends_on, ['01-01', '01-02']);
|
||
assert.deepStrictEqual(result.files_modified, ['src/auth.ts', 'src/middleware.ts']);
|
||
assert.strictEqual(result.autonomous, 'true');
|
||
});
|
||
});
|
||
|
||
// ─── reconstructFrontmatter ─────────────────────────────────────────────────
|
||
|
||
describe('reconstructFrontmatter', () => {
|
||
test('serializes simple key-value', () => {
|
||
const result = reconstructFrontmatter({ name: 'foo' });
|
||
assert.strictEqual(result, 'name: foo');
|
||
});
|
||
|
||
test('serializes empty array as inline []', () => {
|
||
const result = reconstructFrontmatter({ items: [] });
|
||
assert.strictEqual(result, 'items: []');
|
||
});
|
||
|
||
test('serializes short string arrays inline', () => {
|
||
const result = reconstructFrontmatter({ key: ['a', 'b', 'c'] });
|
||
assert.strictEqual(result, 'key: [a, b, c]');
|
||
});
|
||
|
||
test('serializes long arrays as block', () => {
|
||
const result = reconstructFrontmatter({ key: ['one', 'two', 'three', 'four'] });
|
||
assert.ok(result.includes('key:'), 'should have key header');
|
||
assert.ok(result.includes(' - one'), 'should have block array items');
|
||
assert.ok(result.includes(' - four'), 'should have last item');
|
||
});
|
||
|
||
test('quotes values containing colons or hashes', () => {
|
||
const result = reconstructFrontmatter({ url: 'http://example.com' });
|
||
assert.ok(result.includes('"http://example.com"'), 'should quote value with colon');
|
||
|
||
const hashResult = reconstructFrontmatter({ comment: 'value # note' });
|
||
assert.ok(hashResult.includes('"value # note"'), 'should quote value with hash');
|
||
});
|
||
|
||
test('serializes nested objects with proper indentation', () => {
|
||
const result = reconstructFrontmatter({ tech: { added: 'prisma', patterns: 'repo' } });
|
||
assert.ok(result.includes('tech:'), 'should have parent key');
|
||
assert.ok(result.includes(' added: prisma'), 'should have indented child');
|
||
assert.ok(result.includes(' patterns: repo'), 'should have indented child');
|
||
});
|
||
|
||
test('serializes nested arrays within objects', () => {
|
||
const result = reconstructFrontmatter({
|
||
tech: { added: ['prisma', 'jose'] },
|
||
});
|
||
assert.ok(result.includes('tech:'), 'should have parent key');
|
||
assert.ok(result.includes(' added: [prisma, jose]'), 'should serialize nested short array inline');
|
||
});
|
||
|
||
test('skips null and undefined values', () => {
|
||
const result = reconstructFrontmatter({ name: 'foo', skip: null, also: undefined, keep: 'bar' });
|
||
assert.ok(!result.includes('skip'), 'should not include null key');
|
||
assert.ok(!result.includes('also'), 'should not include undefined key');
|
||
assert.ok(result.includes('name: foo'), 'should include non-null key');
|
||
assert.ok(result.includes('keep: bar'), 'should include non-null key');
|
||
});
|
||
|
||
test('round-trip: simple frontmatter', () => {
|
||
const original = '---\nname: test\ntype: execute\nwave: 1\n---\n';
|
||
const extracted1 = extractFrontmatter(original);
|
||
const reconstructed = reconstructFrontmatter(extracted1);
|
||
const roundTrip = `---\n${reconstructed}\n---\n`;
|
||
const extracted2 = extractFrontmatter(roundTrip);
|
||
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve data identity');
|
||
});
|
||
|
||
test('round-trip: nested with arrays', () => {
|
||
const original = '---\nphase: 01\ntech:\n added:\n - prisma\n - jose\n patterns:\n - repository\n - jwt\n---\n';
|
||
const extracted1 = extractFrontmatter(original);
|
||
const reconstructed = reconstructFrontmatter(extracted1);
|
||
const roundTrip = `---\n${reconstructed}\n---\n`;
|
||
const extracted2 = extractFrontmatter(roundTrip);
|
||
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve nested structures');
|
||
});
|
||
|
||
test('round-trip: multiple data types', () => {
|
||
const original = '---\nname: testplan\nwave: 2\ntags: [auth, api, db]\ndeps:\n - dep1\n - dep2\nconfig:\n enabled: true\n count: 5\n---\n';
|
||
const extracted1 = extractFrontmatter(original);
|
||
const reconstructed = reconstructFrontmatter(extracted1);
|
||
const roundTrip = `---\n${reconstructed}\n---\n`;
|
||
const extracted2 = extractFrontmatter(roundTrip);
|
||
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve multiple data types');
|
||
});
|
||
});
|
||
|
||
// ─── spliceFrontmatter ──────────────────────────────────────────────────────
|
||
|
||
describe('spliceFrontmatter', () => {
|
||
test('replaces existing frontmatter preserving body', () => {
|
||
const content = '---\nphase: 01\ntype: execute\n---\n\n# Body Content\n\nParagraph here.';
|
||
const newObj = { phase: '02', type: 'tdd', wave: '1' };
|
||
const result = spliceFrontmatter(content, newObj);
|
||
|
||
// New frontmatter should be present
|
||
const extracted = extractFrontmatter(result);
|
||
assert.strictEqual(extracted.phase, '02');
|
||
assert.strictEqual(extracted.type, 'tdd');
|
||
assert.strictEqual(extracted.wave, '1');
|
||
|
||
// Body should be preserved
|
||
assert.ok(result.includes('# Body Content'), 'body heading should be preserved');
|
||
assert.ok(result.includes('Paragraph here.'), 'body paragraph should be preserved');
|
||
});
|
||
|
||
test('adds frontmatter to content without any', () => {
|
||
const content = 'Plain text with no frontmatter.';
|
||
const newObj = { phase: '01', plan: '01' };
|
||
const result = spliceFrontmatter(content, newObj);
|
||
|
||
// Should start with frontmatter delimiters
|
||
assert.ok(result.startsWith('---\n'), 'should start with opening delimiter');
|
||
assert.ok(result.includes('\n---\n'), 'should have closing delimiter');
|
||
|
||
// Original content should follow
|
||
assert.ok(result.includes('Plain text with no frontmatter.'), 'original content should be preserved');
|
||
|
||
// Frontmatter should be extractable
|
||
const extracted = extractFrontmatter(result);
|
||
assert.strictEqual(extracted.phase, '01');
|
||
assert.strictEqual(extracted.plan, '01');
|
||
});
|
||
|
||
test('preserves content after frontmatter delimiters exactly', () => {
|
||
const body = '\n\nExact content with special chars: $, %, &, <, >\nLine 2\nLine 3';
|
||
const content = '---\nold: value\n---' + body;
|
||
const newObj = { new: 'value' };
|
||
const result = spliceFrontmatter(content, newObj);
|
||
|
||
// The body after the closing --- should be exactly preserved
|
||
const closingIdx = result.indexOf('\n---', 4); // skip the opening ---
|
||
const resultBody = result.slice(closingIdx + 4); // skip \n---
|
||
assert.strictEqual(resultBody, body, 'body content after frontmatter should be exactly preserved');
|
||
});
|
||
});
|
||
|
||
// ─── parseMustHavesBlock ────────────────────────────────────────────────────
|
||
|
||
describe('parseMustHavesBlock', () => {
|
||
test('extracts truths as string array', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
truths:
|
||
- "All tests pass on CI"
|
||
- "Coverage exceeds 80%"
|
||
---
|
||
|
||
Body content.`;
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 2);
|
||
assert.strictEqual(result[0], 'All tests pass on CI');
|
||
assert.strictEqual(result[1], 'Coverage exceeds 80%');
|
||
});
|
||
|
||
test('trims a continuation-KV value so a quoted trailing space does not survive (#1905, root cause of the #1154 false-pass)', () => {
|
||
// A quoted value like `"backstop "` captures the inner trailing space in group 2; left untrimmed,
|
||
// a hand-authored non-inferable `backstop` marker (#1820 spec-optional rail) degrades to `'backstop '`,
|
||
// which `truthVerification` no longer recognizes → the truth silently grades green instead of abstaining.
|
||
// Whitespace is never semantic in a scalar KV value, so the parser must trim it.
|
||
const content = `---
|
||
must_haves:
|
||
truths:
|
||
- statement: user data is never logged
|
||
verification: "backstop "
|
||
---
|
||
Body.`;
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.strictEqual(result[0].verification, 'backstop', 'the captured value is trimmed, not left as "backstop "');
|
||
});
|
||
|
||
test('extracts artifacts as object array', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
artifacts:
|
||
- path: "src/auth.ts"
|
||
provides: "JWT authentication"
|
||
min_lines: 100
|
||
- path: "src/middleware.ts"
|
||
provides: "Route protection"
|
||
min_lines: 50
|
||
---
|
||
|
||
Body.`;
|
||
const result = parseMustHavesBlock(content, 'artifacts');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 2);
|
||
assert.strictEqual(result[0].path, 'src/auth.ts');
|
||
assert.strictEqual(result[0].provides, 'JWT authentication');
|
||
assert.strictEqual(result[0].min_lines, 100);
|
||
assert.strictEqual(result[1].path, 'src/middleware.ts');
|
||
assert.strictEqual(result[1].min_lines, 50);
|
||
});
|
||
|
||
test('extracts key_links with from/to/via/pattern fields', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
key_links:
|
||
- from: "tests/auth.test.ts"
|
||
to: "src/auth.ts"
|
||
via: "import statement"
|
||
pattern: "import.*auth"
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'key_links');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 1);
|
||
assert.strictEqual(result[0].from, 'tests/auth.test.ts');
|
||
assert.strictEqual(result[0].to, 'src/auth.ts');
|
||
assert.strictEqual(result[0].via, 'import statement');
|
||
assert.strictEqual(result[0].pattern, 'import.*auth');
|
||
});
|
||
|
||
test('returns empty array when block not found', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
truths:
|
||
- "Some truth"
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'nonexistent_block');
|
||
assert.deepStrictEqual(result, []);
|
||
});
|
||
|
||
test('returns empty array when no frontmatter', () => {
|
||
const content = 'Plain text without any frontmatter delimiters.';
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.deepStrictEqual(result, []);
|
||
});
|
||
|
||
test('parses key_links with 2-space indentation — issue #1356', () => {
|
||
// Real-world YAML uses 2-space indentation, not 4-space.
|
||
// The parser was hardcoded to expect 4-space indentation which caused
|
||
// "No must_haves.key_links found in frontmatter" for valid YAML.
|
||
const content = `---
|
||
phase: 01-conversion-engine-iva-correctness
|
||
plan: 02
|
||
type: execute
|
||
wave: 2
|
||
depends_on: ["01-01"]
|
||
files_modified:
|
||
- src/features/currency/exchange-rate-store.ts
|
||
- src/features/currency/use-currency-config.ts
|
||
autonomous: true
|
||
requirements:
|
||
- CONV-02
|
||
- CONV-03
|
||
|
||
must_haves:
|
||
truths:
|
||
- "All tests pass"
|
||
artifacts:
|
||
- path: "src/features/currency/use-currency-config.ts"
|
||
key_links:
|
||
- from: "src/features/currency/use-currency-config.ts"
|
||
to: "src/api/generated/company-config/company-config.ts"
|
||
via: "getCompanyConfigControllerFindAllQueryOptions"
|
||
pattern: "getCompanyConfigControllerFindAllQueryOptions"
|
||
- from: "src/features/currency/use-currency-config.ts"
|
||
to: "src/features/currency/exchange-rate-store.ts"
|
||
via: "useExchangeRateStore for MMKV persist"
|
||
pattern: "useExchangeRateStore"
|
||
---
|
||
|
||
# Plan body
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'key_links');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 2, `expected 2 key_links, got ${result.length}: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result[0].from, 'src/features/currency/use-currency-config.ts');
|
||
assert.strictEqual(result[0].to, 'src/api/generated/company-config/company-config.ts');
|
||
assert.strictEqual(result[0].via, 'getCompanyConfigControllerFindAllQueryOptions');
|
||
assert.strictEqual(result[0].pattern, 'getCompanyConfigControllerFindAllQueryOptions');
|
||
assert.strictEqual(result[1].from, 'src/features/currency/use-currency-config.ts');
|
||
assert.strictEqual(result[1].to, 'src/features/currency/exchange-rate-store.ts');
|
||
assert.strictEqual(result[1].via, 'useExchangeRateStore for MMKV persist');
|
||
assert.strictEqual(result[1].pattern, 'useExchangeRateStore');
|
||
});
|
||
|
||
test('parses truths with 2-space indentation — issue #1356', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
truths:
|
||
- "All tests pass on CI"
|
||
- "Coverage exceeds 80%"
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 2);
|
||
assert.strictEqual(result[0], 'All tests pass on CI');
|
||
assert.strictEqual(result[1], 'Coverage exceeds 80%');
|
||
});
|
||
|
||
test('parses artifacts with 2-space indentation — issue #1356', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
artifacts:
|
||
- path: "src/auth.ts"
|
||
provides: "JWT authentication"
|
||
min_lines: 100
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'artifacts');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 1);
|
||
assert.strictEqual(result[0].path, 'src/auth.ts');
|
||
assert.strictEqual(result[0].provides, 'JWT authentication');
|
||
assert.strictEqual(result[0].min_lines, 100);
|
||
});
|
||
|
||
test('#2734: quoted truth containing ":" is preserved as a string — not dropped', () => {
|
||
// When a dash-item is a fully-quoted string that contains ':', the old code
|
||
// fell into the key-value branch, failed the kvMatch regex (because the value
|
||
// started with '"'), and silently left current as {}, losing the string.
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
truths:
|
||
- "App-side UUIDv4: generated locally"
|
||
- "No colon in this one"
|
||
- "Another colon: example"
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 3, `expected 3 truths, got ${result.length}: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result[0], 'App-side UUIDv4: generated locally');
|
||
assert.strictEqual(result[1], 'No colon in this one');
|
||
assert.strictEqual(result[2], 'Another colon: example');
|
||
});
|
||
|
||
test('#2734: single-quoted truth containing ":" is preserved as a string', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
truths:
|
||
- 'Key: value pattern preserved'
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 1);
|
||
assert.strictEqual(result[0], 'Key: value pattern preserved');
|
||
});
|
||
|
||
test('#2757: unquoted truth containing ":" is preserved as a string — not left as {}', () => {
|
||
// Unquoted strings with colons (e.g. Rails idioms) were falling through the KV
|
||
// regex and leaving current as {}, which caused t.trim() to throw in roadmap.cjs.
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
truths:
|
||
- GET /foo/:id resolves to controller#show
|
||
- Service.call(arg:, key:) returns a record
|
||
- Class::Method is idempotent
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'truths');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 3, `expected 3, got ${result.length}: ${JSON.stringify(result)}`);
|
||
assert.ok(typeof result[0] === 'string', `result[0] should be string, got ${typeof result[0]}`);
|
||
assert.ok(typeof result[1] === 'string', `result[1] should be string, got ${typeof result[1]}`);
|
||
assert.ok(typeof result[2] === 'string', `result[2] should be string, got ${typeof result[2]}`);
|
||
assert.ok(result[0].includes(':'), 'colon should be preserved in the string');
|
||
});
|
||
|
||
test('handles nested arrays within artifact objects', () => {
|
||
const content = `---
|
||
phase: 01
|
||
must_haves:
|
||
artifacts:
|
||
- path: "src/api.ts"
|
||
provides: "REST endpoints"
|
||
exports:
|
||
- "GET"
|
||
- "POST"
|
||
---
|
||
`;
|
||
const result = parseMustHavesBlock(content, 'artifacts');
|
||
assert.ok(Array.isArray(result), 'should return an array');
|
||
assert.strictEqual(result.length, 1);
|
||
assert.strictEqual(result[0].path, 'src/api.ts');
|
||
// The nested array should be captured
|
||
assert.ok(result[0].exports !== undefined, 'should have exports field');
|
||
});
|
||
});
|
||
|
||
// ─── stripFrontmatter ───────────────────────────────────────────────────────
|
||
// #2143 audit dedup: stripFrontmatter was byte-identically duplicated in both
|
||
// state.cts and state-transition.cts. Both call sites now import this single
|
||
// canonical implementation from frontmatter.cts.
|
||
|
||
describe('stripFrontmatter', () => {
|
||
test('strips a single frontmatter block, leaving the body', () => {
|
||
const content = '---\nname: foo\ntype: execute\n---\nBody text.\n';
|
||
assert.strictEqual(stripFrontmatter(content), 'Body text.\n');
|
||
});
|
||
|
||
test('returns content unchanged when no frontmatter is present', () => {
|
||
const content = '# Just a heading\n\nSome body text.\n';
|
||
assert.strictEqual(stripFrontmatter(content), content);
|
||
});
|
||
|
||
test('handles CRLF line endings', () => {
|
||
const content = '---\r\nname: foo\r\n---\r\nBody text.\r\n';
|
||
assert.strictEqual(stripFrontmatter(content), 'Body text.\r\n');
|
||
});
|
||
|
||
test('greedily strips multiple stacked frontmatter blocks (corruption recovery)', () => {
|
||
const content = '---\nname: foo\n---\n---\nname: bar\n---\nBody text.\n';
|
||
assert.strictEqual(stripFrontmatter(content), 'Body text.\n');
|
||
});
|
||
|
||
test('leaves a body-only "---" horizontal rule (not at byte 0) untouched', () => {
|
||
const content = 'Body text.\n\n---\n\nMore text.\n';
|
||
assert.strictEqual(stripFrontmatter(content), content);
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-1695-state-patch-clobbers-phase-name.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-1695-state-patch-clobbers-phase-name (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
// Regression test for issue #1695 — `state patch` of an unrelated field clobbers
|
||
// the curated `current_phase_name` frontmatter scalar.
|
||
//
|
||
// Root cause: readModifyWriteStateMd({resync:false}) still runs syncStateFrontmatter,
|
||
// which re-derives EVERY body-derived scalar from body prose. The #1264 restore
|
||
// covers `progress` only and #1230 covers `status`/`stopped_at`; `current_phase_name`
|
||
// was left exposed, and parseProsePhaseField's paren-over-dash preference made the
|
||
// re-derived value wrong (harvesting a parenthetical aside as the phase name).
|
||
//
|
||
// ADR-1769 Phase 6 fix: extend the #1230 delta heuristic to current_phase_name
|
||
// (gated by the field-classification table's preserve-always row). When the
|
||
// transform did NOT change the body Current Phase / Phase source line, the curated
|
||
// frontmatter value wins.
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
function buildStateWithCuratedPhaseName({ phaseName = 'Native Global Hotkey', aside = 'next; Phase 15 landed, UAT deferred' } = {}) {
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'milestone: v1.0',
|
||
'milestone_name: Test',
|
||
'current_phase: "16"',
|
||
`current_phase_name: "${phaseName}"`,
|
||
'status: executing',
|
||
'progress:',
|
||
' total_phases: 20',
|
||
' completed_phases: 15',
|
||
' total_plans: 40',
|
||
' completed_plans: 30',
|
||
' percent: 75',
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 16',
|
||
'Total Plans in Phase: 4',
|
||
'Current Plan: 2',
|
||
'Status: Executing Phase 16',
|
||
'Last Activity: 2026-06-20',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
`Phase: 16 — ${phaseName} (${aside})`,
|
||
'Plan: 2 of 4',
|
||
'Status: Executing Phase 16',
|
||
'Last activity: 2026-06-20 — mid-flight',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
function readFm(statePath) {
|
||
return extractFrontmatter(fs.readFileSync(statePath, 'utf-8'));
|
||
}
|
||
|
||
describe('#1695: state patch of an unrelated field preserves curated current_phase_name', () => {
|
||
let tmpDir;
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('patching Status does NOT clobber the curated current_phase_name', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedPhaseName());
|
||
|
||
const result = runGsdTools(['query', 'state.patch', JSON.stringify({ Status: 'Paused for review' })], tmpDir);
|
||
assert.ok(result.success, `state patch failed: ${result.error}`);
|
||
|
||
const fm = readFm(statePath);
|
||
assert.strictEqual(
|
||
fm.current_phase_name,
|
||
'Native Global Hotkey',
|
||
`current_phase_name must be preserved on an unrelated patch; got ${JSON.stringify(fm.current_phase_name)} (the paren-over-dash re-derivation clobbered it — #1695)`,
|
||
);
|
||
});
|
||
|
||
test('patching Current Plan does NOT clobber the curated current_phase_name', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedPhaseName());
|
||
|
||
const result = runGsdTools(['query', 'state.patch', JSON.stringify({ 'Current Plan': '3' })], tmpDir);
|
||
assert.ok(result.success, `state patch failed: ${result.error}`);
|
||
|
||
const fm = readFm(statePath);
|
||
assert.strictEqual(fm.current_phase_name, 'Native Global Hotkey');
|
||
});
|
||
|
||
test('explicitly patching the body Phase name-source line still advances (delta does not over-pin)', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedPhaseName());
|
||
|
||
// Patching the body 'Phase' field (the parseProsePhaseField source for
|
||
// current_phase_name) changes the source line, so the #1230 delta must NOT
|
||
// fire — syncStateFrontmatter re-derives current_phase_name from the new line.
|
||
// (Acceptance criterion from #1743: the guard must not pin a scalar whose body
|
||
// source genuinely changed.)
|
||
const result = runGsdTools(['query', 'state.patch', JSON.stringify({ Phase: '17 — Brand New Phase Name' })], tmpDir);
|
||
assert.ok(result.success, `state patch failed: ${result.error}`);
|
||
|
||
const fm = readFm(statePath);
|
||
// current_phase_name should be re-derived from the new body 'Phase' line
|
||
// (not pinned to the old curated value).
|
||
assert.notStrictEqual(fm.current_phase_name, 'Native Global Hotkey',
|
||
`current_phase_name must advance when the body Phase source changed; got ${JSON.stringify(fm.current_phase_name)}`);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2770-annotate-deps-int-coerce.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2770-annotate-deps-int-coerce (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
// allow-test-rule: source-text-is-the-product (see #2770)
|
||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||
// runtime loads — testing text content tests the deployed contract.
|
||
|
||
/**
|
||
* Regression — issue #2770
|
||
*
|
||
* `roadmap.annotate-dependencies` crashes with
|
||
* `TypeError: t.trim is not a function` when must_haves.truths contains a
|
||
* non-string scalar (e.g., a YAML int like `- 3` interpreted by an upstream
|
||
* parser as a number, or a kv-shaped item whose value is numeric).
|
||
*
|
||
* The original guard `if (typeof t !== 'string') continue` skipped silently —
|
||
* which avoids the crash but **drops the constraint from cross-cutting
|
||
* analysis**. The required behaviour is to **coerce, not skip**: a numeric
|
||
* scalar `3` must be surfaced as the string "3", and a kv-shaped truth like
|
||
* `{ title: "X", count: 3 }` must contribute its title to the analysis.
|
||
*
|
||
* The two literal cases called out in the issue title (bare-int `depends_on`
|
||
* values) are also exercised here as regression guards on the frontmatter
|
||
* parser to prove the dependency is preserved as a string and never dropped.
|
||
*/
|
||
|
||
const { test, describe, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
function makePlanProject(files = {}) {
|
||
const dir = createTempProject();
|
||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
|
||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||
for (const [rel, content] of Object.entries(files)) {
|
||
const abs = path.join(dir, rel);
|
||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||
fs.writeFileSync(abs, content, 'utf-8');
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
const ROADMAP = [
|
||
'# Roadmap',
|
||
'',
|
||
'### Phase 1: Foundation',
|
||
'**Goal:** Set up project',
|
||
'**Plans:** 2 plans',
|
||
'',
|
||
'Plans:',
|
||
'- [ ] 01-01-PLAN.md — Set up DB',
|
||
'- [ ] 01-02-PLAN.md — Build API',
|
||
'',
|
||
].join('\n');
|
||
|
||
// PLAN where must_haves.truths includes a bare numeric scalar AND a kv-shaped
|
||
// item whose value is numeric — both must be surfaced as cross-cutting
|
||
// constraints when shared across plans, not silently dropped.
|
||
const PLAN_NUMERIC_TRUTH = (wave, sharedTitle) => [
|
||
'---',
|
||
'phase: "1"',
|
||
`plan: "01-0${wave}"`,
|
||
'type: standard',
|
||
`wave: ${wave}`,
|
||
'depends_on: []',
|
||
'files_modified: []',
|
||
'autonomous: true',
|
||
'must_haves:',
|
||
' truths:',
|
||
` - title: ${sharedTitle}`,
|
||
' count: 3',
|
||
' - 42',
|
||
' artifacts: []',
|
||
' key_links: []',
|
||
'---',
|
||
'',
|
||
`<objective>Plan ${wave}</objective>`,
|
||
'',
|
||
].join('\n');
|
||
|
||
describe('bug #2770 — non-string truths must be coerced, not dropped', () => {
|
||
let tmpDir;
|
||
afterEach(() => cleanup(tmpDir));
|
||
|
||
test('numeric scalar truth shared across 2+ plans is surfaced as cross-cutting constraint', () => {
|
||
// Both plans share the numeric truth `42`. Pre-fix: silently dropped by
|
||
// `typeof t !== 'string' continue`, so cross_cutting_constraints == 0.
|
||
// Post-fix: coerced to "42" and surfaced as a constraint.
|
||
const PLAN_BARE_INT_TRUTH = (wave) => [
|
||
'---',
|
||
'phase: "1"',
|
||
`plan: "01-0${wave}"`,
|
||
'type: standard',
|
||
`wave: ${wave}`,
|
||
'depends_on: []',
|
||
'files_modified: []',
|
||
'autonomous: true',
|
||
'must_haves:',
|
||
' truths:',
|
||
' - 42',
|
||
' artifacts: []',
|
||
' key_links: []',
|
||
'---',
|
||
'',
|
||
`<objective>Plan ${wave}</objective>`,
|
||
'',
|
||
].join('\n');
|
||
tmpDir = makePlanProject({
|
||
'.planning/ROADMAP.md': ROADMAP,
|
||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_BARE_INT_TRUTH(1),
|
||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_BARE_INT_TRUTH(2),
|
||
});
|
||
|
||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const out = JSON.parse(result.output);
|
||
assert.strictEqual(
|
||
out.cross_cutting_constraints,
|
||
1,
|
||
'numeric truth shared across plans must be surfaced (coerced), not dropped'
|
||
);
|
||
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
assert.ok(roadmap.includes('Cross-cutting constraints:'),
|
||
'cross-cutting subsection present');
|
||
assert.ok(/-\s*42\b/.test(roadmap),
|
||
'numeric truth "42" surfaced as a string in the roadmap');
|
||
});
|
||
|
||
test('kv-shaped truth with numeric value uses title and contributes to cross-cutting analysis', () => {
|
||
// Both plans share `{ title: 'shared-rule', count: 3 }`. Pre-fix:
|
||
// typeof === 'object' so silently skipped → constraint dropped.
|
||
// Post-fix: title extracted, surfaced in cross-cutting subsection.
|
||
tmpDir = makePlanProject({
|
||
'.planning/ROADMAP.md': ROADMAP,
|
||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_NUMERIC_TRUTH(1, 'shared-rule'),
|
||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_NUMERIC_TRUTH(2, 'shared-rule'),
|
||
});
|
||
|
||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const out = JSON.parse(result.output);
|
||
// Both plans share two truths: the kv-shaped { title: 'shared-rule', ... }
|
||
// and the bare numeric 42. Pre-fix neither would survive the typeof guard;
|
||
// post-fix both are coerced and surfaced.
|
||
assert.strictEqual(
|
||
out.cross_cutting_constraints,
|
||
2,
|
||
'kv-shaped truth and numeric truth both surface, not dropped'
|
||
);
|
||
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
assert.ok(roadmap.includes('shared-rule'),
|
||
'title from kv-shaped truth surfaced in cross-cutting list');
|
||
assert.ok(/-\s*42\b/.test(roadmap),
|
||
'numeric truth surfaced as a string');
|
||
});
|
||
});
|
||
|
||
describe('bug #2770 — bare-int depends_on values parse as preserved strings', () => {
|
||
test('scalar bare-int depends_on parses as string "3" (not dropped, not numeric)', () => {
|
||
// Per issue title: a YAML scalar `depends_on: 3` must be preserved as the
|
||
// string "3". The frontmatter parser already returns strings here; this
|
||
// test pins the behaviour so a future "convert YAML scalars to numbers"
|
||
// optimization cannot silently regress dependency tracking.
|
||
const fm = extractFrontmatter([
|
||
'---',
|
||
'phase: "1"',
|
||
'plan: "01"',
|
||
'depends_on: 3',
|
||
'---',
|
||
'body',
|
||
'',
|
||
].join('\n'));
|
||
assert.strictEqual(typeof fm.depends_on, 'string',
|
||
'scalar depends_on must remain a string after parse');
|
||
assert.strictEqual(fm.depends_on, '3',
|
||
'bare int 3 must be preserved as the string "3"');
|
||
});
|
||
|
||
test('inline-array bare-int depends_on parses to ["3","4"] (preserved as strings)', () => {
|
||
const fm = extractFrontmatter([
|
||
'---',
|
||
'phase: "1"',
|
||
'plan: "01"',
|
||
'depends_on: [3, 4]',
|
||
'---',
|
||
'body',
|
||
'',
|
||
].join('\n'));
|
||
assert.ok(Array.isArray(fm.depends_on),
|
||
'inline array depends_on must be an array');
|
||
assert.deepStrictEqual(fm.depends_on, ['3', '4'],
|
||
'bare ints in inline array must be preserved as strings — never dropped');
|
||
// Critical: assert *length* matches input. A naive `if (typeof !== string) continue`
|
||
// would silently drop entries; we must coerce, not skip.
|
||
assert.strictEqual(fm.depends_on.length, 2,
|
||
'no dependency may be silently dropped during coercion');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3242-state-update-progress-trample.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3242-state-update-progress-trample (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
// Regression tests for issue #3242 — two distinct bugs in state.cjs:
|
||
//
|
||
// Bug A: cmdStateUpdate("Last Activity", date) triggers a full disk-derived
|
||
// progress.* block rebuild via readModifyWriteStateMd → syncStateFrontmatter →
|
||
// buildStateFrontmatter, which tramples manually-curated cross-milestone counters
|
||
// in STATE.md frontmatter. A body-only field update must not modify progress.*.
|
||
//
|
||
// Bug B: buildStateFrontmatter (and the duplicate in cmdStateSync) derives
|
||
// progress.percent = completedPlans / totalPlans. When ROADMAP declares more
|
||
// phases than have dirs on disk, all plans being summarised gives percent: 100
|
||
// even though half the phases are unrealised. The formula must be
|
||
// min(plan_fraction, phase_fraction) to reflect true completion.
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a minimal STATE.md body with frontmatter that has curated progress.*.
|
||
* The progress values are cross-milestone aggregates that must NOT be overwritten
|
||
* by a body-only field update.
|
||
*/
|
||
function buildStateWithCuratedProgress(opts) {
|
||
const {
|
||
completedPlans = 22,
|
||
totalPlans = 22,
|
||
completedPhases = 6,
|
||
totalPhases = 12,
|
||
percent = 50,
|
||
lastActivity = '2026-01-01',
|
||
} = opts || {};
|
||
|
||
return [
|
||
'---',
|
||
'gsd_state_version: 1.0',
|
||
'status: executing',
|
||
'progress:',
|
||
` total_phases: ${totalPhases}`,
|
||
` completed_phases: ${completedPhases}`,
|
||
` total_plans: ${totalPlans}`,
|
||
` completed_plans: ${completedPlans}`,
|
||
` percent: ${percent}`,
|
||
'---',
|
||
'',
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 6',
|
||
'Current Phase Name: test-phase',
|
||
'Total Plans in Phase: 4',
|
||
'Current Plan: 1',
|
||
'Status: Executing Phase 6',
|
||
`Last Activity: ${lastActivity}`,
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* Write a ROADMAP.md with `numPhases` phase headings (matching `## Phase N:` pattern).
|
||
* Only `numRealizedDirs` phase dirs will have plan/summary files on disk.
|
||
*/
|
||
function buildRoadmap(numPhases) {
|
||
const lines = ['# ROADMAP', '', '## Milestone v1.0', ''];
|
||
for (let i = 1; i <= numPhases; i++) {
|
||
lines.push(`### Phase ${i}: phase-${i}`);
|
||
lines.push('');
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Create phase dirs with full plan+summary coverage for the first `count` phases.
|
||
* Each dir gets 1 PLAN + 1 SUMMARY so the disk-scan treats them as complete.
|
||
*/
|
||
function createPhaseDirs(phasesDir, count) {
|
||
for (let i = 1; i <= count; i++) {
|
||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`);
|
||
fs.writeFileSync(path.join(dir, `01-SUMMARY.md`), `# Summary\n`);
|
||
}
|
||
}
|
||
|
||
function createPhasePlanOnlyDirs(phasesDir, count) {
|
||
for (let i = 1; i <= count; i++) {
|
||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`);
|
||
}
|
||
}
|
||
|
||
function readPersistedProgress(statePath) {
|
||
const fm = extractFrontmatter(fs.readFileSync(statePath, 'utf-8'));
|
||
assert.ok(fm.progress, 'persisted frontmatter must have a progress block');
|
||
return Object.fromEntries(
|
||
Object.entries(fm.progress).map(([key, value]) => [key, Number(value)]),
|
||
);
|
||
}
|
||
|
||
function assertProgressEquals(actual, expected) {
|
||
for (const [key, value] of Object.entries(expected)) {
|
||
assert.strictEqual(
|
||
actual[key],
|
||
value,
|
||
`persisted progress.${key} expected ${value}, got ${actual[key]}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug A: state.update must not trample curated progress.* frontmatter
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#3242 Bug A: body-only state.update preserves curated progress frontmatter', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('state.update "Last Activity" does not overwrite progress.completed_plans', (_t) => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
||
completedPlans: 22,
|
||
totalPlans: 22,
|
||
completedPhases: 6,
|
||
totalPhases: 12,
|
||
percent: 50,
|
||
lastActivity: '2026-01-01',
|
||
}));
|
||
|
||
// Write 6 phase dirs with full coverage — disk would report 6/6 phases done,
|
||
// 6/6 plans done (percent=100 from plans-only formula), but frontmatter says 50%.
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
createPhaseDirs(phasesDir, 6);
|
||
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Last Activity', '2026-05-07'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
||
|
||
// Read back and assert via state json (JSON return value, not raw file grep)
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.ok(fm.progress, 'frontmatter must have a progress block');
|
||
|
||
// completed_plans must NOT have been trampled to 6 (disk reality) from the
|
||
// curated 22 that was stored in the frontmatter before the update.
|
||
assert.strictEqual(
|
||
fm.progress.completed_plans,
|
||
22,
|
||
`state.update "Last Activity" must not overwrite curated progress.completed_plans ` +
|
||
`(was 22, got ${fm.progress.completed_plans})`,
|
||
);
|
||
|
||
// total_phases must NOT have been trampled to 6 (disk dirs) from curated 12.
|
||
assert.strictEqual(
|
||
fm.progress.total_phases,
|
||
12,
|
||
`state.update "Last Activity" must not overwrite curated progress.total_phases ` +
|
||
`(was 12, got ${fm.progress.total_phases})`,
|
||
);
|
||
|
||
// percent must NOT have been trampled to 100 (plan-only formula on 6 realized dirs).
|
||
assert.strictEqual(
|
||
fm.progress.percent,
|
||
50,
|
||
`state.update "Last Activity" must not overwrite curated progress.percent ` +
|
||
`(was 50, got ${fm.progress.percent})`,
|
||
);
|
||
});
|
||
|
||
test('state.update "Last Activity" updates the body field itself', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedProgress({ lastActivity: '2026-01-01' }));
|
||
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Last Activity', '2026-05-07'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
||
|
||
// Assert via structured JSON output — not raw file text scanning.
|
||
// state json extracts Last Activity from the body and surfaces it as
|
||
// fm.last_activity, matching the no-source-grep testing standard.
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(
|
||
fm.last_activity,
|
||
'2026-05-07',
|
||
'state.update should have written the new date to the Last Activity body field',
|
||
);
|
||
});
|
||
|
||
test('state.update "Progress" resyncs progress frontmatter from the updated body', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
||
completedPlans: 22,
|
||
totalPlans: 22,
|
||
completedPhases: 6,
|
||
totalPhases: 12,
|
||
percent: 50,
|
||
}).replace('Last Activity: 2026-01-01\n', 'Last Activity: 2026-01-01\nProgress: [█████░░░░░] 50%\n'));
|
||
|
||
const updateResult = runGsdTools(
|
||
['state', 'update', 'Progress', '[████████░░] 80%'],
|
||
tmpDir,
|
||
);
|
||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.strictEqual(fm.progress.percent, 80);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// #1264: state.patch must apply the same progress preservation policy
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#1264: state.patch preserves curated progress frontmatter for non-progress fields', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('query state.patch of Current Phase preserves persisted progress.* values', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const curatedProgress = {
|
||
total_phases: 4,
|
||
completed_phases: 3,
|
||
total_plans: 11,
|
||
completed_plans: 11,
|
||
percent: 75,
|
||
};
|
||
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
||
completedPlans: curatedProgress.completed_plans,
|
||
totalPlans: curatedProgress.total_plans,
|
||
completedPhases: curatedProgress.completed_phases,
|
||
totalPhases: curatedProgress.total_phases,
|
||
percent: curatedProgress.percent,
|
||
}));
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
buildRoadmap(5),
|
||
);
|
||
createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 5);
|
||
|
||
const patchResult = runGsdTools([
|
||
'query',
|
||
'state.patch',
|
||
JSON.stringify({ 'Current Phase': '08.2' }),
|
||
], tmpDir);
|
||
assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`);
|
||
|
||
const output = JSON.parse(patchResult.output);
|
||
assert.deepEqual(output.updated, ['Current Phase']);
|
||
|
||
const progress = readPersistedProgress(statePath);
|
||
assertProgressEquals(progress, curatedProgress);
|
||
});
|
||
|
||
test('query state.patch of Total Plans in Phase still resyncs persisted progress.* from the updated body', () => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
||
completedPlans: 22,
|
||
totalPlans: 22,
|
||
completedPhases: 6,
|
||
totalPhases: 12,
|
||
percent: 50,
|
||
}));
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
buildRoadmap(8),
|
||
);
|
||
createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 8);
|
||
|
||
const patchResult = runGsdTools([
|
||
'query',
|
||
'state.patch',
|
||
JSON.stringify({ 'Total Plans in Phase': '8' }),
|
||
], tmpDir);
|
||
assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`);
|
||
|
||
const output = JSON.parse(patchResult.output);
|
||
assert.deepEqual(output.updated, ['Total Plans in Phase']);
|
||
|
||
const progress = readPersistedProgress(statePath);
|
||
assert.strictEqual(progress.total_plans, 8);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Bug B: progress.percent must use min(plan_fraction, phase_fraction)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#3242 Bug B: progress.percent reflects phase fraction when ROADMAP declares future phases', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('12 declared phases / 6 realized / 6/6 plans done → percent is 50, not 100', (_t) => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
|
||
// Body: 6 realized phases visible to disk scan.
|
||
// Frontmatter: intentionally absent so buildStateFrontmatter runs fresh.
|
||
fs.writeFileSync(statePath, [
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 6',
|
||
'Current Phase Name: test-phase-6',
|
||
'Total Plans in Phase: 1',
|
||
'Current Plan: 1',
|
||
'Status: Executing Phase 6',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n'));
|
||
|
||
// ROADMAP with 12 phase headings — only 6 will have dirs on disk
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
buildRoadmap(12),
|
||
);
|
||
|
||
// 6 fully-realized phases (all plans have summaries)
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
createPhaseDirs(phasesDir, 6);
|
||
|
||
// state json rebuilds frontmatter from disk+body — this exercises buildStateFrontmatter
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.ok(fm.progress, 'frontmatter must have a progress block');
|
||
|
||
// ROADMAP declares 12 phases; only 6 exist on disk → totalPhases = 12
|
||
assert.strictEqual(
|
||
fm.progress.total_phases,
|
||
12,
|
||
`total_phases must reflect ROADMAP-declared count (12), got ${fm.progress.total_phases}`,
|
||
);
|
||
|
||
// 6 of 12 phases realized → phase_fraction = 50%
|
||
// 6/6 plans done → plan_fraction = 100%
|
||
// percent = min(100, 50) = 50
|
||
assert.strictEqual(
|
||
fm.progress.percent,
|
||
50,
|
||
`percent must be 50 (phase fraction), not 100 (plan fraction) — ` +
|
||
`6 of 12 ROADMAP phases realized. Got ${fm.progress.percent}`,
|
||
);
|
||
});
|
||
|
||
test('all phases realized: percent equals plan fraction (no artificial cap)', (_t) => {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
|
||
fs.writeFileSync(statePath, [
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 3',
|
||
'Current Phase Name: final-phase',
|
||
'Total Plans in Phase: 1',
|
||
'Current Plan: 1',
|
||
'Status: Executing Phase 3',
|
||
'Last Activity: 2026-01-01',
|
||
'',
|
||
].join('\n'));
|
||
|
||
// ROADMAP declares 3 phases; all 3 have dirs and full plan+summary coverage
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
buildRoadmap(3),
|
||
);
|
||
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
createPhaseDirs(phasesDir, 3);
|
||
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.ok(fm.progress, 'frontmatter must have progress block');
|
||
|
||
// 3/3 phases done → phase_fraction = 100%
|
||
// 3/3 plans done → plan_fraction = 100%
|
||
// percent = min(100, 100) = 100
|
||
assert.strictEqual(
|
||
fm.progress.percent,
|
||
100,
|
||
`percent must be 100 when all phases are realized and all plans summarized`,
|
||
);
|
||
});
|
||
|
||
test('state sync also reflects phase-fraction-capped percent in body Progress field', () => {
|
||
// state sync updates the body's Progress: field — it must use the same capped formula
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
|
||
fs.writeFileSync(statePath, [
|
||
'# GSD State',
|
||
'',
|
||
'## Configuration',
|
||
'Current Phase: 6',
|
||
'Current Phase Name: phase-6',
|
||
'Total Plans in Phase: 1',
|
||
'Current Plan: 1',
|
||
'Status: Executing Phase 6',
|
||
'Last Activity: 2026-01-01',
|
||
'Progress: [░░░░░░░░░░] 0%',
|
||
'',
|
||
].join('\n'));
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
buildRoadmap(12),
|
||
);
|
||
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
createPhaseDirs(phasesDir, 6);
|
||
|
||
const syncResult = runGsdTools('state sync', tmpDir);
|
||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||
|
||
// Read the body's Progress field via state json (JSON output is authoritative)
|
||
const jsonResult = runGsdTools('state json', tmpDir);
|
||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||
|
||
const fm = JSON.parse(jsonResult.output);
|
||
assert.ok(fm.progress, 'frontmatter must have progress block');
|
||
|
||
// state sync wrote a Progress: body field; state json re-derives percent from disk.
|
||
// Both must agree: 50%, not 100%.
|
||
assert.strictEqual(
|
||
fm.progress.percent,
|
||
50,
|
||
`state sync must cap percent at phase fraction (50%), got ${fm.progress.percent}`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3594-parser-adversarial-frontmatter.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3594-parser-adversarial-frontmatter (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Adversarial frontmatter-parser tests (#3594).
|
||
*
|
||
* Loads each file in `tests/fixtures/adversarial/frontmatter/` and pins
|
||
* the invariants `extractFrontmatter()` must satisfy. The fixtures
|
||
* encode hostile-but-realistic input shapes (duplicate keys, CRLF
|
||
* endings, unclosed blocks, Unicode, null bytes, huge but bounded
|
||
* payloads) that the parser will see in the wild because users edit
|
||
* planning files with multiple tools.
|
||
*
|
||
* Per CONTRIBUTING.md §"Testing Standards / Parser and project-file
|
||
* inputs", these are typed-IR assertions on parser return values —
|
||
* not prose-grep on rendered output. Property-style invariants for
|
||
* the roadmap parser live in
|
||
* `tests/feat-3594-parser-property-style.test.cjs`.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter');
|
||
|
||
function loadFixture(name) {
|
||
// Read as buffer first so null bytes survive into the string. The
|
||
// CRLF fixture also requires we do NOT normalize line endings on read.
|
||
return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf-8');
|
||
}
|
||
|
||
describe('feat-3594: frontmatter parser handles duplicate keys deterministically', () => {
|
||
test('duplicate keys collapse to a single deterministic winner (last-wins is the current contract)', () => {
|
||
const content = loadFixture('duplicate-keys.md');
|
||
const fm = extractFrontmatter(content);
|
||
|
||
// The parser MUST return a single value per key — not an array of
|
||
// both, not a half-formed entry. Whichever value wins, the test pins
|
||
// the current behavior so a silent semantics change is a test failure.
|
||
assert.equal(typeof fm.title, 'string', 'title must be a string, not an array or object');
|
||
assert.equal(typeof fm.status, 'string', 'status must be a string');
|
||
// Current parser behavior: the second occurrence wins because each
|
||
// key: line overwrites the previous in the same indent context.
|
||
// Pin it so a change to first-wins becomes visible.
|
||
assert.equal(fm.title, 'Second', 'duplicate-key collapse must be last-wins (current contract)');
|
||
assert.equal(fm.status, 'blocked', 'duplicate-key collapse must be last-wins (current contract)');
|
||
// Untouched keys round-trip cleanly.
|
||
assert.equal(fm.phase, '01');
|
||
});
|
||
});
|
||
|
||
describe('feat-3594: frontmatter parser handles CRLF endings without bleed', () => {
|
||
test('CRLF-terminated frontmatter parses without trailing \\r in values', () => {
|
||
const content = loadFixture('crlf-mixed.md');
|
||
const fm = extractFrontmatter(content);
|
||
// Each value MUST be \r-free. A bug in `\r?\n` handling would leak
|
||
// \r into the captured group.
|
||
assert.equal(fm.title, 'CRLF Title');
|
||
assert.equal(fm.phase, '02');
|
||
assert.ok(!/\r/.test(JSON.stringify(fm)), 'no \\r should appear in any parsed value');
|
||
// Array items must also be \r-free.
|
||
assert.deepEqual(fm.plans, ['02-01', '02-02']);
|
||
});
|
||
});
|
||
|
||
describe('feat-3594: frontmatter parser handles unclosed blocks safely', () => {
|
||
test('unclosed frontmatter block returns empty object, not partial parse', () => {
|
||
const content = loadFixture('unclosed-block.md');
|
||
const fm = extractFrontmatter(content);
|
||
// The current contract: if the closing `---` is missing, the regex
|
||
// doesn't match and the parser returns {}. The test pins that —
|
||
// a partial parse (returning {title: 'Unclosed Block'}) would be a
|
||
// silent data-leak from the body into "frontmatter."
|
||
assert.deepEqual(fm, {}, 'unclosed block must yield empty frontmatter, not a partial parse');
|
||
});
|
||
});
|
||
|
||
describe('feat-3594: frontmatter parser preserves Unicode round-trip', () => {
|
||
test('non-ASCII keys and values survive parsing', () => {
|
||
const content = loadFixture('unicode-keys-and-values.md');
|
||
const fm = extractFrontmatter(content);
|
||
assert.equal(fm.title, '日本語のタイトル');
|
||
// The parser's key regex is /^(\s*)([a-zA-Z0-9_-]+):.../ so non-ASCII
|
||
// keys (like `相:`) won't be captured. Pin that current behavior so
|
||
// a future broadening to allow Unicode keys is visible (and so the
|
||
// ASCII-only contract is asserted, not silently relied on).
|
||
assert.equal(fm['相'], undefined, 'parser currently only recognizes ASCII keys (regression guard)');
|
||
// The status field has an emoji — must survive.
|
||
assert.equal(fm.status, '🚧 in-flight');
|
||
// Inline array with Greek letters.
|
||
assert.deepEqual(fm.tags, ['α', 'β', 'γ']);
|
||
});
|
||
});
|
||
|
||
describe('feat-3594: frontmatter parser handles null bytes without truncation', () => {
|
||
test('null byte in a value is preserved or normalized, never silently truncates the rest', () => {
|
||
const content = loadFixture('null-byte-value.md');
|
||
const fm = extractFrontmatter(content);
|
||
// The parser MUST NOT crash. It MUST NOT truncate the value at the
|
||
// null byte AND continue parsing as if the rest of the line never
|
||
// existed. We pin: (a) the title still parses, (b) the phase key
|
||
// following the null-byte line still parses (no early-termination),
|
||
// (c) the null-byte value itself is a string.
|
||
assert.equal(fm.title, 'Has null byte');
|
||
assert.equal(fm.phase, '05', 'parser must continue past the null-byte line, not silently stop');
|
||
assert.equal(typeof fm.weird, 'string');
|
||
// The exact null-handling is documented by whatever the current
|
||
// parser does: either preserve the \x00 or strip it. Test pins one.
|
||
assert.ok(fm.weird.includes('before'), 'value before the null byte must be retained');
|
||
});
|
||
});
|
||
|
||
describe('feat-3594: frontmatter parser handles bounded-large inputs in reasonable time', () => {
|
||
test('64KB frontmatter with 2000 array items parses and returns the right shape', () => {
|
||
const content = loadFixture('huge-bounded.md');
|
||
const fm = extractFrontmatter(content);
|
||
assert.equal(fm.phase, '06');
|
||
assert.ok(Array.isArray(fm.plans), 'plans must be parsed as an array');
|
||
assert.equal(fm.plans.length, 2000, 'all 2000 array items must be captured');
|
||
assert.equal(fm.plans[0], 'item-00000');
|
||
assert.equal(fm.plans[1999], 'item-01999');
|
||
});
|
||
});
|
||
|
||
// ─── Cross-cutting invariants over the whole fixture corpus ────────────────
|
||
|
||
describe('feat-3594: frontmatter parser does not throw on ANY corpus fixture', () => {
|
||
// Property-style: whatever weirdness lives in the corpus, extractFrontmatter
|
||
// must return an object — never throw, never return undefined/null. This is
|
||
// the floor every individual fixture also satisfies, but checking it as a
|
||
// sweep catches a future fixture addition where the author forgets to write
|
||
// a per-file test.
|
||
const fixtures = fs.readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.md') && f !== 'README.md');
|
||
for (const fixture of fixtures) {
|
||
test(`fixture "${fixture}" — extractFrontmatter returns a plain object without throwing`, () => {
|
||
const content = loadFixture(fixture);
|
||
let fm;
|
||
assert.doesNotThrow(() => { fm = extractFrontmatter(content); }, `extractFrontmatter must not throw on ${fixture}`);
|
||
assert.equal(typeof fm, 'object', `${fixture}: result must be an object`);
|
||
assert.notEqual(fm, null, `${fixture}: result must not be null`);
|
||
assert.equal(Array.isArray(fm), false, `${fixture}: result must not be an array`);
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3594-parser-property-style.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3594-parser-property-style (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Deterministic property-style parser tests (#3594).
|
||
*
|
||
* Follows TEST-EXAMPLES.md §"Deterministic Property-Style Parser Test":
|
||
* a bounded, seeded loop generates many malformed inputs and asserts a
|
||
* single invariant against each. On failure the seed and case index
|
||
* are printed so the failing input can be reproduced exactly.
|
||
*
|
||
* The generator is a small mulberry32 PRNG so this file has zero
|
||
* external dependencies and is fully reproducible across Node versions.
|
||
* Each test pins its own seed and case count; bumping either is a
|
||
* deliberate test change, not a flake source.
|
||
*
|
||
* Invariant tested (frontmatter): for any random text the parser must
|
||
* either return a plain object or throw — never return null/undefined,
|
||
* never hang, never propagate "Cannot read properties of …" V8 prose.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||
|
||
/**
|
||
* mulberry32 — small fast deterministic PRNG. Seed in, [0,1) out.
|
||
* Same input always produces the same sequence across Node versions.
|
||
*/
|
||
function mulberry32(seed) {
|
||
let a = seed >>> 0;
|
||
return function next() {
|
||
a = (a + 0x6D2B79F5) | 0;
|
||
let t = a;
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build a single malformed-ish frontmatter input. Components are mixed
|
||
* deterministically by the supplied PRNG.
|
||
*/
|
||
function makeInput(rng) {
|
||
const fragments = [
|
||
'---\n',
|
||
'title: Generated\n',
|
||
'phase: 99\n',
|
||
'plans:\n - a\n - b\n',
|
||
'extra: \xff\xfe\xfd\n', // invalid UTF-8 bytes
|
||
'unicode: 日本語\n',
|
||
'crlf: ends\r\nin\rcr\n',
|
||
' indented_key: value\n',
|
||
'duplicate: first\nduplicate: second\n',
|
||
'sparse:\n\n\n',
|
||
'malformed_array: [a, "b", c\n', // unclosed inline array
|
||
'null_byte: before\x00after\n',
|
||
];
|
||
// Pick a random subset of fragments in random order. Always include
|
||
// the opening `---`. Closing `---` is included by 50% of cases so we
|
||
// exercise both well-formed and unclosed shapes.
|
||
const head = fragments[0];
|
||
const rest = shuffle(fragments.slice(1), rng).slice(0, 1 + Math.floor(rng() * 6));
|
||
const closing = rng() < 0.5 ? '---\n' : '';
|
||
return head + rest.join('') + closing + '\nBody.\n';
|
||
}
|
||
|
||
/**
|
||
* Fisher-Yates shuffle driven by the supplied PRNG. Returns a new
|
||
* array; does not mutate the input. Replaces the previous
|
||
* `arr.sort(() => rng() - 0.5)` which was non-transitive — the
|
||
* resulting order depended on V8's sort implementation, not only on
|
||
* the seed, so failing cases were unreproducible across Node versions.
|
||
* Fisher-Yates is O(n), transitive (no comparator), and depends only
|
||
* on the RNG output. Codex review on PR #3633 / #3594.
|
||
*/
|
||
function shuffle(arr, rng) {
|
||
const out = arr.slice();
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rng() * (i + 1));
|
||
const tmp = out[i];
|
||
out[i] = out[j];
|
||
out[j] = tmp;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
test('extractFrontmatter is total over 500 deterministic random inputs (seed=1234)', () => {
|
||
const seed = 1234;
|
||
const rng = mulberry32(seed);
|
||
const count = 500;
|
||
for (let i = 0; i < count; i++) {
|
||
const input = makeInput(rng);
|
||
let result;
|
||
try {
|
||
result = extractFrontmatter(input);
|
||
} catch (err) {
|
||
// If the parser throws, the failure must be a controlled one —
|
||
// not a V8 "Cannot read properties of undefined" that signals a
|
||
// null-deref bug. Print the seed and case index so the input
|
||
// can be reproduced exactly.
|
||
const msg = String((err && err.message) || err);
|
||
assert.doesNotMatch(
|
||
msg,
|
||
/Cannot read propert/i,
|
||
`seed=${seed} case=${i}: parser must not propagate null-deref TypeError; input=${JSON.stringify(input)}`,
|
||
);
|
||
continue;
|
||
}
|
||
// No throw: result MUST be a plain object (not null, not array, not
|
||
// primitive). Print enough on failure to reproduce.
|
||
assert.equal(typeof result, 'object', `seed=${seed} case=${i}: result must be object, got ${typeof result}`);
|
||
assert.notEqual(result, null, `seed=${seed} case=${i}: result must not be null`);
|
||
assert.equal(Array.isArray(result), false, `seed=${seed} case=${i}: result must not be an array`);
|
||
}
|
||
});
|
||
|
||
test('extractFrontmatter handles large frontmatter blocks without body bleed', () => {
|
||
// Deterministic large-input coverage replaces the former wall-clock ratio
|
||
// guard. Timing assertions are host-sensitive; this pins the parser contract
|
||
// instead: parse every frontmatter line once and stop at the first closing
|
||
// delimiter before the body.
|
||
|
||
/** Build a frontmatter string with exactly `lineCount` key:value lines. */
|
||
function buildScaleInput(lineCount) {
|
||
let s = '---\n';
|
||
for (let i = 0; i < lineCount; i++) {
|
||
s += `key${i}: value${i}\n`;
|
||
}
|
||
return s + '---\nBody.\n';
|
||
}
|
||
|
||
for (const lineCount of [20, 200, 2000]) {
|
||
const result = extractFrontmatter(buildScaleInput(lineCount) + 'body_key: not-frontmatter\n');
|
||
assert.equal(Object.keys(result).length, lineCount);
|
||
assert.equal(result.key0, 'value0');
|
||
assert.equal(result[`key${lineCount - 1}`], `value${lineCount - 1}`);
|
||
assert.equal(result.body_key, undefined);
|
||
}
|
||
});
|
||
});
|
||
}
|