Skip to content

Commit 3a8f411

Browse files
committed
fix(tools): narrow fence tracking to the direct-list case
Related to #2144 Signed-off-by: Varun Ramesh <varamesh@nvidia.com>
1 parent 23d040e commit 3a8f411

2 files changed

Lines changed: 137 additions & 546 deletions

File tree

tools/check-docs-mdx

Lines changed: 40 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -33,36 +33,32 @@
3333
#
3434
# Code handling (applies to checks 2-6; check 1 greps the fence-stripped line
3535
# WITHOUT inline-span stripping): fenced code blocks in BOTH backtick
36-
# (```) and tilde (~~~) styles are skipped, following the pinned MDX parser's
37-
# fence behavior. Unlike CommonMark, MDX has no indented code blocks, so a
38-
# fence may have any leading spaces or tabs. It opens on a run of >= 3 of either
39-
# character and closes only on a later line whose leading run is the SAME
40-
# character, at least as long, and followed by nothing but whitespace (so an
41-
# info string such as ```yaml opens a block and never closes one). Inline
42-
# code spans are stripped before scanning; a span opens on a run of N backticks
43-
# and closes at the next run of exactly N backticks, so spans of any
44-
# backtick-run length are honored (not just single-backtick spans).
36+
# (```) and tilde (~~~) styles are skipped by a lexical fence tracker. An
37+
# explicit fence opens on a run of >= 3 of either character after 0-3 leading
38+
# spaces, and closes only on an explicit later line whose leading run is the
39+
# SAME character, at least as long, and followed by nothing but whitespace.
40+
# Thus ```yaml starts a fence when outside one and is content, not a closer,
41+
# when inside one. For an immediate direct top-level bullet or 1. item whose
42+
# content indent is at most three, a nonblank outdent implicitly ends its fence.
43+
# Inline code spans are stripped before scanning; a span opens on a run of N
44+
# backticks and closes at the next run of exactly N backticks.
4545
#
4646
# NOTE: this checker is a fast, dependency-free APPROXIMATION of the MDX
4747
# parser, not a reimplementation of it. tools/check-docs-mdx-parse runs the
4848
# real parser (@mdx-js/mdx, locked in tools/mdx/) and is the authoritative
4949
# gate — it is what the merge-gate 'docs-mdx' job blocks on. A green run HERE
5050
# is not parser-level validation; it means no known hazard pattern matched.
5151
#
52-
# The rules above are deliberately a strict SUBSET of what the parser rejects.
53-
# Erring toward under-reporting is the correct direction: a miss is caught by
54-
# the blocking parse gate, whereas a false positive would force contributors to
55-
# mangle prose the publish step would have accepted. Known misses left to the
56-
# parser: a stray closing tag ('</div>'), an unclosed fragment ('<>'), a
57-
# placeholder sharing a line with well-formed JSX, unbalanced expression braces
58-
# spanning lines, and acorn-level syntax errors.
52+
# The hazard rules deliberately cover common failures rather than every parser
53+
# rejection. Outside the lexical code-handling subset documented above, this
54+
# line-based approximation can disagree with the parser in either direction.
55+
# Known misses left to the parser include a stray closing tag ('</div>'), an
56+
# unclosed fragment ('<>'), a placeholder sharing a line with well-formed JSX,
57+
# unbalanced expression braces spanning lines, and acorn-level syntax errors.
5958
#
60-
# Known code-handling limit: a code span is matched within one line, even
61-
# though CommonMark lets a span cross a line break (an opening backtick run on
62-
# one line closing on a later one). Content between such a break is scanned as
63-
# prose and can surface as a false positive here. This is a deliberate
64-
# limitation of a line-based scanner, not an oversight: the parse gate
65-
# (check-docs-mdx-parse) is the arbiter and will accept the file.
59+
# Known code-handling limits: later list items, lazy continuations, nested lists,
60+
# and mixed containers are parser-owned. Code spans are matched within one line;
61+
# the parser handles multi-line spans.
6662

