Skip to content

Commit 4a45a0b

Browse files
committed
Address recall-mode review findings (14 of 15)
Acts on every concrete finding from the local max-effort review pass except #5 (linkRefResetter interface duplication), which I'm keeping local: exposing it from pkg/markdown would surface a goldmark fork detail on the public surface for less duplication value than it costs. Bugs - fix.go (#1): MDS034 Fix was calling flavor.NewPooledParser() per invocation — same regression I fixed in flavor.Detect last commit, mirrored at the parallel Fix call site. Move the pool to package level in flavor and expose a callback API, flavor.WithSharedParser, used by both Detect's dualFindings and the rule's fixByteRangeFeatures. - rule.go fixGitHubAlerts (#7): the type assertion bq.FirstChild().(*ast.Paragraph) plus lines.At(0) was relying on a cross-package contract with flavor.IsGitHubAlert. Re-check the shape locally so a future relax of IsGitHubAlert cannot turn the walk into a panic. - fix.go taskCheckBoxEdits (#9): nil-check the flavor.NearestBlockAncestor return and the block's Lines() before calling At(0). - detect.go (#11, #15): IsGitHubAlert nil-guards bq and the paragraph's Lines.Len(); findHeadingID nil-guards h. Both are public-API entry points now. - rule.go ApplySettings (#12): iterate settings keys in sorted order so the error for multiple unknown settings is deterministic across Go map randomisation. Simplifications - detect.go (#2): drop the taskCheckBoxFinding specialisation; the TaskCheckBox case in builtinFindingFor calls inlineExtFinding directly. - detect.go (#3): promote isGitHubAlert / lineCol / nearestBlockAncestor to the exported names. The previous private + one-line public-wrapper pair was duplication; the lowercase versions are now gone. - detect.go dualFindings (#4): return nil rather than an always-allocated empty slice on no findings (CLAUDE.md allocation-budget rule). - parser.go (#6): drop NewParser and NewParserWith — they were one-line wrappers around the pooled forms. The public surface is now NewPooledParser, NewPooledParserWith, and WithSharedParser. - contract_test.go (#13): move signature pins to package-scope `var _ = ...` declarations so the staticcheck "could omit type" rule does not fight the explicit-type contract. Reuse - pkg/markdown.Edit + Splice (#8): added an optional `Repl []byte` field to Edit; Splice now supports replacement in addition to deletion. The rule's bespoke `edit` struct and `applyEdits` are gone; fix.go composes a []markdown.Edit and feeds it through markdown.Splice. Adjacent-edits-with-Repl behaviour is now pinned in pkg/markdown's TestSplice. - lint.UnmarshalFrontMatter (#14): extracted the StripFrontMatter → trim `---\n` delimiters → yamlutil.UnmarshalSafe pipeline into one helper in internal/lint/frontmatter.go. internal/integration's rules_test.go switched to it, dropping its open-coded copy and the goldmark-frontmatter import. Test pyramid - Added unit tests for IsGitHubAlert's nil/empty-Lines branches, FindHeadingID's nil-heading branch, and TestWithSharedParser for the new pool callback. Coverage in pkg/markdown/flavor is 100%. - TestApplyEditsHandlesAdjacentEdits moved into pkg/markdown's TestSplice as a sub-test covering the new Repl behaviour. All tests pass, golangci-lint clean, mdsmith check clean. https://claude.ai/code/session_0144ZKUS2Zrg7xBft54qyoti
1 parent 97c992c commit 4a45a0b

13 files changed

Lines changed: 372 additions & 280 deletions

File tree

