diff --git a/diagnostic.go b/diagnostic.go index 83afec5..e53a3ed 100644 --- a/diagnostic.go +++ b/diagnostic.go @@ -237,3 +237,12 @@ func escapeControls(value string) string { } return b.String() } + +func hasErrors(diagnostics []Diagnostic) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Severity == SeverityError { + return true + } + } + return false +} diff --git a/docs/internal/overview.md b/docs/internal/overview.md index d77bc3a..44bf3d3 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -31,7 +31,9 @@ module and package documentation boundary. Its implemented public primitives are - strict schema-driven decoding over detached defaults in `decode.go`, including mapping/sequence semantics, null and numeric policy, safe codec errors, presence, baseline/final trees, and initial default/input origins; -- public lifecycle coordination in `load.go`, bounded canonical import DFS in +- public lifecycle coordination in `load.go`, option selection in `options.go`, + input assembly in `load_input.go`, typed phases/publication in `load_result.go`, + bounded canonical import DFS in `imports.go`, filesystem context/containment in `filesystem.go`, directive parsing/restrictions in `directives.go`, pattern-directed glob expansion in `glob.go`, version-report contexts diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 45b066e..27a8e35 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -293,7 +293,16 @@ profile/CLI provenance remains identical. ## Stage 7 — Extract explicit loader lifecycle phases -Status: pending. +Status: complete. + +Completion: `load.go` now visibly coordinates ordered phases using source assembly +in `load_input.go`, option selection in `options.go`, and typed phase outputs and +publication in `load_result.go`. Version checking lives in `validation.go`. +One composition initializer covers profiles, CLI, and NoSource overrides without +changing budgets. Diagnostics and source handoffs remain explicit, both ownership +clones remain, and publication adds no history copies. Full offline baseline and +race suites passed with Go 1.26.7 on Linux/amd64; lifecycle, early-stop, warning, +precedence, provenance, callback ownership, and secret tests passed unchanged. Evidence: `loadWithSecretLookup` in [load.go](../../load.go) coordinates roughly 200 lines of source assembly, decoding, callbacks, history, secrets, and result diff --git a/load.go b/load.go index 599735d..cf762f6 100644 --- a/load.go +++ b/load.go @@ -114,293 +114,69 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { accumulator := diagnosticAccumulator{} options, diagnostics := selectOptions(source, supplied) + accumulator.add(diagnostics...) if len(diagnostics) != 0 { - accumulator.add(diagnostics...) return nil, accumulator.loadError() } - parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits()) + input, diagnostics := assembleBaseInput(source, options) + accumulator.sources = input.sources + accumulator.add(diagnostics...) if len(diagnostics) != 0 { - if parsed.source != nil { - accumulator.sources = append(accumulator.sources, *parsed.source) - } - accumulator.add(diagnostics...) return nil, accumulator.loadError() } - - inputDocument := parsed.document - var composition *composedDocument - if options.Imports { - composition, accumulator.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits()) - if len(diagnostics) != 0 { - accumulator.add(diagnostics...) - return nil, accumulator.loadError() - } - inputDocument = composition.root - } else { - if parsed.source != nil { - accumulator.sources = append(accumulator.sources, *parsed.source) - } - if options.Filesystem != nil && !activeProfile(options.Profile) { - if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { - accumulator.add(*diagnostic) - return nil, accumulator.loadError() - } - } - } - if !options.Imports && inputDocument != nil { - directiveDiagnostics := disabledDirectiveDiagnostics(inputDocument, Path{}) - if len(directiveDiagnostics) != 0 { - accumulator.add(directiveDiagnostics...) - return nil, accumulator.loadError() - } - } - schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) if schemaIssue != nil { accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) return nil, accumulator.loadError() } - - versionDiagnostics := checkVersion(options.Definition.Version, inputDocument) + versionDiagnostics := checkVersion(options.Definition.Version, input.document) accumulator.add(versionDiagnostics...) if hasErrors(versionDiagnostics) { return nil, accumulator.loadError() } - if activeProfile(options.Profile) { - if composition == nil { - composition, diagnostics = composeDocuments(inputDocument) - if len(diagnostics) != 0 { - accumulator.add(diagnostics...) - return nil, accumulator.loadError() - } - composition.inputBytes = parsed.bytes - composition.inputNodes = countInputNodes(parsed.document) - } - composition, accumulator.sources, diagnostics = loadProfileDocument( - source, options.Filesystem, parsed, composition, accumulator.sources, - options.Profile, options.Definition.Version, defaultImportLimits(), - ) - if len(diagnostics) != 0 { - accumulator.add(diagnostics...) - return nil, accumulator.loadError() - } - inputDocument = composition.root - } - if len(options.Overrides) != 0 { - if composition == nil { - if inputDocument == nil { - composition = &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)} - } else { - composition, diagnostics = composeDocuments(inputDocument) - if len(diagnostics) != 0 { - accumulator.add(diagnostics...) - return nil, accumulator.loadError() - } - composition.inputBytes = parsed.bytes - composition.inputNodes = countInputNodes(inputDocument) - } - } - composition, accumulator.sources, diagnostics = applyOverrides( - composition, accumulator.sources, options.Overrides, options.Definition.Version, defaultImportLimits(), - ) - if len(diagnostics) != 0 { - accumulator.add(diagnostics...) - return nil, accumulator.loadError() - } - inputDocument = composition.root - } - - defaults := reflect.Zero(reflect.TypeFor[T]()) - if options.Definition.Defaults != nil { - defaults = reflect.ValueOf(options.Definition.Defaults()) - } - decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument) - if len(decodeIssues) != 0 { - for _, issue := range decodeIssues { - accumulator.add(diagnosticFromDecodeIssue(issue)) - } + input, diagnostics = applyInputLayers(source, options, input) + accumulator.sources = input.sources + accumulator.add(diagnostics...) + if len(diagnostics) != 0 { return nil, accumulator.loadError() } - if composition != nil { - applyCompositionOrigins(&decoded, composition.origins) - } - configuration := decoded.value.Interface().(T) - metadata := metadataFromDecoded(decoded) - if composition != nil { - applyOverlayMetadata(&metadata, composition.overlays) + decoded, diagnostics := decodeLoadInput(schema, options.Definition, input) + accumulator.add(diagnostics...) + if len(diagnostics) != 0 { + return nil, accumulator.loadError() } - final := decoded.final - var resolutionSnapshots []resolutionSnapshot - if options.Definition.Resolve != nil { - resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument) - accumulator.add(resolved.diagnostics...) - if hasErrors(resolved.diagnostics) { - return nil, accumulator.loadError() - } - final = resolved.final - resolutionSnapshots = resolved.snapshots - applyResolutionMetadata(&metadata, resolved) + resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded) + accumulator.add(diagnostics...) + if hasErrors(diagnostics) { + return nil, accumulator.loadError() } - applications := []historyApplication(nil) - if composition != nil { - applications = cloneHistoryApplications(composition.applications) - } else if inputDocument != nil { - applications = []historyApplication{{layer: LayerBase, value: inputDocument}} - } - if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) { + if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) { accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"}) return nil, accumulator.loadError() } - // Resolver assignments may borrow application-owned mutable values. Detach - // before secret resolution mutates reference fields or validation sees them. - detached, ownershipIssue := cloneConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits()) + + // Detach resolver-borrowed values before reference mutation or validation. + configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets) if ownershipIssue != nil { - accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue))) + accumulator.add(*ownershipIssue) return nil, accumulator.loadError() } - configuration = detached.Interface().(T) - secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup) + secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup) accumulator.add(secretResolution.diagnostics...) if hasErrors(secretResolution.diagnostics) { return nil, accumulator.loadError() } if options.Definition.Validate != nil { - validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups) - accumulator.add(validationDiagnostics...) - if hasErrors(validationDiagnostics) { + diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups) + accumulator.add(diagnostics...) + if hasErrors(diagnostics) { return nil, accumulator.loadError() } } - // A read-only validator can replace a value with an equal borrowed object. - // Projected mutation checks cannot detect that alias, so detach at publication. - published, ownershipIssue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), cloneValidatedSecrets) - if ownershipIssue != nil { - accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue))) - return nil, accumulator.loadError() - } - configuration = published.Interface().(T) - finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) - secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) - return &Result[T]{ - Config: configuration, - metadata: metadata, - sources: cloneSourceInfos(accumulator.sources), - diagnostics: canonicalDiagnostics(accumulator.diagnostics), - baseline: decoded.baseline, - input: decoded.final, - inputOrigins: cloneOriginIndex(decoded.origins), - final: final, - resolution: resolutionSnapshots, - applications: applications, - secrets: secrets, - secretReferences: cloneSecretReferences(secretResolution.references), - }, nil -} - -func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) { - if len(supplied) > 1 { - return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")} - } - var options Options[T] - if len(supplied) == 1 { - options = supplied[0] - } - var diagnostics []Diagnostic - definition := options.Definition - if !validProfileName(options.Profile) { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen")) - } - if options.Filesystem != nil { - diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) - } - if source.kind == sourceNone && options.Imports { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports")) - } - if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) - } - if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context")) - } - if definition.Version != nil { - if !definition.Version.Path.valid() { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) - } - if definition.Version.Check == nil { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) - } - } - return options, canonicalDiagnostics(diagnostics) -} - -func optionDiagnostic(code Code, message string) Diagnostic { - return Diagnostic{Code: code, Severity: SeverityError, Message: message} -} - -func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { - if rule == nil { - return nil - } - version := Version{} - node, present := documentAtPath(root, rule.Path) - if present { - version.Present = true - switch node.Kind { - case document.Null: - version.Null = true - case document.String: - version.Text = node.Text - default: - location, _ := nearestDocumentLocation(root, rule.Path) - primary := documentLocationPointer(location) - return []Diagnostic{{ - Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(), - Message: "schema version must be a string or null", Primary: primary, - }} - } - } - state := &validationState{ - active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, - invalidPath: CodeVersionError, document: root, - } - validation := &Validation{state: state} - err := rule.Check(version, validation) - state.active = false - if err != nil && !hasErrors(state.diagnostics) { - location, _ := nearestDocumentLocation(root, rule.Path) - state.diagnostics = append(state.diagnostics, Diagnostic{ - Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(), - Message: "schema-version check failed", Primary: documentLocationPointer(location), - }) - } - return canonicalDiagnostics(state.diagnostics) -} - -func documentLocationPointer(location document.Location) *Location { - if !locationKnown(location) { - return nil - } - return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} -} - -func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { - return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message} -} - -func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic { - return Diagnostic{ - Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message, - Primary: documentLocationPointer(issue.location), - } -} - -func hasErrors(diagnostics []Diagnostic) bool { - for _, diagnostic := range diagnostics { - if diagnostic.Severity == SeverityError { - return true - } - } - return false + resolved.configuration = configuration + // Publication detaches equal-valued validator replacements, preserving the + // validated secret state, then finalizes metadata and sensitivity. + return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator) } diff --git a/load_input.go b/load_input.go new file mode 100644 index 0000000..f62cc22 --- /dev/null +++ b/load_input.go @@ -0,0 +1,88 @@ +package configkit + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +// assembledInput is the source/document handoff across schema and version checks. +type assembledInput struct { + parsed parsedSource + document *document.Node + composition *composedDocument + sources []SourceInfo +} + +func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { + parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits()) + input := assembledInput{parsed: parsed, document: parsed.document} + if len(diagnostics) != 0 { + input.sources = sourceInfos(parsed.source) + return input, diagnostics + } + if options.Imports { + input.composition, input.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits()) + if len(diagnostics) != 0 { + return input, diagnostics + } + input.document = input.composition.root + } else { + input.sources = sourceInfos(parsed.source) + if options.Filesystem != nil && !activeProfile(options.Profile) { + if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { + return input, []Diagnostic{*diagnostic} + } + } + } + if !options.Imports && input.document != nil { + if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 { + return input, diagnostics + } + } + return input, nil +} + +func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) { + if !activeProfile(options.Profile) && len(options.Overrides) == 0 { + return input, nil + } + composition, diagnostics := compositionForInput(input) + if len(diagnostics) != 0 { + return input, diagnostics + } + if activeProfile(options.Profile) { + composition, input.sources, diagnostics = loadProfileDocument( + source, options.Filesystem, input.parsed, composition, input.sources, + options.Profile, options.Definition.Version, defaultImportLimits(), + ) + if len(diagnostics) != 0 { + return input, diagnostics + } + } + if len(options.Overrides) != 0 { + composition, input.sources, diagnostics = applyOverrides( + composition, input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(), + ) + if len(diagnostics) != 0 { + return input, diagnostics + } + } + input.composition = composition + input.document = composition.root + return input, nil +} + +// compositionForInput initializes one handoff for profiles and CLI, including +// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets. +func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) { + if input.composition != nil { + return input.composition, nil + } + if input.document == nil { + return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil + } + composition, diagnostics := composeDocuments(input.document) + if len(diagnostics) != 0 { + return nil, diagnostics + } + composition.inputBytes = input.parsed.bytes + composition.inputNodes = countInputNodes(input.document) + return composition, nil +} diff --git a/load_result.go b/load_result.go new file mode 100644 index 0000000..006e27f --- /dev/null +++ b/load_result.go @@ -0,0 +1,115 @@ +package configkit + +import ( + "reflect" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +// resolvedLoad carries typed phase outputs needed for validation and publication; +// it owns no source access, options, or long-lived schema state. +type resolvedLoad[T any] struct { + configuration T + decoded decodedConfiguration + metadata Metadata + final *document.Node + snapshots []resolutionSnapshot + applications []historyApplication +} + +func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) { + defaults := reflect.Zero(reflect.TypeFor[T]()) + if definition.Defaults != nil { + defaults = reflect.ValueOf(definition.Defaults()) + } + decoded, decodeIssues := decodeConfiguration(schema, defaults, input.document) + if len(decodeIssues) != 0 { + diagnostics := make([]Diagnostic, len(decodeIssues)) + for i, issue := range decodeIssues { + diagnostics[i] = diagnosticFromDecodeIssue(issue) + } + return decodedConfiguration{}, diagnostics + } + if input.composition != nil { + applyCompositionOrigins(&decoded, input.composition.origins) + } + + return decoded, nil +} + +func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, decoded decodedConfiguration) (resolvedLoad[T], []Diagnostic) { + var diagnostics []Diagnostic + configuration := decoded.value.Interface().(T) + metadata := metadataFromDecoded(decoded) + if input.composition != nil { + applyOverlayMetadata(&metadata, input.composition.overlays) + } + final := decoded.final + var resolutionSnapshots []resolutionSnapshot + if definition.Resolve != nil { + resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document) + diagnostics = append(diagnostics, resolved.diagnostics...) + if hasErrors(resolved.diagnostics) { + return resolvedLoad[T]{}, diagnostics + } + final = resolved.final + resolutionSnapshots = resolved.snapshots + applyResolutionMetadata(&metadata, resolved) + } + applications := []historyApplication(nil) + if input.composition != nil { + applications = cloneHistoryApplications(input.composition.applications) + } else if input.document != nil { + applications = []historyApplication{{layer: LayerBase, value: input.document}} + } + return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications}, diagnostics +} + +func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) { + detached, issue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), mode) + if issue != nil { + diagnostic := diagnosticFromDecodeIssue(decodeIssueFromProjection(issue)) + var zero T + return zero, &diagnostic + } + return detached.Interface().(T), nil +} + +func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Result[T], error) { + // A read-only validator can replace a value with an equal borrowed object. + // Detach without re-reading the environment or discarding validated state. + configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets) + if issue != nil { + accumulator.add(*issue) + return nil, accumulator.loadError() + } + decoded, metadata := resolved.decoded, resolved.metadata + final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots + finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) + secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) + return &Result[T]{ + Config: configuration, + metadata: metadata, + sources: cloneSourceInfos(accumulator.sources), + diagnostics: canonicalDiagnostics(accumulator.diagnostics), + baseline: decoded.baseline, + input: decoded.final, + inputOrigins: cloneOriginIndex(decoded.origins), + final: final, + resolution: resolutionSnapshots, + applications: applications, + secrets: secrets, + secretReferences: cloneSecretReferences(references), + }, nil +} + +func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { + return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message} +} + +func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic { + return Diagnostic{ + Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message, + Primary: documentLocationPointer(issue.location), + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..d226831 --- /dev/null +++ b/options.go @@ -0,0 +1,41 @@ +package configkit + +func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) { + if len(supplied) > 1 { + return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")} + } + var options Options[T] + if len(supplied) == 1 { + options = supplied[0] + } + var diagnostics []Diagnostic + definition := options.Definition + if !validProfileName(options.Profile) { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen")) + } + if options.Filesystem != nil { + diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) + } + if source.kind == sourceNone && options.Imports { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports")) + } + if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) + } + if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context")) + } + if definition.Version != nil { + if !definition.Version.Path.valid() { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) + } + if definition.Version.Check == nil { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) + } + } + return options, canonicalDiagnostics(diagnostics) +} + +func optionDiagnostic(code Code, message string) Diagnostic { + return Diagnostic{Code: code, Severity: SeverityError, Message: message} +} diff --git a/validation.go b/validation.go index ed1cca2..0d42a8b 100644 --- a/validation.go +++ b/validation.go @@ -186,3 +186,49 @@ func validationValueMutations(before, after *document.Node) []Diagnostic { } return diagnostics } + +func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { + if rule == nil { + return nil + } + version := Version{} + node, present := documentAtPath(root, rule.Path) + if present { + version.Present = true + switch node.Kind { + case document.Null: + version.Null = true + case document.String: + version.Text = node.Text + default: + location, _ := nearestDocumentLocation(root, rule.Path) + primary := documentLocationPointer(location) + return []Diagnostic{{ + Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(), + Message: "schema version must be a string or null", Primary: primary, + }} + } + } + state := &validationState{ + active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, + invalidPath: CodeVersionError, document: root, + } + validation := &Validation{state: state} + err := rule.Check(version, validation) + state.active = false + if err != nil && !hasErrors(state.diagnostics) { + location, _ := nearestDocumentLocation(root, rule.Path) + state.diagnostics = append(state.diagnostics, Diagnostic{ + Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(), + Message: "schema-version check failed", Primary: documentLocationPointer(location), + }) + } + return canonicalDiagnostics(state.diagnostics) +} + +func documentLocationPointer(location document.Location) *Location { + if !locationKnown(location) { + return nil + } + return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} +}