-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathvalidate.go
More file actions
1728 lines (1655 loc) · 61.9 KB
/
Copy pathvalidate.go
File metadata and controls
1728 lines (1655 loc) · 61.9 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 schema
import (
"bytes"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/jeduden/mdsmith/cue/cuelite"
"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/yamlutil"
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
)
// DocHeading is a heading collected from the document under
// validation.
//
// Fields are ordered pointer-containing (string) first, then scalar
// (int) last, per docs/development/high-performance-go.md "Struct
// layout" — Go's GC ptrdata spans through the last pointer-containing
// field, so a scalar between Text and the surrounding ints would
// force that span to cover it too. ExtractDocHeadings returns a
// []DocHeading per document, so the layout affects every element the
// GC scans in that slice.
type DocHeading struct {
Text string
Level int
Line int
}
// ExtractDocHeadings walks the document AST and collects every
// heading in source order, with its source line.
func ExtractDocHeadings(f *lint.File) []DocHeading {
var out []DocHeading
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
h, ok := n.(*ast.Heading)
if !ok {
return ast.WalkContinue, nil
}
text := headingText(h, f.Source)
line := headingLine(h, f)
out = append(out, DocHeading{Level: h.Level, Text: text, Line: line})
return ast.WalkContinue, nil
})
return out
}
// headingLine returns the 1-based line number of h. Goldmark
// occasionally produces ATX headings with an empty Lines() slice;
// when that happens we walk inline descendants for the first Text
// segment, matching the fallback in internal/rules/astutil. A
// truly empty heading (no Lines, no Text descendants) reports line
// 1 so callers that filter by line windows never lose the
// heading.
func headingLine(h *ast.Heading, f *lint.File) int {
if h.Lines().Len() > 0 {
return f.LineOfOffset(h.Lines().At(0).Start)
}
line := 1
_ = ast.Walk(h, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering || n == h {
return ast.WalkContinue, nil
}
t, ok := n.(*ast.Text)
if !ok {
return ast.WalkContinue, nil
}
line = f.LineOfOffset(t.Segment.Start)
return ast.WalkStop, nil
})
return line
}
// MakeDiag is the diagnostic constructor the validator uses. Callers
// supply it so the schema package stays free of rule-ID coupling.
type MakeDiag func(file string, line int, msg string) lint.Diagnostic
// Validate walks the document AST against sch, emitting diagnostics
// for missing/extra/out-of-order sections, level mismatches,
// frontmatter that fails the schema's CUE constraints, and filename
// patterns. mkDiag builds the diagnostic with the caller's rule ID.
//
// docFM is the document's parsed front matter (nil when absent).
// When fmIsCUE is true, the front-matter values are themselves CUE
// expressions (the `cue-frontmatter` placeholder); the CUE check is
// skipped because the values are not concrete data.
func Validate(
f *lint.File, sch *Schema, docFM map[string]any, fmIsCUE bool,
mkDiag MakeDiag,
) []lint.Diagnostic {
if sch == nil || sch.IsEmpty() {
return nil
}
var diags []lint.Diagnostic
diags = append(diags, validateFilename(f, sch, mkDiag)...)
if !fmIsCUE {
diags = append(diags, validateFrontmatterDiags(f, sch, docFM, mkDiag)...)
}
rootLevel := sch.EffectiveRootLevel()
heads := ExtractDocHeadings(f)
body := skipBelow(heads, rootLevel)
_, sd := validateScopes(f, sch, sch.Sections, sch.Closed, body, 0, rootLevel, docFM, mkDiag)
diags = append(diags, sd...)
diags = append(diags, ValidateContent(f, sch, docFM, mkDiag)...)
return diags
}
// validateFrontmatterDiags compiles the schema's CUE expression,
// unifies it with the document front matter, and emits one
// diagnostic per resulting CUE error (rather than collapsing all
// of them into a single line). Each diagnostic is a
// SchemaDiagnostic rendered through Format(): the field that
// failed, the value the user wrote, the constraint they
// violated, and — when applicable — a hint.
//
// A schema-side compile or marshal failure surfaces as a single
// fallback diagnostic at line 1 so users still see a signal
// without needing to chase the underlying CUE error. The
// formerly-flat "front matter does not satisfy schema CUE
// constraints" message is intentionally retired; see plan 147.
func validateFrontmatterDiags(
f *lint.File, sch *Schema, docFM map[string]any, mkDiag MakeDiag,
) []lint.Diagnostic {
expr := sch.FrontmatterCUE()
if strings.TrimSpace(expr) == "" {
return nil
}
anchor := nonBodyDiagLine(f)
// Route the schema-side compile through RunCache.CompiledCUE when one
// is in scope so N host files sharing a schema compile its CUE source
// exactly once per Run. The cached cuelite.Value is immutable and
// context-free, so the per-file Unify below — which lifts the document's
// front matter directly via CompileMap and meets it with the shared
// schema — reads the cached value without mutating it, safe under -race
// regardless of operand order.
var cache *lint.RunCache
if f != nil {
cache = f.RunCache
}
compiled := CachedCompile(cache, expr)
schemaVal := compiled.Value
if err := compiled.Err(); err != nil {
return []lint.Diagnostic{
compileFailureDiag(sch, "schema", "valid schema CUE", err).
Emit(mkDiag, f.Path, anchor)}
}
if docFM == nil {
docFM = map[string]any{}
}
// A front-matter value the in-house lifter cannot represent (a value type
// no YAML/JSON decoder produces, e.g. a channel) is a data-shape failure,
// not a schema-constraint violation. Surface it as the dedicated
// front-matter diagnostic so the reader sees the shape problem rather than
// a confusing per-field constraint message. The JSON round-trip is gone
// (plan 218), so this replaces the former json.Marshal failure branch.
if liftErr := cuelite.LiftMap(docFM).Err(); liftErr != nil {
return []lint.Diagnostic{
compileFailureDiag(sch, "front matter", "representable front-matter values", liftErr).
Emit(mkDiag, f.Path, anchor)}
}
// Validate the front-matter map DIRECTLY against the compiled schema,
// with no json.Marshal → CompileJSON round-trip (plan 218 hot path).
// CompileMap lifts the map and unifies it with the cached schema; the
// context-free Value is shared safely across parallel workers with no
// per-file recompile and no mutation.
merged := schemaVal.CompileMap(docFM)
verr := merged.Validate()
if verr == nil {
// Skip the docFrontmatterKeyLines YAML re-parse when there
// is nothing for the deprecation walker to do — the common
// success path then pays no per-file overhead.
if len(sch.FrontmatterMeta) == 0 || len(docFM) == 0 {
return nil
}
return validateDeprecatedFieldsWithLines(
f, sch, docFM, docFrontmatterKeyLines(f), mkDiag)
}
// cuelite.Errors returns a non-empty list for any non-nil validation
// error (the documented Validate invariant), so there is no separate
// (untestable) "valid CUE" fallback; the per-error diagnostics below
// cover every reachable validation failure.
keyLines := docFrontmatterKeyLines(f)
out := dedupedCUEErrorDiags(f, sch, docFM, cuelite.Errors(verr), keyLines, mkDiag)
return append(out, validateDeprecatedFieldsWithLines(f, sch, docFM, keyLines, mkDiag)...)
}
// dedupedCUEErrorDiags maps each unique CUE error to a
// SchemaDiagnostic. A struct dedup key avoids accidental collisions
// when one of the components (notably the raw-CUE-expression
// Expected fallback and a placeholder-bearing Field) legitimately
// contains the same delimiter a flat string key would have used.
func dedupedCUEErrorDiags(
f *lint.File, sch *Schema, docFM map[string]any,
cueErrs []*cuelite.PathError, keyLines map[string]int, mkDiag MakeDiag,
) []lint.Diagnostic {
type dedupKey struct{ field, actual, expected string }
seen := make(map[dedupKey]struct{}, len(cueErrs))
out := make([]lint.Diagnostic, 0, len(cueErrs))
for _, ce := range cueErrs {
d := schemaDiagFromCUEError(sch, docFM, ce)
key := dedupKey{field: d.Field, actual: d.Actual, expected: d.Expected}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, d.Emit(mkDiag, f.Path, fmDiagLine(f, ce.Path(), keyLines)))
}
return out
}
// validateDeprecatedFieldsWithLines walks sch.FrontmatterMeta and
// emits one Warning-severity diagnostic per deprecated field that
// still appears in docFM. The keyLines argument is reused from the
// CUE error loop so the deprecation diagnostic anchors at the same
// source line as a co-occurring type-mismatch error.
//
// `replaced-by:` rides on the lint.Diagnostic so LSP clients and CI
// scripts can route the warning without scanning the message body;
// the human-facing text honours `message:` first per plan 136. The
// parser guarantees every FrontmatterMeta entry has Deprecated=true
// (ExtractFieldMeta accepts the metadata form only when the
// `deprecated: true` discriminator is present), so the walker does
// not re-check the flag.
func validateDeprecatedFieldsWithLines(
f *lint.File, sch *Schema, docFM map[string]any,
keyLines map[string]int, mkDiag MakeDiag,
) []lint.Diagnostic {
if len(sch.FrontmatterMeta) == 0 || len(docFM) == 0 {
return nil
}
// Walk in sorted key order so a schema with two deprecated
// fields and both present in the document emits diagnostics in
// a stable order on every run.
keys := make([]string, 0, len(sch.FrontmatterMeta))
for k := range sch.FrontmatterMeta {
keys = append(keys, k)
}
sort.Strings(keys)
var out []lint.Diagnostic
for _, k := range keys {
meta := sch.FrontmatterMeta[k]
bare := strings.TrimSuffix(k, "?")
if _, present := docFM[bare]; !present {
continue
}
d := SchemaDiagnostic{
Field: bare,
Deprecated: true,
ReplacedBy: meta.ReplacedBy,
DeprecationMessage: meta.Message,
SchemaRef: schemaRef(sch, k),
}
warn := d.Emit(mkDiag, f.Path, fmDiagLine(f, []string{bare}, keyLines))
warn.Severity = lint.Warning
warn.Deprecated = true
warn.ReplacedBy = meta.ReplacedBy
out = append(out, warn)
}
return out
}
// NonBodyDiagLine returns the body-coord line value that, after
// lint.File.AdjustDiagnostics adds f.LineOffset, lands on the
// absolute first line of the file (typically the opening `---`
// fence of stripped front matter). It is the canonical anchor
// for diagnostics that do not correspond to a specific body
// line — schema-level compile failures, filename pattern
// violations, and structure diagnostics for sections that are
// missing entirely.
//
// The previous "anchor at line 1" pattern landed on the first
// body line in front-matter-stripped mode, which
// checker.FilterGeneratedDiags could mistakenly drop if the
// document body started with a generated section (e.g. a
// leading <?catalog?> directive). Using `1 - LineOffset`
// produces a non-positive body-coord that FilterGeneratedDiags
// cannot match against any generated line range, and the
// engine's AdjustDiagnostics adds the offset back so the
// surfaced diagnostic still anchors at the file's first line.
//
// When f.LineOffset == 0 (the non-stripped path, used by tests
// via lint.NewFile) the return value is just 1, matching the
// previous behaviour.
func NonBodyDiagLine(f *lint.File) int {
return 1 - f.LineOffset
}
// nonBodyDiagLine is the package-internal alias used inside the
// schema package; external callers reach for NonBodyDiagLine.
func nonBodyDiagLine(f *lint.File) int {
return NonBodyDiagLine(f)
}
// MissingSectionAnchor returns the body line to anchor a
// missing-section diagnostic. candidate is the natural insertion
// point — the line of the heading the missing section should follow —
// and is used when it is a real body line (> 0 and within the file)
// outside every generated range. An out-of-range candidate (e.g. an
// insertion-point sentinel past EOF) is treated as unusable so the
// result never exceeds len(f.Lines) and can never map to an
// out-of-range editor/LSP position. A missing section has no body line
// of its own, and anchoring inside a generated section would let
// checker.FilterGeneratedDiags drop the diagnostic. When candidate is
// unusable, the non-body anchor is
// used: a non-positive value survives filtering and maps back to file
// line 1, and a positive value (nothing stripped, line 1) is fine as
// long as line 1 is not itself generated. The remaining case — a
// document that opens with a generated section so the positive non-body
// anchor sits inside it — anchors at the first body line outside every
// generated range (a positive line that both survives filtering and
// formats as a valid location), or 0 when the whole file is generated
// and no safe positive anchor exists, so the diagnostic still surfaces
// rather than being dropped or printed as file:0 (plan 230).
func MissingSectionAnchor(f *lint.File, candidate int) int {
if candidate > 0 && candidate <= len(f.Lines) && !lineInGeneratedRange(f, candidate) {
return candidate
}
fallback := NonBodyDiagLine(f)
if fallback <= 0 || !lineInGeneratedRange(f, fallback) {
return fallback
}
// fallback is a positive line (nothing stripped) inside a leading
// generated range, where FilterGeneratedDiags would drop it. Prefer
// the first body line outside every generated range; fall back to 0
// only when the whole file is generated.
if line := firstNonGeneratedLine(f); line > 0 {
return line
}
return 0
}
// firstNonGeneratedLine returns the first 1-based body line that lies
// outside every generated range, or 0 when the file is empty or
// entirely generated (no such line exists).
func firstNonGeneratedLine(f *lint.File) int {
for line := 1; line <= len(f.Lines); line++ {
if !lineInGeneratedRange(f, line) {
return line
}
}
return 0
}
// precedingHeadingLine returns the line of the document heading just
// before docIdx — the section a missing scope should follow — or 0
// when there is no preceding heading.
func precedingHeadingLine(docHeads []DocHeading, docIdx int) int {
if idx := docIdx - 1; idx >= 0 && idx < len(docHeads) {
return docHeads[idx].Line
}
return 0
}
// lineInGeneratedRange reports whether the 1-based body line falls
// within any of the file's generated-section ranges.
func lineInGeneratedRange(f *lint.File, line int) bool {
for _, r := range f.GeneratedRanges {
if r.Contains(line) {
return true
}
}
return false
}
// fmDiagLine returns the line to anchor a front-matter diagnostic
// at, expressed in the body-line coordinate system the engine
// uses before lint.File.AdjustDiagnostics fires. When the doc's
// FM has a tracked source line for the offending key, the line
// is the file-relative line of that key minus f.LineOffset; the
// engine's AdjustDiagnostics shift then lands the diagnostic on
// the absolute file line of the key.
//
// In front-matter-stripped mode (f.LineOffset > 0) the body-
// coordinate value is non-positive for keys inside the FM block.
// That is intentional: AdjustDiagnostics adds LineOffset back to
// reach the absolute line, which is positive. Callers that
// consume Diagnostic.Line without running it through the engine
// must normalise the value first (see ValidateFrontmatterDiags
// for the contract). In unstripped mode (LineOffset == 0) the
// returned value already equals the absolute file line.
//
// When no per-key line is known the function falls back to
// nonBodyDiagLine(f) — a non-positive body coordinate in
// stripped mode that AdjustDiagnostics resolves to the first
// absolute line of the file. The fallback used to be a flat
// "1", which landed on the first body line in stripped mode
// and could be silently dropped by FilterGeneratedDiags when
// the document body started with a generated section
// (PR #284 Copilot review).
func fmDiagLine(f *lint.File, path []string, keyLines map[string]int) int {
if len(path) == 0 || len(keyLines) == 0 {
return nonBodyDiagLine(f)
}
line, ok := keyLines[path[0]]
if !ok {
// Top-level path may carry an optional-key suffix in the
// schema; the doc itself never does, so a miss here means
// the key was absent from the document (and therefore has
// no source line to point at).
return nonBodyDiagLine(f)
}
return line - f.LineOffset
}
// docFrontmatterKeyLines parses the document's front matter and
// returns the file-relative line of each top-level key. Works in
// both front-matter-stripped mode (FM lives in f.FrontMatter) and
// unstripped mode (FM still at the top of f.Source) so the same
// helper serves the LSP / CLI path and the rule's unit-test
// callers that build files via lint.NewFile directly.
func docFrontmatterKeyLines(f *lint.File) map[string]int {
if fm := f.FrontMatter; len(fm) > 0 {
return parseFMBlockKeyLines(fm)
}
if bytes.HasPrefix(f.Source, []byte("---\n")) {
if fm, _ := lint.StripFrontMatter(f.Source); fm != nil {
return parseFMBlockKeyLines(fm)
}
}
return nil
}
// parseFMBlockKeyLines extracts top-level key lines from a YAML
// front-matter block that includes its `---` delimiters. yaml.Node
// line numbers are 1-based within the body between the fences; the
// file-relative line is one more (the opening `---` occupies the
// first source line).
//
// The closing fence is removed via TrimSuffix rather than a
// bytes.Index scan: callers feed us a block returned by
// lint.StripFrontMatter which always ends with `---\n`, and a
// scan-for-first-match would truncate the body early if a YAML
// block scalar legitimately contained the `---\n` sequence as a
// value.
func parseFMBlockKeyLines(fm []byte) map[string]int {
body := bytes.TrimPrefix(fm, []byte("---\n"))
body = bytes.TrimSuffix(body, []byte("---\n"))
if len(bytes.TrimSpace(body)) == 0 {
return nil
}
node, err := yamlutil.UnmarshalNodeSafe(body)
if err != nil {
return nil
}
return yamlutil.TopLevelMappingLines(&node, 1)
}
// schemaDiagFromCUEError converts one CUE-error leaf into a
// SchemaDiagnostic. The CUE error's Path() names the offending
// field; we look up the raw constraint expression on the schema
// to render an "expected" string in user vocabulary, and pull
// the actual value out of docFM so the message shows exactly
// what the user wrote.
//
// Precision note: lookupConstraint and schemaRef both resolve
// against path[0] (the top-level frontmatter key the schema's
// Frontmatter map indexes by). For nested CUE errors — e.g.
// `meta.owner` against a schema whose `meta:` value is itself
// a struct constraint — Field still shows the full dotted
// path so the reader can locate the failing leaf, but Expected
// renders the parent constraint (the top-level CUE expression)
// rather than the leaf. Today every shipped schema in
// mdsmith uses single-segment frontmatter constraints, so
// this asymmetry is hypothetical; option (a) from the PR #284
// review (CUE LookupPath on ce.Path() for leaf-level
// resolution) is the natural follow-up if nested frontmatter
// constraints land later.
func schemaDiagFromCUEError(
sch *Schema, docFM map[string]any, ce *cuelite.PathError,
) SchemaDiagnostic {
path := ce.Path()
field := "front matter"
if len(path) > 0 {
field = strings.Join(path, ".")
}
d := SchemaDiagnostic{
Field: field,
SchemaRef: schemaRef(sch, schemaKeyForPath(sch, path)),
}
actualVal, hasActual := lookupFM(docFM, path)
if hasActual {
d.Actual = formatActual(actualVal)
}
if expr := lookupConstraint(sch, path); expr != "" {
d.Expected = RenderExpected(expr)
if hasActual {
d.Hint = RenderHint(expr, actualVal)
} else {
// A required field that the document omitted. Show
// the same <missing> sentinel structure diagnostics
// use so every diagnostic answers the same three
// questions: which field, what value, what's
// expected.
d.Actual = "<missing>"
}
} else {
// Extra field: close() rejected a key that is not in the
// schema's frontmatter map. There is no per-field
// constraint to render, and the schema source already
// names the declared set; the diagnostic body says so
// explicitly so the reader can compare against the
// schema file. <extra field> is the actual-slot sentinel
// for the (rare) case where the key has no value to show
// (e.g. an empty mapping entry).
if !hasActual {
d.Actual = "<extra field>"
}
d.Expected = "not declared in schema"
}
return d
}
// schemaKeyForPath finds the Frontmatter map key (with the
// optional "?" suffix preserved) that owns the given CUE error
// path. The required-key form "x" and optional-key form "x?"
// produce identical CUE paths, so we accept either when looking
// up the constraint and the source line.
func schemaKeyForPath(sch *Schema, path []string) string {
if len(path) == 0 {
return ""
}
first := path[0]
if _, ok := sch.Frontmatter[first]; ok {
return first
}
if _, ok := sch.Frontmatter[first+"?"]; ok {
return first + "?"
}
return ""
}
func lookupConstraint(sch *Schema, path []string) string {
if key := schemaKeyForPath(sch, path); key != "" {
return sch.Frontmatter[key]
}
return ""
}
// lookupFM walks docFM along path and returns the leaf value.
// The boolean reports whether the path resolved; a present-but-
// nil value still reports true so the diagnostic can show
// "null" rather than "<missing>".
//
// path segments come from CUE's error.Path() and may be mixed
// map keys and numeric list indices (e.g. "tags", "1" for a
// failing element of a list-shaped field). lookupFM understands
// both: it descends through `map[string]any` by key and through
// `[]any` by parsing the segment as a non-negative integer.
// Falling back to <missing> on a list-shaped path would
// misreport "field present but a particular index failed" as
// "field absent" — see the Copilot review comment on PR #284.
func lookupFM(docFM map[string]any, path []string) (any, bool) {
if len(path) == 0 {
return nil, false
}
cur := any(docFM)
for _, p := range path {
switch typed := cur.(type) {
case map[string]any:
v, ok := typed[p]
if !ok {
return nil, false
}
cur = v
case []any:
idx, err := strconv.Atoi(p)
if err != nil || idx < 0 || idx >= len(typed) {
return nil, false
}
cur = typed[idx]
default:
return nil, false
}
}
return cur, true
}
// schemaRef builds the `schema: ...` suffix from the schema's
// Source label and (when known) the line of the named key. An
// unknown source falls back to a generic "schema" label so
// every diagnostic still carries a reference field.
func schemaRef(sch *Schema, key string) string {
src := sch.Source
if src == "" {
src = "schema"
}
if key != "" {
if line, ok := sch.FrontmatterLines[key]; ok && line > 0 {
return fmt.Sprintf("%s:%d", src, line)
}
}
return src
}
// compileFailureDiag builds a SchemaDiagnostic for the
// early-return CUE / JSON failure paths in
// validateFrontmatterDiags. The `Field` names what failed to
// process ("schema", "front matter"), `expected` carries the
// shape-specific contract (e.g. "valid schema CUE",
// "JSON-marshalable front matter"), the `Actual` carries the
// underlying error message, and the schema reference points the
// reader at the source so the diagnostic stays consistent with
// the rest of MDS020's output. Threading `expected` through the
// helper keeps each early-return path's message accurate —
// using a single generic phrase would have made the
// json.Marshal failure path read as "Expected: compilable CUE"
// even though CUE isn't involved at that step.
func compileFailureDiag(sch *Schema, field, expected string, err error) SchemaDiagnostic {
return SchemaDiagnostic{
Field: field,
Actual: fmt.Sprintf("%v", err),
Expected: expected,
SchemaRef: schemaRef(sch, ""),
}
}
// ValidateFrontmatterDiags exposes the per-error CUE-diagnostic
// walker to callers outside the validator (notably the
// requiredstructure rule's legacy file-schema path, which has
// its own heading-template parser but reuses the schema
// package's actionable front-matter diagnostics).
//
// Line numbers on returned diagnostics are in the engine's
// body-coordinate system: they are the absolute file line of
// the offending front-matter key minus f.LineOffset. In
// front-matter-stripped mode (f.LineOffset > 0) the body-
// coordinate value is non-positive for keys inside the FM
// block; lint.File.AdjustDiagnostics then shifts it back into
// the absolute file line. Callers that bypass the engine — for
// instance unit tests inspecting the raw slice — must either
// run the result through f.AdjustDiagnostics or normalise the
// values themselves before treating them as 1-based positions.
func ValidateFrontmatterDiags(
f *lint.File, sch *Schema, docFM map[string]any, mkDiag MakeDiag,
) []lint.Diagnostic {
return validateFrontmatterDiags(f, sch, docFM, mkDiag)
}
// FormatSchemaRef builds the "source:line" suffix used by every
// SchemaDiagnostic so callers outside the schema package emit
// the same shape. An unknown source falls back to "schema".
func FormatSchemaRef(sch *Schema, key string) string {
return schemaRef(sch, key)
}
// skipBelow returns a filtered slice that omits every heading
// whose level is shallower than rootLevel. The previous
// truncate-at-first-deep-heading variant only stripped a leading
// title, but an out-of-place shallower heading in the middle of the
// document would later terminate matchScope at the root and leave
// subsequent required scopes unmatched. Filtering throughout
// removes those terminators so the root walk continues across
// stray H1-level headings.
func skipBelow(heads []DocHeading, rootLevel int) []DocHeading {
out := make([]DocHeading, 0, len(heads))
for _, h := range heads {
if h.Level >= rootLevel {
out = append(out, h)
}
}
return out
}
// IsClaimed reports whether idx is a member of the claimed set.
func IsClaimed(claimed map[int]struct{}, idx int) bool {
_, ok := claimed[idx]
return ok
}
// validateScopes walks scopes (the listed children of a single level)
// against docHeads starting at docIdx. expectedLevel is the heading
// level these scopes should appear at. Returns the new docIdx
// (position after consuming this scope-list) and emitted diagnostics.
//
// closed controls handling of unlisted headings at this level: when
// true, an unlisted heading flags a diagnostic; when false, it is
// tolerated. A slot scope (`regex: '.+', repeat: {min: 0}`) always
// tolerates unlisted headings at its position.
func validateScopes(
f *lint.File, sch *Schema, scopes []Scope, closed bool, docHeads []DocHeading,
docIdx int, expectedLevel int, docFM map[string]any,
mkDiag MakeDiag,
) (int, []lint.Diagnostic) {
var diags []lint.Diagnostic
claimed := make(map[int]struct{})
claimCounts := make(map[int]int)
allowExtra := false
for i, sc := range scopes {
if sc.Preamble {
// The preamble has no heading to match. Its rules: are
// applied by the per-scope walker in MDS020 against the
// [parent-start, first-child-heading) line range.
claimed[i] = struct{}{}
continue
}
if isSlotMatcher(sc.Matcher) {
allowExtra = true
claimed[i] = struct{}{}
continue
}
if IsClaimed(claimed, i) {
continue
}
newIdx, scDiags, claimedThis := matchScope(
f, sch, scopes, i, expectedLevel, docHeads, docIdx,
claimed, claimCounts, allowExtra, closed, docFM, mkDiag)
diags = append(diags, scDiags...)
docIdx = newIdx
if claimedThis {
allowExtra = false
} else if !IsClaimed(claimed, i) && sc.Required() {
// Anchor the missing section at the heading it should
// follow (the preceding document heading), so the
// squiggle lands where the section belongs rather than
// at file line 1. MissingSectionAnchor falls back to the
// non-body anchor when there is no preceding heading or
// the insertion point sits inside a generated section.
diags = append(diags, missingSectionDiag(
formatHeading(expectedLevel, displayHeading(sc)), sch).
Emit(mkDiag, f.Path, MissingSectionAnchor(f, precedingHeadingLine(docHeads, docIdx))))
}
}
newIdx, leftoverDiags := handleLeftoverHeadings(
f, sch, scopes, claimed, claimCounts, docHeads, docIdx, expectedLevel,
closed, allowExtra, docFM, mkDiag)
diags = append(diags, leftoverDiags...)
return newIdx, diags
}
// missingSectionDiag builds a SchemaDiagnostic for a required
// section that is absent from the document. The field carries
// the heading marker form (`## Goal`) so the reader can grep for
// the exact text; the actual is "<missing>" and the schema
// reference points back at the schema source.
func missingSectionDiag(heading string, sch *Schema) SchemaDiagnostic {
return SchemaDiagnostic{
Field: heading,
Actual: "<missing>",
Expected: "section to be present",
SchemaRef: schemaRef(sch, ""),
}
}
// unexpectedSectionDiag builds a SchemaDiagnostic for a heading
// that appears in the document but is not declared in the
// schema. expected, when non-empty, names the section the
// validator was looking for at that position; the message
// surfaces it as a hint so the reader can decide whether they
// added the wrong heading or omitted a different one.
func unexpectedSectionDiag(heading, expected string, sch *Schema) SchemaDiagnostic {
d := SchemaDiagnostic{
Field: heading,
Actual: "<present>",
Expected: "not declared in schema",
SchemaRef: schemaRef(sch, ""),
}
if expected != "" {
d.Hint = fmt.Sprintf("expected %q here instead", expected)
}
return d
}
// outOfOrderDiag builds a SchemaDiagnostic for a heading whose
// text matches a declared section but appears at the wrong
// position relative to its siblings. expectedAfter, when
// non-empty, names the section it should follow.
func outOfOrderDiag(heading, expectedAfter string, sch *Schema) SchemaDiagnostic {
d := SchemaDiagnostic{
Field: heading,
Actual: "<out of order>",
Expected: "in declared order",
SchemaRef: schemaRef(sch, ""),
}
if expectedAfter != "" {
d.Hint = fmt.Sprintf("expected after %q", expectedAfter)
} else {
d.Hint = "expected before this position"
}
return d
}
// levelMismatchSchemaDiag builds a SchemaDiagnostic for a
// heading whose text matches a declared section but appears at
// the wrong heading level (e.g. `## Step` where the schema
// declared `### Step`).
func levelMismatchSchemaDiag(text string, expectedLevel, actualLevel int, sch *Schema) SchemaDiagnostic {
return SchemaDiagnostic{
Field: text,
Actual: "h" + strconv.Itoa(actualLevel),
Expected: "h" + strconv.Itoa(expectedLevel),
SchemaRef: schemaRef(sch, ""),
}
}
// handleLeftoverHeadings processes doc headings that survived the
// scope iteration. A leftover that matches an unclaimed listed
// scope is flagged as out-of-order regardless of open/closed — the
// user listed the section, so its position is still a constraint —
// and its child sections are validated recursively so nested
// required sections still surface. Other leftovers depend on
// closed: flagged as unexpected in closed scopes, silently
// consumed in open ones.
func handleLeftoverHeadings(
f *lint.File, sch *Schema, scopes []Scope, claimed map[int]struct{}, claimCounts map[int]int,
docHeads []DocHeading, docIdx, expectedLevel int,
closed, allowExtra bool, docFM map[string]any, mkDiag MakeDiag,
) (int, []lint.Diagnostic) {
var diags []lint.Diagnostic
for docIdx < len(docHeads) {
dh := docHeads[docIdx]
if dh.Level < expectedLevel {
break
}
if dh.Level != expectedLevel {
docIdx++
continue
}
if idx := unclaimedListedScope(scopes, dh, claimed, docFM); idx >= 0 {
newIdx, claimDiags := claimLateScope(
f, sch, scopes, idx, expectedLevel, docHeads, docIdx,
claimed, claimCounts, docFM, mkDiag)
diags = append(diags, claimDiags...)
docIdx = newIdx
continue
}
// A heading that matches an already-claimed scope is an
// extra occurrence after the scope's run has closed. The
// trailing pass runs after every in-order matchScope has
// returned, so every claimed scope here is finalised:
// either the heading pushes the scope past its `max`
// ("exceeds allowed occurrences") or it appears outside
// the contiguous run ("out of order"). Both shapes are
// surfaced explicitly rather than letting an open schema
// absorb the heading silently.
if idx, exceeded := claimedScopeMatches(
scopes, dh, claimed, claimCounts, docFM); idx >= 0 {
sc := scopes[idx]
msg := fmt.Sprintf(
"section %q out of order: matcher runs must be "+
"contiguous with scope %q's earlier run",
formatHeading(dh.Level, dh.Text),
formatHeading(expectedLevel, displayHeading(sc)))
if exceeded {
msg = fmt.Sprintf(
"section %q exceeds scope %q's allowed occurrences",
formatHeading(dh.Level, dh.Text),
formatHeading(expectedLevel, displayHeading(sc)))
}
diags = append(diags, mkDiag(f.Path, dh.Line, msg))
docIdx++
continue
}
if !allowExtra && closed {
diags = append(diags, unexpectedSectionDiag(
formatHeading(dh.Level, dh.Text), "", sch).Emit(mkDiag, f.Path, dh.Line))
}
docIdx++
}
return docIdx, diags
}
// claimedScopeMatches reports the first already-claimed non-slot,
// non-preamble scope whose matcher accepts dh, increments that
// scope's claim count, and returns whether the new count pushes
// the scope past its `max`. Returns (-1, false) when no claimed
// scope matches.
//
// Callers decide the diagnostic wording: an `exceeded == true`
// match is a max-exceeded extra ("exceeds allowed occurrences"),
// while a within-max match is a non-contiguous extra ("out of
// order: matcher runs must be contiguous") that the caller may
// still need to suppress when it represents a contiguous
// continuation of a run still being assembled (see
// matchRun.handleNonMatch).
//
// Unbounded matchers (`max == 0`) never exceed; they return
// `(idx, false)` so callers can still detect non-contiguity.
func claimedScopeMatches(
scopes []Scope, dh DocHeading, claimed map[int]struct{},
claimCounts map[int]int, docFM map[string]any,
) (int, bool) {
for i, sc := range scopes {
if !IsClaimed(claimed, i) {
continue
}
if sc.Preamble || isSlotMatcher(sc.Matcher) {
continue
}
if !scopeMatchesHeading(sc, dh, docFM) {
continue
}
_, max := sc.Matcher.Repeat.Bounds()
claimCounts[i]++
return i, max > 0 && claimCounts[i] > max
}
return -1, false
}
// claimLateScope marks a late-arriving listed scope as claimed,
// emits its out-of-order diagnostic, and recurses into the scope's
// nested children so missing-required-section diagnostics still
// surface beneath a late parent.
//
// Known limitation: this path consumes one heading per call.
// `repeat.max` exceeded by a subsequent same-text heading is
// flagged separately by handleLeftoverHeadings via
// claimedScopeMatches. A `repeat.min > 1` shortfall on a
// late-claimed scope is structurally unreachable here — the
// only paths that can leave a repeated scope unclaimed are
// already covered by matchScope's finishRun (in-order) or
// claimOutOfOrder's min check (out-of-order during iteration).
// Sequential ordering is not enforced for recovery paths; the
// in-order matchScope path is the canonical enforcement
// surface.
func claimLateScope(
f *lint.File, sch *Schema, scopes []Scope, idx, expectedLevel int,
docHeads []DocHeading, docIdx int,
claimed map[int]struct{}, claimCounts map[int]int,
docFM map[string]any, mkDiag MakeDiag,
) (int, []lint.Diagnostic) {
sc := scopes[idx]
dh := docHeads[docIdx]
diags := []lint.Diagnostic{outOfOrderDiag(
formatHeading(dh.Level, dh.Text), "", sch).Emit(mkDiag, f.Path, dh.Line)}
claimed[idx] = struct{}{}
claimCounts[idx]++
docIdx++
if len(sc.Sections) > 0 {
newIdx, childDiags := validateScopes(
f, sch, sc.Sections, sc.Closed,
docHeads, docIdx, expectedLevel+1, docFM, mkDiag)
diags = append(diags, childDiags...)
docIdx = newIdx
}
return docIdx, diags
}
// unclaimedListedScope returns the index of the first unclaimed
// non-slot, non-preamble scope whose matcher accepts dh, or -1
// when no listed scope is a candidate.
func unclaimedListedScope(
scopes []Scope, dh DocHeading, claimed map[int]struct{},
docFM map[string]any,
) int {
for i, sc := range scopes {
if IsClaimed(claimed, i) || sc.Preamble || isSlotMatcher(sc.Matcher) {
continue
}
if scopeMatchesHeading(sc, dh, docFM) {
return i
}
}
return -1
}
// matchScope advances docIdx looking for a run of headings that
// matches scopes[idx]'s matcher. Intervening doc headings either
// belong to a later listed scope (out-of-order), are unexpected
// (closed + no wildcard), or are descended into as part of an
// earlier scope's subtree. Returns the new docIdx, diagnostics,
// and whether the scope was claimed at least once.
func matchScope(
f *lint.File, sch *Schema, scopes []Scope, idx, expectedLevel int,
docHeads []DocHeading, docIdx int,
claimed map[int]struct{}, claimCounts map[int]int,
allowExtra, closed bool,
docFM map[string]any, mkDiag MakeDiag,
) (int, []lint.Diagnostic, bool) {
state := matchRun{
f: f, sch: sch, scopes: scopes, idx: idx, expectedLevel: expectedLevel,
claimed: claimed, claimCounts: claimCounts,
allowExtra: allowExtra, closed: closed,
docFM: docFM, mkDiag: mkDiag,
}
state.min, state.max = scopes[idx].Matcher.Repeat.Bounds()
for docIdx < len(docHeads) && (state.max == 0 || state.consumed < state.max) {
done, next, ok := state.step(docHeads, docIdx)
docIdx = next
if done {
state.finishRun()
return docIdx, state.diags, ok
}
}
state.finishRun()
// A run that hit its max while more matching headings remain