docs/development/markdown-library.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,11 @@ The stable surface:
230230
`Flavor` type and constants, the `Feature`
231231
type and constants, `AllFeatures`, `Supports`,
232232
`ParseFlavor`, the `Finding` and
233-
`HeadingIDExtra` shapes, `Detect`, the four
234-
`NewParser*` / `NewPooledParser*`
235-
constructors, and the small rewriter helpers
233+
`HeadingIDExtra` shapes, `Detect`, the
234+
`NewPooledParser` / `NewPooledParserWith`
235+
constructors, the `WithSharedParser` callback
236+
for borrowing from the package-shared pool,
237+
and the small rewriter helpers
236238
(`FindHeadingID`, `IsGitHubAlert`, `LineCol`,
237239
`NearestBlockAncestor`).
238240
- Sub-package `pkg/markdown/flavor/ext`: the

internal/integration/rules_test.go

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111

1212
"github.com/jeduden/mdsmith/internal/lint"
1313
"github.com/jeduden/mdsmith/internal/rule"
14-
"github.com/jeduden/mdsmith/internal/yamlutil"
1514

1615
_ "github.com/jeduden/mdsmith/internal/rules/ambiguousemphasis"
1716
_ "github.com/jeduden/mdsmith/internal/rules/atxheadingwhitespace"
@@ -114,23 +113,17 @@ func parseFixtureFrontMatter(
114113
) (map[string]any, []expectedDiag, []byte) {
115114
t.Helper()
116115

117-
prefix, content := lint.StripFrontMatter(data)
118-
if prefix == nil {
119-
require.False(t, requireDiagnostics, "bad fixture is missing front matter with expected diagnostics")
120-
return nil, nil, data
121-
}
122-
123-
// lint.StripFrontMatter returns the prefix including its --- fences;
124-
// trim them so the remainder is plain YAML.
125-
delim := []byte("---\n")
126-
body := bytes.TrimPrefix(prefix, delim)
127-
body = bytes.TrimSuffix(body, delim)
128-
129116
var fm fixtureFrontMatter
130-
if err := yamlutil.UnmarshalSafe(body, &fm); err != nil {
117+
content, err := lint.UnmarshalFrontMatter(data, &fm)
118+
if err != nil {
131119
t.Fatalf("decoding front matter: %v", err)
132120
}
133-
121+
if fm.Diagnostics == nil && fm.Settings == nil {
122+
// lint.UnmarshalFrontMatter returns the original bytes when no
123+
// front matter exists; treat that as "no diagnostics declared".
124+
require.False(t, requireDiagnostics, "bad fixture is missing front matter with expected diagnostics")
125+
return nil, nil, content
126+
}
134127
if requireDiagnostics && len(fm.Diagnostics) == 0 {
135128
t.Fatal("bad fixture front matter must contain a non-empty diagnostics key")
136129
}

internal/lint/frontmatter.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ func StripFrontMatter(source []byte) (prefix, content []byte) {
1717
return markdown.StripFrontMatter(source)
1818
}
1919

20+
// UnmarshalFrontMatter strips the leading YAML front matter block off
21+
// source, decodes it into v via yamlutil.UnmarshalSafe, and returns
22+
// the body with the block removed. body equals source when there is
23+
// no front matter; in that case v is left untouched and err is nil.
24+
// Centralises the "---\n" delimiter trim that several call sites
25+
// were repeating after StripFrontMatter.
26+
func UnmarshalFrontMatter(source []byte, v any) (body []byte, err error) {
27+
prefix, content := markdown.StripFrontMatter(source)
28+
if prefix == nil {
29+
return content, nil
30+
}
31+
delim := []byte("---\n")
32+
yamlBody := bytes.TrimPrefix(prefix, delim)
33+
yamlBody = bytes.TrimSuffix(yamlBody, delim)
34+
if err := yamlutil.UnmarshalSafe(yamlBody, v); err != nil {
35+
return content, err
36+
}
37+
return content, nil
38+
}
39+
2040
// CountLines returns the number of newline-terminated lines in b,
2141
// forwarded from pkg/markdown.
2242
func CountLines(b []byte) int {

internal/rules/markdownflavor/fix.go

Lines changed: 42 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/yuin/goldmark/ast"
77
extast "github.com/yuin/goldmark/extension/ast"
8+
gparser "github.com/yuin/goldmark/parser"
89
"github.com/yuin/goldmark/text"
910

1011
"github.com/jeduden/mdsmith/internal/lint"
@@ -13,32 +14,24 @@ import (
1314
"github.com/jeduden/mdsmith/pkg/markdown/flavor/ext"
1415
)
1516

16-
// edit describes a single byte-range substitution to apply to source.
17-
// applyEdits assumes non-overlapping spans and rewrites the buffer in
18-
// one pass.
19-
type edit struct {
20-
start, end int
21-
repl []byte
22-
}
23-
2417
// fixByteRangeFeatures collects edits for the six byte-range features
2518
// (heading IDs, strikethrough, task lists, superscript, subscript, and
2619
// bare-URL autolinks) and returns the rewritten source. Features that
27-
// the configured flavor accepts are skipped. The function returns the
28-
// source unchanged when no edit applies.
20+
// the configured flavor accepts are skipped. Returns f.Source unchanged
21+
// when no edit applies.
2922
func (r *Rule) fixByteRangeFeatures(f *lint.File) []byte {
30-
var edits []edit
23+
var edits []markdown.Edit
3124

3225
if r.needsAnyDualFix() {
33-
dualParser, reset := flavor.NewPooledParser()
34-
defer reset()
35-
doc := dualParser.Parse(text.NewReader(f.Source))
36-
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
37-
if !entering {
26+
flavor.WithSharedParser(func(p gparser.Parser) {
27+
doc := p.Parse(text.NewReader(f.Source))
28+
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
29+
if !entering {
30+
return ast.WalkContinue, nil
31+
}
32+
edits = append(edits, r.dualNodeEdits(f, n)...)
3833
return ast.WalkContinue, nil
39-
}
40-
edits = append(edits, r.dualNodeEdits(f, n)...)
41-
return ast.WalkContinue, nil
34+
})
4235
})
4336
}
4437

@@ -55,7 +48,14 @@ func (r *Rule) fixByteRangeFeatures(f *lint.File) []byte {
5548
if len(edits) == 0 {
5649
return f.Source
5750
}
58-
return applyEdits(f.Source, edits)
51+
// markdown.Splice expects ascending, non-overlapping edits; the
52+
// detection layer never produces overlapping fixes, but a dual-AST
53+
// walk and a bare-URL pass merged here can land out of source order,
54+
// so sort before handing off.
55+
sort.SliceStable(edits, func(i, j int) bool {
56+
return edits[i].Start < edits[j].Start
57+
})
58+
return markdown.Splice(f.Source, edits)
5959
}
6060

6161
// needsAnyDualFix reports whether any dual-parser fixable feature is
@@ -78,7 +78,7 @@ func (r *Rule) needsAnyDualFix() bool {
7878
// dualNodeEdits returns the edits to remove an unsupported feature
7979
// produced from a dual-parser AST node. Returns nil when the node is
8080
// either supported or not a fixable feature.
81-
func (r *Rule) dualNodeEdits(f *lint.File, n ast.Node) []edit {
81+
func (r *Rule) dualNodeEdits(f *lint.File, n ast.Node) []markdown.Edit {
8282
switch node := n.(type) {
8383
case *ast.Heading:
8484
if flavor.Supports(r.Flavor, flavor.FeatureHeadingIDs) {
@@ -112,7 +112,7 @@ func (r *Rule) dualNodeEdits(f *lint.File, n ast.Node) []edit {
112112
// headingIDEdits returns the edit that drops a "{#id}" attribute block
113113
// plus any whitespace separating it from the heading text. Returns nil
114114
// when the heading carries no id attribute.
115-
func headingIDEdits(f *lint.File, h *ast.Heading) []edit {
115+
func headingIDEdits(f *lint.File, h *ast.Heading) []markdown.Edit {
116116
hx, ok := flavor.FindHeadingID(f.Source, h)
117117
if !ok {
118118
return nil
@@ -121,7 +121,7 @@ func headingIDEdits(f *lint.File, h *ast.Heading) []edit {
121121
for start > 0 && (f.Source[start-1] == ' ' || f.Source[start-1] == '\t') {
122122
start--
123123
}
124-
return []edit{{start: start, end: hx.AttrEnd}}
124+
return []markdown.Edit{{Start: start, End: hx.AttrEnd}}
125125
}
126126

127127
// delimiterPairEdits returns edits removing the opening and closing
@@ -131,62 +131,48 @@ func headingIDEdits(f *lint.File, h *ast.Heading) []edit {
131131
// markup like `~~*bold*~~` or a softbreak inside the wrapper, where
132132
// reconstructing each child's own marker span is brittle. The fix
133133
// declines and the diagnostic remains for the user to resolve.
134-
func delimiterPairEdits(n ast.Node, markerLen int) []edit {
134+
func delimiterPairEdits(n ast.Node, markerLen int) []markdown.Edit {
135135
t, ok := n.FirstChild().(*ast.Text)
136136
if !ok || t.NextSibling() != nil {
137137
return nil
138138
}
139-
return []edit{
140-
{start: t.Segment.Start - markerLen, end: t.Segment.Start},
141-
{start: t.Segment.Stop, end: t.Segment.Stop + markerLen},
139+
return []markdown.Edit{
140+
{Start: t.Segment.Start - markerLen, End: t.Segment.Start},
141+
{Start: t.Segment.Stop, End: t.Segment.Stop + markerLen},
142142
}
143143
}
144144

145145
// taskCheckBoxEdits removes the "[X]" run plus a single trailing
146146
// space when present. Per the plan, the bullet itself is preserved.
147147
// The dual parser places every TaskCheckBox at the start of a
148-
// TextBlock so block.Lines().At(0).Start always points at '['.
149-
func taskCheckBoxEdits(f *lint.File, n *extast.TaskCheckBox) []edit {
148+
// TextBlock so block.Lines().At(0).Start always points at '['; we
149+
// still guard against a nil block ancestor (degenerate orphan node)
150+
// and an empty Lines list so a malformed AST cannot panic the fix.
151+
func taskCheckBoxEdits(f *lint.File, n *extast.TaskCheckBox) []markdown.Edit {
150152
block := flavor.NearestBlockAncestor(n)
151-
start := block.Lines().At(0).Start
153+
if block == nil {
154+
return nil
155+
}
156+
lines := block.Lines()
157+
if lines == nil || lines.Len() == 0 {
158+
return nil
159+
}
160+
start := lines.At(0).Start
152161
end := start + 3
153162
if end < len(f.Source) && f.Source[end] == ' ' {
154163
end++
155164
}
156-
return []edit{{start: start, end: end}}
165+
return []markdown.Edit{{Start: start, End: end}}
157166
}
158167

159168
// wrapBareURL wraps a bare URL in angle brackets so the renderer
160169
// treats it as a CommonMark autolink. The detector reports a precise
161170
// span via fin.Start / fin.End.
162-
func wrapBareURL(source []byte, fin flavor.Finding) edit {
171+
func wrapBareURL(source []byte, fin flavor.Finding) markdown.Edit {
163172
url := source[fin.Start:fin.End]
164173
repl := make([]byte, 0, len(url)+2)
165174
repl = append(repl, '<')
166175
repl = append(repl, url...)
167176
repl = append(repl, '>')
168-
return edit{start: fin.Start, end: fin.End, repl: repl}
169-
}
170-
171-
// applyEdits rewrites src by appending unchanged spans and replacement
172-
// bytes in a single pass. Edits are sorted by ascending start offset;
173-
// the detection layer never produces overlapping edits for the
174-
// features we fix, so applyEdits assumes non-overlapping spans.
175-
func applyEdits(src []byte, edits []edit) []byte {
176-
sort.SliceStable(edits, func(i, j int) bool {
177-
return edits[i].start < edits[j].start
178-
})
179-
size := len(src)
180-
for _, e := range edits {
181-
size += len(e.repl) - (e.end - e.start)
182-
}
183-
out := make([]byte, 0, size)
184-
cursor := 0
185-
for _, e := range edits {
186-
out = append(out, src[cursor:e.start]...)
187-
out = append(out, e.repl...)
188-
cursor = e.end
189-
}
190-
out = append(out, src[cursor:]...)
191-
return out
177+
return markdown.Edit{Start: fin.Start, End: fin.End, Repl: repl}
192178
}

internal/rules/markdownflavor/fix_test.go

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,17 +174,6 @@ func TestRuleDualNodeEditsSupportedFeaturesReturnNil(t *testing.T) {
174174
assert.Nil(t, r.dualNodeEdits(nil, &ext.SubscriptNode{}))
175175
}
176176

177-
// TestApplyEditsHandlesAdjacentEdits guards the single-pass build in
178-
// applyEdits: adjacent edits (e.g. an opening and closing delimiter
179-
// of a strikethrough) must compose into a contiguous output without
180-
// dropping or duplicating bytes between them.
181-
func TestApplyEditsHandlesAdjacentEdits(t *testing.T) {
182-
src := []byte("ab~~xy~~cd")
183-
edits := []edit{
184-
{start: 2, end: 4}, // opening "~~"
185-
{start: 6, end: 8}, // closing "~~"
186-
{start: 0, end: 0, repl: []byte("> ")}, // pure insertion at start
187-
}
188-
got := applyEdits(src, edits)
189-
assert.Equal(t, "> abxycd", string(got))
190-
}
177+
// Splice's single-pass behaviour (adjacent edits, pure insertion,
178+
// replacement bytes) is exercised in pkg/markdown's TestSplice; the
179+
// rule layer just feeds edits into markdown.Splice.

internal/rules/markdownflavor/rule.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package markdownflavor
1212
import (
1313
"bytes"
1414
"fmt"
15+
"sort"
1516
"strings"
1617

1718
"github.com/yuin/goldmark/ast"
@@ -49,9 +50,18 @@ func (r *Rule) Category() string { return "structural" }
4950
// EnabledByDefault implements rule.Defaultable. MDS034 is opt-in.
5051
func (r *Rule) EnabledByDefault() bool { return false }
5152

52-
// ApplySettings implements rule.Configurable.
53+
// ApplySettings implements rule.Configurable. Keys are processed in
54+
// sorted order so the error reported for multiple unknown settings is
55+
// deterministic across runs (Go's map iteration order is randomised,
56+
// which would otherwise produce flaky fixture goldens).
5357
func (r *Rule) ApplySettings(settings map[string]any) error {
54-
for k, v := range settings {
58+
keys := make([]string, 0, len(settings))
59+
for k := range settings {
60+
keys = append(keys, k)
61+
}
62+
sort.Strings(keys)
63+
for _, k := range keys {
64+
v := settings[k]
5565
switch k {
5666
case "flavor":
5767
s, ok := v.(string)
@@ -160,8 +170,18 @@ func (r *Rule) fixGitHubAlerts(f *lint.File) []byte {
160170
if !flavor.IsGitHubAlert(bq, f.Source) {
161171
return ast.WalkContinue, nil
162172
}
163-
para := bq.FirstChild().(*ast.Paragraph)
173+
// flavor.IsGitHubAlert validates the (Paragraph, non-empty Lines)
174+
// shape, but the assertion + At(0) couple us to that contract
175+
// across a package boundary. Re-check locally so a future relax
176+
// of IsGitHubAlert cannot turn this walk into a panic.
177+
para, ok := bq.FirstChild().(*ast.Paragraph)
178+
if !ok {
179+
return ast.WalkContinue, nil
180+
}
164181
lines := para.Lines()
182+
if lines == nil || lines.Len() == 0 {
183+
return ast.WalkContinue, nil
184+
}
165185
seg := lines.At(0)
166186
markerLine, _ := flavor.LineCol(f.Source, seg.Start)
167187
skip[markerLine] = true

0 commit comments

Comments
 (0)