Extract explicit configuration loading phases
This commit is contained in:
@@ -237,3 +237,12 @@ func escapeControls(value string) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasErrors(diagnostics []Diagnostic) bool {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if diagnostic.Severity == SeverityError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
||||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
||||||
baseline/final trees, and initial default/input origins;
|
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
|
`imports.go`, filesystem context/containment in `filesystem.go`, directive
|
||||||
parsing/restrictions in `directives.go`, pattern-directed glob expansion in
|
parsing/restrictions in `directives.go`, pattern-directed glob expansion in
|
||||||
`glob.go`, version-report contexts
|
`glob.go`, version-report contexts
|
||||||
|
|||||||
@@ -293,7 +293,16 @@ profile/CLI provenance remains identical.
|
|||||||
|
|
||||||
## Stage 7 — Extract explicit loader lifecycle phases
|
## 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
|
Evidence: `loadWithSecretLookup` in [load.go](../../load.go) coordinates roughly
|
||||||
200 lines of source assembly, decoding, callbacks, history, secrets, and result
|
200 lines of source assembly, decoding, callbacks, history, secrets, and result
|
||||||
|
|||||||
274
load.go
274
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) {
|
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
input, diagnostics := assembleBaseInput(source, options)
|
||||||
if len(diagnostics) != 0 {
|
accumulator.sources = input.sources
|
||||||
if parsed.source != nil {
|
|
||||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
|
||||||
}
|
|
||||||
accumulator.add(diagnostics...)
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
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]())
|
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
||||||
if schemaIssue != nil {
|
if schemaIssue != nil {
|
||||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
|
||||||
versionDiagnostics := checkVersion(options.Definition.Version, inputDocument)
|
|
||||||
accumulator.add(versionDiagnostics...)
|
accumulator.add(versionDiagnostics...)
|
||||||
if hasErrors(versionDiagnostics) {
|
if hasErrors(versionDiagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if activeProfile(options.Profile) {
|
input, diagnostics = applyInputLayers(source, options, input)
|
||||||
if composition == nil {
|
accumulator.sources = input.sources
|
||||||
composition, diagnostics = composeDocuments(inputDocument)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
accumulator.add(diagnostics...)
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
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]())
|
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
|
||||||
if options.Definition.Defaults != nil {
|
accumulator.add(diagnostics...)
|
||||||
defaults = reflect.ValueOf(options.Definition.Defaults())
|
if len(diagnostics) != 0 {
|
||||||
}
|
|
||||||
decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument)
|
|
||||||
if len(decodeIssues) != 0 {
|
|
||||||
for _, issue := range decodeIssues {
|
|
||||||
accumulator.add(diagnosticFromDecodeIssue(issue))
|
|
||||||
}
|
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if composition != nil {
|
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
|
||||||
applyCompositionOrigins(&decoded, composition.origins)
|
accumulator.add(diagnostics...)
|
||||||
}
|
if hasErrors(diagnostics) {
|
||||||
|
|
||||||
configuration := decoded.value.Interface().(T)
|
|
||||||
metadata := metadataFromDecoded(decoded)
|
|
||||||
if composition != nil {
|
|
||||||
applyOverlayMetadata(&metadata, composition.overlays)
|
|
||||||
}
|
|
||||||
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()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
final = resolved.final
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
||||||
resolutionSnapshots = resolved.snapshots
|
|
||||||
applyResolutionMetadata(&metadata, resolved)
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
// Resolver assignments may borrow application-owned mutable values. Detach
|
|
||||||
// before secret resolution mutates reference fields or validation sees them.
|
// Detach resolver-borrowed values before reference mutation or validation.
|
||||||
detached, ownershipIssue := cloneConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits())
|
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
|
||||||
if ownershipIssue != nil {
|
if ownershipIssue != nil {
|
||||||
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
accumulator.add(*ownershipIssue)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
configuration = detached.Interface().(T)
|
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
|
||||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
|
||||||
accumulator.add(secretResolution.diagnostics...)
|
accumulator.add(secretResolution.diagnostics...)
|
||||||
if hasErrors(secretResolution.diagnostics) {
|
if hasErrors(secretResolution.diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if options.Definition.Validate != nil {
|
if options.Definition.Validate != nil {
|
||||||
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups)
|
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
|
||||||
accumulator.add(validationDiagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if hasErrors(validationDiagnostics) {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A read-only validator can replace a value with an equal borrowed object.
|
resolved.configuration = configuration
|
||||||
// Projected mutation checks cannot detect that alias, so detach at publication.
|
// Publication detaches equal-valued validator replacements, preserving the
|
||||||
published, ownershipIssue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), cloneValidatedSecrets)
|
// validated secret state, then finalizes metadata and sensitivity.
|
||||||
if ownershipIssue != nil {
|
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
88
load_input.go
Normal file
88
load_input.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
115
load_result.go
Normal file
115
load_result.go
Normal file
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
41
options.go
Normal file
41
options.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
@@ -186,3 +186,49 @@ func validationValueMutations(before, after *document.Node) []Diagnostic {
|
|||||||
}
|
}
|
||||||
return diagnostics
|
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}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user