@@ -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+ }
0 commit comments