Skip to content

fix(scripts): read a fence opener as a run, not as three backticks (#9194) - #9332

Merged
baozhoutao merged 1 commit into
mainfrom
claude/9194-fence-opener-commonmark-run
Sep 13, 2026
Merged

fix(scripts): read a fence opener as a run, not as three backticks (#9194)#9332
baozhoutao merged 1 commit into
mainfrom
claude/9194-fence-opener-commonmark-run

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #9194.

Fixes, not Part of: all four acceptance items from the triage comment are met in this PR and nothing about the card is left for a second change. The follow-up found while measuring is filed separately as #9331 and is ⛔ not touched here.

What was wrong

Two doc gates carried the same opening-fence predicate, verbatim, and the same wrong answer with it:

const fence = /^\s*```(\S*)\s*$/.exec(lines[i]);

(\S*) is greedy over non-space, so a four-backtick opener matched as a three-backtick opener whose language was the leftover backtick plus the real one. Read out of the gates' own scanners on origin/main — ⛔ not from a grep:

fence line=150 lang="`markdown" ok=true wrapped=true bodyLines=0 body=[]
fence line=155 lang="plaintext"  ok=true wrapped=true bodyLines=0 body=[]

A language that does not exist, two phantom fences both reporting a successful parse (the object-body retry wraps '' into {}, which parses), and the nesting inverted — line 150 opened, the real inner opener at 151 closed, and the JavaScript on 152–154 sat outside any fence, invisible to both gates.

What this does — A, with B folded into its structure

Per the triage ruling: A, the CommonMark rule. The predicate now lives once, in scripts/markdown-fence-scan.mjs: a run of three or more backticks or tildes opens, and only a run of the same character that is at least as long, carrying no info string, closes. A shorter run inside a longer one is ordinary body text, which is exactly how a page teaches nested fences.

B did not need a separate check. B was "reject a language capture containing a backtick and report it". Here the run is consumed greedily and — per CommonMark — an info string containing a backtick is not an opener at all, so a backtick-bearing language is no longer detected after being counted; it is unrepresentable. That is the repair order the ruling asked for (remove the construct that permits the error → make the correct form the only spelling → only then add a check), and the check that remains is an assertion in the pin test, not a runtime report on a state that can no longer occur.

⛔ C was not taken, as ruled: the affected page is legitimately teaching nested fences.

Single authority, not two patched copies — and which fork that was

Triage's fork: extract, or pin the two copies against each other if their module boundaries make extraction too costly. It was the cheap case, and the answer is extraction. The two gates already share a module boundary — check-doc-expression-carriage.mjs imports its scan surface from check-doc-component-types.mjs (objectui#7878) rather than carrying a copy — so there was no boundary to force. Both now import openFence / closesFence, and each site carries a ⛔ comment naming the authority.

What keeps them consistent from now on is scripts/__tests__/markdown-fence-scan.test.ts, which fails if either gate grows a private fence predicate again: it masks comments (prose about a regex is not a regex) and looks for the shape of an anchored fence-matching regex literal, not for the one pattern that was removed — with a positive control asserting the guard still matches both the removed spelling and the run-aware one, so a guard that stopped matching anything cannot pass by describing nothing.

Prior art is named rather than re-derived: body-dialect-census.mjs (keepFencedCodeOnly) and check-doc-fence-languages.mjs were already run-aware. They are offset-based and self-contained respectively; this module is the line-based spelling the two doc gates needed, and it is the one a third gate should import instead of writing a fourth.

Acceptance — all four, measured

1. The instance. Read back out of scanFences after the fix:

fence line=150 lang="markdown" ok=false bodyLines=5
     body=["```javascript","function hello() {","  console.log(\"Hello, World!\");","}","```"]

Language markdown, no phantom fences (the fence at 155 is gone), and the JavaScript on 152–154 is inside the fence. ⚠️ One precision on the acceptance wording: under CommonMark there is no inner fence — the inner ```javascript and its closer are literal body text of the four-backtick fence, which is why the body is 5 lines and not 3. What the acceptance is about holds either way: the JavaScript is no longer outside every fence.

2. The lit control — an ODD number of stray markers. A fixture carrying exactly one stray four-backtick marker, inside a five-backtick wrapper that is legitimately teaching it (the even-count corpus instance resynchronises by accident and cannot show this). Both gates driven over it:

before after
fence languages seen json, markdown , `json`, , plaintext json, markdown, json
the prose paragraph read as a fence body prose
the quoted example judged as a real json fence body of the wrapper
the last node (omega) invisible — read as prose inside a scanned json fence
end of file unterminated fence clean
component-types sites 2 + an unterminated report 3, correct languages

3. No backtick-bearing language column. The fence inventory the census reads held one such language before (`markdown) and holds none after; --list over the real surface greps zero.

4. Both gates. Both verified, and the whole-corpus diff of both scanners' output, before vs after, is exactly three lines — the two phantom fences replaced by one real one. No other fence, no type site, and no counter moved:

- FENCE content/docs/plugins/plugin-markdown.mdx:150 lang="`markdown" ok=true bodyLines=0
- FENCE content/docs/plugins/plugin-markdown.mdx:155 lang="plaintext"  ok=true bodyLines=0
+ FENCE content/docs/plugins/plugin-markdown.mdx:150 lang="markdown"   ok=false bodyLines=5
- COUNTERS {"files":188,"codeBlocks":1107,...,"typeSites":897,...} FENCES 1107
+ COUNTERS {"files":188,"codeBlocks":1106,...,"typeSites":897,...} FENCES 1106

Two deliberate divergences from strict CommonMark, both measured

Both are documented in the module header:

  • Indentation is not capped at three spaces. The replaced predicates accepted any leading whitespace; tightening that would silently drop fences the gates read today. Measured over every tracked .md/.mdx in the tree: 12 marker lines are indented past three spaces, all of them in .github/prompts/component.prompt.md, which is on neither gate's scan surface. A separate card if anyone wants it.
  • Multi-token info strings and tilde fences are accepted. Behaviour-neutral today: the same sweep found zero marker lines with a multi-token info string and zero tilde fences on the corpus.

Reverse verification

The implementation was committed first, then ablated: the module's rule was swapped back to the replaced semantics (exactly three, greedy info, no info guard), proven on disk (three injected markers, zero original spellings remaining), and the pins re-run.

openFence('````markdown') -> {"marker":"`","run":3,"info":"`markdown","lang":"`markdown"}
a bare 3-backtick line closes it? -> true
ABLATED vitest EXIT=1 — Tests 7 failed | 9 passed (16)

The defect is reproduced exactly, and the seven reds include both gate-level desync pins and the corpus invariant. Restored with git checkout HEAD --, verified byte-identical by blob hash, 16/16 green again.

Commands run

command exit
node scripts/check-doc-component-types.mjs 0
node scripts/check-doc-expression-carriage.mjs 0
node scripts/check-doc-expression-carriage.mjs --self-test 0
node scripts/markdown-fence-scan.mjs --self-test 0
pnpm exec vitest run scripts/__tests__ (153 files) 0
pnpm type-check:scripts 0
pnpm exec eslint on the six changed files 0
node scripts/check-changeset-presence.mjs 0
node scripts/check-control-bytes.mjs 0
node scripts/check-new-cross-file-line-citations.mjs 0 (0 new)
node scripts/check-lint-coverage.mjs · check-type-check-coverage.mjs · check-unreferenced-sources.mjs 0

⚠️ pnpm type-check:scripts is run by name and deliberately: it is a separate command from per-package type-check, it is the only thing that compiles scripts/, and a PR on this lane shipped red this shift by running the latter and not the former.

Two other files moved, both because the change made an existing claim stale, and both predicted by the claim's own comment:

  • scripts/__tests__/check-doc-expression-carriage.test.ts — the orphan-copy list for the "is LOUD when the instrument itself is broken" pin. Its comment says in as many words that falling behind the gate's imports turns that test into a module-resolution stack trace; this PR adds a fourth import, so the list gains a fourth file.
  • scripts/markdown-test-inputs.mjs — the new pin test names a document in the corpus, so it owes a row in the adjudicated ledger.

Changeset: empty frontmatter — tooling only, no package is released.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr


Generated by Claude Code

…9194)

