* test(#1882): stage one file per commit in the base-ref ancestry fixture CI failed on ubuntu-24 inside this test's setup loop, before any code under test ran: at commit 32 of 60 the index referenced a blob whose object write had not landed -- "invalid object ... for 'base-31.txt' / Error building trees". The loop staged with `git add .`, which re-stages every file already in the tree. Across 60 iterations that rehashes O(n squared) blobs -- roughly 1,800 stagings and 60 full index rewrites to add 60 one-line files -- and that churn is what the object store failed under. Each commit only ever adds a single new file, so staging that one path is equivalent and removes the redundant work entirely. Verified the loop still builds the intended history: 61 commits, git fsck clean. The fixture already carries a note from an earlier fix in this epic recording that it passed on ubuntu-22 and windows-24 and failed on ubuntu-24 for the same commit. That was a different stage -- fetch versus diff -- but the same lane and the same brittleness, so this is the second time this fixture's cost has surfaced as a red build rather than as a test failure. Not caused by this PR's change, which touches two configuration lists and cannot reach a scratch git repository in tmpdir. Fixed here rather than deferred, because the run surfaced it. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): prove an unreadable ROADMAP is indistinguishable from an absent one Failing-first. Encodes the issue's runtime repro: an unreadable ROADMAP.md makes getRoadmapPhaseInternal return the same null it returns for "phase not found", and getMilestoneInfo return the same {v1.0, milestone} it returns for a project with no roadmap at all -- so a permission or I/O fault reads as a brand-new project. Half these cases exist to hold the opposite line. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted to a synthetic Error carrying no errno, and that lands in the SAME catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. The absent case, the errno-less error, a non-string errno, unparseable content and a genuinely missing phase are all pinned silent. One case guards a decision rather than behaviour: an unreadable STATE.md alone must stay silent, because the inner catch that swallows it is deliberate and documented under the #2245 audit as an optional enhancement falling back to ROADMAP-only heuristics. Two more pin the invariant ADR-1411 names explicitly -- neither function may throw, because src/state.cts removed its own defensive try/catch on the strength of that guarantee. Assertions are on the frozen reason enum and the emission counter, never on diagnostic prose. Faults are injected by overriding the platformReadSync seam and restoring in t.after(), never chmod 0o000, which root bypasses. Adds the ROADMAP_UNREADABLE reason to the shared vocabulary as scaffolding; no call site emits it yet, which is what makes these tests red. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): report an unreadable ROADMAP instead of reading it as absent getRoadmapPhaseInternal returned null for a read failure exactly as it does for "phase not found", and getMilestoneInfo returned {v1.0, milestone} exactly as it does for a project with no roadmap -- so a permission or I/O fault presented as a brand-new project and workflows synthesised a blank phase or skipped requirement extraction with no signal. Both return values are preserved exactly, per ADR-1411's amendment: continuity is correct, the silence was the defect. Each catch now reports through the shared unusable-input seam that shipped with #1882 rather than a second copy of the same mechanism. The discriminator is the errno, and it is load-bearing in the silent direction. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted into a synthetic Error with no code that lands in the same catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. A genuine read fault always carries an errno; absence never does. The parse is regex over text and cannot throw, so nothing else reaches these catches. Neither function gains a throw. ADR-1411 names this explicitly: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because it never throws, and two tests pin that. The inner STATE.md catch stays untouched and silent -- its fallback to ROADMAP-only heuristics is a deliberate, documented optional-enhancement path, not a fault. Where the fix belongs was the design question. platformReadSync does not leak: it keeps absent and unusable as two channels, exactly as an abstraction should. Both callers re-collapsed that distinction, so the fix is caller-side and the projection seam -- with roughly ninety other dependents -- is untouched. Closes #1881 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): admit the roadmap reason to the locked vocabulary The seam documents adding a reason as three coordinated changes -- the enum entry, the emitting call site, and the test that locks Object.keys(...).sort(). This PR made the first two and the lock caught the third, which is the whole point of pinning the key set rather than asserting each value exists. The roadmap suite no longer re-locks the full set. Two complete locks would mean two files to update every time a later phase adds a reason, and #1883 and #1884 are both going to. The canonical lock stays in the seam's own suite; the roadmap suite asserts only the value it introduces. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): resolve the roadmap path inside the try, not outside it Naming the file in the diagnostic required the resolved path in the catch, and the obvious way to get it was to hoist `path.join(planningDir(cwd), 'ROADMAP.md')` above the try. planningDir throws a plain Error for an invalid GSD_WORKSTREAM or GSD_PROJECT segment -- one containing a slash, backslash or `..` -- so hoisting it let that throw escape uncaught. That broke the exact invariant ADR-1411 names as this file's hazard: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because that function never throws. Of its callers only archivePhaseDirectories wraps it; cmdInitExecutePhase, cmdInitNewMilestone, cmdInitMilestoneOp, cmdInitManager, cmdInitProgress, cmdProgressRender and cmdStats all call it bare, so a workstream name with a slash in it crashed the CLI outright instead of degrading. The previous commit asserted "neither function gains a throw -- two tests pin that". That was false. Both tests inject faults through platformReadSync only and never through planningDir, so neither could have exercised the path that broke. The guarantee was claimed, not demonstrated. The path is now declared before the try and resolved inside it, so the catch can still name the file when there is one, and a path that never resolved reports nothing and returns the sentinel unchanged. The two test names are narrowed to what they actually prove -- that a failing READ does not throw -- and a new case injects the planningDir failure directly, which is what would have caught this. getRoadmapPhaseInternal carried the same hazard, resolving the path outside its try since before this branch. It is fixed the same way rather than left: ADR-227 is explicit that throwing breaks pipeline continuity, this read path already degrades to null for every other failure, and a PR whose purpose is hardening this invariant is the wrong place to leave the sibling crashing. Behaviour otherwise unchanged and re-verified: healthy lookups, EACCES reporting on both functions, absent-roadmap silence, and the errno discriminator all unaffected. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#1881): backfill changeset pr number to 2729 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
459 lines
22 KiB
JavaScript
459 lines
22 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(),
|
|
['FRONTMATTER_UNTERMINATED', 'ROADMAP_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);
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
});
|
|
});
|