Skip to content

Commit f483690

Browse files
committed
fix(MDS020): anchor non-body diagnostics outside body line range
Address two Copilot review comments (validate.go:223 and the suppressed-confidence comment on validate.go:1025): diagnostics that don't correspond to a body line (filename/path-pattern mismatches, missing required sections, FM compile failures, fmDiagLine fallback) used to emit at body-coord line 1. In front-matter-stripped mode, line 1 is the first body line, so engine.filterGeneratedDiags could silently drop the diagnostic if the document body started with a generated section. Introduce schema.NonBodyDiagLine(f) returning `1 - f.LineOffset` — a non-positive body coord in stripped mode that filterGeneratedDiags cannot match against any generated line range. The engine's AdjustDiagnostics adds the offset back so the surfaced diagnostic still anchors at the file's first line. All "doesn't-belong-to-a-body-line" emit sites switch to the new helper: - schema.validateFrontmatterDiags: every early-return path (invalid CUE schema, json.Marshal failure, CompileBytes failure, validator fallback) - schema.validateScopes: missing-section diagnostic - schema.validateFilename: both invalid-pattern and mismatch paths - schema.fmDiagLine: both fallback returns (empty keyLines, key absent from map) - requiredstructure.missingSectionDiagLegacy: legacy file- schema path - requiredstructure.readDocFrontMatterRaw: YAML alias-rejected and unmarshal-error paths - requiredstructure.checkPathPatterns: path-pattern mismatch - requiredstructure.checkFilenamePattern: both paths Existing fixture tests use lint.NewFile (LineOffset == 0) so they keep observing line 1 as before; the change only affects the lint.NewFileFromSource(..., true) production path. New TestNonBodyDiagLine_StrippedAndUnstripped pins the helper's contract. https://claude.ai/code/session_01QcXckX3yVwReho2kaq3qRK
1 parent 87eccb4 commit f483690

3 files changed

Lines changed: 92 additions & 18 deletions

File tree

internal/rules/requiredstructure/rule.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,7 +1030,11 @@ func missingSectionDiagLegacy(
10301030
Expected: "section to be present",
10311031
SchemaRef: ref,
10321032
}
1033-
return makeDiag(f.Path, 1, d.Format())
1033+
// Missing sections have no body line to point at; use the
1034+
// non-body anchor so the engine's filterGeneratedDiags can't
1035+
// drop the diagnostic when body line 1 sits inside a
1036+
// generated section.
1037+
return makeDiag(f.Path, schema.NonBodyDiagLine(f), d.Format())
10341038
}
10351039

10361040
// buildSchemaRefForLegacy returns the schema reference string
@@ -1361,12 +1365,12 @@ func readDocFrontMatterRaw(f *lint.File) (map[string]any, []lint.Diagnostic) {
13611365
}
13621366