Both doc gates carried the same opening-fence predicate, verbatim, and the same
wrong answer with it: a capture greedy over non-space read a four-backtick
opener as a three-backtick fence whose language was the leftover backtick plus
the real one. On the page that legitimately teaches nested fences the nesting
then inverted -- the four-backtick line opened, the real inner opener closed,
and the code between them sat outside any fence, invisible to both gates, while
two empty non-fences were counted as successfully parsed in the coverage
figures and a language that does not exist got its own column.

The rule now lives once, in scripts/markdown-fence-scan.mjs, as CommonMark
states it: a run of three or more backticks or tildes opens, and only a run of
the same character that is at least as long, carrying no info string, closes.
A backtick-bearing language is no longer detectable-after-the-fact but
unrepresentable -- the run is consumed greedily and an info string holding a
backtick is not an opener at all.

Extraction rather than a two-way pin, because the two gates already shared a
module boundary: one imports the other's scan surface. The new pin test is the
recurrence guard -- it fails if either gate grows a private fence predicate
again, and its lit control carries an ODD number of stray four-backtick markers,
which is the shape that desynchronises pairing for the rest of a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
@github-actions github-actions Bot added the tests label Sep 13, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 13, 2026 01:13
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 80830ab Sep 13, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/9194-fence-opener-commonmark-run branch September 13, 2026 01:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants