diff --git a/scripts/prose-matcher.js b/scripts/prose-matcher.js index a2ed298..2056042 100644 --- a/scripts/prose-matcher.js +++ b/scripts/prose-matcher.js @@ -12,13 +12,15 @@ // punctuation (the em dash entry, for example) matches as a plain // substring, because its neighbours are legitimately letters. // - Markdown code is not prose. Fenced blocks and inline `code` spans -// are skipped. One exception: a document wrapped WHOLE in a single -// outer ```markdown (or ```md) fence is a copy wrapper, not a code -// sample, so its contents are unwrapped and scanned normally -// (handoff blocks travel this way). Fences inside the unwrapped body -// count as real code again. -// - Every hit reports its 1-based line number in the ORIGINAL text, -// so a hit inside an unwrapped handoff still points at the real line. +// are skipped. One exception: a ```markdown (or ```md) fence is a +// copy wrapper, not a code sample, so its contents are scanned as +// prose wherever the fence appears (a handoff block travels this way, +// usually behind a lead-in sentence). Fences INSIDE a wrapper count +// as real code again; nesting is by fence length, the CommonMark +// rule, so a wrapper must use more backticks than any inner fence +// (handoffs use four). To skip a genuine markdown SAMPLE, fence it as +// plain code (bare ``` or a non-markdown language), not ```markdown. +// - Every hit reports its 1-based line number in the ORIGINAL text. // // List loading is strict and throws: an unreadable file, a non-array // root, or a malformed entry raises an Error naming the problem. Each @@ -94,55 +96,41 @@ export function compilePhrases(list) { // ─── markdown region extraction ────────────────────────────────────────── const FENCE_LINE = /^\s*(`{3,}|~{3,})(.*)$/; -const OUTER_OPEN = /^(`{3,}|~{3,})\s*(markdown|md)\s*$/i; -const OUTER_CLOSE = /^(`{3,}|~{3,})\s*$/; - -// A whole-document ```markdown wrapper is a copy wrapper, not code. -// Detection is deliberately narrow: the FIRST non-empty line opens the -// fence with a markdown/md info string, and the LAST non-empty line -// closes it with the same character and at least the same run length. -// Anything less exact stays a normal code fence. -export function unwrapOuterMarkdownFence(text) { - const lines = text.split('\n'); - let first = 0; - while (first < lines.length && lines[first].trim() === '') first += 1; - let last = lines.length - 1; - while (last >= 0 && lines[last].trim() === '') last -= 1; - if (first >= last) return { lines, offset: 0, unwrapped: false }; - const open = lines[first].trim().match(OUTER_OPEN); - const close = lines[last].trim().match(OUTER_CLOSE); - if ( - open && close - && close[1][0] === open[1][0] - && close[1].length >= open[1].length - ) { - return { lines: lines.slice(first + 1, last), offset: first + 1, unwrapped: true }; - } - return { lines, offset: 0, unwrapped: false }; -} +const WRAPPER_INFO = /^(markdown|md)$/i; // Returns [{ line, text }] for prose lines only, with inline code spans // blanked (spaces preserve column positions). `line` is 1-based against -// the original text, including any unwrapped outer fence. +// the original text. +// +// One pass with a fence stack, following CommonMark's length nesting. A +// fenced block opened with a markdown/md info string is a "wrapper" +// (scan its contents as prose); any other info string, or none, is +// "code" (skip its contents). A block closes on a bare fence line of the +// same character and at least the opener's run length. Inside a code +// block only a close counts; a fence line inside a wrapper opens a +// nested block. So a wrapper's prose is scanned wherever the wrapper +// sits, and a real code fence inside it is still skipped. export function extractProseLines(text) { - const { lines, offset } = unwrapOuterMarkdownFence(text); const out = []; - let fence = null; - lines.forEach((raw, i) => { + const stack = []; + text.split('\n').forEach((raw, i) => { const m = raw.match(FENCE_LINE); if (m) { - if (!fence) { - fence = { char: m[1][0], len: m[1].length }; - } else if (m[1][0] === fence.char && m[1].length >= fence.len && m[2].trim() === '') { - fence = null; + const run = m[1]; + const info = m[2].trim(); + const top = stack[stack.length - 1]; + if (top && info === '' && run[0] === top.char && run.length >= top.len) { + stack.pop(); + return; } + if (top && top.kind === 'code') return; // fence-shaped code content + stack.push({ char: run[0], len: run.length, kind: WRAPPER_INFO.test(info) ? 'wrapper' : 'code' }); return; } - if (fence) return; - out.push({ - line: offset + i + 1, - text: raw.replace(/`[^`]*`/g, (s) => ' '.repeat(s.length)), - }); + const top = stack[stack.length - 1]; + if (!top || top.kind === 'wrapper') { + out.push({ line: i + 1, text: raw.replace(/`[^`]*`/g, (s) => ' '.repeat(s.length)) }); + } }); return out; } diff --git a/tests/prose-matcher.test.js b/tests/prose-matcher.test.js index 7dc51b1..94fea7d 100644 --- a/tests/prose-matcher.test.js +++ b/tests/prose-matcher.test.js @@ -8,7 +8,6 @@ import { assertValidPhraseList, loadPhraseList, compilePhrases, - unwrapOuterMarkdownFence, extractProseLines, matchLine, scanProse, @@ -82,38 +81,43 @@ test('a longer closing run still closes the fence', () => { assert.deepEqual(hits.map((h) => h.line), [4]); }); -// ─── the outer markdown copy wrapper ───────────────────────────────────── +// ─── the markdown copy wrapper (scanned wherever it appears) ────────────── -test('a whole-document markdown fence is unwrapped and scanned', () => { +test('a whole-document markdown fence is scanned as prose', () => { const text = '```markdown\nHandoff prose with zorbly flux.\n```\n'; const hits = scanProse(text, PHRASES); assert.equal(hits.length, 1); - // Line 2 in the ORIGINAL document, inside the wrapper. assert.equal(hits[0].line, 2); }); -test('fences inside the unwrapped wrapper are code again', () => { - const text = '```markdown\nprose line\n```\nzorbly flux in inner code\n```\nprose zorbly flux\n```\n'; +test('a markdown wrapper behind a lead-in sentence is still scanned (the handoff shape)', () => { + // The realistic handoff: a sentence, then the copy block. This is the + // grader-caught bug the session-745 R4 disproof surfaced. + const text = 'Here is your handoff, copy it:\n\n```markdown\nWe hit zorbly flux in the backlog.\n```\n'; const hits = scanProse(text, PHRASES); - assert.deepEqual(hits.map((h) => h.line), [6]); + // Line 1 is prose but carries no listed phrase; the hit is the wrapper's line 4. + assert.deepEqual(hits.map((h) => h.line), [4]); }); -test('a markdown fence that is not the whole document stays code', () => { - const text = 'intro prose\n```markdown\nzorbly flux quoted as a sample\n```\n'; - assert.equal(scanProse(text, PHRASES).length, 0); +test('a real code fence inside a wrapper is skipped; wrapper prose around it is scanned', () => { + // Four-backtick wrapper (the shape /handoff emits) so the inner + // three-backtick code fence nests by length. + const text = '````markdown\nprose zorbly flux one\n```\nzorbly flux in inner code\n```\nprose zorbly flux two\n````\n'; + const hits = scanProse(text, PHRASES); + assert.deepEqual(hits.map((h) => h.line), [2, 6]); }); -test('unwrap detection tolerates surrounding blank lines', () => { - const { unwrapped } = unwrapOuterMarkdownFence('\n\n```md\nbody\n```\n\n'); - assert.equal(unwrapped, true); +test('a plain code fence is still skipped even with a lead-in', () => { + const text = 'intro prose\n```\nzorbly flux quoted as a sample\n```\n'; + assert.equal(scanProse(text, PHRASES).length, 0); }); -test('a plain code fence spanning the whole document is NOT unwrapped', () => { - const { unwrapped } = unwrapOuterMarkdownFence('```\nbody\n```\n'); - assert.equal(unwrapped, false); +test('a non-markdown language fence stays code', () => { + const text = 'intro\n```js\nconst zorblyFlux = 1; // zorbly flux\n```\n'; + assert.equal(scanProse(text, PHRASES).length, 0); }); -test('extractProseLines reports 1-based original line numbers', () => { +test('extractProseLines reports 1-based line numbers', () => { const lines = extractProseLines('a\nb'); assert.deepEqual(lines.map((l) => l.line), [1, 2]); });