Skip to content

Commit 81cc3f5

Browse files
author
merge-queue-bot
committed
Merge PR #225: Centralize YAML handling with safe unmarshal wrappers
2 parents 2b749c4 + 7535ba1 commit 81cc3f5

15 files changed

Lines changed: 285 additions & 188 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ footer: |
3333
| 114 || sonnet | [MDS034 message clarity and flavor-vs-rule docs](plan/114_mds034-message-and-flavor-vs-rule-docs.md) |
3434
| 120 | 🔲 | sonnet | [Unify glob matcher and field naming across mdsmith](plan/120_glob-unification.md) |
3535
| 121 | 🔲 | opus | [Expose mdsmith to VS Code via Language Server Protocol](plan/121_vscode-integration.md) |
36-
| 121 | 🔲 | sonnet | [Review and centralize YAML handling](plan/121_yaml-handling-review.md) |
36+
| 121 | | sonnet | [Review and centralize YAML handling](plan/121_yaml-handling-review.md) |
3737
| 122 | 🔲 | sonnet | [VS Code hover help and palette commands](plan/122_vscode-hover-and-palette.md) |
3838
| 124 | 🔲 | sonnet | [No space inside code spans rule](plan/124_no-space-in-code-spans.md) |
3939
| 125 | 🔲 | sonnet | [No space inside link text rule](plan/125_no-space-in-link-text.md) |

cmd/mdsmith/main.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"runtime/debug"
1010

1111
flag "github.com/spf13/pflag"
12-
"gopkg.in/yaml.v3"
1312

