Skip to content

Commit 74f15cc

Browse files
author
merge-queue-bot
committed
Merge PR #676: feat(lint): back InlineBlocks with a byte-level inline scanner
2 parents b62004b + b18558b commit 74f15cc

6 files changed

Lines changed: 1171 additions & 22 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,6 @@ footer: |
216216
| 2606192025 || sonnet | [Replace os.DirFS with os.OpenRoot to contain symlink escapes](plan/2606192025_rootfs-openroot-symlink-containment.md) |
217217
| 2606192026 || sonnet | [Add per-goroutine recover() to CLI engine runner worker goroutines](plan/2606192026_engine-runner-panic-recovery.md) |
218218
| 2606192027 || sonnet | [Security hardening batch — 2026-06-19 full-repo audit (low/info)](plan/2606192027_security-hardening-batch-2026-06-19-full-repo.md) |
219-
| 2606202100 | 🔲 | opus | [Parity perf: make InlineBlocks a light inline scan, not a goldmark re-parse](plan/2606202100_parity-light-inline-scan.md) |
219+
| 2606202100 | 🔳 | opus | [Parity perf: make InlineBlocks a light inline scan, not a goldmark re-parse](plan/2606202100_parity-light-inline-scan.md) |
220220
| 2606210840 || sonnet | [Same-file anchor-resolution rule for true gomarklint parity](plan/2606210840_same-file-anchor-resolution-rule.md) |
221221
<?/catalog?>

internal/integration/inline_index_equivalence_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
"github.com/stretchr/testify/require"
1111

1212
"github.com/jeduden/mdsmith/internal/lint"
13+
"github.com/jeduden/mdsmith/internal/rule"
14+
_ "github.com/jeduden/mdsmith/internal/rules"
15+
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
1316
)
1417

1518
// TestInlineIndexEquivalence_CodeSpans is the Layer 1 counterpart to
@@ -55,3 +58,127 @@ func TestInlineIndexEquivalence_CodeSpans(t *testing.T) {
5558
}
5659
require.NotZero(t, checked, "expected at least one parse-skip-eligible corpus file")
5760
}
61+
62+
// parityInlineRuleIDs are the parity inline rules whose diagnostics must be
63+
// byte-identical between the goldmark AST path and the nil-AST inline scan.
64+
// They are the rules the plan's equivalence gate names: bare URLs (MDS012),
65+
// empty alt text (MDS032), and link validity (MDS062).
66+
var parityInlineRuleIDs = []string{"MDS012", "MDS032", "MDS062"}
67+
68+
// TestInlineIndexEquivalence_ParityRules holds the Layer 1 inline scan to
69+
// byte-identity with goldmark for every parity inline rule, across the
70+
// parse-skip-eligible repository corpus. For each eligible file it runs each
71+
// rule once over an AST-backed File and once over a nil-AST File (which reads
72+
// the inline scan via lint.InlineBlocks) and requires the diagnostic slices
73+
// to match exactly. A divergence here means the scanner produced a different
74+
// inline node stream than goldmark — the gate the plan requires the scanner
75+
// to clear before it can ship.
76+
func TestInlineIndexEquivalence_ParityRules(t *testing.T) {
77+
root := repoRoot(t)
78+
files := collectMarkdownCorpus(t, root)
79+
require.NotEmpty(t, files)
80+
81+
var checked int
82+
for _, path := range files {
83+
source, err := os.ReadFile(path)
84+
require.NoError(t, err)
85+
_, body := lint.StripFrontMatter(source)
86+
87+
if lint.SourceMayHaveCodeBlock(body) || bytes.Contains(body, []byte("<?")) {
88+
continue
89+
}
90+
checked++
91+
92+
rel, _ := filepath.Rel(root, path)
93+
t.Run(rel, func(t *testing.T) {
94+
astFile, err := lint.NewFile(path, body)
95+
require.NoError(t, err)
96+
l0File := lint.NewFileLines(path, body)
97+
98+
for _, id := range parityInlineRuleIDs {
99+
r := rule.ByID(id)
100+
require.NotNil(t, r, "rule %s not registered", id)
101+
assert.Equal(t, r.Check(astFile), r.Check(l0File),
102+
"%s diagnostics differ between AST and inline scan", id)
103+
}
104+
})
105+
}
106+
require.NotZero(t, checked, "expected at least one parse-skip-eligible corpus file")
107+
}
108+
109+
// inlineNodeRec is a flat, comparable projection of the inline AST fields the
110+
// parity rules read: the kind, a Text node's segment bounds and line-break /
111+
// raw flags, and a link's or image's destination and title. Two trees that
112+
// agree on the ordered slice of these records produce identical diagnostics
113+
// for every parity inline rule, so the slice is the byte-identity oracle.
114+
type inlineNodeRec struct {
115+
kind string
116+
start, stop int
117+
dest, title string
118+
soft, hard, raw bool
119+
}
120+
121+
// collectInlineNodeRecs walks n in document order and records every Text,
122+
// Link, Image, AutoLink, and CodeSpan node. base maps a Text node's
123+
// run-local segment offsets to document-absolute offsets.
124+
func collectInlineNodeRecs(n ast.Node, base int, out *[]inlineNodeRec) {
125+
switch x := n.(type) {
126+
case *ast.Text:
127+
*out = append(*out, inlineNodeRec{
128+
kind: "Text", start: base + x.Segment.Start, stop: base + x.Segment.Stop,
129+
soft: x.SoftLineBreak(), hard: x.HardLineBreak(), raw: x.IsRaw(),
130+
})
131+
case *ast.Link:
132+
*out = append(*out, inlineNodeRec{kind: "Link", dest: string(x.Destination), title: string(x.Title)})
133+
case *ast.Image:
134+
*out = append(*out, inlineNodeRec{kind: "Image", dest: string(x.Destination), title: string(x.Title)})
135+
case *ast.AutoLink:
136+
*out = append(*out, inlineNodeRec{kind: "AutoLink"})
137+
case *ast.CodeSpan:
138+
*out = append(*out, inlineNodeRec{kind: "CodeSpan"})
139+
}
140+
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
141+
collectInlineNodeRecs(c, base, out)
142+
}
143+
}
144+
145+
// TestInlineIndexEquivalence_NodeStream is the deepest equivalence gate: for
146+
// every parse-skip-eligible corpus file it compares the full inline node
147+
// stream produced on the nil-AST path (lint.InlineBlocks — which uses the
148+
// byte scanner, falling back to goldmark per run) against the goldmark
149+
// whole-document parse, node by node. It catches divergences the
150+
// per-rule diagnostic gate cannot see (a Text split or destination that no
151+
// enabled rule happens to observe), so the scanner cannot ship a different
152+
// inline tree than goldmark even on a construct no current rule reads.
153+
func TestInlineIndexEquivalence_NodeStream(t *testing.T) {
154+
root := repoRoot(t)
155+
files := collectMarkdownCorpus(t, root)
156+
require.NotEmpty(t, files)
157+
158+
var checked int
159+
for _, path := range files {
160+
source, err := os.ReadFile(path)
161+
require.NoError(t, err)
162+
_, body := lint.StripFrontMatter(source)
163+
164+
if lint.SourceMayHaveCodeBlock(body) || bytes.Contains(body, []byte("<?")) {
165+
continue
166+
}
167+
checked++
168+
169+
rel, _ := filepath.Rel(root, path)
170+
t.Run(rel, func(t *testing.T) {
171+
astFile, err := lint.NewFile(path, body)
172+
require.NoError(t, err)
173+
l0File := lint.NewFileLines(path, body)
174+
175+
var got, want []inlineNodeRec
176+
for _, blk := range lint.InlineBlocks(l0File) {
177+
collectInlineNodeRecs(blk.Node, blk.Offset, &got)
178+
}
179+
collectInlineNodeRecs(astFile.AST, 0, &want)
180+
assert.Equal(t, want, got, "inline node stream differs between AST and scan")
181+
})
182+
}
183+
require.NotZero(t, checked, "expected at least one parse-skip-eligible corpus file")
184+
}

