diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1e79a9e..78bdbeb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement shared source version checks -Status: Stages 1–2 complete; source-local extraction has not started. +Status: Stages 1–4 complete; recursive import integration has not started. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. The [feature roadmap](source-version-checks.md) owns target behavior and @@ -305,6 +305,26 @@ No environmental limitation affected validation; checks ran on Linux/amd64. Validate: load/grouped/version/destination/hook tests and everyday check. Done: private enabled main behavior works without changing old callers. +Completion evidence: added the private mode to ordinary and layered engine +options and the shared load request. Both entry points validate enabled modes +and their shared checker before source acquisition; enabled loads build schema +before invoking the main checker. The parsed main document is checked at its +source-local path before import traversal or layered composition, and its node +remains in the application payload. A single semantic destination-prefixed rule +now protects mounted main ownership during decoding, inheritance, acquired-input +placement, overlays, and CLI overrides; declaration callbacks and diagnostics +retain local paths. Default mode keeps its prior post-composition check timing. +The per-load assembled input retains main evidence and successful warning +diagnostics. NoSource invokes the existing absent-main callback once in either +enabled mode. These checks passed with Go `go1.26.8 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'SourceVersion|Version|Load|Grouped|Destination|Hook'` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. +The engine mode remains private; public options are scheduled separately. + ## Stage 5 — Integrate recursive imports, warnings, and accounting 1. Thread shared state through import entry/traversal. Check each acquired child diff --git a/internal/engine/layered.go b/internal/engine/layered.go index 30f7937..05d4e63 100644 --- a/internal/engine/layered.go +++ b/internal/engine/layered.go @@ -16,6 +16,7 @@ type Underlay struct{ Inputs []Input } // LayeredOptions selects lower-priority tiers and final application rules. type LayeredOptions[T any] struct { Definition Definition[T] + SourceVersionMode sourceVersionMode Underlays []Underlay Overlay string OverlayFilesystem *FilesystemContext @@ -37,7 +38,8 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp source: main.Source, imports: main.Imports, filesystem: main.Filesystem, destination: main.Destination.clone(), }, - definition: options.Definition, overlay: options.Overlay, + definition: options.Definition, sourceVersionMode: options.SourceVersionMode, + overlay: options.Overlay, overlayFilesystem: options.OverlayFilesystem, overrides: append([]Override(nil), options.Overrides...), underlays: make([]loadGroup, len(options.Underlays)), diff --git a/internal/engine/load.go b/internal/engine/load.go index 63e37e8..42fc4d0 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -22,11 +22,12 @@ type Definition[T any] struct { // Imports enables explicit path and glob imports; Overlay selects one fixed overlay; // Overrides contains only values explicitly supplied by an application CLI. type Options[T any] struct { - Definition Definition[T] - Imports bool - Overlay string - Filesystem *FilesystemContext - Overrides []Override + Definition Definition[T] + Imports bool + Overlay string + Filesystem *FilesystemContext + Overrides []Override + SourceVersionMode sourceVersionMode } // FilesystemContext supplies filesystem roots for imports and overlays. @@ -143,6 +144,8 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem}, definition: options.Definition, overlay: options.Overlay, overrides: options.Overrides, ordinary: true, + sourceVersionMode: options.SourceVersionMode, + effectiveVersion: effectiveSourceVersionRule(options.Definition.Version, Path{}, options.SourceVersionMode), } return executeLoadRequest(request, secrets, lookup) } @@ -151,28 +154,46 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo accumulator := diagnosticAccumulator{} accounting := &importAccounting{} mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem} + mainOptions.SourceVersionMode = request.sourceVersionMode + mainOptions.Definition.Version = request.protectionVersion() + mainOptions.Overrides = request.overrides if request.ordinary { mainOptions.Overlay = request.overlay } - input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, request.main.destination, accounting, nil, nil) + var schema *schemaDescriptor + if request.sourceVersionMode != sourceVersionModeDefault { + var schemaIssue *schemaIssue + schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets) + if schemaIssue != nil { + accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) + return nil, accumulator.loadError() + } + } + input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, request.definition.Version, request.sourceVersionMode) accumulator.sources = input.sources + accumulator.add(input.versionDiagnostics...) accumulator.add(diagnostics...) - if len(diagnostics) != 0 { + if hasErrors(diagnostics) { return nil, accumulator.loadError() } - schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets) - if schemaIssue != nil { - accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) - return nil, accumulator.loadError() + if schema == nil { + var schemaIssue *schemaIssue + schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets) + if schemaIssue != nil { + accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) + return nil, accumulator.loadError() + } } - versionDiagnostics := checkVersion(request.definition.Version, input.document) - accumulator.add(versionDiagnostics...) - if hasErrors(versionDiagnostics) { - return nil, accumulator.loadError() + if request.sourceVersionMode == sourceVersionModeDefault { + versionDiagnostics := checkVersion(request.definition.Version, input.document) + accumulator.add(versionDiagnostics...) + if hasErrors(versionDiagnostics) { + return nil, accumulator.loadError() + } } if request.ordinary { - input, diagnostics = applyInputLayers(request.main.source, Options[T]{Definition: request.definition, Overlay: request.overlay, Filesystem: request.main.filesystem, Overrides: request.overrides}, input) + input, diagnostics = applyInputLayers(request.main.source, mainOptions, input) } else { input, diagnostics = assembleLayeredInput(request, input, accounting) } @@ -208,7 +229,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo accumulator.add(problems...) return nil, accumulator.loadError() } - if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil { + if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil { accumulator.add(*problem) return nil, accumulator.loadError() } @@ -217,7 +238,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo input.document = expanded.root } - decoded, diagnostics := decodeLoadInput(schema, request.definition, input) + decodeDefinition := request.definition + decodeDefinition.Version = request.protectionVersion() + decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input) accumulator.add(diagnostics...) if len(diagnostics) != 0 { return nil, accumulator.loadError() diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 3967ffc..4f9887b 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -8,12 +8,14 @@ import ( // assembledInput is the source/document handoff across schema and version checks. type assembledInput struct { - parsed parsedSource - document *document.Node - composition *composedDocument - inheritance *inheritanceScope - sources []SourceInfo - contexts map[string]ResourceContext + parsed parsedSource + document *document.Node + composition *composedDocument + inheritance *inheritanceScope + sources []SourceInfo + contexts map[string]ResourceContext + versionEvidence *sourceVersionEvidence + versionDiagnostics []Diagnostic } func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { @@ -21,6 +23,10 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput } func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) { + return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil, sourceVersionModeDefault) +} + +func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, localVersion *VersionRule, mode sourceVersionMode) (assembledInput, []Diagnostic) { limits := defaultImportLimits() parseLimits := defaultParseLimits() parseLimits.source.remaining = limits.bytes - accounting.bytes @@ -42,6 +48,14 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat } return input, diagnostics } + if mode != sourceVersionModeDefault { + processed := processSourceVersion(parsed.document, localVersion, sourceVersionMain, destination, mode) + input.versionEvidence = processed.evidence + input.versionDiagnostics = processed.diagnostics + if hasErrors(processed.diagnostics) { + return input, processed.diagnostics + } + } if options.Imports { input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken) if len(diagnostics) != 0 { diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index c353a5c..becef88 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -10,12 +10,21 @@ type loadRequest[T any] struct { main loadInput underlays []loadGroup definition Definition[T] + sourceVersionMode sourceVersionMode + effectiveVersion *VersionRule overlay string overlayFilesystem *FilesystemContext overrides []Override ordinary bool } +func (request loadRequest[T]) protectionVersion() *VersionRule { + if request.effectiveVersion != nil { + return request.effectiveVersion + } + return request.definition.Version +} + type loadInput struct { source Source imports bool @@ -33,8 +42,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi } else { request.main.destination = normalized } + request.effectiveVersion = effectiveSourceVersionRule(request.definition.Version, request.main.destination, request.sourceVersionMode) _, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{ Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, + SourceVersionMode: request.sourceVersionMode, }}) diagnostics = append(diagnostics, mainDiagnostics...) for groupIndex := range request.underlays { @@ -68,7 +79,7 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) { groups := make([]*composedDocument, 0, len(request.underlays)) for index, group := range request.underlays { - composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting) + composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.protectionVersion(), accounting) input.sources = append(input.sources, sources...) mergeResourceContexts(&input.contexts, contexts) if len(diagnostics) != 0 { @@ -115,7 +126,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a selection := Source{kind: sourceBytes} selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}} var diagnostics []Diagnostic - input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.definition.Version, defaultImportLimits()) + input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits()) if len(diagnostics) != 0 { return input, diagnostics } @@ -127,7 +138,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a } if len(request.overrides) != 0 { var diagnostics []Diagnostic - input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.definition.Version, defaultImportLimits()) + input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.protectionVersion(), defaultImportLimits()) if len(diagnostics) != 0 { return input, diagnostics } diff --git a/internal/engine/options.go b/internal/engine/options.go index 1de169a..591e8c7 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -33,6 +33,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) } } + if options.SourceVersionMode != sourceVersionModeDefault && options.SourceVersionMode != sourceVersionModeIfPresent && options.SourceVersionMode != sourceVersionModeRequired { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid")) + } else if options.SourceVersionMode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker")) + } return options, canonicalDiagnostics(diagnostics) } diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index 58a51c5..cb610d7 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -41,6 +41,21 @@ type sourceVersionProcessingResult struct { diagnostics []Diagnostic } +func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule { + if local == nil { + return nil + } + if mode == sourceVersionModeDefault { + return local + } + effective := *local + effective.Path = Path{ + segments: append(clonePathSegments(destination.segments, len(local.Path.segments)), local.Path.segments...), + invalid: destination.invalid || local.Path.invalid, + } + return &effective +} + type sourceVersionPathAbsence uint8 const ( diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 174f23b..2f433b0 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -1,11 +1,24 @@ package engine import ( + "errors" "testing" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) +type sourceVersionMountedService struct { + Version string `yaml:"version"` +} + +type sourceVersionMountedConfig struct { + Service sourceVersionMountedService `yaml:"service"` +} + +type sourceVersionNullConfig struct { + Version *string `yaml:"version"` +} + func sourceVersionTestDocument(t *testing.T, input string) *document.Node { t.Helper() parsed, diagnostics := parseSource(Bytes("source-version.yml", []byte(input)), "source-version", defaultParseLimits()) @@ -374,6 +387,211 @@ func TestSourceVersionNoSourceMainRetainsAbsentCallback(t *testing.T) { } } +func TestSourceVersionLoadChecksMountedMainLocallyAndProtectsEffectivePath(t *testing.T) { + t.Parallel() + + checks := 0 + definition := Definition[sourceVersionMountedConfig]{Version: &VersionRule{ + Path: Keys("version"), + Check: func(version Version, validation *Validation) error { + checks++ + if !version.Present || version.Null || version.Text != "9007199254740993" { + return errors.New("unexpected source-local version") + } + validation.Warning(Path{}, "advisory") + return nil + }, + }} + main := Input{Source: Bytes("main.yml", []byte("version: 9007199254740993\n")), Destination: Keys("service")} + loaded, err := LoadLayered(main, testSecretBinding(), LayeredOptions[sourceVersionMountedConfig]{ + Definition: definition, SourceVersionMode: sourceVersionModeRequired, + }) + if err != nil || loaded == nil { + t.Fatalf("mounted main load = %#v, %v", loaded, err) + } + if loaded.Config.Service.Version != "9007199254740993" || checks != 1 { + t.Fatalf("mounted main config/callbacks = %#v, %d", loaded.Config, checks) + } + warnings := testLoaded(loaded).Diagnostics() + if len(warnings) != 1 || warnings[0].Code != CodeVersionWarning || warnings[0].Severity != SeverityWarning { + t.Fatalf("mounted main warnings = %#v", warnings) + } + + checks = 0 + _, err = LoadLayered(main, testSecretBinding(), LayeredOptions[sourceVersionMountedConfig]{ + Definition: definition, SourceVersionMode: sourceVersionModeRequired, + Overrides: []Override{{Path: Keys("service").Key("version"), Value: "4", Flag: "--version"}}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("mounted protected override error = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOverrideVersion, "/service/version") + if checks != 1 { + t.Fatalf("mounted protected override callbacks = %d, want 1", checks) + } +} + +func TestSourceVersionLoadChecksNullAndNoSourceMain(t *testing.T) { + t.Parallel() + + t.Run("required actual main absence fails before the checker", func(t *testing.T) { + t.Parallel() + checks := 0 + _, err := Load(Bytes("main.yml", []byte("other: value\n")), testSecretBinding(), Options[sourceVersionNullConfig]{ + SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil }, + }}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("missing required main version = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionError, "/version") + if checks != 0 { + t.Fatalf("missing required main invoked checker %d times", checks) + } + }) + + t.Run("required null is passed to the checker", func(t *testing.T) { + t.Parallel() + checks := 0 + loaded, err := Load(Bytes("main.yml", []byte("version: null\n")), testSecretBinding(), Options[sourceVersionNullConfig]{ + SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), + Check: func(version Version, _ *Validation) error { + checks++ + if !version.Present || !version.Null || version.Text != "" { + return errors.New("unexpected null version") + } + return nil + }, + }}, + }) + if err != nil || loaded == nil || checks != 1 || loaded.Config.Version != nil { + t.Fatalf("required null load = %#v, callbacks %d, %v", loaded, checks, err) + } + }) + + for _, mode := range []sourceVersionMode{sourceVersionModeIfPresent, sourceVersionModeRequired} { + mode := mode + t.Run(map[sourceVersionMode]string{sourceVersionModeIfPresent: "if-present", sourceVersionModeRequired: "required"}[mode], func(t *testing.T) { + t.Parallel() + checks := 0 + loaded, err := Load(NoSource(), testSecretBinding(), Options[sourceVersionNullConfig]{ + SourceVersionMode: mode, + Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), Check: func(version Version, _ *Validation) error { + checks++ + if version.Present || version.Null { + return errors.New("NoSource should report absence") + } + return nil + }, + }}, + }) + if err != nil || loaded == nil || checks != 1 || len(testLoaded(loaded).Sources()) != 0 { + t.Fatalf("NoSource load = %#v, callbacks %d, %v", loaded, checks, err) + } + }) + } +} + +func TestSourceVersionLoadReportsMountedMainErrorsAtLocalPath(t *testing.T) { + t.Parallel() + + checks := 0 + _, err := LoadLayered(Input{ + Source: Bytes("main.yml", []byte("version: 5\n")), Destination: Keys("service"), + }, testSecretBinding(), LayeredOptions[sourceVersionMountedConfig]{ + SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[sourceVersionMountedConfig]{Version: &VersionRule{ + Path: Keys("version"), + Check: func(Version, *Validation) error { checks++; return errors.New("rejected") }, + }}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("mounted main rejection = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionHookFailed, "/version") + if checks != 1 { + t.Fatalf("mounted main checker calls = %d, want 1", checks) + } +} + +func TestSourceVersionLoadChecksMainBeforeLayeredAcquisition(t *testing.T) { + t.Parallel() + + checks := 0 + _, err := LoadLayered(Input{Source: Bytes("main.yml", []byte("version: 5\n"))}, testSecretBinding(), LayeredOptions[sourceVersionNullConfig]{ + SourceVersionMode: sourceVersionModeIfPresent, + Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return errors.New("rejected") }, + }}, + Underlays: []Underlay{{Inputs: []Input{{Source: Bytes("unread.yml", []byte("[malformed"))}}}}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("main rejection = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionHookFailed, "/version") + if checks != 1 { + t.Fatalf("main checker calls = %d, want 1", checks) + } +} + +func TestSourceVersionEnabledBuildsSchemaBeforeMainCallback(t *testing.T) { + t.Parallel() + + checks := 0 + _, err := Load(Bytes("main.yml", []byte("version: 5\n")), testSecretBinding(), Options[int]{ + SourceVersionMode: sourceVersionModeIfPresent, + Definition: Definition[int]{Version: &VersionRule{ + Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil }, + }}, + }) + if err == nil || checks != 0 { + t.Fatalf("invalid schema load = %v, checker calls %d; want schema failure before callback", err, checks) + } +} + +func TestSourceVersionLoadRejectsOptionsBeforeReadingMain(t *testing.T) { + t.Parallel() + + checkerCalls := 0 + definition := Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), Check: func(Version, *Validation) error { checkerCalls++; return nil }, + }} + _, err := Load(Bytes("malformed.yml", []byte("[not valid")), testSecretBinding(), Options[sourceVersionNullConfig]{ + Definition: definition, SourceVersionMode: sourceVersionMode(255), + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("invalid-mode error = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOptionsInvalid, "") + if checkerCalls != 0 { + t.Fatalf("invalid mode invoked checker %d times", checkerCalls) + } + + _, err = Load(Bytes("malformed.yml", []byte("[not valid")), testSecretBinding(), Options[sourceVersionNullConfig]{SourceVersionMode: sourceVersionModeRequired}) + if !errors.As(err, &loadErr) { + t.Fatalf("missing-rule error = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOptionsInvalid, "") + + _, err = LoadLayered(Input{Source: Bytes("malformed.yml", []byte("[not valid"))}, testSecretBinding(), LayeredOptions[sourceVersionNullConfig]{ + SourceVersionMode: sourceVersionModeRequired, + }) + if !errors.As(err, &loadErr) { + t.Fatalf("layered missing-rule error = %T %v, want *LoadError", err, err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOptionsInvalid, "") +} + func mustParseSourceVersionPath(t *testing.T, input string) Path { t.Helper() path, err := ParsePath(input)