6763
set -euo pipefail
6864

@@ -136,169 +132,48 @@ readonly FENCE_AWK='
136132
}
137133
return width
138134
}
139-
function column_width(s, i, ch, width) {
140-
width = 0
141-
for (i = 1; i <= length(s); i++) {
142-
ch = substr(s, i, 1)
143-
if (ch == "\t") width += 4 - (width % 4)
144-
else width++
145-
}
146-
return width
147-
}
148-
function thematic_break(s, text) {
149-
text = s
150-
sub(/\r$/, "", text)
151-
return text ~ /^[ \t]*(-[ \t]*){3,}$/ ||
152-
text ~ /^[ \t]*(\*[ \t]*){3,}$/ ||
153-
text ~ /^[ \t]*(_[ \t]*){3,}$/
154-
}
155-
function setext_underline(s, text) {
156-
text = s
157-
sub(/^[ \t]+/, "", text); sub(/\r$/, "", text); sub(/[ \t]+$/, "", text)
158-
return text ~ /^(=+|-+)$/
159-
}
160-
function table_delimiter(s, text, outer) {
161-
text = s
162-
sub(/^[ \t]+/, "", text); sub(/\r$/, "", text); sub(/[ \t]+$/, "", text)
163-
outer = (text ~ /^\|.*\|$/)
164-
gsub(/[ \t]/, "", text)
165-
if (text !~ /\|/ && !outer) return 0
166-
sub(/^\|/, "", text); sub(/\|$/, "", text)
167-
return text ~ /^:?-+:?(\|:?-+:?)*$/
168-
}
169-
function lazy_text(s, text, fence_rest) {
170-
text = s
171-
sub(/^[ \t]+/, "", text)
172-
sub(/\r$/, "", text)
173-
if (text !~ /[^ \t]/) return 0
174-
if (thematic_break(text)) return 0
175-
if (text ~ /^(>|#{1,6}([ \t]|$)|~{3,})/) return 0
176-
if (match(text, /^`{3,}/)) {
177-
fence_rest = substr(text, RSTART + RLENGTH)
178-
if (index(fence_rest, "`") == 0) return 0
179-
}
180-
if (text ~ /^\{/ || text ~ /^<[^ \t]/) return 0
181-
if (text ~ /^([-+*]|[0-9]{1,9}[.)])[ \t]+/) return 0
182-
return 1
183-
}
184135
NR <= fm_end { next }
185-
# Fence rules shared by both checker passes. Getting any of these wrong
186-
# leaves the tracker open and silently skips every later hazard.
187-
# indent: any leading spaces or tabs, matching the MDX parser
136+
# Lexical fence rules shared by both checker passes:
137+
# indent: 0-3 leading spaces; tabs are not fence indentation
188138
# open: a backtick in the info string makes the line prose, not a
189139
# fence (ambiguous with an inline code span); tildes are exempt
190-
# close: same char, at least as long, nothing after but whitespace, so
191-
# ```yaml only ever opens; the \r admits a CRLF closer
192-
# scope: a fence opened inside a list item ends implicitly when a
193-
# nonblank line leaves that item. list_content/fscope carry
194-
# container indents; list_lazy/top_lazy preserve CommonMark
195-
# paragraph continuation lines, and thematic breaks never
196-
# create lists.
140+
# close: same char, at least as long, nothing after but whitespace;
141+
# the optional \r admits a CRLF closer
142+
# scope: an immediate direct top-level bullet or 1. item may own an
143+
# indented fence; any nonblank outdent ends that scope
197144
{
198145
blank = ($0 ~ /^[ \t]*\r?$/)
199146
ind = indent_width($0)
200-
fence_in_marker = 0
201-
is_fence = match($0, /^[ \t]*(`{3,}|~{3,})/)
147+
is_fence = match($0, /^ {0,3}(`{3,}|~{3,})/)
202148
if (is_fence) {
203-
m = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", m)
149+
m = substr($0, RSTART, RLENGTH); sub(/^ +/, "", m)
204150
ch = substr(m, 1, 1); len = length(m)
205151
rest = substr($0, RSTART + RLENGTH)
206152
}
207153
208-
# A nonblank outdent ends both the list item and any unclosed fence it
209-
# contains. Re-process the boundary line below: it may be prose, a new
210-
# list item, or the opener for a following top-level fence.
154+
# Re-process a direct list boundary as prose or as a new outer fence.
211155
if (in_fence && fscope > 0 && !blank && ind < fscope) {
212-
in_fence = 0; fscope = 0
156+
in_fence = 0; fscope = 0; list_indent = 0
213157
}
214158
215159
if (in_fence) {
216-
if (is_fence && ch == fch && len >= flen && rest ~ /^[ \t]*\r?$/) { in_fence = 0; next }
160+
if (is_fence && ch == fch && len >= flen && rest ~ /^[ \t]*\r?$/) { in_fence = 0; fscope = 0; next }
217161
next
218162
}
219163
220-
# Maintain just enough list-container state to distinguish an
221-
# outdented list-fence boundary from a valid less-indented top-level
222-
# closer. CommonMark lazy paragraph lines keep the item open. A blank
223-
# ends laziness and also ends an item whose marker had no content.
224-
table_continuation = top_table && $0 ~ /\|/
225-
if (top_table && !table_continuation) top_table = 0
226-
setext_line = top_lazy && setext_underline($0)
227-
rejected_marker = 0
228-
list_candidate = !setext_line && !table_continuation && !thematic_break($0) && match($0, /^[ \t]*([-+*]|[0-9]{1,9}[.)])([ \t]+|[ \t]*\r?$)/)
229-
if (list_candidate) {
230-
marker_prefix_len = RLENGTH
231-
marker_indent = ind
232-
content = substr($0, RSTART + marker_prefix_len)
233-
marker = substr($0, 1, marker_prefix_len)
234-
sub(/^[ \t]*/, "", marker)
235-
match(marker, /^([-+*]|[0-9]{1,9}[.)])/)
236-
marker = substr(marker, RSTART, RLENGTH)
237-
content_empty = (content ~ /^\r?$/)
238-
if (content_empty) {
239-
content_indent = marker_indent + length(marker) + 1
240-
} else {
241-
content_indent = column_width(substr($0, 1, marker_prefix_len))
242-
}
243-
can_interrupt = !content_empty && (marker !~ /^[0-9]/ || marker + 0 == 1)
244-
target_depth = list_depth
245-
while (target_depth > 0 && marker_indent < list_content[target_depth] && marker_indent != list_marker[target_depth]) target_depth--
246-
continues_list = target_depth > 0 && marker_indent == list_marker[target_depth]
247-
paragraph_open = target_depth > 0 ? list_lazy[target_depth] : top_lazy
248-
if (!continues_list && paragraph_open && !can_interrupt) {
249-
list_candidate = 0; rejected_marker = 1; list_depth = target_depth
250-
}
251-
}
252-
if (list_candidate) {
253-
if (match(content, /^(`{3,}|~{3,})/)) {
254-
fence_in_marker = 1; is_fence = 1
255-
m = substr(content, RSTART, RLENGTH)
256-
ch = substr(m, 1, 1); len = length(m)
257-
rest = substr(content, RSTART + RLENGTH)
258-
}
259-
list_depth = target_depth
260-
if (list_depth == 0 || marker_indent != list_marker[list_depth]) {
261-
if (list_depth > 0) { list_lazy[list_depth] = 0; list_empty[list_depth] = 0 }
262-
list_depth++
263-
}
264-
list_marker[list_depth] = marker_indent
265-
list_content[list_depth] = content_indent
266-
list_lazy[list_depth] = lazy_text(content)
267-
list_empty[list_depth] = content_empty
268-
top_lazy = 0; top_pipe = 0; top_table = 0
269-
} else if (blank) {
270-
while (list_depth > 0 && list_empty[list_depth]) list_depth--
271-
for (i = 1; i <= list_depth; i++) list_lazy[i] = 0
272-
top_lazy = 0; top_pipe = 0; top_table = 0
273-
} else if (rejected_marker) {
274-
if (list_depth > 0) {
275-
list_empty[list_depth] = 0; list_lazy[list_depth] = 1
276-
top_lazy = 0
277-
} else {
278-
top_lazy = 1; top_pipe = ($0 ~ /\|/)
279-
}
280-
} else {
281-
while (list_depth > 0 && ind < list_content[list_depth] && !(list_lazy[list_depth] && lazy_text($0))) list_depth--
282-
if (list_depth > 0) {
283-
list_empty[list_depth] = 0
284-
if (is_fence && (ch != "`" || index(rest, "`") == 0)) list_lazy[list_depth] = 0
285-
else list_lazy[list_depth] = lazy_text($0)
286-
top_lazy = 0
287-
} else if (table_continuation) {
288-
top_lazy = 0; top_pipe = 0
289-
} else if (setext_line) {
290-
top_lazy = 0; top_pipe = 0
291-
} else if (top_lazy && top_pipe && table_delimiter($0)) {
292-
top_lazy = 0; top_pipe = 0; top_table = 1
293-
} else {
294-
top_lazy = lazy_text($0)
295-
top_pipe = top_lazy && $0 ~ /\|/
296-
}
164+
# Deliberately narrow list recognition: direct top-level bullets and
165+
# ordered items starting at 1, with a normal content indent <= 3.
166+
# Exclude thematic breaks, which otherwise resemble bullet items.
167+
marker = !($0 ~ /^(-[ \t]*){3,}\r?$/ || $0 ~ /^(\*[ \t]*){3,}\r?$/) && match($0, /^([-+*]|1[.)]) +/)
168+
if (marker && RLENGTH <= 3 && substr($0, RLENGTH + 1, 1) ~ /[^ \t\r]/) {
169+
list_indent = RLENGTH
170+
} else if (!blank && list_indent > 0 && ind < list_indent) {
171+
list_indent = 0
297172
}
298173
299174
if (is_fence && (ch != "`" || index(rest, "`") == 0)) {
300175
in_fence = 1; fch = ch; flen = len
301-
fscope = fence_in_marker ? content_indent : ((list_depth > 0 && ind >= list_content[list_depth]) ? list_content[list_depth] : 0)
176+
fscope = (list_indent > 0 && ind >= list_indent) ? list_indent : 0
302177
next
303178
}
304179
}
@@ -424,8 +299,7 @@ for file in "${FILES[@]}"; do
424299
# valid content. Deciding whether tags actually balance needs parser
425300
# state, which this script does not have; the trade is that a placeholder
426301
# sharing a line with real JSX is caught by tools/check-docs-mdx-parse
427-
# instead of here. That is the safe direction — see the strict-subset
428-
# note in the header.
302+
# instead of here, as described by the parser-ownership note above.
429303
line ~ /<[a-zA-Z]/ && line !~ /\/>/ && line !~ /<\// && line !~ /<(img|br|hr|input|source|meta|link|area|base|col|embed|param|track|wbr)([ \t>\/])/ && line !~ /<https?:\/\// {
430304
printf "MDX: bare <word> tag outside code fence: %s:%d: %s\n", FILENAME, NR, $0
431305
errors++
@@ -439,8 +313,7 @@ for file in "${FILES[@]}"; do
439313
# parse cleanly through @mdx-js/mdx — micromark only enters JSX-tag mode
440314
# when a name-ish character follows immediately, so `<` + space stays
441315
# literal text. Flagging them forced contributors to backtick prose the
442-
# publish step accepts, which is the false-positive direction this script
443-
# must avoid (see the strict-subset note in the header).
316+
# publish step accepts, which this approximation must avoid.
444317
#
445318
# `</div>` and `<>` are NOT covered here: both are real MDX errors, but
446319
# deciding whether a tag is balanced needs parser state. They are caught

0 commit comments

Comments
 (0)