-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrule.go
More file actions
2612 lines (2432 loc) · 87.5 KB
/
Copy pathrule.go
File metadata and controls
2612 lines (2432 loc) · 87.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package requiredstructure
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"github.com/bmatcuk/doublestar/v4"
"github.com/jeduden/mdsmith/internal/bytelimit"
"github.com/jeduden/mdsmith/internal/fieldinterp"
"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/oscompat"
"github.com/jeduden/mdsmith/internal/piparser"
"github.com/jeduden/mdsmith/internal/placeholders"
"github.com/jeduden/mdsmith/internal/rule"
"github.com/jeduden/mdsmith/internal/rules/astutil"
rulesettings "github.com/jeduden/mdsmith/internal/rules/settings"
"github.com/jeduden/mdsmith/internal/schema"
"github.com/jeduden/mdsmith/internal/yamlutil"
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
"gopkg.in/yaml.v3"
)
func init() {
rule.Register(&Rule{})
}
// Rule checks that a document's heading structure matches a schema.
//
// A rule instance carries an ordered list of schema sources (Sources)
// — one per layer (kind, override, or top-level rule entry) that
// declared a `schema:` (file) or `inline-schema:` (inline map). The
// rule loads each source at Check time and composes them via
// schema.Compose; a file resolving to multiple kinds therefore layers
// each kind's constraints rather than letting the last one win.
//
// Schema and InlineSchema mirror the first source's parsed form when
// exactly one source is present. They support tests that drive the
// rule directly through ApplySettings with the legacy single-source
// keys; the kind-level loader still rejects configurations that set
// both keys on the same layer.
type Rule struct {
Schema string // first source's file path (single-source convenience)
InlineSchema *schema.Schema // first source's parsed inline schema
Sources []SchemaSource // ordered list of schema sources (canonical)
Placeholders []string // placeholder tokens to treat as opaque
PathPatterns []PathPattern // kind-level path-pattern entries
}
// SchemaSource is one entry in the rule's schema-sources list. Either
// File or Inline is set, never both. Inline schemas are pre-parsed at
// ApplySettings time so a malformed schema surfaces as a config-load
// error rather than a per-file diagnostic at Check time. File sources
// stay as paths because the rule reads them through the lint.File's
// RootFS at Check time.
type SchemaSource struct {
File string
Inline *schema.Schema
}
// PathPattern records a kind's `path-pattern:` constraint: the kind
// that declared it and the glob the workspace-relative path of every
// file in the kind must match. Populated by the config merge layer
// from KindBody.PathPattern.
type PathPattern struct {
Kind string
Pattern string
}
// ID implements rule.Rule.
func (r *Rule) ID() string { return "MDS020" }
// Name implements rule.Rule.
func (r *Rule) Name() string { return "required-structure" }
// WordlistTarget implements rule.WordlistConsumer: resolved `lists:`
// entries union into this rule's "placeholders" setting.
func (r *Rule) WordlistTarget() string { return "placeholders" }
var _ rule.WordlistConsumer = (*Rule)(nil)
// Category implements rule.Rule.
func (r *Rule) Category() string { return "structural" }
// ApplySettings implements rule.Configurable.
//
// Three input shapes are accepted, all collapsed into Sources:
//
// - `schema-sources` (canonical, set by the merge layer): a list of
// {file: path} / {inline: map} entries in source order.
// - `schema` (legacy single-source): a file path; equivalent to a
// one-entry schema-sources list.
// - `inline-schema` (legacy single-source): a YAML map; equivalent
// to a one-entry inline schema-sources list.
//
// When called via the merge layer the rule sees only schema-sources;
// the legacy keys are retained for tests and direct callers. Mixing
// `schema` and `inline-schema` in the same settings call is rejected
// as before — the merge layer only produces schema-sources, so the
// guard fires only on hand-authored configs.
func (r *Rule) ApplySettings(settings map[string]any) error {
if err := rejectDualSchemaSettings(settings); err != nil {
return err
}
r.Sources = nil
for k, v := range settings {
if err := r.applySetting(k, v); err != nil {
return err
}
}
return nil
}
func (r *Rule) applySetting(key string, value any) error {
switch key {
case "schema":
return r.applySchemaSetting(value)
case "inline-schema":
return r.applyInlineSchemaSetting(value)
case "schema-sources":
return r.applySchemaSourcesSetting(value)
case "placeholders":
return r.applyPlaceholdersSetting(value)
case "path-patterns":
pp, err := parsePathPatterns(value)
if err != nil {
return fmt.Errorf("required-structure: %w", err)
}
r.PathPatterns = pp
return nil
case "archetype", "archetype-roots":
return fmt.Errorf(
"required-structure: setting %q has been removed; "+
"use `schema:` with an explicit path, or declare a kind "+
"under `kinds:` — see docs/guides/file-kinds.md", key)
default:
return fmt.Errorf("required-structure: unknown setting %q", key)
}
}
func (r *Rule) applySchemaSetting(v any) error {
s, ok := v.(string)
if !ok {
return fmt.Errorf("required-structure: schema must be a string, got %T", v)
}
if s == "" {
return nil
}
if isLikelyArchetypeName(s) {
return fmt.Errorf(
"required-structure: schema %q looks like a bare name; "+
"name-based lookup has been removed — set `schema:` to "+
"an explicit path (e.g. schemas/%s.md), or declare a "+
"kind under `kinds:` — see docs/guides/file-kinds.md", s, s)
}
r.Schema = s
r.Sources = append(r.Sources, SchemaSource{File: s})
return nil
}
func (r *Rule) applyInlineSchemaSetting(v any) error {
m, ok := v.(map[string]any)
if !ok {
return fmt.Errorf(
"required-structure: inline-schema must be a mapping, got %T", v)
}
if len(m) == 0 {
return nil
}
sch, err := schema.ParseInline(m, "inline kind schema")
if err != nil {
return fmt.Errorf("required-structure: invalid inline-schema: %w", err)
}
r.InlineSchema = sch
r.Sources = append(r.Sources, SchemaSource{Inline: sch})
return nil
}
func (r *Rule) applySchemaSourcesSetting(v any) error {
sources, err := parseSchemaSources(v)
if err != nil {
return fmt.Errorf("required-structure: %w", err)
}
r.Sources = append(r.Sources, sources...)
r.reflectSingleSource()
return nil
}
func (r *Rule) applyPlaceholdersSetting(v any) error {
toks, ok := rulesettings.ToStringSlice(v)
if !ok {
return fmt.Errorf(
"required-structure: placeholders must be a list of strings, got %T", v,
)
}
if err := placeholders.Validate(toks); err != nil {
return fmt.Errorf("required-structure: %w", err)
}
r.Placeholders = toks
return nil
}
// reflectSingleSource keeps Schema and InlineSchema in sync with
// Sources when exactly one source is configured. Multi-source
// configs leave both as their previous values — callers must read
// Sources to enumerate the list.
func (r *Rule) reflectSingleSource() {
if len(r.Sources) != 1 {
return
}
switch {
case r.Sources[0].File != "":
r.Schema = r.Sources[0].File
r.InlineSchema = nil
case r.Sources[0].Inline != nil:
r.InlineSchema = r.Sources[0].Inline
r.Schema = ""
}
}
// parseSchemaSources reads the `schema-sources` rule setting: a list
// of `{file: path}` / `{inline: map}` entries installed by the merge
// layer. Inline maps are parsed eagerly so a malformed schema fails
// at config-load time rather than per file at Check time.
func parseSchemaSources(v any) ([]SchemaSource, error) {
list, ok := v.([]any)
if !ok {
return nil, fmt.Errorf(
"schema-sources must be a list of {file|inline} entries, got %T", v)
}
out := make([]SchemaSource, 0, len(list))
for i, item := range list {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("schema-sources[%d]: entry must be a map, got %T", i, item)
}
filePath, hasFile := m["file"]
inlineV, hasInline := m["inline"]
if hasFile && hasInline {
return nil, fmt.Errorf(
"schema-sources[%d]: entry may set only one of `file` or `inline`", i)
}
switch {
case hasFile:
fp, ok := filePath.(string)
if !ok || fp == "" {
return nil, fmt.Errorf(
"schema-sources[%d].file must be a non-empty string, got %T", i, filePath)
}
out = append(out, SchemaSource{File: fp})
case hasInline:
im, ok := inlineV.(map[string]any)
if !ok || len(im) == 0 {
return nil, fmt.Errorf(
"schema-sources[%d].inline must be a non-empty mapping, got %T", i, inlineV)
}
// The merge layer (config.applyInlineSchemaSource) attaches
// the kind's defining file as `source` so the schema
// reference is navigable; fall back to the generic label
// when it is absent (e.g. a direct `inline-schema:` setting).
label := "inline kind schema"
if src, ok := m["source"].(string); ok && src != "" {
label = src
}
sch, err := schema.ParseInline(im, label)
if err != nil {
return nil, fmt.Errorf("schema-sources[%d].inline: %w", i, err)
}
out = append(out, SchemaSource{Inline: sch})
default:
return nil, fmt.Errorf(
"schema-sources[%d]: entry must set `file` or `inline`", i)
}
}
return out, nil
}
// rejectDualSchemaSettings refuses a settings map that supplies both
// `schema` (file path) and `inline-schema` (inline map). The merge
// layer clears the prior source when a later layer installs a new
// one, so the rule normally sees only one — this guard catches the
// case where a single config layer lists both.
func rejectDualSchemaSettings(settings map[string]any) error {
pathV, hasPath := settings["schema"]
mapV, hasInline := settings["inline-schema"]
if !hasPath || !hasInline {
return nil
}
path, _ := pathV.(string)
inline, _ := mapV.(map[string]any)
if path == "" || len(inline) == 0 {
return nil
}
return fmt.Errorf(
"required-structure: cannot set both `schema` (%q) and "+
"`inline-schema` on the same layer — pick one source",
path)
}
// DefaultSettings implements rule.Configurable.
func (r *Rule) DefaultSettings() map[string]any {
return map[string]any{
"schema": "",
"placeholders": []string{},
}
}
// isSchemaFile reports whether f is the rule's configured schema
// file. The compose code path uses isSchemaFileAt against an
// explicit path; this helper preserves the original single-source
// convenience for callers and tests.
func (r *Rule) isSchemaFile(f *lint.File) bool {
return r.isSchemaFileAt(f, r.Schema)
}
// isLikelyArchetypeName reports whether s looks like a bare archetype
// name (a single identifier with no path separator and no file
// extension), which is the most common migration mistake when moving
// from `archetype:` to `schema:`.
func isLikelyArchetypeName(s string) bool {
if s == "" {
return false
}
if strings.ContainsAny(s, "/\\") {
return false
}
return filepath.Ext(s) == ""
}
// SettingMergeMode implements rule.ListMerger.
func (r *Rule) SettingMergeMode(key string) rule.MergeMode {
if key == "placeholders" {
return rule.MergeAppend
}
if key == "path-patterns" {
return rule.MergeAppend
}
if key == "schema-sources" {
return rule.MergeAppend
}
return rule.MergeReplace
}
// TranslateLayerSettings implements rule.SettingsTranslator. It
// collapses one config layer's user-facing `schema:` (file path)
// or `inline-schema:` (map) keys into a single-entry
// `schema-sources` list and strips the legacy keys. Because the
// rule declares `schema-sources` as MergeAppend, layers that pass
// through deep-merge then accumulate their sources instead of
// scalar-replacing the previous layer — which is what lets a file
// resolving to several kinds compose every kind's schema
// (plan 156).
//
// Empty values (`schema: ""`, `inline-schema: {}`) are stripped
// without contributing a source, so the rule's own DefaultSettings
// `schema: ""` placeholder never pollutes the composed list. The
// input map is treated as read-only; a new map is returned only
// when a legacy key is present.
func (r *Rule) TranslateLayerSettings(settings map[string]any) map[string]any {
// A single layer that sets BOTH a non-empty `schema:` and a
// non-empty `inline-schema:` is a config error. Pass the layer
// through untouched so the keys survive deep-merge and the
// rule's own rejectDualSchemaSettings (run from ApplySettings)
// still surfaces the original error — stripping them here would
// silently drop the inline source. Cross-layer composition is
// unaffected: this only fires when one map carries both.
if hasDualSchemaSource(settings) {
return settings
}
source, hadKey := extractSchemaSourceFromSettings(settings)
if !hadKey {
return settings
}
out := cloneSettingsDeep(settings)
delete(out, "schema")
delete(out, "inline-schema")
if source != nil {
existing, _ := out["schema-sources"].([]any)
out["schema-sources"] = append(existing, source)
}
return out
}
// hasDualSchemaSource reports whether one settings map sets both a
// non-empty `schema:` path and a non-empty `inline-schema:` map.
// It mirrors rejectDualSchemaSettings' non-empty semantics so the
// translator and the rule's guard agree on what counts as a
// dual-source layer.
func hasDualSchemaSource(s map[string]any) bool {
path, _ := s["schema"].(string)
inline, _ := s["inline-schema"].(map[string]any)
return path != "" && len(inline) > 0
}
// extractSchemaSourceFromSettings inspects a settings map for a
// schema-source declaration. It returns (source, true) when either
// legacy key is present — even if the value is empty / no-op — so
// the caller strips the key; (nil, false) means no schema key
// appears at all and the settings pass through untouched.
func extractSchemaSourceFromSettings(s map[string]any) (any, bool) {
hadKey := false
if v, ok := s["schema"]; ok {
hadKey = true
if path, ok := v.(string); ok && path != "" {
return map[string]any{"file": path}, true
}
}
if v, ok := s["inline-schema"]; ok {
hadKey = true
if m, ok := v.(map[string]any); ok && len(m) > 0 {
return map[string]any{"inline": cloneSettingsDeep(m)}, true
}
}
if !hadKey {
return nil, false
}
return nil, true
}
// cloneSettingsDeep deep-copies a settings map so a translated
// layer never aliases the caller's nested maps or slices.
func cloneSettingsDeep(s map[string]any) map[string]any {
if s == nil {
return nil
}
out := make(map[string]any, len(s))
for k, v := range s {
out[k] = cloneSettingsValue(v)
}
return out
}
func cloneSettingsValue(v any) any {
switch x := v.(type) {
case map[string]any:
out := make(map[string]any, len(x))
for k, vv := range x {
out[k] = cloneSettingsValue(vv)
}
return out
case []any:
out := make([]any, len(x))
for i, e := range x {
out[i] = cloneSettingsValue(e)
}
return out
case []string:
out := make([]string, len(x))
copy(out, x)
return out
case []int:
out := make([]int, len(x))
copy(out, x)
return out
default:
return v
}
}
// parsePathPatterns reads the `path-patterns` rule setting: a list of
// {kind, pattern} maps installed by the config merge layer from each
// kind's `path-pattern:` field. The merge layer is the only documented
// producer; the parser still validates shape so a hand-written rule
// override fails loudly instead of silently dropping entries.
func parsePathPatterns(v any) ([]PathPattern, error) {
list, ok := v.([]any)
if !ok {
return nil, fmt.Errorf(
"path-patterns must be a list of {kind, pattern} maps, got %T", v)
}
out := make([]PathPattern, 0, len(list))
for i, item := range list {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf(
"path-patterns[%d] must be a map, got %T", i, item)
}
kindV, hasKind := m["kind"]
patV, hasPat := m["pattern"]
if !hasKind || !hasPat {
return nil, fmt.Errorf(
"path-patterns[%d] must set both `kind` and `pattern`", i)
}
kind, ok := kindV.(string)
if !ok || kind == "" {
return nil, fmt.Errorf(
"path-patterns[%d].kind must be a non-empty string, got %T", i, kindV)
}
pat, ok := patV.(string)
if !ok || pat == "" {
return nil, fmt.Errorf(
"path-patterns[%d].pattern must be a non-empty string, got %T", i, patV)
}
// Validate the pattern as a doublestar glob at config time
// so an unmatched bracket or other syntax error surfaces as
// a config error instead of an MDS020 diagnostic on every
// file assigned to the kind.
if !doublestar.ValidatePattern(filepath.ToSlash(pat)) {
return nil, fmt.Errorf(
"path-patterns[%d].pattern %q is not a valid doublestar glob",
i, pat)
}
out = append(out, PathPattern{Kind: kind, Pattern: pat})
}
return out, nil
}
// Check implements rule.Rule.
func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
var diags []lint.Diagnostic
// Warn when <?require?> appears in a non-schema file.
if reqLine := findRequireDirectiveLine(f); reqLine > 0 {
if !r.isAnySchemaFile(f) {
d := makeDiag(f.Path, reqLine,
"<?require?> is only recognized in schema files; this directive has no effect here")
d.Severity = lint.Warning
diags = append(diags, d)
}
}
// Kind-level path-pattern constraints run independently of the
// schema source: a kind may declare `path-pattern:` without an
// attached schema, and a schema-bearing kind may add a pattern
// on top of a `<?require filename:?>` directive.
diags = append(diags, r.checkPathPatterns(f)...)
sources := r.effectiveSources()
if len(sources) == 0 {
return diags
}
// Single-source: use the legacy paths so file schemas keep their
// heading- and body-sync features (`# {id}: {name}`, body lines
// under Meta-Information). Composition is irrelevant when only
// one source is configured.
//
// Exception: when the file source declares `extends:`, route
// through the multi-source compose path so plan-135 inheritance
// applies. The legacy parser does not implement extends; without
// this re-route the parent's constraints would silently drop.
// Body-sync still runs through the per-source bodySyncDiagnostics
// helper inside checkComposedSources, so the child's `{field}`
// template features survive the switch.
if len(sources) == 1 {
src := sources[0]
if src.Inline != nil {
return append(diags, r.checkSingleInlineSchema(f, src.Inline)...)
}
if src.File != "" {
return append(diags, r.dispatchSingleFileSchema(f, src.File, sources)...)
}
return diags
}
return append(diags, r.checkComposedSources(f, sources)...)
}
// dispatchSingleFileSchema loads the schema once, peeks at the
// front matter for the reserved `extends:` key, and routes to the
// legacy single-file path or the compose path accordingly. Loading
// here avoids the double read the previous helper introduced: the
// legacy path reuses the bytes via checkSingleFileSchemaFromData,
// and the compose path re-loads through schema.ParseFile only when
// extends actually applies.
func (r *Rule) dispatchSingleFileSchema(
f *lint.File, schemaPath string, sources []SchemaSource,
) []lint.Diagnostic {
data, schPath, err := r.loadSchemaAt(f, schemaPath)
if err != nil {
return []lint.Diagnostic{r.diag(f.Path, 1, err.Error())}
}
if schemaDataDeclaresExtends(data) {
return r.checkComposedSources(f, sources)
}
return r.checkSingleFileSchemaFromData(f, schemaPath, data, schPath)
}
// schemaDataDeclaresExtends reports whether the raw schema bytes
// carry a reserved `extends:` key in their YAML front matter. The
// modern `schema.ParseFile` pipeline rejects malformed `extends:`
// values (non-string, empty/whitespace string) with clear errors;
// routing here on any present-and-non-null `extends:` lets those
// errors surface to the user instead of being silently swallowed
// by the legacy parser. An explicit YAML `null` matches the
// no-extends case so legacy diagnostics line up.
//
// A parse failure or missing front matter returns false — the
// legacy parser then surfaces those errors with its existing
// diagnostic shape.
func schemaDataDeclaresExtends(data []byte) bool {
prefix, _ := lint.StripFrontMatter(data)
if prefix == nil {
return false
}
yamlBytes := extractYAML(prefix)
var raw map[string]any
if err := yamlutil.UnmarshalSafe(yamlBytes, &raw); err != nil {
return false
}
v, ok := raw["extends"]
if !ok {
return false
}
// Explicit YAML `null` matches schema.ParseFile's no-extends
// treatment; stay on the legacy path so diagnostics align.
// Every other value (non-empty string, malformed string,
// non-string type) routes to the compose path so the modern
// parser can surface its specific error.
return v != nil
}
// effectiveSources returns the rule's sources list, falling back to
// a single-entry list built from the legacy Schema / InlineSchema
// fields when Sources is empty. This lets tests drive the rule
// directly with the older fields while still routing through the
// new multi-source code path.
func (r *Rule) effectiveSources() []SchemaSource {
if len(r.Sources) > 0 {
return r.Sources
}
if r.InlineSchema != nil && !r.InlineSchema.IsEmpty() {
return []SchemaSource{{Inline: r.InlineSchema}}
}
if r.Schema != "" {
return []SchemaSource{{File: r.Schema}}
}
return nil
}
// isAnySchemaFile reports whether f matches any of the configured
// file sources. When a file plays the role of its own schema (e.g.
// rule-readme's proto.md), the warning-on-misplaced-<?require?>
// check must skip it.
func (r *Rule) isAnySchemaFile(f *lint.File) bool {
for _, src := range r.effectiveSources() {
if src.File == "" {
continue
}
if r.isSchemaFileAt(f, src.File) {
return true
}
}
return false
}
// checkSingleInlineSchema runs the validator against a single inline
// schema. Inline schemas do not support frontmatter-body {field}
// sync (no source body content) so the legacy syncPoints code path
// is skipped.
func (r *Rule) checkSingleInlineSchema(f *lint.File, sch *schema.Schema) []lint.Diagnostic {
diags := make([]lint.Diagnostic, 0, 8)
docFMRaw, fmDiags := readDocFrontMatterRaw(f)
diags = append(diags, fmDiags...)
fmIsCUE := placeholders.HasCUEFrontmatter(r.Placeholders)
diags = append(diags, schema.Validate(f, sch, docFMRaw, fmIsCUE, makeDiag)...)
diags = append(diags, r.applyScopeRules(f, sch, docFMRaw)...)
diags = append(diags, schema.ValidateCrossReferences(f, sch, makeDiag)...)
diags = append(diags, schema.ValidateAcronyms(f, sch, docFMRaw, makeDiag)...)
diags = append(diags, schema.ValidateIndex(f, sch, makeDiag)...)
return diags
}
// Fix implements rule.FixableRule. For single file-based schemas it
// rewrites body lines whose {field} template matches but whose value
// disagrees with the document's front matter (body-sync fix). For
// any configured inline schema (single-source or composed across
// kinds) that declares an `index:` block, Fix also emits the JSON
// side-output next to the source file. `mdsmith check` skips the
// write, preserving check's read-only contract (plan 143).
//
// Fix swallows errors (composition and WriteIndex both). WriteIndex
// itself records any I/O failure in the package-level cache keyed
// by f.Path; the next Check reads that cache and surfaces the
// underlying error in place of the generic "missing / out of date"
// message, so users are not trapped in a fix loop without signal.
// Composition errors are similarly swallowed — they re-surface on
// the next Check pass through the same checkComposedSources path.
func (r *Rule) Fix(f *lint.File) []byte {
sch, err := r.composedSchemaForFix(f)
if err == nil && sch != nil && !sch.IsEmpty() && sch.Index != nil {
_ = schema.WriteIndex(f, sch)
}
sources := r.effectiveSources()
if len(sources) == 1 && sources[0].File != "" && !r.isSchemaFileAt(f, sources[0].File) {
schData, schPath, loadErr := r.loadSchemaAt(f, sources[0].File)
if loadErr == nil {
parsedSch, parseErr := cachedParseSchema(f, schData, schPath)
if parseErr == nil {
docFMRaw, _ := readDocFrontMatterRaw(f)
return fixBodySyncIn(f, parsedSch, docFMRaw)
}
}
}
return f.Source
}
// fixBodySyncIn rewrites body lines whose {field} template matches but
// whose resolved front-matter value disagrees with the document text.
// It returns f.Source unchanged when no lines need rewriting.
func fixBodySyncIn(f *lint.File, sch *parsedSchema, docFM map[string]any) []byte {
if len(docFM) == 0 || len(sch.SyncPoints) == 0 {
return f.Source
}
docHeadings := extractHeadings(f)
work := make([][]byte, len(f.Lines))
copy(work, f.Lines)
modified := false
docIdx := 0
for schIdx, req := range sch.Headings {
if isSectionWildcard(req) {
continue
}
syncs := sch.SyncPoints[schIdx]
if len(syncs) == 0 {
_, docIdx = advanceToMatch(req, docHeadings, docIdx)
continue
}
matchedDoc, newIdx := advanceToMatch(req, docHeadings, docIdx)
docIdx = newIdx
if matchedDoc < 0 {
continue
}
dh := docHeadings[matchedDoc]
startLine := dh.Line + 1
endLine := len(f.Lines)
if matchedDoc+1 < len(docHeadings) {
endLine = docHeadings[matchedDoc+1].Line - 1
}
for _, sp := range syncs {
if patchedLine, ok := resolveBodySyncLine(sp, docFM, work, startLine, endLine); ok {
work[patchedLine.idx] = patchedLine.val
modified = true
}
}
}
if !modified {
return f.Source
}
return bytes.Join(work, []byte("\n"))
}
// patchedLine carries the index and new value for a line that needs rewriting.
type patchedLine struct {
idx int
val []byte
}
// resolveBodySyncLine returns the line index and replacement bytes for sp
// if the document contains a stale template-match line in [startLine, endLine).
// ok is false when sp is not a body sync point, the field is missing, the
// line already matches, or no template-matching line is found.
func resolveBodySyncLine(
sp syncPoint, docFM map[string]any,
work [][]byte, startLine, endLine int,
) (patchedLine, bool) {
if !sp.InBody {
return patchedLine{}, false
}
path := fieldinterp.ParseCUEPath(sp.Field)
if path == nil {
return patchedLine{}, false
}
if _, err := fieldinterp.ResolvePath(docFM, path); err != nil {
return patchedLine{}, false
}
// Convert expected once so per-line comparisons and the eventual
// replacement both work from the same []byte, no string() cast.
expectedBytes := []byte(resolveFields(sp.BodyText, docFM))
re := sp.compiled
for i := startLine - 1; i < endLine && i < len(work); i++ {
trimmed := bytes.TrimSpace(work[i])
if bytes.Equal(trimmed, expectedBytes) {
continue // already correct; keep scanning for stale duplicates
}
if re.Match(trimmed) {
leadLen := len(work[i]) - len(bytes.TrimLeft(work[i], " \t"))
val := make([]byte, leadLen, leadLen+len(expectedBytes))
copy(val, work[i][:leadLen])
val = append(val, expectedBytes...)
return patchedLine{idx: i, val: val}, true
}
}
return patchedLine{}, false
}
// buildSchemaHeading constructs a schemaHeading from a docHeading,
// pre-compiling the field-interpolation regex when the text contains
// {field} references so matchesSchema pays no per-call compile cost.
// Pattern construction is delegated to buildFieldPattern, which owns
// the QuoteMeta+".+" logic and its MustCompile invariant. cache is
// forwarded to buildFieldPattern; see its doc comment.
func buildSchemaHeading(h docHeading, cache *fieldPatternCache) schemaHeading {
sh := schemaHeading{Level: h.Level, Text: h.Text}
if fieldinterp.ContainsField(h.Text) {
sh.compiled = buildFieldPattern(h.Text, cache)
}
return sh
}
// composedSchemaForFix returns the same composed *schema.Schema
// that checkComposedSources validates against — but without
// running validation or body-sync (Fix doesn't need either).
// Returns nil with no error when the rule has no schema source or
// every source is empty / self-referential. A file source pointing
// at the file currently being fixed is skipped so a schema
// doesn't drive its own index side-output.
func (r *Rule) composedSchemaForFix(f *lint.File) (*schema.Schema, error) {
sources := r.effectiveSources()
if len(sources) == 0 {
return nil, nil
}
parsed := make([]*schema.Schema, 0, len(sources))
for _, src := range sources {
if src.Inline != nil {
if src.Inline.IsEmpty() {
continue
}
parsed = append(parsed, src.Inline)
continue
}
if src.File == "" {
continue
}
sch, err := r.parseFileSchemaForCompose(f, src.File)
if err != nil {
return nil, err
}
if sch == nil {
continue
}
parsed = append(parsed, sch)
}
if len(parsed) == 0 {
return nil, nil
}
return schema.Compose(parsed...)
}
// ComposedSchema parses and composes every schema source the rule
// resolved for f and returns the composed schema, or nil when the
// rule has no schema source. Exposed for the `extract` subcommand,
// which projects the same composed schema MDS020 validates against;
// it reuses composedSchemaForFix so the two paths cannot drift.
func (r *Rule) ComposedSchema(f *lint.File) (*schema.Schema, error) {
return r.composedSchemaForFix(f)
}
// checkSingleFileSchemaFromData runs the legacy validation with a
// pre-loaded schema buffer. The Check dispatch reads the schema
// once and routes the bytes here when extends is not declared, so
// the common single-source path avoids a second read.
func (r *Rule) checkSingleFileSchemaFromData(
f *lint.File, schemaPath string, schData []byte, schPath string,
) []lint.Diagnostic {
var diags []lint.Diagnostic
sch, err := cachedParseSchema(f, schData, schPath)
if err != nil {
return append(diags, r.diag(f.Path, 1,
fmt.Sprintf("invalid schema %q: %v", schemaPath, err)))
}
// Skip the schema file itself when schemas come from disk.
if r.isSchemaFileAt(f, schemaPath) {
return diags
}
docHeadings := extractHeadings(f)
docFMRaw, fmDiags := readDocFrontMatterRaw(f)
diags = append(diags, fmDiags...)
// Check filename pattern.
diags = append(diags, checkFilenamePattern(f, sch, r.Schema)...)
// Check structure: required headings present and in order.
diags = append(diags, checkStructure(f, sch, docHeadings, r.Schema)...)
// Validate document front matter against schema-embedded CUE constraints,
// unless the cue-frontmatter placeholder token is configured (which marks
// the front-matter values as CUE expressions rather than concrete data).
if !placeholders.HasCUEFrontmatter(r.Placeholders) {
fmSch := &schema.Schema{
Frontmatter: sch.Config.Frontmatter,
FrontmatterLines: sch.Config.FrontmatterLines,
FrontmatterMeta: sch.Config.FrontmatterMeta,
Source: r.Schema,
}
diags = append(diags, schema.ValidateFrontmatterDiags(f, fmSch, docFMRaw, makeDiag)...)
}
// Check frontmatter-body sync using raw map for nested access.
diags = append(diags, checkSync(f, sch, docHeadings, docFMRaw)...)
return diags
}
// checkComposedSources loads every source, composes them via
// schema.Compose, and validates the document against the composed
// schema. Each FILE source ALSO runs the legacy heading- and
// body-sync check (proto.md `# {id}: {name}` and Meta-Information
// body lines) — composition cannot express those checks today, so
// per-source legacy validation preserves them. Sources that name a
// file the rule is currently linting are skipped (self-validation).
func (r *Rule) checkComposedSources(f *lint.File, sources []SchemaSource) []lint.Diagnostic {
var diags []lint.Diagnostic
docFMRaw, fmDiags := readDocFrontMatterRaw(f)
diags = append(diags, fmDiags...)
parsed := make([]*schema.Schema, 0, len(sources))
for _, src := range sources {
if src.Inline != nil {
if src.Inline.IsEmpty() {
continue
}
parsed = append(parsed, src.Inline)
continue
}
if src.File == "" {
continue
}
// Per-source legacy body-sync. Loads via the legacy parser so
// proto.md-style {field} interpolation and Meta-Information
// body sync still fire for each file source.
if !r.isSchemaFileAt(f, src.File) {
diags = append(diags, r.bodySyncDiagnostics(f, src.File, docFMRaw)...)
}
sch, err := r.parseFileSchemaForCompose(f, src.File)
if err != nil {
diags = append(diags, r.diag(f.Path, 1, err.Error()))
continue
}
if sch == nil {
// f was the schema itself — skip its composition entry.
continue
}
parsed = append(parsed, sch)
}
if len(parsed) == 0 {
return diags
}
composed, err := schema.Compose(parsed...)
if err != nil {
return append(diags, r.diag(f.Path, 1,
fmt.Sprintf("composing schemas: %v", err)))
}
// composed is non-nil here: parsed contains at least one
// non-nil schema (the empty-source filter above guarantees it),
// and schema.Compose returns its single input unchanged when
// len(parsed) == 1. IsEmpty() can still hold when every parsed
// schema was itself empty (e.g. a proto.md with no headings).
if composed.IsEmpty() {
return diags
}
fmIsCUE := placeholders.HasCUEFrontmatter(r.Placeholders)
diags = append(diags, schema.Validate(f, composed, docFMRaw, fmIsCUE, makeDiag)...)
diags = append(diags, r.applyScopeRules(f, composed, docFMRaw)...)
diags = append(diags, schema.ValidateCrossReferences(f, composed, makeDiag)...)
diags = append(diags, schema.ValidateAcronyms(f, composed, docFMRaw, makeDiag)...)
diags = append(diags, schema.ValidateIndex(f, composed, makeDiag)...)
return diags
}
// parseFileSchemaForCompose loads a proto.md file source via the
// unified schema.ParseFile parser so the result composes with inline
// schemas. Returns (nil, nil) when f is the schema file itself —
// the caller skips that entry so a schema doesn't validate against
// itself.
func (r *Rule) parseFileSchemaForCompose(f *lint.File, schemaPath string) (*schema.Schema, error) {
if r.isSchemaFileAt(f, schemaPath) {
return nil, nil
}
reader := &schema.FileReader{
RootFS: f.RootFS,
RootDir: f.RootDir,
MaxBytes: f.MaxInputBytes,
}
sch, err := schema.ParseFile(reader, schemaPath)
if err != nil {
return nil, fmt.Errorf("cannot load schema %q: %v", schemaPath, err)
}
return sch, nil
}
// bodySyncDiagnostics runs only the heading- and body-sync portion
// of the legacy file-schema check for a single file source. The
// composed structure validation runs separately; calling
// checkSingleFileSchema here would double-report missing-section
// and frontmatter-CUE diagnostics.
func (r *Rule) bodySyncDiagnostics(f *lint.File, schemaPath string, docFMRaw map[string]any) []lint.Diagnostic {