Files
msd-core/tests/unusable-input.test.cjs
Tom Boucher 3fd03bec4c fix(#3760): refuse a legacy-key migration into a non-object config section (#3767)
* test(#3760): failing-first regression for non-object config section

Locks the contract from the issue's Expected section before any fix exists:
a legacy-key section holding a string, number, boolean or array must be
preserved verbatim, reported, and never persisted in an expanded form.

Covers both blocks the issue names (branching_strategy -> git.*, sub_repos ->
planning.*), the migrateOnDisk multiRepo branch that shares the shape, and the
loader write paths that are what actually reach the user's config.json.
Includes the negative-space cases that must keep hoisting ({} , null, absent
section, canonical-nested-wins) and two fast-check properties.

Refs #3760

* fix(#3760): refuse a legacy-key migration into a non-object config section

normalizeLegacyKeys hoisted a legacy top-level key into its canonical nested
section by spreading `result[section] ?? {}`. `??` guards only null and
undefined, so a section holding a string was enumerated by index —
`{...'main'}` is `{0:'m',1:'a',2:'i',3:'n'}` — while a number or boolean
spread to `{}` and the value vanished. Because a fired block always pushed a
Normalization, and every caller treats a non-empty normalizations array as
'config is dirty', that shape was written back to .planning/config.json and
the original value became unrecoverable.

Both blocks the issue names are fixed via one shared hoistLegacyKey helper,
plus the two further sites that share the shape and are reachable from the
same input: migrateOnDisk's multiRepo branch, and the loader's two
`if (!planning) planning = {}` guards, where a non-empty string is truthy and
the following assignment threw a strict-mode TypeError that the enclosing
catch swallowed — discarding the user's entire config.

A present non-object section now blocks its own migration. The section, the
legacy key, and the file are left byte-identical; no Normalization is pushed,
so nothing marks the config dirty; the refusal is reported in-band as
`skipped[]` and out-of-band through the ADR-1411 warnUnusableInput seam
(new frozen reason config_section_not_object). null and undefined keep their
long-standing 'absent' meaning and still create the section.

This is the nested-section analog of the ADR-227 shape check _readConfigFile
already performs on the top-level document: valid JSON is not a config object.
isConfigSection is exported and shared by both modules rather than copied.

Fixes #3760

* fix(#3760): keep the multiRepo marker when planning cannot receive it

Follow-up from the isolated adversarial review, and the same defect class as
the two blocks the issue names — in the block it did not name.

normalizeLegacyKeys block 3 deleted `multiRepo` and pushed a Normalization
before anything consulted the planning section, deferring 'can this section
receive sub_repos?' to the caller that runs filesystem detection. By then the
marker was already gone and the config was already dirty, so with
{"multiRepo":true,"planning":"docs"} the loader wrote the file back with
multiRepo removed, the sub_repos injection silently no-opped against the
string, and no diagnostic was emitted at all. migrateOnDisk warned for the
same input; the ~30-caller loadConfig path did not.

Section validity is knowable from the parsed config alone — detection is only
needed for the VALUE, not for whether the destination can hold it. The refusal
moves into block 3: the marker is kept, no Normalization is pushed, and a
skipped entry is recorded, so all three callers inherit the preservation and
the diagnostic together. The caller-side guards drop to pure narrowing.

Also from review: skipped[] now reports sectionType ('string' | 'number' |
'boolean' | 'array') instead of sectionValue. migrateOnDisk's report is printed
verbatim by `migrate-config`, and this module already masks config values on
the set/unset output path; the type is the whole diagnostic and the value is
still in the file.

And `migrate-config --raw` no longer answers a refused migration with 'No
legacy keys found — config is already canonical.' Legacy keys WERE found and
declined, and the decline is the one thing only the user can fix by hand.

Refs #3760

* fix(#3760): keep configuration.cjs dependency-free; emit from its callers

The remote matrix caught a regression my own change introduced: adding
`require('./unusable-input.cjs')` to configuration.cts broke the #3571
install-layout contract. `configuration.cjs` must load from a layout holding
only itself plus bin/shared/*.manifest.json — the installer does not co-locate
arbitrary siblings — so the new require failed at load time:

  Cannot find module './unusable-input.cjs'
  Require stack:
  - /tmp/gsd-3571-.../.codex/gsd-core/bin/lib/configuration.cjs

pinned by 'co-located bin/shared manifests let configuration.cjs load without
sdk/shared' in tests/install.test.cjs (3 failures).

The contract is deliberate and the test is right, so the module goes back to
zero sibling requires and the out-of-band diagnostic moves to the callers that
already carry a dependency budget and hold the resolved path: cmdMigrateConfig
(config.cts) and loadConfigResolved (config-loader.cts). normalizeLegacyKeys
keeps reporting refusals in-band via skipped[], which is what lets it be pure
and dependency-free at the same time.

The emission-count and dedup assertions move to tests/config-loader.test.cjs,
where the diagnostic now originates. A new assertion pins the inverse for the
module itself — migrateOnDisk must emit ZERO diagnostics while still reporting
skipped[] — so regrowing a sibling require fails a unit test instead of only
the install suite. CONTEXT.md records why the emitter is the caller.

Refs #3760

* chore(#3760): backfill changeset pr number to 3767

---------

Co-authored-by: sim <sim@local>
2026-08-22 18:57:19 -04:00

600 lines
28 KiB
JavaScript

'use strict';
/**
* Unusable-input diagnostic — the out-of-band half of ADR-1411's "corrupt is not absent"
* amendment (epic #1879), and its first adopter, extractFrontmatter (#1882).
*
* What is under test is a BEHAVIOUR CHANGE ON A SILENT CHANNEL: every return value is
* preserved exactly, and the only observable difference is that a genuinely-unusable input
* now produces one diagnostic. So the assertions here are all on typed surfaces — the
* frozen reason enum and the dedup-set size — never on the diagnostic prose, per
* CONTRIBUTING.md's ban on raw text matching against stdout/stderr/file content.
*
* Independence note: the dedup set is process-global. Every case below resets it AND uses a
* path unique to that case. #2674 is the cautionary precedent in this repo — a reset helper
* that cleared two of three sets was a silent no-op for the very suite that existed to test
* it, and the cases only passed because each happened to pick a key no other case reused.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const {
UNUSABLE_REASON,
warnUnusableInput,
_resetUnusableInputWarningsForTests,
_unusableInputWarningCountForTests,
_unusableInputEmissionCountForTests,
_sanitizeSourceForTests,
} = require('../gsd-core/bin/lib/unusable-input.cjs');
const { extractFrontmatter, UNTERMINATED_KEY_THRESHOLD } = require('../gsd-core/bin/lib/frontmatter.cjs');
/**
* Run `fn` with stderr captured, and report how many NEW diagnostics it produced.
*
* The count comes from the dedup set, not from parsing what was written — that is the typed
* surface. stderr is stubbed only to keep the suite's own output clean; the stub is restored
* in a `finally` inside this standalone helper, which is the one place CONTRIBUTING.md
* permits try/finally (a helper with no access to test context).
*/
function emissionsDuring(fn) {
const before = _unusableInputEmissionCountForTests();
const original = process.stderr.write;
process.stderr.write = () => true;
try {
fn();
} finally {
process.stderr.write = original;
}
return _unusableInputEmissionCountForTests() - before;
}
/** Parse `content` under a path unique to the calling case, returning [result, emissions]. */
function parseUnder(content, sourcePath) {
let result;
const emitted = emissionsDuring(() => {
result = extractFrontmatter(content, sourcePath);
});
return [result, emitted];
}
const TRUNCATED_LF = '---\nphase: 01\nplan: 02\n';
const TRUNCATED_CRLF = '---\r\nphase: 01\r\nplan: 02\r\n';
// ─── The reason vocabulary is a contract ─────────────────────────────────────
describe('UNUSABLE_REASON', () => {
test('is frozen and holds exactly the reasons that have an emitting call site', () => {
_resetUnusableInputWarningsForTests();
assert.ok(Object.isFrozen(UNUSABLE_REASON), 'enum must be frozen');
// Locking the key set is what makes adding a reason three coordinated changes
// (enum + call site + this assertion) instead of a silent widening.
assert.deepStrictEqual(
Object.keys(UNUSABLE_REASON).sort(),
['CONFIG_SECTION_NOT_OBJECT', 'CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'PROJECT_UNREADABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'],
);
assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated');
});
test('an unrecognised reason emits nothing rather than a diagnostic naming undefined', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({ reason: 'not_a_real_reason', source: '/u/unknown.md' });
assert.strictEqual(wrote, false, 'unknown reason must report that it wrote nothing');
});
assert.strictEqual(emitted, 0);
});
});
// ─── STATE_UNREADABLE: a STATE.md that exists but could not be read ─────────
describe('STATE_UNREADABLE', () => {
test('a genuinely unreadable STATE.md produces exactly one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({
reason: UNUSABLE_REASON.STATE_UNREADABLE,
source: '/u/state-unreadable.md',
});
assert.strictEqual(wrote, true);
});
assert.strictEqual(emitted, 1);
});
test('the same STATE.md path reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/state-unreadable-dedup/STATE.md';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source });
const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source });
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two different STATE.md paths are never suppressed as one', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
warnUnusableInput({
reason: UNUSABLE_REASON.STATE_UNREADABLE,
source: '/u/state-unreadable-a/STATE.md',
});
warnUnusableInput({
reason: UNUSABLE_REASON.STATE_UNREADABLE,
source: '/u/state-unreadable-b/STATE.md',
});
});
assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault');
});
test('the reason value is the frozen string "state_unreadable"', () => {
assert.strictEqual(UNUSABLE_REASON.STATE_UNREADABLE, 'state_unreadable');
});
});
// ─── CONFIG_UNREADABLE: a config.json that exists but could not be read/parsed ─
describe('CONFIG_UNREADABLE', () => {
test('a genuinely unreadable config.json produces exactly one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable.json',
});
assert.strictEqual(wrote, true);
});
assert.strictEqual(emitted, 1);
});
test('the same config.json path reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/config-unreadable-dedup/config.json';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source });
const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source });
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two different config.json paths are never suppressed as one', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable-a/config.json',
});
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable-b/config.json',
});
});
assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault');
});
test('the reason value is the frozen string "config_unreadable"', () => {
assert.strictEqual(UNUSABLE_REASON.CONFIG_UNREADABLE, 'config_unreadable');
});
});
// ─── PROJECT_UNREADABLE: a PROJECT.md that exists but could not be read ──────
describe('PROJECT_UNREADABLE', () => {
test('a genuinely unreadable PROJECT.md produces exactly one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({
reason: UNUSABLE_REASON.PROJECT_UNREADABLE,
source: '/u/project-unreadable.md',
});
assert.strictEqual(wrote, true);
});
assert.strictEqual(emitted, 1);
});
test('the same PROJECT.md path reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/project-unreadable-dedup/PROJECT.md';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source });
const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source });
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two different PROJECT.md paths are never suppressed as one', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
warnUnusableInput({
reason: UNUSABLE_REASON.PROJECT_UNREADABLE,
source: '/u/project-unreadable-a/PROJECT.md',
});
warnUnusableInput({
reason: UNUSABLE_REASON.PROJECT_UNREADABLE,
source: '/u/project-unreadable-b/PROJECT.md',
});
});
assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault');
});
test('the reason value is the frozen string "project_unreadable"', () => {
assert.strictEqual(UNUSABLE_REASON.PROJECT_UNREADABLE, 'project_unreadable');
});
});
// ─── The discriminator: truncated vs. everything that merely looks like it ───
describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => {
test('unterminated fence carrying two keys is reported, and still returns {}', () => {
_resetUnusableInputWarningsForTests();
const [result, emitted] = parseUnder(TRUNCATED_LF, '/u/truncated-lf.md');
assert.deepStrictEqual(result, {}, 'return value must be preserved exactly');
assert.strictEqual(emitted, 1);
});
test('CRLF unterminated fence is reported identically to LF', () => {
_resetUnusableInputWarningsForTests();
const [result, emitted] = parseUnder(TRUNCATED_CRLF, '/u/truncated-crlf.md');
assert.deepStrictEqual(result, {});
assert.strictEqual(emitted, 1);
});
test('an indented "---" is not a closing fence, so the file is still truncated', () => {
_resetUnusableInputWarningsForTests();
const [result, emitted] = parseUnder('---\nphase: 01\nplan: 02\n ---\n', '/u/indented-close.md');
assert.deepStrictEqual(result, {});
assert.strictEqual(emitted, 1);
});
});
describe('extractFrontmatter — stays silent on everything that is not corruption', () => {
// Each row here is a document that reaches, or nearly reaches, the same branch as a
// truncated file. A diagnostic on any of them is a false positive on valid input.
const silentCases = [
['a document with no frontmatter at all', 'plain body\nmore\n'],
['a Markdown thematic break at byte 0', '---\nSome heading text\n\nA paragraph, no more dashes.\n'],
['a thematic break followed by a second one', '---\nIntro\n\n---\n\nMore\n'],
['a well-formed but empty frontmatter block', '---\n---\nbody\n'],
['an opening fence with nothing after it', '---\n'],
['a bare "---" with no newline', '---'],
['an empty document', ''],
['a BOM before the fence', '\uFEFF---\ntitle: x\n---\nbody\n'],
['a blank line before the fence', '\n---\ntitle: x\n---\n'],
['an opening fence with a trailing space', '--- \ntitle: x\n---\n'],
// #1882 review blocker: a thematic break above ONE labelled prose line is ordinary
// technical writing and parses as exactly one key. Each of these was flagged as
// corruption before the threshold moved to two.
['a thematic break above a Note: paragraph', '---\nNote: this is just a markdown paragraph, not frontmatter.\n'],
['a thematic break above an Author byline', '---\nAuthor: Jane Doe\n'],
['a thematic break above a TODO line', '---\nTODO: fix this later\n'],
['a thematic break above a See: link', '---\nSee: https://example.com\n'],
];
silentCases.forEach(([label, content], caseIndex) => {
test(`${label} produces no diagnostic`, () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder(content, `/u/silent-${caseIndex}.md`);
assert.strictEqual(emitted, 0, `${label} must not be reported as corruption`);
});
});
test('a thematic break above TWO labelled lines followed by prose stays silent', () => {
// Review finding: raising the key threshold to 2 only moved the boundary — two labelled
// lines are as common in ordinary prose as one. What separates a truncated write from a
// document opening with a rule is that a truncated write ends mid-block, so EVERY line is
// still frontmatter-shaped; this document goes on to prose.
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder(
'---\nAuthor: Jane Doe\nReviewed-by: John Smith\n\nOrdinary prose, no other --- anywhere.\n',
'/u/two-labelled-lines.md',
);
assert.strictEqual(emitted, 0, 'a labelled preamble above prose is not a truncated file');
});
// The shape check decides whether an unterminated region reads as an interrupted frontmatter
// block or as a document that merely opened with a rule. These four pin each half of that
// decision independently; without them the predicate's individual branches are unconstrained
// and a mutation that drops any one of them still passes.
test('a blank line inside an interrupted block does not disqualify it', () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\nphase: 01\n\nplan: 02\n', '/u/shape-blank-line.md');
assert.strictEqual(emitted, 1, 'blank lines are skipped, not treated as non-frontmatter');
});
test('an unindented list item counts as frontmatter-shaped', () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\nphase: 01\nmust_haves:\n- alpha\n', '/u/shape-flat-list.md');
assert.strictEqual(emitted, 1);
});
test('an indented folded-scalar continuation counts as frontmatter-shaped', () => {
// This line is neither a key nor a list item, so it is the only case that exercises the
// indented-continuation branch on its own.
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\nphase: 01\ndescription: >\n folded text\n', '/u/shape-folded.md');
assert.strictEqual(emitted, 1);
});
test('two keys followed by prose is NOT frontmatter-shaped', () => {
// The negative half: enough keys to clear the threshold, but the region goes on to prose,
// so it is a document opening with a rule rather than an interrupted write.
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder(
'---\nphase: 01\nplan: 02\n\nOrdinary prose sentence here.\n',
'/u/shape-then-prose.md',
);
assert.strictEqual(emitted, 0, 'key count alone must not be sufficient');
});
test('a truncated block whose values are nested lists is still reported', () => {
// The shape check must not reject legitimate frontmatter: list items and indented
// continuations are frontmatter-shaped too.
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\nphase: 01\nmust_haves:\n - alpha\n - beta\n', '/u/nested.md');
assert.strictEqual(emitted, 1);
});
test('a well-formed document still parses its keys and stays silent', () => {
_resetUnusableInputWarningsForTests();
let parsed;
const emitted = emissionsDuring(() => {
parsed = extractFrontmatter('---\ntitle: x\nstatus: draft\n---\nbody\n', '/u/well-formed.md');
});
assert.deepStrictEqual(parsed, { title: 'x', status: 'draft' });
assert.strictEqual(emitted, 0);
});
test('a four-dash close keeps its pre-existing lenient parse and stays silent', () => {
_resetUnusableInputWarningsForTests();
let parsed;
const emitted = emissionsDuring(() => {
parsed = extractFrontmatter('---\ntitle: x\n----\n', '/u/four-dash.md');
});
assert.deepStrictEqual(parsed, { title: 'x' });
assert.strictEqual(emitted, 0);
});
});
// ─── Boundary: the discriminator's threshold is ">= 2 parsed keys" ──────────
describe('extractFrontmatter — key-count boundary around the >=2 threshold', () => {
test('below threshold: zero keys is silent', () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\njust prose, no colon\n', '/u/boundary-0.md');
assert.strictEqual(emitted, 0);
});
test('limit-1: exactly one key is silent — a labelled line under a thematic break', () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\na: 1\n', '/u/boundary-1.md');
assert.strictEqual(emitted, 0, 'one key is ambiguous with ordinary Markdown');
});
test('limit: exactly two keys is reported', () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\na: 1\nb: 2\n', '/u/boundary-2.md');
assert.strictEqual(emitted, UNTERMINATED_KEY_THRESHOLD - 1);
});
test('limit+1: three keys is reported exactly once, not once per key', () => {
_resetUnusableInputWarningsForTests();
const [, emitted] = parseUnder('---\na: 1\nb: 2\nc: 3\n', '/u/boundary-3.md');
assert.strictEqual(emitted, 1);
});
});
// ─── Deduplication: both halves of the composite key ────────────────────────
describe('diagnostic deduplication', () => {
test('the same file reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const [, first] = parseUnder(TRUNCATED_LF, '/u/dedup-same.md');
const [, second] = parseUnder(TRUNCATED_LF, '/u/dedup-same.md');
assert.strictEqual(first, 1);
assert.strictEqual(second, 0, 'a repeat of the same fault must be suppressed');
});
test('a genuine second failure in a DIFFERENT file is never suppressed', () => {
_resetUnusableInputWarningsForTests();
const [, a] = parseUnder(TRUNCATED_LF, '/u/dedup-fileA.md');
const [, b] = parseUnder(TRUNCATED_LF, '/u/dedup-fileB.md');
assert.strictEqual(a, 1);
assert.strictEqual(b, 1, 'keying too coarsely would hide a real second fault');
});
test('the key includes the cause, so one file can report two different causes', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/dedup-two-causes.md';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({
reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
source,
});
const repeat = warnUnusableInput({
reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
source,
});
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two spellings of one Windows path may report twice — the accepted trade', () => {
// Separator folding was REMOVED: it collapsed genuinely distinct POSIX files whose
// names contain a backslash. The residual cost is that one Windows file written two
// ways can report twice. Mild noise is strictly preferable to a swallowed diagnostic,
// and this test pins the direction of that trade so it is not silently reversed.
_resetUnusableInputWarningsForTests();
const [, backslash] = parseUnder(TRUNCATED_LF, 'C:\\proj\\phases\\PLAN.md');
const [, forward] = parseUnder(TRUNCATED_LF, 'C:/proj/phases/PLAN.md');
assert.strictEqual(backslash, 1);
assert.strictEqual(forward, 1, 'noise is acceptable; a lost diagnostic is not');
});
test('path-less callers dedup on content, so identical content reports once', () => {
_resetUnusableInputWarningsForTests();
const [, first] = parseUnder(TRUNCATED_LF, undefined);
const [, second] = parseUnder(TRUNCATED_LF, undefined);
assert.strictEqual(first, 1);
assert.strictEqual(second, 0);
});
test('path-less callers with DIFFERENT content each report', () => {
_resetUnusableInputWarningsForTests();
const [, first] = parseUnder('---\nalpha: 1\na2: x\n', undefined);
const [, second] = parseUnder('---\nbeta: 2\nb2: y\n', undefined);
assert.strictEqual(first, 1);
assert.strictEqual(second, 1);
});
test('an empty-string path falls back to the content key rather than keying on ""', () => {
_resetUnusableInputWarningsForTests();
const [, first] = parseUnder('---\ngamma: 1\ng2: x\n', ' ');
const [, second] = parseUnder('---\ndelta: 2\nd2: y\n', ' ');
assert.strictEqual(first, 1);
assert.strictEqual(second, 1, 'blank paths must not collapse distinct files into one key');
});
test('only the offending file is reported when a good file is parsed alongside it', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
extractFrontmatter('---\nok: 1\n---\nbody\n', '/u/mixed-good.md');
extractFrontmatter(TRUNCATED_LF, '/u/mixed-bad.md');
extractFrontmatter('---\nalso: 2\n---\nbody\n', '/u/mixed-good-2.md');
});
assert.strictEqual(emitted, 1);
});
test('anonymous-first then named reports twice — the documented asymmetry', () => {
// Pinned deliberately. A path-less caller cannot identify its file, so suppressing the
// later NAMED report would also suppress a genuine second failure in a DIFFERENT file
// whenever two files share byte-identical truncated content. ADR-1411 ranks that swallow
// the worse failure, so the duplicate is accepted and the named line carries the filename.
_resetUnusableInputWarningsForTests();
const [, anonymous] = parseUnder(TRUNCATED_LF, undefined);
const [, named] = parseUnder(TRUNCATED_LF, '/u/anon-then-named.md');
assert.strictEqual(anonymous, 1);
assert.strictEqual(named, 1, 'the named report must still name the file');
});
test('one file parsed both with and without a path reports exactly once', () => {
// A read wrapper knows the path; a pure core downstream (state-transition.cts, per
// ADR-1769) is handed only the string. Keying those two parses separately reported the
// SAME truncated file twice, under a path key and a digest key.
_resetUnusableInputWarningsForTests();
const [, named] = parseUnder(TRUNCATED_LF, '/u/both-identities.md');
const [, anonymous] = parseUnder(TRUNCATED_LF, undefined);
assert.strictEqual(named, 1);
assert.strictEqual(anonymous, 0, 'the same file must not report twice under two keys');
});
test('widening the key does not merge two genuinely different files', () => {
_resetUnusableInputWarningsForTests();
const [, a] = parseUnder('---\nphase: 01\nplan: 02\n', '/u/widen-a.md');
const [, b] = parseUnder('---\nphase: 09\nplan: 09\n', '/u/widen-b.md');
assert.strictEqual(a, 1);
assert.strictEqual(b, 1, 'distinct content in distinct files must still both report');
});
test('the reset seam actually clears state, so the same key can report again', () => {
// #2674 shape: a reset that silently fails to clear turns every later dedup assertion
// into a vacuous pass. Prove the seam by re-reporting a key that was just suppressed.
_resetUnusableInputWarningsForTests();
const [, first] = parseUnder(TRUNCATED_LF, '/u/reset-seam.md');
const [, suppressed] = parseUnder(TRUNCATED_LF, '/u/reset-seam.md');
_resetUnusableInputWarningsForTests();
const [, afterReset] = parseUnder(TRUNCATED_LF, '/u/reset-seam.md');
assert.strictEqual(first, 1);
assert.strictEqual(suppressed, 0);
assert.strictEqual(afterReset, 1, 'reset must genuinely empty the dedup set');
assert.strictEqual(_unusableInputEmissionCountForTests(), 1,
'exactly one diagnostic was written after the reset');
assert.ok(_unusableInputWarningCountForTests() >= 1,
'and at least one identity was interned for it');
});
});
// ─── Hostile input ───────────────────────────────────────────────────────────
describe('hostile input', () => {
test('a literal backslash in a POSIX filename does not collide with a real directory', () => {
// Review finding: folding backslashes to '/' unconditionally made these two GENUINELY
// different files share one key on Linux/macOS, where '\\' is a legal filename
// character, and silently swallowed the second diagnostic.
_resetUnusableInputWarningsForTests();
const [, withBackslash] = parseUnder(TRUNCATED_LF, '/repo/weird\\name/PLAN.md');
const [, withSlash] = parseUnder(TRUNCATED_LF, '/repo/weird/name/PLAN.md');
assert.strictEqual(withBackslash, 1);
assert.strictEqual(withSlash, 1, 'two distinct files must never silence each other');
});
test('a path spelled like the unnamed-digest fallback cannot pre-seed suppression', () => {
// Review finding: the digest of any predictable content can be computed and used as a
// filename, so the two key namespaces must be disjoint by construction.
_resetUnusableInputWarningsForTests();
const crypto = require('node:crypto');
const digest = crypto.createHash('sha256').update(TRUNCATED_LF).digest('hex').slice(0, 16);
const [, forged] = parseUnder(TRUNCATED_LF, `<unnamed:${digest}>`);
const [, realFile] = parseUnder(TRUNCATED_LF, '/u/forge-victim.md');
assert.strictEqual(forged, 1);
assert.strictEqual(realFile, 1,
'a crafted filename must never suppress a real file reported by its own path');
// The anonymous re-report of byte-identical content IS suppressed, deliberately: that is
// the same-file guard (named read first, path-less re-parse second). The forged name buys
// an attacker nothing there, because ANY path-ful report of that content does the same.
});
test('a NUL in the path cannot forge a collision with another key', () => {
_resetUnusableInputWarningsForTests();
// The key separator is NUL. If it were not stripped, "a\0frontmatter_unterminated"
// supplied as a *path* would collide with the real key for path "a".
const [, forged] = parseUnder(TRUNCATED_LF, '/u/collide\u0000frontmatter_unterminated');
const [, genuine] = parseUnder(TRUNCATED_LF, '/u/collide');
assert.strictEqual(forged, 1);
assert.strictEqual(genuine, 1, 'a crafted path must not suppress a real report');
});
test('control characters are stripped from the source before it is used', () => {
// Asserted on the sanitizer's RETURN VALUE, not by capturing what reached stderr.
// Scraping the rendered stream and regex-testing it is the shape CONTRIBUTING.md bans
// (Prohibited: Raw Text Matching on Test Outputs) — the rule targets the mechanism,
// not just prose-wording checks, so the typed surface is the correct fix.
_resetUnusableInputWarningsForTests();
const cleaned = _sanitizeSourceForTests('/u/ansi\u001b[31mred\u0007\u0000.md');
assert.strictEqual(cleaned, '/u/ansi[31mred.md');
for (const ch of cleaned) {
assert.ok(ch.charCodeAt(0) > 31 && ch.charCodeAt(0) !== 127,
'sanitized source must contain no C0 or DEL bytes');
}
});
test('a large unterminated region completes without pathological behaviour', () => {
_resetUnusableInputWarningsForTests();
const big = '---\n' + Array.from({ length: 5000 }, (_, i) => `k${i}: v${i}`).join('\n') + '\n';
const [result, emitted] = parseUnder(big, '/u/large-unterminated.md');
assert.deepStrictEqual(result, {}, 'still returns the preserved sentinel');
assert.strictEqual(emitted, 1);
});
test('a failing stderr write is swallowed and never escalates into a throw', (t) => {
// Fault injection by method override + restore, never chmod 0o000: root bypasses mode
// bits, so a permission-based version of this test would silently pass with zero
// coverage in root Docker/CI.
_resetUnusableInputWarningsForTests();
const original = process.stderr.write;
t.after(() => { process.stderr.write = original; });
process.stderr.write = () => { throw new Error('EPIPE injected'); };
const result = extractFrontmatter(TRUNCATED_LF, '/u/broken-stderr.md');
assert.deepStrictEqual(result, {}, 'a broken stderr must not change the return value');
assert.strictEqual(_unusableInputEmissionCountForTests(), 0,
'a write that threw must not be counted as a diagnostic the operator saw');
});
});