13631367
if err := yamlutil.RejectYAMLAliases(yamlBytes); err != nil {
1364-
return nil, []lint.Diagnostic{makeDiag(f.Path, 1,
1368+
return nil, []lint.Diagnostic{makeDiag(f.Path, schema.NonBodyDiagLine(f),
13651369
fmt.Sprintf("front matter: %v", err))}
13661370
}
13671371
var raw map[string]any
13681372
if err := yaml.Unmarshal(yamlBytes, &raw); err != nil {
1369-
return nil, []lint.Diagnostic{makeDiag(f.Path, 1,
1373+
return nil, []lint.Diagnostic{makeDiag(f.Path, schema.NonBodyDiagLine(f),
13701374
fmt.Sprintf("front matter: invalid YAML: %v", err))}
13711375
}
13721376
return raw, nil
@@ -1456,7 +1460,7 @@ func (r *Rule) checkPathPatterns(f *lint.File) []lint.Diagnostic {
14561460
Expected: fmt.Sprintf("path matching glob %s", pp.Pattern),
14571461
SchemaRef: fmt.Sprintf("kinds[%s] / path-pattern", pp.Kind),
14581462
}
1459-
diags = append(diags, makeDiag(f.Path, 1, d.Format()))
1463+
diags = append(diags, makeDiag(f.Path, schema.NonBodyDiagLine(f), d.Format()))
14601464
}
14611465
return diags
14621466
}
@@ -1493,6 +1497,11 @@ func checkFilenamePattern(
14931497
if pattern == "" {
14941498
return nil
14951499
}
1500+
// Filename diagnostics describe the document as a whole;
1501+
// use the non-body anchor so filterGeneratedDiags can't
1502+
// drop them when body line 1 sits inside a generated
1503+
// section.
1504+
anchor := schema.NonBodyDiagLine(f)
14961505
base := filepath.Base(f.Path)
14971506
matched, err := filepath.Match(pattern, base)
14981507
if err != nil {
@@ -1507,7 +1516,7 @@ func checkFilenamePattern(
15071516
Hint: err.Error(),
15081517
SchemaRef: buildSchemaRefForLegacy(schemaSource),
15091518
}
1510-
return []lint.Diagnostic{makeDiag(f.Path, 1, d.Format())}
1519+
return []lint.Diagnostic{makeDiag(f.Path, anchor, d.Format())}
15111520
}
15121521
if !matched {
15131522
// `glob` keeps the wording aligned with schema.validateFilename
@@ -1518,7 +1527,7 @@ func checkFilenamePattern(
15181527
Expected: fmt.Sprintf("filename matching glob %s", pattern),
15191528
SchemaRef: buildSchemaRefForLegacy(schemaSource),
15201529
}
1521-
return []lint.Diagnostic{makeDiag(f.Path, 1, d.Format())}
1530+
return []lint.Diagnostic{makeDiag(f.Path, anchor, d.Format())}
15221531
}
15231532
return nil
15241533
}

internal/schema/validate.go

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,22 +134,23 @@ func validateFrontmatterDiags(
134134
return nil
135135
}
136136
ctx := cuecontext.New()
137+
anchor := nonBodyDiagLine(f)
137138
schemaVal := ctx.CompileString(expr)
138139
if err := schemaVal.Err(); err != nil {
139-
return []lint.Diagnostic{mkDiag(f.Path, 1,
140+
return []lint.Diagnostic{mkDiag(f.Path, anchor,
140141
compileFailureDiag(sch, "schema", "valid schema CUE", err).Format())}
141142
}
142143
if docFM == nil {
143144
docFM = map[string]any{}
144145
}
145146
data, err := json.Marshal(docFM)
146147
if err != nil {
147-
return []lint.Diagnostic{mkDiag(f.Path, 1,
148+
return []lint.Diagnostic{mkDiag(f.Path, anchor,
148149
compileFailureDiag(sch, "front matter", "JSON-marshalable front matter", err).Format())}
149150
}
150151
dataVal := ctx.CompileBytes(data)
151152
if err := dataVal.Err(); err != nil {
152-
return []lint.Diagnostic{mkDiag(f.Path, 1,
153+
return []lint.Diagnostic{mkDiag(f.Path, anchor,
153154
compileFailureDiag(sch, "front matter", "valid front matter", err).Format())}
154155
}
155156
merged := schemaVal.Unify(dataVal)
@@ -159,7 +160,7 @@ func validateFrontmatterDiags(
159160
}
160161
cueErrs := errors.Errors(verr)
161162
if len(cueErrs) == 0 {
162-
return []lint.Diagnostic{mkDiag(f.Path, 1,
163+
return []lint.Diagnostic{mkDiag(f.Path, anchor,
163164
SchemaDiagnostic{
164165
Field: "front matter",
165166
Actual: fmt.Sprintf("%v", verr),
@@ -188,6 +189,38 @@ func validateFrontmatterDiags(
188189
return out
189190
}
190191

192+
// NonBodyDiagLine returns the body-coord line value that, after
193+
// lint.File.AdjustDiagnostics adds f.LineOffset, lands on the
194+
// absolute first line of the file (typically the opening `---`
195+
// fence of stripped front matter). It is the canonical anchor
196+
// for diagnostics that do not correspond to a specific body
197+
// line — schema-level compile failures, filename pattern
198+
// violations, and structure diagnostics for sections that are
199+
// missing entirely.
200+
//
201+
// The previous "anchor at line 1" pattern landed on the first
202+
// body line in front-matter-stripped mode, which
203+
// engine.filterGeneratedDiags could mistakenly drop if the
204+
// document body started with a generated section (e.g. a
205+
// leading <?catalog?> directive). Using `1 - LineOffset`
206+
// produces a non-positive body-coord that filterGeneratedDiags
207+
// cannot match against any generated line range, and the
208+
// engine's AdjustDiagnostics adds the offset back so the
209+
// surfaced diagnostic still anchors at the file's first line.
210+
//
211+
// When f.LineOffset == 0 (the non-stripped path, used by tests
212+
// via lint.NewFile) the return value is just 1, matching the
213+
// previous behaviour.
214+
func NonBodyDiagLine(f *lint.File) int {
215+
return 1 - f.LineOffset
216+
}
217+
218+
// nonBodyDiagLine is the package-internal alias used inside the
219+
// schema package; external callers reach for NonBodyDiagLine.
220+
func nonBodyDiagLine(f *lint.File) int {
221+
return NonBodyDiagLine(f)
222+
}
223+
191224
// fmDiagLine returns the line to anchor a front-matter diagnostic
192225
// at, expressed in the body-line coordinate system the engine
193226
// uses before lint.File.AdjustDiagnostics fires. When the doc's
@@ -205,20 +238,25 @@ func validateFrontmatterDiags(
205238
// for the contract). In unstripped mode (LineOffset == 0) the
206239
// returned value already equals the absolute file line.
207240
//
208-
// When no per-key line is known the function falls back to line
209-
// 1 — the conventional "start of file" anchor, which the engine
210-
// also shifts to the first body line.
241+
// When no per-key line is known the function falls back to
242+
// nonBodyDiagLine(f) — a non-positive body coordinate in
243+
// stripped mode that AdjustDiagnostics resolves to the first
244+
// absolute line of the file. The fallback used to be a flat
245+
// "1", which landed on the first body line in stripped mode
246+
// and could be silently dropped by filterGeneratedDiags when
247+
// the document body started with a generated section
248+
// (PR #284 Copilot review).
211249
func fmDiagLine(f *lint.File, path []string, keyLines map[string]int) int {
212250
if len(path) == 0 || len(keyLines) == 0 {
213-
return 1
251+
return nonBodyDiagLine(f)
214252
}
215253
line, ok := keyLines[path[0]]
216254
if !ok {
217255
// Top-level path may carry an optional-key suffix in the
218256
// schema; the doc itself never does, so a miss here means
219257
// the key was absent from the document (and therefore has
220258
// no source line to point at).
221-
return 1
259+
return nonBodyDiagLine(f)
222260
}
223261
return line - f.LineOffset
224262
}
@@ -525,7 +563,11 @@ func validateScopes(
525563
if found {
526564
allowExtra = false
527565
} else if !claimed[i] && sc.Required && !sc.Repeats {
528-
diags = append(diags, mkDiag(f.Path, 1,
566+
// Missing sections have no body line to point at;
567+
// use the non-body anchor so filterGeneratedDiags
568+
// can't drop the diagnostic if body line 1 sits
569+
// inside a generated section.
570+
diags = append(diags, mkDiag(f.Path, nonBodyDiagLine(f),
529571
missingSectionDiag(formatHeading(expectedLevel, sc.Heading), sch).Format()))
530572
}
531573
}
@@ -1007,6 +1049,11 @@ func validateFilename(
10071049
if pattern == "" {
10081050
return nil
10091051
}
1052+
// Filename and path diagnostics describe the document as a
1053+
// whole, not a body line; use the non-body anchor so the
1054+
// engine's filterGeneratedDiags can't drop them when the
1055+
// document body starts with a generated section.
1056+
anchor := nonBodyDiagLine(f)
10101057
base := filepath.Base(f.Path)
10111058
matched, err := filepath.Match(pattern, base)
10121059
if err != nil {
@@ -1021,7 +1068,7 @@ func validateFilename(
10211068
Hint: err.Error(),
10221069
SchemaRef: schemaRef(sch, ""),
10231070
}
1024-
return []lint.Diagnostic{mkDiag(f.Path, 1, d.Format())}
1071+
return []lint.Diagnostic{mkDiag(f.Path, anchor, d.Format())}
10251072
}
10261073
if !matched {
10271074
// `glob` makes the constraint syntax explicit: users
@@ -1036,7 +1083,7 @@ func validateFilename(
10361083
Expected: fmt.Sprintf("filename matching glob %s", pattern),
10371084
SchemaRef: schemaRef(sch, ""),
10381085
}
1039-
return []lint.Diagnostic{mkDiag(f.Path, 1, d.Format())}
1086+
return []lint.Diagnostic{mkDiag(f.Path, anchor, d.Format())}
10401087
}
10411088
return nil
10421089
}

internal/schema/validate_coverage_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,24 @@ func TestCompileFailureDiag_FieldsRoundTrip(t *testing.T) {
179179
assert.Equal(t, "kind t", d.SchemaRef)
180180
}
181181

182+
// TestNonBodyDiagLine_StrippedAndUnstripped exercises the
183+
// helper directly: a file built with FM stripping returns
184+
// a non-positive body coord (so filterGeneratedDiags can't
185+
// match it), and a file built without stripping returns 1
186+
// unchanged.
187+
func TestNonBodyDiagLine_StrippedAndUnstripped(t *testing.T) {
188+
stripped, err := lint.NewFileFromSource("doc.md",
189+
[]byte("---\nfoo: 1\n---\n# Body\n"), true)
190+
require.NoError(t, err)
191+
require.Greater(t, stripped.LineOffset, 0)
192+
assert.LessOrEqual(t, NonBodyDiagLine(stripped), 0)
193+
194+
unstripped, err := lint.NewFile("doc.md", []byte("# Body\n"))
195+
require.NoError(t, err)
196+
assert.Equal(t, 0, unstripped.LineOffset)
197+
assert.Equal(t, 1, NonBodyDiagLine(unstripped))
198+
}
199+
182200
// TestValidateFrontmatterDiags_JSONMarshalFailureCarriesRef
183201
// regresses the json.Marshal early-return path. A channel
184202
// value in docFM is non-marshalable, so the validator falls

0 commit comments

Comments
 (0)