Files
msd-core/tests/frontmatter-cli.test.cjs
Tom Boucher e20744eacb enhance(#3884): failure is a value — strict argv, and --pick that signals absence (#3922)
* test(#3884): failing-first coverage for strict argv and absence-signalling --pick

ADR-3473 §8.4 says failure is a value. Three families currently encode failure as
success, and this commit pins each one RED before the fix lands.

Measured on this tree, 2026-08-26:

  gsd-tools generate-slug "test" --pick nonexistent
    -> empty stdout, exit 0                                     (#3365)

  gsd-tools audit-open --pick nonexistent_field
    -> dumps the entire human-readable audit report, exit 0

  gsd-tools generate-slug "Hello World" --raw --pick bogus
    -> prints "hello-world", another field's value, exit 0

  gsd-tools query state.planned-phase 3        (positional, no --phase)
    -> exit 0; STATE.md's "Phase: 2 of 5 (Widget Support)" is overwritten to
       "Phase: null - READY TO EXECUTE" and the frontmatter gains a corrupted
       current_phase_name                                        (#3358)

tests/pick-flag.test.cjs:27 previously asserted the #3365 defect as the contract
("returns empty string for missing field", success === true). That assertion is
replaced by the required behavior rather than deleted.

The new parseNamedArgs block calls the spec-object signature that does not exist
yet, so it fails today by construction. The 11 existing behavior-lock tests are
left untouched here; they are corrected in the implementation commit.

C1/C4 assert at the consumer's output - STATE.md's bytes - per ADR-3180
Decision 4(b). A unit assertion on the parser would have passed throughout this
defect's life.

Design:      .gsd/phase/feat-3884-failure-is-a-value/40-design.md
Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* enhance(#3884): failure is a value — strict argv, and --pick that signals absence

Implements ADR-3473 §8.4. Absence, emptiness and failure stop being interchangeable
ways to say "I could not answer".

parseNamedArgs (src/command-arg-projection.cts)
  Takes a spec object with a REQUIRED `positionals: number | 'rest'` and returns the
  hub's Result shape instead of a bare Record. Declaring the positional arity is what
  makes #3358's call site unrepresentable rather than merely detectable: an unrecognized
  flag or a token past the declared boundary is now InvalidArgs, naming the offending
  token and listing the accepted flags. The legacy positional-array call shape throws
  a TypeError — an internal invariant violation per ADR-3473 Decision 2, so a stale
  hand-written .cjs call site fails loudly instead of destructuring undefined off a
  Result. parseNamedArgsOrExit projects a failure onto the caller's error(); it is a
  projection over the one parser, not a second parser.

  Measured before, against a STATE.md with a populated phase-2 block:
    query state.planned-phase 3        (positional, no --phase)
    -> exit 0; "Phase: 2 of 5 (Widget Support)" overwritten to
       "Phase: null - READY TO EXECUTE", frontmatter gains a corrupted
       current_phase_name
  After: exit 1, `unexpected positional argument "3"`, STATE.md byte-identical.
  The flag form is unchanged and still updates STATE.md.

--pick <field> (gsd-core/bin/gsd-tools.cjs)
  extractField returns {found,value}, and the pick block no longer shares one catch
  between "output was not JSON" and "field was absent". An absent field exits 1 with
  pick_field_absent, naming the field and the keys that do exist; non-JSON output exits 1
  with pick_output_not_json instead of dumping the command's entire output. A field that
  is PRESENT with value null, '', 0 or false still prints at exit 0 — that is an answer,
  not a failure, and it is what keeps `--pick count` printing 0 on a fresh project.

  Measured before: `audit-open --pick nonexistent_field` printed the whole human-readable
  audit report at exit 0, and `generate-slug X --raw --pick bogus` printed "hello-world" —
  a different field's value, confidently, at exit 0.

  ADR-3409 Decision 7 explicitly deferred this contract fix to #3473; this is it. The
  sub-issue's "returns 0 when the count is zero OR absent" wording is superseded by the
  ADR rule it implements: zero prints 0, absence exits non-zero. Defaulting absence to 0
  would demote "could not answer" to "the answer is zero" — the hazard
  docs/how-to/resolve-unreachable-guard-findings.md already warns against.

Guard ledger (ADR-3473 Decision 6)
  scripts/lint-unreachable-guard-drift.cjs Detector A is RETIRED. Its premise — that a
  `--pick ... || echo` arm can never fire — is now false, so the shape it forbade is the
  correct idiom and keeping it would forbid the fix. Detector B (glob-consuming cat/ls,
  a nullglob mechanism this change does not touch) is retained in full, as are the shared
  scanner, the escape-marker parser and the baseline. Net: -1 detector, 0 added. The file
  is not deleted.

Call-site audit
  45 prompt-layer --pick invocations, every one a plain X=$(...) assignment — none in an
  if test, && chain, or a pipeline whose status is consumed, and no shell block in
  workflows/commands/agents/references sets -e. Of the 13 (command, field) pairs the
  prompt layer reads, 10 are always present; the 3 sometimes-absent ones each sit behind
  a prior found/existence check. No ADR-3409-class "field the command never produces"
  remains.

Design:      .gsd/phase/feat-3884-failure-is-a-value/40-design.md
Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3884): escape untrusted tokens in diagnostics, and cover five unpinned rows

Two review findings, both fixed here rather than recorded as limits.

1. A newline in an untrusted token forged a second stderr line.

   Before, plain-text mode:
     $ gsd-tools query state.planned-phase $'foo\nError: forged second line'
     Error: unexpected positional argument "foo
     Error: forged second line"

   After:
     Error: unexpected positional argument "foo\nError: forged second line"

   --json-errors mode was never affected — io.error runs that payload through
   JSON.stringify. Plain-text mode writes 'Error: ' + message verbatim, and the
   three new InvalidArgs reasons plus the two new --pick diagnostics all
   interpolate a token that comes straight from argv.

   Fixed with ONE shared helper, formatDiagnosticToken (src/io.cts), applied at
   every interpolation site — not a copy per site. It is deliberately NOT
   applied inside error() itself: several callers in this tree emit intentional
   multi-line diagnostics, and escaping newlines there would mangle them.

   The available-top-level-keys list needed the same treatment for a reason the
   review did not anticipate: `frontmatter get <file>` reads an ARBITRARY user
   document and echoes that document's own keys into the diagnostic. Verified
   reachable — a frontmatter key containing a newline reaches the key list — so
   formatKeyForDiagnosticList is guarding a live path, not a hypothetical one.
   Ordinary keys still render plain and unquoted; a fix that merely dropped the
   key would also have passed a "one line" assertion, so the test pins the
   escaped key's presence too.

2. Five behavior-table rows were implemented but nothing pinned them:
   B7  a dotted path that dies partway
   B9  bracket syntax on a non-array
   B10 a negative array index, in and out of range
   B14 a JSON root that is not an object
   B17 an @file: payload over 50KB

   B17 is the load-bearing one. output() writes @file:<path> instead of inline
   JSON past 50000 characters, and --pick resolves that BEFORE parsing; with no
   test, a future reordering of those two steps turns every large result into a
   false pick_output_not_json. The fixture seeds 1200 phase directories and
   measures the payload at 62474 characters, asserting the spill actually
   happened rather than assuming it.

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3884): correct the strict-argv surface against a full verification run

The first full run came back with 90 failures across 12 files, none in the new
tests. They were the argv surface telling me what it actually is. Ten root
causes; each classified before anything was changed.

I over-implemented, and that is reverted.

  ADR-3473 §8.4 says parseNamedArgs rejects "unrecognized and positional
  tokens". It says nothing about a value flag whose value is missing. Making
  that an error was my design decision, not the rule, and it broke a
  deliberately recorded contract: `--prd` with no value resolving to null
  (tests/init.test.cjs emptyPrdValueIsFalsyAndTreatedAsAbsent, row B5;
  tests/section-manifest-init-facts.test.cjs "flag-shaped value"). The
  "requires a value" branch is deleted outright rather than kept behind an
  option — an unused strictness mode is speculative generality. Unknown-flag
  and unexpected-positional rejection, which is what §8.4 actually mandates,
  is unchanged.

--wave needed a third flag kind the original design did not anticipate.

  `--wave N` is documented (commands/gsd/execute-phase.md:4,48) and the
  shipped workflow reconstructs and passes it (execute-phase.md:84), while
  #2932 records token-PRESENCE semantics: the CLI cares only that the flag
  appeared, and the value belongs to the workflow layer. That is neither a
  boolean flag nor a value flag, so `optionalValueFlags` now exists —
  presence-only in `data`, and the validation cursor consumes a following
  non-flag token so it is not reported as a stray positional. Every other
  declared boolean flag was checked against every argument-hint and prose
  usage in commands/, workflows/, agents/ and docs/; `--wave` is the only one
  of this shape.

Five tests were pinning forms that never worked.

  tests/adr857-core-without-capabilities.test.cjs passed
  `init plan-phase --phase 01-stub`, but the documented form is positional
  (docs/CLI-TOOLS.md:776) and the handler reads args[2] — which for that form
  is the literal string "--phase". Measured on the pre-fix build against a
  real .planning/phases/01-stub/ directory:

    init plan-phase 01-stub          -> phase_found=true
    init plan-phase --phase 01-stub  -> phase_found=false

  The test asserted only exit 0 and key presence, so it had been green while
  proving nothing about phase resolution. Corrected to the documented form and
  strengthened to assert phase_found === true. Same class in state.test.cjs
  (`--plan-count`, a flag that does not exist; the real one is `--plans`),
  milestone-archive.test.cjs (`init new-milestone --json`, silently ignored),
  and concurrency-safety.test.cjs (a bare positional field name whose
  OR-assertion passed because a whole-document dump happens to contain the
  substring it looked for).

Six handlers had no argv validation at all — the same #3358 shape this phase
exists to close, found while fixing the rest: init verify-work / phase-op /
review / todos / remove-workspace read args[2] with nothing checking the rest,
and validate health read --repair/--backfill through a bare args.includes()
scan that bypassed the parser entirely. All now go through the seam, so the
flag has one owner.

tests/init-debug.test.cjs rows C4/C5 asserted that an unrecognized flag must
NOT fail. That is the behavior §8.4 removes, and Decision 8 says a caller's
local expectation does not override §8, so they are inverted and renamed —
a test still called "ignores an unrecognized flag" while asserting rejection
would be its own defect. Row C6's point is its PWNED canary; that assertion is
kept verbatim and only its exit-status expectation changed, because the
hostile token is now rejected rather than absorbed.

The blast-radius estimate in 40-design.md is corrected rather than quietly
left wrong. get_impact reported MEDIUM / 8 symbols upstream, and that was
accurate for what the graph can see — parseNamedArgs's callers. It cannot see
that those callers' handlers accept argv shapes wider than the code reading
args[2] suggests, which is where the real surface was.

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3884): withdraw the validate-health tightening, finish the A2/A3 revert

Second full run: 46 failures, down from 90. Four causes, two of them mine.

Reverted `validate health` entirely — it was scope creep, and it broke a real flag.

  ~30 of the 46 read `unknown flag "--json"; accepted: --repair, --backfill`.
  The previous commit routed `validate health` through the parser on the
  reasoning that a flag should have one owner. That was wrong twice over:
  §8.4 names parseNamedArgs and count queries, and `validate health` was never
  a parseNamedArgs call site — it read its flags, just not through the parser,
  so it had no silent-drop defect to fix. Tightening it omitted `--json`, which
  the health-diagnostic suites use heavily. The handler is now byte-for-behaviour
  back to its pre-branch form. `validate context` stays converted: it genuinely
  was a call site, and its `--json` is now declared rather than read by a second
  `args.includes` scan.

  The five handlers that had NO validation at all — init verify-work / phase-op /
  review / todos / remove-workspace — stay fixed. Those read args[2] with nothing
  checking the rest, which is the #3358 shape this phase owns.

Finished the A2/A3 revert. Three tests still encoded the deleted
"a value flag with a missing value is an error" rule, including one added by the
previous commit for that rule. All three now assert the reverted null contract,
and the ones whose titles said "rejected" are renamed — a test named for a
contract it no longer asserts is its own defect.

`--wave=` and `--wave --weird` are correctly rejected. Neither is documented in
commands/gsd/execute-phase.md, gsd-core/workflows/execute-phase.md or docs/, and
neither is emitted by the shipped prompt layer, so both are unrecognized tokens
that §8.4 mandates rejecting. `doesNotConsumeFollowingFlagAsWaveValue` keeps the
property it exists for — asserted directly now, at the parser, that `--wave` does
not swallow a following flag as its value — and only its exit-status expectation
changed.

A contradiction inside this branch, surfaced by the audit and resolved the safe way.

  Two pre-existing #3573 tests call `state begin-phase '2'` and
  `state planned-phase '2'` with a bare positional, relying on the old permissive
  parser to ignore it. This branch's own #3358 regression test requires that exact
  argv to be REJECTED. The two are mutually exclusive.

  Widening the router to accept a bare positional — mirroring complete-phase —
  would have silently re-opened #3358, and was verified to do exactly that: with
  the widened router, `query state.planned-phase 3` returned exit 0 and wrote
  current_phase_name again. It is reverted. docs/CLI-TOOLS.md:116 and
  docs/COMMANDS.md:2192 document only the `--phase N` form for both verbs, so the
  two #3573 tests move to it. Their assertions were never about the call shape —
  only that total_phases survives the resync — and both still pass.

  complete-phase is untouched: its bare positional IS documented, and it keeps the
  dynamic boundary and the negative-space note that record why.

The audit that produced this is in the PR body: for every handler whose declaration
changed, the flags it reads anywhere in its body, the flags the shipped surface
documents, and the shapes the suite passes, compared. The `--json` miss was a
pattern, not an accident — declaring a handler's flags from its parseNamedArgs call
alone misses whatever it reads elsewhere.

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3884): backfill the changeset PR number

Refs #3884

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:12:13 -04:00

786 lines
37 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Workflow .md / agent .md / command .md / reference .md files — their text
// IS what the runtime loads. Testing text content tests the deployed contract.
// Per CONTRIBUTING.md exception matrix.
/**
* GSD Tools Tests - frontmatter CLI integration
*
* Integration tests for the 4 frontmatter subcommands (get, set, merge, validate)
* exercised through gsd-tools.cjs via execSync.
*
* Each test creates its own temp file, runs the CLI command, asserts output,
* and cleans up in afterEach (per-test cleanup with individual temp files).
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runNode } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { runGsdTools, parseFrontmatter } = require('./helpers.cjs');
// Track temp files for cleanup
let tempFiles = [];
function writeTempFile(content) {
const tmpFile = path.join(os.tmpdir(), `gsd-fm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
fs.writeFileSync(tmpFile, content, 'utf-8');
tempFiles.push(tmpFile);
return tmpFile;
}
afterEach(() => {
for (const f of tempFiles) {
try { fs.unlinkSync(f); } catch { /* already cleaned */ }
}
tempFiles = [];
});
// ─── frontmatter get ────────────────────────────────────────────────────────
describe('frontmatter get', () => {
test('returns all fields as JSON', () => {
const file = writeTempFile('---\nphase: 01\nplan: 01\ntype: execute\n---\nbody text');
const result = runGsdTools(['frontmatter', 'get', file]);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.phase, '01');
assert.strictEqual(parsed.plan, '01');
assert.strictEqual(parsed.type, 'execute');
});
test('returns specific field with --field', () => {
const file = writeTempFile('---\nphase: 01\nplan: 02\ntype: tdd\n---\nbody');
const result = runGsdTools(['frontmatter', 'get', file, '--field', 'phase']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.phase, '01');
});
test('returns error for missing field', () => {
const file = writeTempFile('---\nphase: 01\n---\n');
const result = runGsdTools(['frontmatter', 'get', file, '--field', 'nonexistent']);
// The command succeeds (exit 0) but returns an error object in JSON
assert.ok(result.success, 'Command should exit 0');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
assert.ok(parsed.error.includes('Field not found'), 'Error should mention "Field not found"');
});
test('returns error for missing file', () => {
const result = runGsdTools('frontmatter get /nonexistent/path/file.md');
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
test('handles file with no frontmatter', () => {
const file = writeTempFile('Plain text with no frontmatter delimiters.');
const result = runGsdTools(['frontmatter', 'get', file]);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.deepStrictEqual(parsed, {}, 'Should return empty object for no frontmatter');
});
});
// ─── frontmatter set ────────────────────────────────────────────────────────
describe('frontmatter set', () => {
test('updates existing field', () => {
const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody');
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'phase', '--value', '02']);
assert.ok(result.success, `Command failed: ${result.error}`);
// Read back and verify
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.phase, '02');
});
test('adds new field', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'status', '--value', 'active']);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.status, 'active');
});
test('handles JSON array value', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'tags', '--value', '["a","b"]']);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.ok(Array.isArray(fm.tags), 'tags should be an array');
assert.deepStrictEqual(fm.tags, ['a', 'b']);
});
test('returns error for missing file', () => {
const result = runGsdTools('frontmatter set /nonexistent/file.md --field phase --value "01"');
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
test('preserves body content after set', () => {
const bodyText = '\n\n# My Heading\n\nSome paragraph with special chars: $, %, &.';
const file = writeTempFile('---\nphase: 01\n---' + bodyText);
runGsdTools(['frontmatter', 'set', file, '--field', 'phase', '--value', '02']);
const content = fs.readFileSync(file, 'utf-8');
assert.ok(content.includes('# My Heading'), 'heading should be preserved');
assert.ok(content.includes('Some paragraph with special chars: $, %, &.'), 'body content should be preserved');
});
});
// ─── frontmatter merge ──────────────────────────────────────────────────────
describe('frontmatter merge', () => {
test('merges multiple fields into frontmatter', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"plan":"02","type":"tdd"}']);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.phase, '01', 'original field should be preserved');
assert.strictEqual(fm.plan, '02', 'merged field should be present');
assert.strictEqual(fm.type, 'tdd', 'merged field should be present');
});
test('overwrites existing fields on conflict', () => {
const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody');
const result = runGsdTools(['frontmatter', 'merge', file, '--data', '{"phase":"02"}']);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.phase, '02', 'conflicting field should be overwritten');
assert.strictEqual(fm.type, 'execute', 'non-conflicting field should be preserved');
});
test('returns error for missing file', () => {
const result = runGsdTools(`frontmatter merge /nonexistent/file.md --data '{"phase":"01"}'`);
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
test('returns error for invalid JSON data', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(['frontmatter', 'merge', file, '--data', 'not json']);
// cmdFrontmatterMerge calls error() which exits with code 1
assert.ok(!result.success, 'Command should fail with non-zero exit code');
assert.ok(result.error.includes('Invalid JSON'), 'Error should mention invalid JSON');
});
});
// ─── frontmatter validate ───────────────────────────────────────────────────
describe('frontmatter validate', () => {
test('reports valid for complete plan frontmatter', () => {
const content = `---
phase: 01
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/auth.ts]
autonomous: true
must_haves:
truths:
- "All tests pass"
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid');
assert.deepStrictEqual(parsed.missing, [], 'No fields should be missing');
assert.strictEqual(parsed.schema, 'plan');
});
test('reports invalid with missing fields', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, false, 'Should be invalid');
assert.ok(parsed.missing.length > 0, 'Should have missing fields');
// plan schema requires: phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
// phase is present, so 7 should be missing
assert.strictEqual(parsed.missing.length, 7, 'Should have 7 missing required fields');
assert.ok(parsed.missing.includes('plan'), 'plan should be in missing');
assert.ok(parsed.missing.includes('type'), 'type should be in missing');
assert.ok(parsed.missing.includes('must_haves'), 'must_haves should be in missing');
});
test('validates against summary schema', () => {
const content = `---
phase: 01
plan: 01
subsystem: testing
tags: [unit-tests, yaml]
duration: 5min
completed: 2026-02-25
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'summary']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid for summary schema');
assert.strictEqual(parsed.schema, 'summary');
});
test('validates against verification schema', () => {
const content = `---
phase: 01
verified: 2026-02-25
status: passed
score: 5/5
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'verification']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid for verification schema');
assert.strictEqual(parsed.schema, 'verification');
});
test('returns error for unknown schema', () => {
const file = writeTempFile('---\nphase: 01\n---\n');
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'unknown']);
// cmdFrontmatterValidate calls error() which exits with code 1
assert.ok(!result.success, 'Command should fail with non-zero exit code');
assert.ok(result.error.includes('Unknown schema'), 'Error should mention unknown schema');
});
// #2847 review finding: a bare FRONTMATTER_SCHEMAS[schemaName] lookup resolves
// prototype-chain keys to Object.prototype members instead of undefined, so the
// `!schema` guard never fires and the command crashes with an uncaught TypeError
// ("Cannot read properties of undefined (reading 'filter')") and a stack trace
// instead of reporting "Unknown schema". Now that --schema is an agent-bound
// variable ($SCHEMA in agents/gsd-planner.md's validate_plan step) rather than a
// fixed literal, this is reachable from prompt state.
for (const schemaName of ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf']) {
test(`--schema ${schemaName} reports Unknown schema, not a crash`, () => {
const file = writeTempFile('---\nphase: 01\n---\n');
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', schemaName]);
assert.ok(!result.success, `--schema ${schemaName} should fail with a non-zero exit code, not crash`);
assert.ok(
result.error.includes('Unknown schema'),
`--schema ${schemaName} error should be "Unknown schema...", not a TypeError stack trace; got: ${result.error}`
);
assert.ok(
!result.error.includes('TypeError') && !result.error.includes('Cannot read properties'),
`--schema ${schemaName} must not surface a raw TypeError; got: ${result.error}`
);
});
}
test('returns error for missing file', () => {
const result = runGsdTools('frontmatter validate /nonexistent/file.md --schema plan');
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
});
// ─── frontmatter validate: plan-gap-closure schema (#2847) ───────────────────
//
// Regression coverage for #2847: "--gaps does not load planner-gap-closure.md,
// so generated gap plans may miss gap_closure metadata". A gap-closure plan
// with every other required field but no `gap_closure` used to report
// `valid: true` against the only schema the planner validated against
// (`plan`). Row 1 below is the failing-first regression test: it fails on
// pre-fix `FRONTMATTER_SCHEMAS` (no `plan-gap-closure` key exists — the CLI
// exits 1 with "Unknown schema: plan-gap-closure") and passes after the fix.
describe('frontmatter validate: plan-gap-closure schema (#2847)', () => {
const PLAN_BODY_NO_GAP_CLOSURE = `---
phase: 01
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/auth.ts]
autonomous: true
must_haves:
truths:
- "All tests pass"
---
body`;
// Row 1 — failing-first regression test.
test('rejects plan-gap-closure frontmatter missing gap_closure (#2847)', () => {
const file = writeTempFile(PLAN_BODY_NO_GAP_CLOSURE);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan-gap-closure']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, false, 'Should be invalid: gap_closure is missing');
assert.ok(parsed.missing.includes('gap_closure'), 'gap_closure should be reported missing');
assert.strictEqual(parsed.missing.length, 1, 'Only gap_closure should be missing; all other fields are present');
assert.deepStrictEqual(parsed.invalidValue, [], 'gap_closure is ABSENT here, not wrong-valued — invalidValue must stay empty');
assert.strictEqual(parsed.schema, 'plan-gap-closure');
});
// Row 2 — happy path.
test('accepts complete plan-gap-closure frontmatter', () => {
const content = `---
phase: 01
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/auth.ts]
autonomous: true
must_haves:
truths:
- "All tests pass"
gap_closure: true
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan-gap-closure']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid: gap_closure is present');
assert.deepStrictEqual(parsed.missing, []);
assert.ok(parsed.present.includes('gap_closure'));
assert.deepStrictEqual(parsed.invalidValue, [], 'gap_closure has the correct value here — invalidValue must be empty');
assert.strictEqual(parsed.schema, 'plan-gap-closure');
});
// Row 3 — empty/near-empty input boundary.
test('reports all plan-gap-closure fields missing except phase for near-empty frontmatter', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan-gap-closure']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, false);
// plan-gap-closure requires 9 fields; phase is present, so 8 should be missing.
assert.strictEqual(parsed.missing.length, 8, 'Should have 8 missing required fields');
assert.ok(parsed.missing.includes('gap_closure'), 'gap_closure should be among the missing fields');
});
// Row 4 — negative space: standard-mode ('plan' schema) plans are unaffected by #2847's fix.
test('plan schema (standard/reviews mode) still reports valid without gap_closure — unaffected by #2847 fix', () => {
const file = writeTempFile(PLAN_BODY_NO_GAP_CLOSURE);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'plan schema must not require gap_closure (AC(3): standard mode unaffected)');
assert.deepStrictEqual(parsed.missing, []);
assert.strictEqual(parsed.schema, 'plan');
});
// Row 5 — CRLF cross-platform newline handling.
test('parses plan-gap-closure frontmatter with CRLF line endings', () => {
const content = [
'---',
'phase: 01',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [src/auth.ts]',
'autonomous: true',
'must_haves:',
' truths:',
' - "All tests pass"',
'gap_closure: true',
'---',
'body',
].join('\r\n');
const file = writeTempFile(content);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan-gap-closure']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'CRLF frontmatter must parse identically to LF for plan-gap-closure');
assert.ok(parsed.present.includes('gap_closure'));
});
// Row 6 — gap_closure: false must be REJECTED, not merely present.
//
// #2847 review finding: --gaps-only filters strictly on gap_closure === true
// (execute-phase.md, partial-wave.md). A presence-only check (matching every
// other required field) lets `gap_closure: false` validate as valid:true,
// which is #2847's exact reported symptom — --gaps-only still spawns zero
// executors — one value away. plan-gap-closure's requiredValues entry closes
// this: gap_closure must be present AND equal "true" (extractFrontmatter
// parses every scalar as a string; FrontmatterValue has no boolean member).
test('gap_closure: false is rejected — plan-gap-closure requires the value true, not mere presence', () => {
const content = `---
phase: 01
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/auth.ts]
autonomous: true
must_haves:
truths:
- "All tests pass"
gap_closure: false
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(['frontmatter', 'validate', file, '--schema', 'plan-gap-closure']);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, false, 'gap_closure: false must NOT satisfy plan-gap-closure');
assert.ok(parsed.missing.includes('gap_closure'), 'gap_closure must be reported missing when its value is false');
assert.ok(!parsed.present.includes('gap_closure'), 'gap_closure must not be reported present when its value is false');
// #2847 review: presence alone is not the whole story here — the field IS in the
// file, just wrong-valued. invalidValue distinguishes that from a genuinely absent
// field (Row 1) so a caller (or a human) gets an actionable "the value is wrong",
// not "this field is missing" for a field they can plainly see in the plan.
assert.ok(
parsed.invalidValue.includes('gap_closure'),
'gap_closure must be reported in invalidValue — present but wrong-valued, distinct from genuinely absent'
);
});
// Row 7 — invalidValue vs missing distinction, spelled out directly (not just
// implied by Rows 1/2/6 individually).
test('invalidValue distinguishes "present but wrong value" from "absent" for the same missing-reporting field', () => {
const absentResult = JSON.parse(
runGsdTools(['frontmatter', 'validate', writeTempFile(PLAN_BODY_NO_GAP_CLOSURE), '--schema', 'plan-gap-closure']).output
);
const wrongValueContent = PLAN_BODY_NO_GAP_CLOSURE.replace('---\nbody', 'gap_closure: TRUE\n---\nbody');
const wrongValueResult = JSON.parse(
runGsdTools(['frontmatter', 'validate', writeTempFile(wrongValueContent), '--schema', 'plan-gap-closure']).output
);
// Both report gap_closure as missing (the field does not satisfy the schema either way)...
assert.ok(absentResult.missing.includes('gap_closure'));
assert.ok(wrongValueResult.missing.includes('gap_closure'));
// ...but only the wrong-VALUE case appears in invalidValue.
assert.deepStrictEqual(absentResult.invalidValue, [], 'a genuinely absent field must not appear in invalidValue');
assert.ok(
wrongValueResult.invalidValue.includes('gap_closure'),
'gap_closure: TRUE (capitalized YAML boolean, rejected — the validator requires the exact literal lowercase true) must appear in invalidValue'
);
});
});
// ─── frontmatter set/merge: must_haves object-list preservation (#1572) ──────
// `frontmatter set`/`merge` round-tripped the WHOLE frontmatter through the lossy
// extractFrontmatter → reconstructFrontmatter pair, which flattens must_haves
// object-list items ({path, provides} maps) to scalar strings and re-emits them as a
// malformed inline array — destroying `provides:` whenever an UNRELATED field changed.
// The fix preserves the original raw text for any structurally-unchanged top-level key.
const { parseMustHavesBlock } = require('../gsd-core/bin/lib/frontmatter.cjs');
describe('frontmatter set/merge preserves must_haves object-lists (#1572)', () => {
const ARTIFACTS_PLAN = [
'---',
'phase: 1',
'wave: 1',
'plan: 01-01',
'type: implementation',
'depends_on: []',
'files_modified: []',
'autonomous: true',
'must_haves:',
' artifacts:',
' - path: src/foo.ts',
' provides: the foo',
' - path: src/bar.ts',
' provides: the bar',
'---',
'# body',
'',
].join('\n');
const PROHIBITIONS_PLAN = [
'---',
'phase: 1',
'wave: 1',
'must_haves:',
' prohibitions:',
' - statement: no direct DB calls',
' status: enforced',
' - statement: no print statements',
' status: pending',
'---',
'# body',
'',
].join('\n');
function runAndParse(plan, cmdArgsForFile) {
const file = writeTempFile(plan);
runGsdTools(cmdArgsForFile(file));
const after = fs.readFileSync(file, 'utf-8');
return after;
}
test('set on an unrelated scalar preserves every must_haves.artifacts entry (path + provides)', () => {
const after = runAndParse(ARTIFACTS_PLAN, f => ['frontmatter', 'set', f, '--field', 'wave', '--value', '2']);
assert.deepEqual(
parseMustHavesBlock(after, 'artifacts'),
[
{ path: 'src/foo.ts', provides: 'the foo' },
{ path: 'src/bar.ts', provides: 'the bar' },
],
'must_haves.artifacts object-list must survive a set on an unrelated field (#1572)',
);
});
test('merge of an unrelated field preserves every must_haves.artifacts entry', () => {
const after = runAndParse(ARTIFACTS_PLAN, f => ['frontmatter', 'merge', f, '--data', JSON.stringify({ wave: 2 })]);
assert.deepEqual(
parseMustHavesBlock(after, 'artifacts'),
[
{ path: 'src/foo.ts', provides: 'the foo' },
{ path: 'src/bar.ts', provides: 'the bar' },
],
'must_haves.artifacts object-list must survive a merge of an unrelated field (#1572)',
);
});
test('must_haves.prohibitions object-list is preserved on an unrelated set (same code path)', () => {
const after = runAndParse(PROHIBITIONS_PLAN, f => ['frontmatter', 'set', f, '--field', 'wave', '--value', '2']);
assert.deepEqual(
parseMustHavesBlock(after, 'prohibitions'),
[
{ statement: 'no direct DB calls', status: 'enforced' },
{ statement: 'no print statements', status: 'pending' },
],
'must_haves.prohibitions object-list must survive a set on an unrelated field (#1572)',
);
});
test('round-trip is stable: setting wave twice still preserves artifacts (per-key preservation is idempotent)', () => {
const file = writeTempFile(ARTIFACTS_PLAN);
runGsdTools(['frontmatter', 'set', file, '--field', 'wave', '--value', '2']);
runGsdTools(['frontmatter', 'set', file, '--field', 'wave', '--value', '3']);
const after = fs.readFileSync(file, 'utf-8');
assert.deepEqual(
parseMustHavesBlock(after, 'artifacts'),
[
{ path: 'src/foo.ts', provides: 'the foo' },
{ path: 'src/bar.ts', provides: 'the bar' },
],
'must_haves.artifacts must survive repeated sets on an unrelated field',
);
});
test('directly setting must_haves to a new object-list fails closed instead of emitting [object Object] (#1572 codex review)', () => {
// A CHANGED key whose value is an object-list cannot be faithfully serialized by the
// lossy writer (it would emit "[object Object]"). Rather than silently destroy the
// data, spliceFrontmatter throws — the command fails and the file is left unchanged.
const file = writeTempFile(ARTIFACTS_PLAN);
const result = runGsdTools([
'frontmatter', 'set', file, '--field', 'must_haves',
'--value', JSON.stringify({ artifacts: [{ path: 'src/new.ts', provides: 'new thing' }] }),
]);
assert.ok(
!result.success,
'frontmatter set of a must_haves object-list must fail closed (refuse to emit "[object Object]")',
);
const after = fs.readFileSync(file, 'utf-8');
assert.ok(!/\[object Object\]/.test(after), 'the file must not contain "[object Object]" after a refused set');
assert.deepEqual(
parseMustHavesBlock(after, 'artifacts'),
[
{ path: 'src/foo.ts', provides: 'the foo' },
{ path: 'src/bar.ts', provides: 'the bar' },
],
'the original must_haves.artifacts must be intact after the refused set',
);
});
});
// Bug #1660 — frontmatter set of an object-list field (e.g. must_haves) is a silent no-op
// when the new value's lossy parse projection equals the original's. Folded into the owning
// frontmatter-cli test (no new top-level bug-NNNN file).
describe('Bug #1660: frontmatter set of an object-list field fails closed instead of a silent no-op', () => {
const PLAN_WITH_MUST_HAVES = [
'---', 'phase: 1', 'wave: 1',
'must_haves:', ' artifacts:', ' - path: src/foo.ts', ' provides: the foo',
'---', '# body', '',
].join('\n');
test('setting must_haves to a value that flattens to the original projection fails closed (no silent no-op)', () => {
const file = writeTempFile(PLAN_WITH_MUST_HAVES);
const before = fs.readFileSync(file, 'utf-8');
// New value {artifacts:["path: src/foo.ts"]} — its extractFrontmatter projection equals
// the original's flattened projection, so the set would otherwise be a silent no-op.
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'must_haves', '--value', JSON.stringify({ artifacts: ['path: src/foo.ts'] })]);
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'a no-op set of an object-list field must surface an error, not silent {updated:true}');
const after = fs.readFileSync(file, 'utf-8');
assert.equal(after, before, 'the file must be unchanged when the set is refused (no silent partial write)');
});
test('an idempotent set of a scalar (wave, same value) still reports updated (no false positive)', () => {
const file = writeTempFile('---\nphase: 1\nwave: 1\n---\n# body\n');
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'wave', '--value', '1']);
const parsed = JSON.parse(result.output);
assert.equal(parsed.updated, true, 'an idempotent SCALAR set must still report {updated:true} (not fail-closed)');
assert.ok(!parsed.error, 'an idempotent scalar set must not produce an error');
});
test('an idempotent set of a scalar array (tags, same value) still reports updated (no false positive)', () => {
const file = writeTempFile('---\nphase: 1\ntags: ["a","b"]\n---\n# body\n');
const result = runGsdTools(['frontmatter', 'set', file, '--field', 'tags', '--value', '["a","b"]']);
const parsed = JSON.parse(result.output);
assert.equal(parsed.updated, true, 'an idempotent scalar-ARRAY set must still report {updated:true} (arrays round-trip; not fail-closed)');
assert.ok(!parsed.error, 'an idempotent scalar-array set must not produce an error');
});
});
// ─── #1778: thread workflow must use the 1.6 named-flag frontmatter.set form ─
//
// The thread workflow's CLOSE and RESUME branches previously invoked the
// pre-1.6 positional shape (frontmatter.set <file> <field> <value>). Since 1.6
// the dispatcher (gsd-tools.cjs) reads field/value from the named --field/
// --value flags via parseNamedArgs; the positional form leaves field/value
// undefined, cmdFrontmatterSet errors `file, field, and value required`, and
// the status/updated writes are silently skipped — so closing a thread never
// marked it status: resolved and resuming never marked it status: in_progress.
describe('#1778: thread workflow uses the 1.6 named-flag frontmatter.set form', () => {
test('behavioral: named-flag form writes the field; positional form errors and does not mutate', () => {
// 1.6 named-flag form — must succeed and write status: resolved.
const goodFile = writeTempFile('---\nstatus: open\nupdated: "2025-01-01"\n---\n\n# thread body\n');
const good = runGsdTools(['frontmatter', 'set', goodFile, '--field', 'status', '--value', 'resolved']);
assert.ok(good.success, `named-flag form must succeed; stderr: ${good.error}`);
assert.strictEqual(
parseFrontmatter(fs.readFileSync(goodFile, 'utf-8')).status,
'resolved',
'named-flag form must write status: resolved into the file',
);
// Pre-1.6 positional form — must fail and NOT mutate.
//
// #3884 (ADR-3473 §8.4): the strict parser now rejects the stray
// positional tokens ("status", "resolved") BEFORE cmdFrontmatterSet's own
// "file, field, and value required" guard ever runs, so the error text
// changed. The behavioral contract this test guards — fails, and does
// NOT mutate the file — is unchanged and, if anything, strengthened (the
// rejection now happens earlier, at argv-parsing time, not deep inside
// the command).
const badFile = writeTempFile('---\nstatus: open\nupdated: "2025-01-01"\n---\n\n# thread body\n');
const bad = runGsdTools(['frontmatter', 'set', badFile, 'status', 'resolved']);
assert.ok(!bad.success, 'positional form must fail (it is the bug being guarded against)');
assert.ok(
(bad.error + bad.output).includes('unexpected positional argument'),
`positional form must error with the documented message; got:\n${bad.error}${bad.output}`,
);
assert.strictEqual(
parseFrontmatter(fs.readFileSync(badFile, 'utf-8')).status,
'open',
'positional form must NOT mutate the file (the silent-failure bug)',
);
});
test('workflow parity: no gsd-core/workflows/*.md emits the positional frontmatter.set form', () => {
const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows');
const files = fs.readdirSync(workflowsDir).filter((f) => f.endsWith('.md'));
assert.ok(files.length > 0, 'expected at least one workflow under gsd-core/workflows/');
const offenders = [];
for (const name of files) {
const full = path.join(workflowsDir, name);
const lines = fs.readFileSync(full, 'utf-8').split(/\r?\n/);
lines.forEach((line, i) => {
// Match any frontmatter.set invocation (dot or space form, with or
// without the `gsd_run query` prefix). The 1.6 contract requires
// --field AND --value on every set call; a set line missing --field
// is the pre-1.6 positional form (#1778).
if (!/frontmatter[.\s]+set\b/.test(line)) return;
if (!/--field\b/.test(line) || !/--value\b/.test(line)) {
offenders.push(`${name}:${i + 1}: ${line.trim()}`);
}
});
}
assert.deepStrictEqual(
offenders,
[],
`These workflow frontmatter.set invocations are missing the 1.6 --field/--value named flags (the #1778 positional-form bug):\n ${offenders.join('\n ')}\n\nUse: gsd_run query frontmatter.set <file> --field <field> --value <value>`,
);
});
test('thread workflow CLOSE writes status: resolved and RESUME writes status: in_progress via named flags', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', 'thread.md'), 'utf-8');
// CLOSE mode: status resolved + updated, both via named flags.
assert.ok(
/frontmatter\.set\s+\S*\.planning\/threads\/\{SLUG\}\.md\s+--field\s+status\s+--value\s+resolved\b/.test(src),
'CLOSE mode must invoke: frontmatter.set .planning/threads/{SLUG}.md --field status --value resolved',
);
assert.ok(
/frontmatter\.set\s+\S*\.planning\/threads\/\{SLUG\}\.md\s+--field\s+updated\s+--value\s+YYYY-MM-DD\b/.test(src),
'CLOSE mode must invoke: frontmatter.set .planning/threads/{SLUG}.md --field updated --value YYYY-MM-DD',
);
// RESUME mode: status in_progress + updated, both via named flags.
assert.ok(
/frontmatter\.set\s+\S*\.planning\/threads\/\{SLUG\}\.md\s+--field\s+status\s+--value\s+in_progress\b/.test(src),
'RESUME mode must invoke: frontmatter.set .planning/threads/{SLUG}.md --field status --value in_progress',
);
});
});
// ─── #1882: the user-reachable surface actually distinguishes the two cases ───
describe('frontmatter get — truncated vs absent frontmatter (#1882)', () => {
const TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
function runCapturingStderr(file) {
const r = runNode([TOOLS, 'frontmatter', 'get', file, '--raw'], {
env: { ...process.env, GSD_TEST_MODE: '1' },
timeoutMs: PROBE_TIMEOUT_MS,
});
const legacy = toLegacyResult(r);
return { status: legacy.status, stdout: legacy.stdout.trim(), stderr: legacy.stderr.trim() };
}
// This is the wired keystone for #1882: the diagnostic is only "delivered" if it reaches
// the surface a user actually invokes. The assertion is a DIFFERENTIAL between two runs —
// whether stderr is empty — which is a behavioural claim, not a match against the message
// wording, so it stays inside CONTRIBUTING.md's ban on raw text matching.
test('a truncated file is reported while an absent-frontmatter file stays silent', () => {
const truncated = writeTempFile('---\nphase: 01\nplan: half-written\n');
const absent = writeTempFile('plain body with no frontmatter\n');
const bad = runCapturingStderr(truncated);
const good = runCapturingStderr(absent);
// The contract every one of the ~50 callers depends on is unchanged for both.
assert.strictEqual(bad.status, 0, 'truncated file must not change the exit code');
assert.strictEqual(good.status, 0);
assert.deepStrictEqual(JSON.parse(bad.stdout), {}, 'return value must be preserved');
assert.deepStrictEqual(JSON.parse(good.stdout), {});
// ...and the only difference is that corruption is no longer silent.
assert.notStrictEqual(bad.stderr, '', 'a truncated frontmatter must be reported');
assert.strictEqual(good.stderr, '', 'a file with no frontmatter is not corrupt');
});
test('a Markdown thematic break at byte 0 is not reported as corruption', () => {
const thematicBreak = writeTempFile('---\nSome heading text\n\nA paragraph, no more dashes.\n');
const r = runCapturingStderr(thematicBreak);
assert.strictEqual(r.status, 0);
assert.deepStrictEqual(JSON.parse(r.stdout), {});
assert.strictEqual(r.stderr, '', 'a horizontal rule is valid Markdown, not a truncated file');
});
});