test(3594): adversarial parser fixtures + frontmatter/roadmap matrix + property-style suite (#3633)
* test(3594): adversarial parser fixtures + frontmatter/roadmap matrix + property-style suite
Lands the adversarial parser-input corpus that CONTRIBUTING.md
§"QA Matrix Requirements / Parser and project-file inputs" and
TEST-EXAMPLES.md §"Parser Adversarial Fixtures" describe.
New tests/fixtures/adversarial/ layout:
frontmatter/
duplicate-keys.md — same key twice (collapses last-wins)
crlf-mixed.md — CRLF endings throughout
unclosed-block.md — `---` open with no close
unicode-keys-and-values.md — non-ASCII + emoji + Greek
null-byte-value.md — U+0000 in a value
huge-bounded.md — 2000-item array, ~30KB
roadmap/
phase-heading-inside-fenced-code.md — #2787 fence shadowing
nested-fenced-code.md — outer + inner ``` blocks
unicode-phase-titles.md — JP / Greek / emoji titles
repeated-phase-ids.md — phase 1 declared twice
decimal-phase-mixed.md — 2 vs 2.1 vs 2.10 vs 21
markdown-headings-inside-html-comment.md — comment shadowing
Test files (all node:test, no try/finally in test bodies, no source-grep,
no raw-text matching on stdout/file content):
tests/feat-3594-parser-adversarial-frontmatter.test.cjs (12 tests)
Loads each fixture, pins parser invariants on extractFrontmatter()
return shape. Cross-corpus "does not throw on any fixture" sweep.
tests/feat-3594-parser-adversarial-roadmap.test.cjs (18 tests)
Loads each fixture into a temp project's .planning/ROADMAP.md and
drives `gsd-tools roadmap get-phase <N>` via the runCli harness
introduced by #3593. Asserts on the typed JSON payload.
tests/feat-3594-parser-property-style.test.cjs (2 tests)
Deterministic mulberry32 PRNG generates 500 malformed-ish
frontmatter inputs per test. Pins (a) extractFrontmatter is total
over the corpus (no null-deref TypeError, always returns a plain
object on success), (b) the suite completes well under 2 seconds
(quadratic-regression guard).
Known-open bugs surfaced and pinned (intentionally NOT fixed in this
PR — separate issues warranted):
- CJS roadmap parser matches `## Phase N:` headings inside fenced
code blocks (the SDK parser tracks fences per the #2787 comment in
sdk/src/query/roadmap.ts but the CJS path has not caught up).
- CJS roadmap parser matches `## Phase N:` headings inside HTML
comments.
Both are documented in-test with the "currently STILL matches it
(open: needs <fix>)" naming pattern so the day the production fix
lands, flipping the assertion from `found: true` to `found: false` is
the regression guard.
Test totals:
- 32 new feat-3594-* tests (12 frontmatter + 18 roadmap + 2 property)
- 108/108 pass when running together with the pre-existing
frontmatter.test.cjs + roadmap.test.cjs suites (76 of theirs).
Closes #3594
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(3594): use Fisher-Yates shuffle for deterministic seeded inputs
Replaces `arr.sort(() => rng() - 0.5)` with a Fisher-Yates shuffle
driven by the supplied PRNG. The sort-based shuffle is non-transitive:
V8's TimSort behavior on non-transitive comparators is engine-defined,
so the same seed produced different orderings across Node versions —
undermining the test's stated reproducibility guarantee.
Fisher-Yates is O(n), transitive (no comparator at all), and consumes
exactly n-1 RNG values in a fixed order. The mulberry32 seed now
determines the input sequence end-to-end.
Codex review on PR #3633.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/3594-parser-adversarial-fixtures.md
Normal file
5
.changeset/3594-parser-adversarial-fixtures.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"get-shit-done-cc": patch
|
||||
---
|
||||
|
||||
**Added: adversarial parser fixture corpus and tests.** New `tests/fixtures/adversarial/{frontmatter,roadmap}/` directories with hostile-but-realistic inputs (duplicate keys, CRLF endings, unclosed blocks, Unicode, null bytes, fenced-code headings, decimal phase prefix collisions, HTML-commented headings), exercised by new behavioral tests (`feat-3594-parser-adversarial-frontmatter.test.cjs`, `feat-3594-parser-adversarial-roadmap.test.cjs`) plus one deterministic seeded property-style test (`feat-3594-parser-property-style.test.cjs`) covering 500 generated inputs per assertion. Pins current parser behavior for known still-open regressions (#2787 fenced-code-block headings in the CJS parser; HTML-comment heading false positives) so the future fix lands as a one-line assertion flip.
|
||||
152
tests/feat-3594-parser-adversarial-frontmatter.test.cjs
Normal file
152
tests/feat-3594-parser-adversarial-frontmatter.test.cjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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('../get-shit-done/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 under 2 seconds and returns the right shape', () => {
|
||||
const content = loadFixture('huge-bounded.md');
|
||||
const startedAt = Date.now();
|
||||
const fm = extractFrontmatter(content);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
// Time bound is generous — a parser regression that makes this O(n^2)
|
||||
// would blow well past 2s on this fixture.
|
||||
assert.ok(elapsedMs < 2000, `parse took ${elapsedMs}ms — should be < 2000ms`);
|
||||
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`);
|
||||
});
|
||||
}
|
||||
});
|
||||
236
tests/feat-3594-parser-adversarial-roadmap.test.cjs
Normal file
236
tests/feat-3594-parser-adversarial-roadmap.test.cjs
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Adversarial roadmap-parser tests (#3594).
|
||||
*
|
||||
* Loads each fixture in `tests/fixtures/adversarial/roadmap/` as the
|
||||
* project's `.planning/ROADMAP.md` and pins invariants on the public
|
||||
* `gsd-tools roadmap get-phase <N>` surface — which routes through the
|
||||
* SDK bridge when available and the CJS handler otherwise.
|
||||
*
|
||||
* Per CONTRIBUTING.md §"Testing Standards / Parser and project-file
|
||||
* inputs", the assertion target is the typed JSON shape the CLI emits,
|
||||
* not stderr prose. The harness in `tests/helpers/cli-negative.cjs`
|
||||
* (introduced by #3627 / #3593) is reused here for consistency.
|
||||
*
|
||||
* Several fixtures encode known historical regressions:
|
||||
* - fenced-code-block headings shadowing real phases (#2787)
|
||||
* - decimal phase prefix collisions (#3537)
|
||||
* - HTML-comment heading false positives
|
||||
*
|
||||
* Pre-existing parser bugs surfaced by these fixtures are NOT fixed in
|
||||
* this PR — fixing them is out of scope for "add adversarial test
|
||||
* coverage." Where a fixture exposes a still-open bug, the test
|
||||
* asserts the *currently observed* behavior with a comment naming the
|
||||
* open issue, so the flip from RED→GREEN is a one-line change the day
|
||||
* the real fix lands.
|
||||
*/
|
||||
|
||||
'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 { runCli } = require('./helpers/cli-negative.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'roadmap');
|
||||
|
||||
function loadFixture(name) {
|
||||
return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temp project whose ROADMAP.md is the named fixture's content.
|
||||
* Returns the project directory; caller is responsible for cleanup.
|
||||
*/
|
||||
function projectWithFixture(t, fixtureName) {
|
||||
const projectDir = createTempProject('roadmap-adv-' + fixtureName.replace(/\W+/g, '-') + '-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), loadFixture(fixtureName));
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `gsd-tools roadmap get-phase <N>` and parse the JSON payload.
|
||||
* Returns `{ ok, exit, parsed, raw }` so tests can assert on either
|
||||
* the exit code or the structured payload.
|
||||
*/
|
||||
function getPhase(projectDir, phaseNum) {
|
||||
// No --json-errors — the get-phase command outputs JSON on success
|
||||
// via the normal stdout path. Reading the parsed payload is what the
|
||||
// workflows downstream do, so that's what we test.
|
||||
const result = runCli(['roadmap', 'get-phase', phaseNum], { cwd: projectDir, jsonErrors: false });
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
// Leave parsed null; tests that depend on it must handle that.
|
||||
}
|
||||
return {
|
||||
exit: result.status,
|
||||
ok: result.status === 0,
|
||||
parsed,
|
||||
raw: result.stdout,
|
||||
stderr: result.stderr,
|
||||
hasStackTrace: result.hasStackTrace,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fenced code block heading shadowing ────────────────────────────────────
|
||||
|
||||
describe('feat-3594: roadmap parser and fenced-code-block headings (#2787)', () => {
|
||||
test('phase 1 in real prose is found even when ## Phase 999 appears inside a ``` block', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'phase-heading-inside-fenced-code.md');
|
||||
const result = getPhase(projectDir, '1');
|
||||
assert.equal(result.hasStackTrace, false, 'no V8 stack trace');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true, 'phase 1 must be found');
|
||||
assert.equal(result.parsed.phase_number, '1');
|
||||
assert.match(result.parsed.phase_name, /real phase one/);
|
||||
});
|
||||
|
||||
test('phase 999 inside a fenced block: CJS parser currently STILL matches it (open: needs fence-stripping)', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'phase-heading-inside-fenced-code.md');
|
||||
const result = getPhase(projectDir, '999');
|
||||
assert.equal(result.hasStackTrace, false, 'no stack trace');
|
||||
// The CJS regex parser does not strip fenced code blocks before
|
||||
// matching. The SDK roadmap parser tracks fenced blocks (per #2787
|
||||
// comment in sdk/src/query/roadmap.ts) — the CJS path has not caught
|
||||
// up. This test pins the current behavior so the day someone wires
|
||||
// CJS fence-stripping, flipping `found: true` to `found: false`
|
||||
// becomes the regression guard.
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true, 'CJS parser currently matches inside fences (known open bug)');
|
||||
// The matched heading is the one INSIDE the fenced block. Match
|
||||
// its distinctive substring so a future "fix" that strips fences
|
||||
// and instead matches a different (real) phase 999 (which we don't
|
||||
// have in this fixture, so impossible) still fails the right test.
|
||||
assert.match(
|
||||
result.parsed.phase_name,
|
||||
/fenced code block/i,
|
||||
'currently-matched heading must be the one inside the fence',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Decimal phase prefix collisions ────────────────────────────────────────
|
||||
|
||||
describe('feat-3594: roadmap parser handles decimal phase prefix collisions (#3537)', () => {
|
||||
test('asking for phase "2" returns the integer phase, NOT phase 2.1 or 2.10', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'decimal-phase-mixed.md');
|
||||
const result = getPhase(projectDir, '2');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true);
|
||||
assert.equal(result.parsed.phase_number, '2');
|
||||
assert.match(result.parsed.phase_name, /integer phase two/);
|
||||
});
|
||||
|
||||
test('asking for phase "2.1" returns the decimal child', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'decimal-phase-mixed.md');
|
||||
const result = getPhase(projectDir, '2.1');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true);
|
||||
assert.equal(result.parsed.phase_number, '2.1');
|
||||
assert.match(result.parsed.phase_name, /decimal child/);
|
||||
});
|
||||
|
||||
test('asking for phase "2.10" returns the decimal sibling, NOT phase 2.1', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'decimal-phase-mixed.md');
|
||||
const result = getPhase(projectDir, '2.10');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true);
|
||||
assert.equal(result.parsed.phase_number, '2.10');
|
||||
assert.match(result.parsed.phase_name, /decimal phase 2\.10/);
|
||||
});
|
||||
|
||||
test('asking for phase "21" returns phase 21, NOT phase 2 (prefix-collision guard)', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'decimal-phase-mixed.md');
|
||||
const result = getPhase(projectDir, '21');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true);
|
||||
assert.equal(result.parsed.phase_number, '21');
|
||||
assert.match(result.parsed.phase_name, /phase twenty-one/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unicode phase titles ───────────────────────────────────────────────────
|
||||
|
||||
describe('feat-3594: roadmap parser preserves Unicode phase titles', () => {
|
||||
test('Japanese title round-trips through phase_name', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'unicode-phase-titles.md');
|
||||
const result = getPhase(projectDir, '1');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.phase_name, '日本語フェーズ — initial setup');
|
||||
});
|
||||
|
||||
test('emoji + smart-quote title survives', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'unicode-phase-titles.md');
|
||||
const result = getPhase(projectDir, '2');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.match(result.parsed.phase_name, /🚧/);
|
||||
assert.match(result.parsed.phase_name, /Émile/);
|
||||
});
|
||||
|
||||
test('Greek-letter title survives', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'unicode-phase-titles.md');
|
||||
const result = getPhase(projectDir, '3');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.phase_name, 'αβγ δεζ ηθι');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Repeated phase IDs ─────────────────────────────────────────────────────
|
||||
|
||||
describe('feat-3594: roadmap parser handles repeated phase IDs deterministically', () => {
|
||||
test('two declarations of phase 1: parser returns the FIRST match (current behavior)', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'repeated-phase-ids.md');
|
||||
const result = getPhase(projectDir, '1');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true);
|
||||
// The regex uses `content.match(...)` which returns the FIRST match.
|
||||
// Pin that — a future change to last-wins or de-dup would fire.
|
||||
assert.match(result.parsed.phase_name, /first declaration/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HTML comments ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('feat-3594: roadmap parser and HTML-commented headings', () => {
|
||||
test('phase 1 in real prose is found even when phase 998/999 appear inside <!-- ... -->', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'markdown-headings-inside-html-comment.md');
|
||||
const result = getPhase(projectDir, '1');
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true);
|
||||
assert.equal(result.parsed.phase_name, 'real phase');
|
||||
});
|
||||
|
||||
test('phase 999 inside an HTML comment: CJS parser currently STILL matches it (open: needs comment-stripping)', (t) => {
|
||||
const projectDir = projectWithFixture(t, 'markdown-headings-inside-html-comment.md');
|
||||
const result = getPhase(projectDir, '999');
|
||||
assert.equal(result.hasStackTrace, false, 'no stack trace');
|
||||
// Same shape as the fenced-code-block case: the CJS regex parser
|
||||
// doesn't strip HTML comments before matching.
|
||||
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
|
||||
assert.equal(result.parsed.found, true, 'CJS parser currently matches inside HTML comments (known open bug)');
|
||||
assert.match(result.parsed.phase_name, /HTML comment/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-corpus invariant ────────────────────────────────────────────────
|
||||
|
||||
describe('feat-3594: roadmap parser does not crash on ANY corpus fixture', () => {
|
||||
const fixtures = fs.readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.md') && f !== 'README.md');
|
||||
for (const fixture of fixtures) {
|
||||
test(`fixture "${fixture}" — get-phase with arbitrary IDs must not crash`, (t) => {
|
||||
const projectDir = projectWithFixture(t, fixture);
|
||||
for (const id of ['1', '2', '99', '999', '0', '2.1']) {
|
||||
const result = getPhase(projectDir, id);
|
||||
assert.equal(result.hasStackTrace, false, `${fixture} id=${id}: no V8 stack frame allowed`);
|
||||
// exit status varies (0 for found, non-zero for not-found —
|
||||
// both are valid). What's pinned: the parser produced SOME output
|
||||
// (either valid JSON or a clean stderr) without crashing.
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
BIN
tests/feat-3594-parser-property-style.test.cjs
Normal file
BIN
tests/feat-3594-parser-property-style.test.cjs
Normal file
Binary file not shown.
31
tests/fixtures/adversarial/frontmatter/README.md
vendored
Normal file
31
tests/fixtures/adversarial/frontmatter/README.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Adversarial Frontmatter Fixtures (#3594)
|
||||
|
||||
Reusable hostile inputs for `get-shit-done/bin/lib/frontmatter.cjs`
|
||||
`extractFrontmatter()` and downstream consumers.
|
||||
|
||||
Each fixture is a single markdown file whose name encodes the abuse
|
||||
category. Tests load them by relative path so the corpus can grow
|
||||
without test code changes. Adding a new fixture means: drop the file
|
||||
in here, add an entry to the matrix in
|
||||
`tests/feat-3594-parser-adversarial-frontmatter.test.cjs`, decide
|
||||
what invariant the parser must satisfy (typically "does not throw,
|
||||
does not return half-parsed garbage, does not silently lose data").
|
||||
|
||||
Categories present:
|
||||
|
||||
- `duplicate-keys.md` — same key appears twice. Parser must produce
|
||||
a deterministic result; tests document which value wins (last-wins
|
||||
is the current behavior).
|
||||
- `crlf-mixed.md` — CRLF endings throughout the frontmatter block.
|
||||
Parser must handle the `\r` consistently and not bleed it into
|
||||
values.
|
||||
- `unclosed-block.md` — opening `---` with no closing `---`. Parser
|
||||
must return empty frontmatter (or a clean error), never partial.
|
||||
- `unicode-keys-and-values.md` — non-ASCII keys/values. Parser must
|
||||
round-trip them as-is.
|
||||
- `null-byte-value.md` — value contains a U+0000 null. Parser must
|
||||
preserve or normalize it; must not crash and must not truncate
|
||||
silently.
|
||||
- `huge-bounded.md` — a deliberately-large but bounded frontmatter
|
||||
block (~64KB of array items). Parser must complete in reasonable
|
||||
time with a typed result, not OOM or hang.
|
||||
9
tests/fixtures/adversarial/frontmatter/crlf-mixed.md
vendored
Normal file
9
tests/fixtures/adversarial/frontmatter/crlf-mixed.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: CRLF Title
|
||||
phase: 02
|
||||
plans:
|
||||
- 02-01
|
||||
- 02-02
|
||||
---
|
||||
|
||||
Body with mixed CRLF endings.
|
||||
13
tests/fixtures/adversarial/frontmatter/duplicate-keys.md
vendored
Normal file
13
tests/fixtures/adversarial/frontmatter/duplicate-keys.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: First
|
||||
title: Second
|
||||
status: active
|
||||
status: blocked
|
||||
phase: 01
|
||||
---
|
||||
|
||||
Body content for duplicate-keys fixture.
|
||||
|
||||
When the parser encounters a key twice in the same block, the test pins
|
||||
what is currently observed (last-wins) so a silent semantics change
|
||||
becomes a test failure rather than a quiet data shift.
|
||||
2006
tests/fixtures/adversarial/frontmatter/huge-bounded.md
vendored
Normal file
2006
tests/fixtures/adversarial/frontmatter/huge-bounded.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tests/fixtures/adversarial/frontmatter/null-byte-value.md
vendored
Normal file
BIN
tests/fixtures/adversarial/frontmatter/null-byte-value.md
vendored
Normal file
Binary file not shown.
6
tests/fixtures/adversarial/frontmatter/unclosed-block.md
vendored
Normal file
6
tests/fixtures/adversarial/frontmatter/unclosed-block.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Unclosed Block
|
||||
phase: 03
|
||||
|
||||
This frontmatter has no closing --- line, and the body starts here without
|
||||
a delimiter. Parser must NOT consume body lines as frontmatter keys.
|
||||
8
tests/fixtures/adversarial/frontmatter/unicode-keys-and-values.md
vendored
Normal file
8
tests/fixtures/adversarial/frontmatter/unicode-keys-and-values.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 日本語のタイトル
|
||||
相: 04
|
||||
status: 🚧 in-flight
|
||||
tags: [α, β, γ]
|
||||
---
|
||||
|
||||
Body.
|
||||
28
tests/fixtures/adversarial/roadmap/README.md
vendored
Normal file
28
tests/fixtures/adversarial/roadmap/README.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Adversarial Roadmap Fixtures (#3594)
|
||||
|
||||
Hostile / messy ROADMAP.md inputs for
|
||||
`get-shit-done/bin/lib/roadmap.cjs` (`searchPhaseInContent`,
|
||||
`cmdRoadmapGetPhase`, `cmdRoadmapAnalyze`) and the SDK roadmap parser
|
||||
in `sdk/src/query/roadmap.ts`.
|
||||
|
||||
Tests in `tests/feat-3594-parser-adversarial-roadmap.test.cjs` and
|
||||
`tests/feat-3594-parser-property-style.test.cjs` consume these.
|
||||
|
||||
Categories:
|
||||
|
||||
- `phase-heading-inside-fenced-code.md` — a ``` md ``` block contains
|
||||
a `## Phase 999: fake` heading. The parser MUST ignore headings
|
||||
inside fenced code blocks. Historical regression #2787.
|
||||
- `nested-fenced-code.md` — outer ``` ``` ``` ``` ` block with inner
|
||||
``` ``` ``` ``` ` block. Headings inside either layer must be
|
||||
ignored.
|
||||
- `unicode-phase-titles.md` — phase titles with non-ASCII characters.
|
||||
Parser must preserve them in the returned `phase_name`.
|
||||
- `repeated-phase-ids.md` — same integer phase number listed twice.
|
||||
Parser behavior must be deterministic (first-wins or last-wins is
|
||||
fine; the test pins whichever).
|
||||
- `decimal-phase-mixed.md` — integer phase 2 and decimal phase 2.1
|
||||
share a prefix. Parser must not return phase 2 when asked for 2.1
|
||||
(or vice versa). Historical regression #3537.
|
||||
- `markdown-headings-inside-html-comment.md` — `<!-- ## Phase 999
|
||||
--> ` patterns. Parser must not be fooled by comments.
|
||||
21
tests/fixtures/adversarial/roadmap/decimal-phase-mixed.md
vendored
Normal file
21
tests/fixtures/adversarial/roadmap/decimal-phase-mixed.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Roadmap
|
||||
|
||||
## Phase 2: integer phase two
|
||||
|
||||
**Goal:** the integer parent.
|
||||
**Requirements:** [R2]
|
||||
|
||||
## Phase 2.1: decimal child of phase two
|
||||
|
||||
**Goal:** decimal subphase. Must not be returned when caller asks for phase 2.
|
||||
**Requirements:** [R2.1]
|
||||
|
||||
## Phase 2.10: decimal phase 2.10
|
||||
|
||||
**Goal:** prefix-collision guard — "2.10" must NOT be matched by a "2.1" lookup.
|
||||
**Requirements:** [R2.10]
|
||||
|
||||
## Phase 21: phase twenty-one
|
||||
|
||||
**Goal:** prefix-collision guard — "21" must NOT be matched by a "2" lookup.
|
||||
**Requirements:** [R21]
|
||||
14
tests/fixtures/adversarial/roadmap/markdown-headings-inside-html-comment.md
vendored
Normal file
14
tests/fixtures/adversarial/roadmap/markdown-headings-inside-html-comment.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Roadmap
|
||||
|
||||
<!--
|
||||
## Phase 999: this heading is inside an HTML comment
|
||||
|
||||
The parser must NOT treat commented-out headings as real phases.
|
||||
-->
|
||||
|
||||
## Phase 1: real phase
|
||||
|
||||
**Goal:** the only real phase.
|
||||
**Requirements:** [R1]
|
||||
|
||||
<!-- ## Phase 998: single-line comment with a heading -->
|
||||
20
tests/fixtures/adversarial/roadmap/nested-fenced-code.md
vendored
Normal file
20
tests/fixtures/adversarial/roadmap/nested-fenced-code.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Roadmap
|
||||
|
||||
````md
|
||||
Outer block opened with four backticks.
|
||||
|
||||
```md
|
||||
## Phase 998: nested fake phase
|
||||
|
||||
Inner block opened with three backticks.
|
||||
```
|
||||
|
||||
## Phase 997: outer fake phase
|
||||
|
||||
Still inside the outer four-tick block — the three-tick close above
|
||||
does NOT terminate it.
|
||||
````
|
||||
|
||||
## Phase 1: real phase
|
||||
|
||||
**Goal:** the only real phase in this file.
|
||||
18
tests/fixtures/adversarial/roadmap/phase-heading-inside-fenced-code.md
vendored
Normal file
18
tests/fixtures/adversarial/roadmap/phase-heading-inside-fenced-code.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Roadmap
|
||||
|
||||
Some intro prose.
|
||||
|
||||
```md
|
||||
## Phase 999: this heading is inside a fenced code block
|
||||
|
||||
It must NOT be parsed as a real phase. Historical regression #2787.
|
||||
```
|
||||
|
||||
## Phase 1: real phase one
|
||||
|
||||
**Goal:** Ship something.
|
||||
**Requirements:** [R1]
|
||||
|
||||
## Phase 2: real phase two
|
||||
|
||||
**Goal:** Ship something else.
|
||||
16
tests/fixtures/adversarial/roadmap/repeated-phase-ids.md
vendored
Normal file
16
tests/fixtures/adversarial/roadmap/repeated-phase-ids.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Roadmap
|
||||
|
||||
## Phase 1: first declaration of phase 1
|
||||
|
||||
**Goal:** the original.
|
||||
**Requirements:** [R1]
|
||||
|
||||
## Phase 2: phase two
|
||||
|
||||
**Goal:** routine.
|
||||
**Requirements:** [R2]
|
||||
|
||||
## Phase 1: duplicate declaration of phase 1
|
||||
|
||||
**Goal:** this should not silently shadow the first one.
|
||||
**Requirements:** [R3]
|
||||
15
tests/fixtures/adversarial/roadmap/unicode-phase-titles.md
vendored
Normal file
15
tests/fixtures/adversarial/roadmap/unicode-phase-titles.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Roadmap
|
||||
|
||||
## Phase 1: 日本語フェーズ — initial setup
|
||||
|
||||
**Goal:** ensure non-ASCII titles round-trip.
|
||||
**Requirements:** [R1]
|
||||
|
||||
## Phase 2: 🚧 Émile's "phase" with smart quotes
|
||||
|
||||
**Goal:** mixed Unicode + ASCII + smart quotes survive parsing.
|
||||
**Requirements:** [R2]
|
||||
|
||||
## Phase 3: αβγ δεζ ηθι
|
||||
|
||||
**Goal:** Greek letters in the title.
|
||||
Reference in New Issue
Block a user