Files
msd-core/tests/feat-3594-parser-adversarial-frontmatter.test.cjs
Tom Boucher b1317633db 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>
2026-05-16 00:23:27 -04:00

153 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`);
});
}
});