Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 34 additions & 46 deletions scripts/prose-matcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
38 changes: 21 additions & 17 deletions tests/prose-matcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
assertValidPhraseList,
loadPhraseList,
compilePhrases,
unwrapOuterMarkdownFence,
extractProseLines,
matchLine,
scanProse,
Expand Down Expand Up @@ -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]);
});
Expand Down