1413
"github.com/jeduden/mdsmith/internal/concepts"
1514
"github.com/jeduden/mdsmith/internal/config"
@@ -22,6 +21,7 @@ import (
2221
"github.com/jeduden/mdsmith/internal/query"
2322
"github.com/jeduden/mdsmith/internal/rule"
2423
ruledocs "github.com/jeduden/mdsmith/internal/rules"
24+
"github.com/jeduden/mdsmith/internal/yamlutil"
2525

2626
// Import all rule packages so their init() functions register rules.
2727
_ "github.com/jeduden/mdsmith/internal/rules/ambiguousemphasis"
@@ -415,11 +415,8 @@ func readFrontMatterRaw(path string, maxBytes int64) (map[string]any, error) {
415415
delim := []byte("---\n")
416416
yamlBytes := prefix[len(delim) : len(prefix)-len(delim)]
417417

418-
if err := lint.RejectYAMLAliases(yamlBytes); err != nil {
419-
return nil, fmt.Errorf("parsing front matter: %w", err)
420-
}
421418
var raw map[string]any
422-
if err := yaml.Unmarshal(yamlBytes, &raw); err != nil {
419+
if err := yamlutil.UnmarshalSafe(yamlBytes, &raw); err != nil {
423420
return nil, fmt.Errorf("parsing front matter: %w", err)
424421
}
425422
// Distinguish empty front matter (---\n---\n) from absent front matter.
@@ -463,7 +460,7 @@ func runInit(args []string) int {
463460
fm := true
464461
cfg.FrontMatter = &fm
465462

466-
data, err := yaml.Marshal(cfg)
463+
data, err := yamlutil.Marshal(cfg)
467464
if err != nil {
468465
fmt.Fprintf(os.Stderr, "mdsmith: marshalling config: %v\n", err)
469466
return 2

internal/archetype/gensection/parse.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77

88
"github.com/jeduden/mdsmith/internal/fieldinterp"
99
"github.com/jeduden/mdsmith/internal/lint"
10-
"gopkg.in/yaml.v3"
10+
"github.com/jeduden/mdsmith/internal/yamlutil"
1111
)
1212

1313
// MarkerPair holds the line numbers and parsed content of a start/end marker pair.
@@ -219,13 +219,7 @@ func ParseYAMLBody(
219219
) (map[string]any, []lint.Diagnostic) {
220220
var rawMap map[string]any
221221
if mp.YAMLBody != "" {
222-
if err := lint.RejectYAMLAliases([]byte(mp.YAMLBody)); err != nil {
223-
return nil, []lint.Diagnostic{
224-
MakeDiag(ruleID, ruleName, filePath, mp.StartLine,
225-
fmt.Sprintf("generated section YAML: %v", err)),
226-
}
227-
}
228-
if err := yaml.Unmarshal([]byte(mp.YAMLBody), &rawMap); err != nil {
222+
if err := yamlutil.UnmarshalSafe([]byte(mp.YAMLBody), &rawMap); err != nil {
229223
return nil, []lint.Diagnostic{
230224
MakeDiag(ruleID, ruleName, filePath, mp.StartLine,
231225
fmt.Sprintf("generated section has invalid YAML: %v", err)),

internal/config/convention.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ func copyConventionPreset(p map[string]RuleCfg) map[string]RuleCfg {
9797
// clean type error. Inspecting the raw node tag is the only way to
9898
// catch the type mismatch before that coercion happens.
9999
func validateConventionScalar(data []byte) error {
100+
// yaml.Unmarshal into yaml.Node does not expand aliases, so this
101+
// is safe without an alias-rejection pre-check. Errors are swallowed
102+
// because Load's subsequent UnmarshalSafe call will surface them.
100103
var node yaml.Node
101104
if err := yaml.Unmarshal(data, &node); err != nil {
102-
// A YAML parse error is reported by Load's yaml.Unmarshal
103-
// call already; do not double-report.
104105
return nil
105106
}
106107
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {

internal/config/load.go

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"os"
77
"path/filepath"
88

9-
"github.com/jeduden/mdsmith/internal/lint"
109
"github.com/jeduden/mdsmith/internal/rule"
10+
"github.com/jeduden/mdsmith/internal/yamlutil"
1111
"gopkg.in/yaml.v3"
1212
)
1313

@@ -25,18 +25,14 @@ func Load(path string) (*Config, error) {
2525
return nil, fmt.Errorf("reading config file: %w", err)
2626
}
2727

28-
if err := lint.RejectYAMLAliases(data); err != nil {
29-
return nil, fmt.Errorf("parsing config file: %w", err)
30-
}
31-
32-
// Catch non-string `convention:` values before yaml.Unmarshal
28+
// Catch non-string `convention:` values before UnmarshalSafe
3329
// silently coerces them into the string field.
3430
if err := validateConventionScalar(data); err != nil {
3531
return nil, fmt.Errorf("parsing config file: %w", err)
3632
}
3733

3834
var cfg Config
39-
if err := yaml.Unmarshal(data, &cfg); err != nil {
35+
if err := yamlutil.UnmarshalSafe(data, &cfg); err != nil {
4036
return nil, fmt.Errorf("parsing config file: %w", err)
4137
}
4238

@@ -81,11 +77,8 @@ func Load(path string) (*Config, error) {
8177
// present in data, or an empty set on parse error. It rejects
8278
// anchor/alias usage for the same reason yamlHasKey does.
8379
func topLevelKeySet(data []byte) map[string]bool {
84-
if err := lint.RejectYAMLAliases(data); err != nil {
85-
return map[string]bool{}
86-
}
87-
var node yaml.Node
88-
if err := yaml.Unmarshal(data, &node); err != nil {
80+
node, err := yamlutil.UnmarshalNodeSafe(data)
81+
if err != nil {
8982
return map[string]bool{}
9083
}
9184
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {

internal/corpus/config.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import (
77
"strings"
88
"time"
99

10-
"github.com/jeduden/mdsmith/internal/lint"
11-
"gopkg.in/yaml.v3"
10+
"github.com/jeduden/mdsmith/internal/yamlutil"
1211
)
1312

1413
const (
@@ -32,11 +31,8 @@ func LoadConfig(path string) (*Config, error) {
3231
return nil, fmt.Errorf("read config: %w", err)
3332
}
3433

35-
if err := lint.RejectYAMLAliases(content); err != nil {
36-
return nil, fmt.Errorf("parse config yaml: %w", err)
37-
}
3834
var cfg Config
39-
if err := yaml.Unmarshal(content, &cfg); err != nil {
35+
if err := yamlutil.UnmarshalSafe(content, &cfg); err != nil {
4036
return nil, fmt.Errorf("parse config yaml: %w", err)
4137
}
4238
applyConfigDefaults(&cfg)
@@ -82,11 +78,8 @@ func mergeLocalOverrides(configPath string, cfg *Config) error {
8278
return fmt.Errorf("read local override config: %w", err)
8379
}
8480

85-
if err := lint.RejectYAMLAliases(content); err != nil {
86-
return fmt.Errorf("parse local override config: %w", err)
87-
}
8881
var local localOverrideConfig
89-
if err := yaml.Unmarshal(content, &local); err != nil {
82+
if err := yamlutil.UnmarshalSafe(content, &local); err != nil {
9083
return fmt.Errorf("parse local override config: %w", err)
9184
}
9285

internal/kindsout/kindsout.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"strings"
1313

1414
"github.com/jeduden/mdsmith/internal/config"
15-
"gopkg.in/yaml.v3"
15+
"github.com/jeduden/mdsmith/internal/yamlutil"
1616
)
1717

1818
// --- JSON shapes ---
@@ -202,7 +202,7 @@ func WriteBodyText(w io.Writer, name string, body config.KindBody) error {
202202
Rules: body.Rules,
203203
Categories: body.Categories,
204204
}
205-
data, err := yaml.Marshal(wrap)
205+
data, err := yamlutil.Marshal(wrap)
206206
if err != nil {
207207
return err
208208
}

internal/lint/frontmatter.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package lint
33
import (
44
"bytes"
55

6-
"gopkg.in/yaml.v3"
6+
"github.com/jeduden/mdsmith/internal/yamlutil"
77
)
88

99
// StripFrontMatter removes YAML front matter delimited by "---\n"
@@ -47,14 +47,10 @@ func ParseFrontMatterKinds(fm []byte) ([]string, error) {
4747
return nil, nil
4848
}
4949

50-
if err := RejectYAMLAliases(body); err != nil {
51-
return nil, err
52-
}
53-
5450
var parsed struct {
5551
Kinds []string `yaml:"kinds"`
5652
}
57-
if err := yaml.Unmarshal(body, &parsed); err != nil {
53+
if err := yamlutil.UnmarshalSafe(body, &parsed); err != nil {
5854
return nil, err
5955
}
6056
return parsed.Kinds, nil

internal/lint/yamlsafe.go

Lines changed: 0 additions & 55 deletions
This file was deleted.

internal/lint/yamlsafe_test.go

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,71 +7,27 @@ import (
77
"github.com/stretchr/testify/require"
88
)
99

10-
var yamlAliasTests = []struct {
11-
name string
12-
input string
13-
wantErr bool
14-
}{
15-
{"clean YAML", "title: Hello\nauthor: World\n", false},
16-
{"anchor definition", "base: &base\n name: foo\n", true},
17-
{"alias reference", "child:\n <<: *base\n", true},
18-
{"ampersand in quoted string", "title: \"Q&A Session\"\n", false},
19-
{"ampersand in single quoted string", "title: 'Q&A'\n", false},
20-
{"asterisk in quoted string", "note: \"use *bold* text\"\n", false},
21-
{"ampersand in unquoted value", "title: Q&A\n", false},
22-
{"billion laughs chain", "a: &a [\"lol\"]\nb: &b [*a,*a]\nc: &c [*b,*b]\n", true},
23-
{"empty input", "", false},
24-
{"asterisk not followed by identifier", "note: 5 * 3 = 15\n", false},
25-
{"anchor at start of line", "&anchor value\n", true},
26-
{"alias at start of value", "key: *alias\n", true},
27-
{"block scalar with ampersand", "key: |\n &name in block\n", false},
28-
{"block scalar with asterisk", "key: >\n *name in folded\n", false},
29-
{"comment with ampersand", "key: val # &anchor\n", false},
30-
{"comment with asterisk", "key: val # *alias\n", false},
31-
{"escaped quote in double string", "key: \"she said \\\"&hello\\\"\"\n", false},
32-
{"doubled single quote", "key: 'it''s &here'\n", false},
33-
{"syntax error ignored", "key: [unclosed\n", false},
34-
{"multi-document clean", "title: a\n---\ntitle: b\n", false},
35-
}
36-
37-
func TestRejectYAMLAliases(t *testing.T) {
38-
for _, tt := range yamlAliasTests {
39-
t.Run(tt.name, func(t *testing.T) {
40-
err := RejectYAMLAliases([]byte(tt.input))
41-
if tt.wantErr {
42-
require.Error(t, err)
43-
assert.Contains(t, err.Error(), "anchors/aliases are not permitted")
44-
} else {
45-
assert.NoError(t, err)
46-
}
47-
})
48-
}
49-
}
50-
10+
// TestRejectYAMLAliases_FrontMatter verifies that ParseFrontMatterKinds
11+
// rejects YAML anchors/aliases via the yamlutil safe-unmarshal path.
5112
func TestRejectYAMLAliases_FrontMatter(t *testing.T) {
5213
t.Run("anchor in front matter is rejected", func(t *testing.T) {
53-
doc := []byte("---\na: &a [\"lol\"]\nb: &b [*a,*a]\nc: &c [*b,*b]\n---\n# Title\n")
14+
doc := []byte("---\na: &a [\"lol\"]\nb: &b [*a,*a]\nc: &c [*b,*b]\nkinds: [doc]\n---\n# Title\n")
5415
prefix, content := StripFrontMatter(doc)
5516
require.NotNil(t, prefix)
5617
assert.Contains(t, string(content), "# Title")
5718

58-
delim := []byte("---\n")
59-
yamlBytes := prefix[len(delim) : len(prefix)-len(delim)]
60-
61-
err := RejectYAMLAliases(yamlBytes)
19+
_, err := ParseFrontMatterKinds(prefix)
6220
require.Error(t, err)
6321
assert.Contains(t, err.Error(), "anchors/aliases are not permitted")
6422
})
6523

6624
t.Run("clean front matter is accepted", func(t *testing.T) {
67-
doc := []byte("---\ntitle: \"Q&A Guide\"\nstatus: draft\n---\n# Title\n")
25+
doc := []byte("---\ntitle: \"Q&A Guide\"\nkinds: [doc]\n---\n# Title\n")
6826
prefix, _ := StripFrontMatter(doc)
6927
require.NotNil(t, prefix)
7028

71-
delim := []byte("---\n")
72-
yamlBytes := prefix[len(delim) : len(prefix)-len(delim)]
73-
74-
err := RejectYAMLAliases(yamlBytes)
75-
assert.NoError(t, err)
29+
kinds, err := ParseFrontMatterKinds(prefix)
30+
require.NoError(t, err)
31+
assert.Equal(t, []string{"doc"}, kinds)
7632
})
7733
}

0 commit comments

Comments
 (0)