internal/lint/inline_blocks.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func scanInlineBlocks(f *File) []InlineBlock {
138138
start := f.LineStartOffset(runStart)
139139
end := f.lineEndOffset(i - 1)
140140
out = append(out, InlineBlock{
141-
Node: parseInlineWithRefsArena(f.Source[start:end], refs, a),
141+
Node: inlineRunNode(f.Source[start:end], refs, a),
142142
Offset: start,
143143
})
144144
}
@@ -182,6 +182,25 @@ func (f *File) trailingEmptyLine(i int) bool {
182182
return i == len(f.Lines)-1 && len(f.Lines[i]) == 0
183183
}
184184

185+
// inlineRunNode returns the inline node tree for one inline-bearing run. It
186+
// first tries the Layer 1 byte scanner (scanInlineRun), which reconstructs
187+
// the run's inline nodes without any goldmark parse; the scanner succeeds for
188+
// single-paragraph runs whose only inline constructs are plain text, inline
189+
// links, inline images, autolinks, and code spans. When the scanner declines
190+
// (a block marker, emphasis, reference link, raw HTML, backslash escape, or
191+
// any shape it does not reproduce byte-identically), it falls back to the
192+
// goldmark parse so the result stays identical to the whole-document parse.
193+
// The per-run fallback keeps the equivalence gate green by construction: a
194+
// run the scanner cannot prove identical is parsed by goldmark exactly as
195+
// before. Reference definitions are only needed on the fallback path (the
196+
// scanner does not resolve reference links), so refs is forwarded there.
197+
func inlineRunNode(run []byte, refs []Reference, a *arena.Arena) ast.Node {
198+
if node, ok := scanInlineRun(run, a); ok {
199+
return node
200+
}
201+
return parseInlineWithRefsArena(run, refs, a)
202+
}
203+
185204
// parseInlineWithRefsArena parses block as a standalone Markdown document
186205
// with the given link reference definitions pre-seeded into the parse
187206
// context, so a reference-style link or image in block resolves against a

0 commit comments

Comments
 (0)