diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 78bdbeb..bff978f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement shared source version checks -Status: Stages 1–4 complete; recursive import integration has not started. +Status: Stages 1–5 complete; direct underlay 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 @@ -343,6 +343,27 @@ The engine mode remains private; public options are scheduled separately. Validate: import/glob/destination/accounting/version tests and everyday check. Done: imports use one policy without lost warnings or changed physical traversal. +Completion evidence: threaded one per-load source-version state through the +main import entry and recursive traversal. Each parsed child is checked against +its source-local rule before placement; accepted headers are removed from an +owned payload while the original root still supplies raw byte/node charges and +the processor's original import declarations drive nested traversal. Effective +main ownership is checked against the remaining placed payload. Evidence is +retained with the acquired SourceID, and warning diagnostics accumulate without +stopping traversal. Existing identity, cycle, optional-source, source-record, +resource-context, and accounting paths remain in use. Added coverage for nested +and glob imports at mounted relative destinations, accepted unequal declarations, +rejected hidden declarations, required omissions with source-local locations, +optional missing imports, evidence identity, and raw header byte/node charges. The +existing default import-version failure coverage also passes. These checks +passed with Go `go1.26.8 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'SourceVersion|Version|Import|Glob|Destination' -count=1` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 6 — Integrate direct underlays and their strict groups 1. Thread state through acquireUnderlayGroup/grouped assembly. Direct underlays diff --git a/internal/engine/imports.go b/internal/engine/imports.go index 9833e8c..250ad17 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -45,10 +45,11 @@ type importAccounting struct { } type importTraversal struct { - access *filesystemAccess - accounting *importAccounting - limits importLimits - rule *VersionRule + access *filesystemAccess + accounting *importAccounting + limits importLimits + rule *VersionRule + versionState *sourceVersionState documents []*document.Node sources []SourceInfo @@ -62,12 +63,16 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse } func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) { + return loadImportedDocumentsTrackedWithVersion(source, context, main, destination, rule, limits, accounting, visited, hostToken, nil) +} + +func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) { if nativeContext(source, context) { access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context) if diagnostic != nil { return nil, sourceInfos(main.source), []Diagnostic{*diagnostic} } - return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited) + return loadImportedWithAccessTrackedWithVersion(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState) } rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main) if diagnostic != nil { @@ -82,7 +87,7 @@ func loadImportedDocumentsTracked(source Source, context *FilesystemContext, mai if hostToken != nil { access.token = hostToken } - return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited) + return loadImportedWithAccessTrackedWithVersion(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState) } func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) { @@ -90,6 +95,10 @@ func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, } func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) { + return loadImportedWithAccessTrackedWithVersion(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, nil) +} + +func loadImportedWithAccessTrackedWithVersion(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) { if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) { _ = access.close() return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)} @@ -118,7 +127,7 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath accounting.bytes += main.bytes accounting.nodes += countInputNodes(main.document) state := importTraversal{ - access: access, accounting: accounting, limits: limits, rule: rule, + access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState, documents: make([]*document.Node, 0, 1), sources: []SourceInfo{mainInfo}, active: make(map[resourceIdentity]int), visited: visited, @@ -139,7 +148,15 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath state.active[mainKey] = 0 state.visited[mainKey] = mainRoute state.stack = append(state.stack, mainRoute) - body, declarations, diagnostics := splitImportDirectives(main.document) + var body *document.Node + var declarations []importDeclaration + var diagnostics []Diagnostic + if versionState != nil && versionState.mode != sourceVersionModeDefault && versionState.mainProcessing != nil { + body = versionState.mainProcessing.payload + declarations = versionState.mainProcessing.imports + } else { + body, declarations, diagnostics = splitImportDirectives(main.document) + } if len(diagnostics) == 0 { _, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes) placementNodes := destinationWrapperNodeCount(destination) @@ -298,9 +315,20 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati } s.accounting.nodes += nodeCount - body, declarations, diagnostics := splitImportDirectives(root) - if len(diagnostics) != 0 { - return diagnostics + var body *document.Node + var declarations []importDeclaration + var diagnostics []Diagnostic + if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault { + processed, failures := s.versionState.process(root, sourceVersionContribution, destination, sourceID) + if len(failures) != 0 { + return failures + } + body, declarations = processed.payload, processed.imports + } else { + body, declarations, diagnostics = splitImportDirectives(root) + if len(diagnostics) != 0 { + return diagnostics + } } _, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes) if err != nil { @@ -311,10 +339,14 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)} } s.accounting.nodes += placementNodes - if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil { + protectionRule := s.rule + if s.versionState != nil && s.versionState.effectiveRule != nil { + protectionRule = s.versionState.effectiveRule + } + if versionNode := fragmentVersionClaim(placed, protectionRule); versionNode != nil { versionLocation := publicLocation(versionNode.Location) return []Diagnostic{{ - Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(), + Code: CodeImportVersion, Severity: SeverityError, Path: protectionRule.Path.clone(), Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation, }} } diff --git a/internal/engine/load.go b/internal/engine/load.go index 42fc4d0..f6d2160 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -170,9 +170,12 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo return nil, accumulator.loadError() } } - input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, request.definition.Version, request.sourceVersionMode) + versionState := &sourceVersionState{ + localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode, + } + input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState) accumulator.sources = input.sources - accumulator.add(input.versionDiagnostics...) + accumulator.add(versionState.diagnostics...) accumulator.add(diagnostics...) if hasErrors(diagnostics) { return nil, accumulator.loadError() diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 4f9887b..c01627a 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -8,14 +8,13 @@ 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 - versionEvidence *sourceVersionEvidence - versionDiagnostics []Diagnostic + parsed parsedSource + document *document.Node + composition *composedDocument + inheritance *inheritanceScope + sources []SourceInfo + contexts map[string]ResourceContext + versionState *sourceVersionState } func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { @@ -23,10 +22,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) + return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil) } -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) { +func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (assembledInput, []Diagnostic) { limits := defaultImportLimits() parseLimits := defaultParseLimits() parseLimits.source.remaining = limits.bytes - accounting.bytes @@ -34,7 +33,7 @@ func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T if parsed.source != nil { parsed.source.Destination = destination.clone() } - input := assembledInput{parsed: parsed, document: parsed.document} + input := assembledInput{parsed: parsed, document: parsed.document, versionState: versionState} if len(diagnostics) != 0 { input.sources = sourceInfos(parsed.source) // Mapping value issues already identify application content. Other early @@ -48,16 +47,19 @@ func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T } 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 versionState != nil && versionState.mode != sourceVersionModeDefault { + var sourceID string + if parsed.source != nil { + sourceID = parsed.source.ID } + processed, failures := versionState.process(parsed.document, sourceVersionMain, destination, sourceID) + if len(failures) != 0 { + return input, failures + } + versionState.mainProcessing = &processed } if options.Imports { - input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken) + input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithVersion(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState) if len(diagnostics) != 0 { return input, diagnostics } diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index cb610d7..faa6ca8 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -23,6 +23,7 @@ type sourceVersionAncestor struct { } type sourceVersionEvidence struct { + sourceID string path Path resolvedPath Path destination Path @@ -34,6 +35,35 @@ type sourceVersionEvidence struct { redacted bool } +type sourceVersionState struct { + localRule *VersionRule + effectiveRule *VersionRule + mode sourceVersionMode + mainProcessing *sourceVersionProcessingResult + evidence []*sourceVersionEvidence + diagnostics []Diagnostic +} + +func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string) (sourceVersionProcessingResult, []Diagnostic) { + if state == nil || state.mode == sourceVersionModeDefault { + return sourceVersionProcessingResult{}, nil + } + result := processSourceVersion(root, state.localRule, role, destination, state.mode) + if result.evidence != nil { + result.evidence.sourceID = sourceID + state.evidence = append(state.evidence, result.evidence) + } + var failures []Diagnostic + for _, diagnostic := range result.diagnostics { + if diagnostic.Severity == SeverityWarning { + state.diagnostics = append(state.diagnostics, diagnostic) + } else if diagnostic.Severity == SeverityError { + failures = append(failures, diagnostic) + } + } + return result, failures +} + type sourceVersionProcessingResult struct { payload *document.Node imports []importDeclaration diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 2f433b0..eaaeaf8 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -19,6 +19,17 @@ type sourceVersionNullConfig struct { Version *string `yaml:"version"` } +type sourceVersionImportGroup struct { + Name string `yaml:"name"` +} + +type sourceVersionImportConfig struct { + Service struct { + Version string `yaml:"version"` + Groups map[string]sourceVersionImportGroup `yaml:"groups"` + } `yaml:"service"` +} + func sourceVersionTestDocument(t *testing.T, input string) *document.Node { t.Helper() parsed, diagnostics := parseSource(Bytes("source-version.yml", []byte(input)), "source-version", defaultParseLimits()) @@ -592,6 +603,213 @@ func TestSourceVersionLoadRejectsOptionsBeforeReadingMain(t *testing.T) { requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOptionsInvalid, "") } +func TestSourceVersionImportsCheckNestedAndGlobSourcesBeforeComposition(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `version: main +configkit: + imports: + - glob: fragments/*.yml + destination: [groups] +`) + firstPath := writeImportFile(t, directory, "fragments/a.yml", `version: first +configkit: + imports: + - path: nested/child.yml + destination: [nested] +a: {name: A} +`) + nestedPath := writeImportFile(t, directory, "fragments/nested/child.yml", "version: nested\nname: N\n") + secondPath := writeImportFile(t, directory, "fragments/b.yml", "version: second\nb: {name: B}\n") + + checked := make(map[string]int) + loaded, err := LoadLayered(Input{ + Source: File(mainPath), Imports: true, Destination: Keys("service"), + }, testSecretBinding(), LayeredOptions[sourceVersionImportConfig]{ + SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[sourceVersionImportConfig]{Version: &VersionRule{ + Path: Keys("version"), + Check: func(version Version, validation *Validation) error { + checked[version.Text]++ + validation.Warning(Path{}, "accepted") + return nil + }, + }}, + }) + if err != nil || loaded == nil { + var loadErr *LoadError + if errors.As(err, &loadErr) { + t.Fatalf("source-local import load = %#v, diagnostics %#v", loaded, loadErr.Diagnostics()) + } + t.Fatalf("source-local import load = %#v, %v", loaded, err) + } + if loaded.Config.Service.Version != "main" || loaded.Config.Service.Groups["a"].Name != "A" || + loaded.Config.Service.Groups["b"].Name != "B" || loaded.Config.Service.Groups["nested"].Name != "N" { + t.Fatalf("mounted source-local imports = %#v", loaded.Config.Service) + } + if len(checked) != 4 || checked["main"] != 1 || checked["first"] != 1 || checked["nested"] != 1 || checked["second"] != 1 { + t.Fatalf("checked declarations = %#v, want each independent source once", checked) + } + warnings := testLoaded(loaded).Diagnostics() + if len(warnings) != 4 { + t.Fatalf("accepted source warnings = %#v, want one per checked source", warnings) + } + for _, warning := range warnings { + if warning.Code != CodeVersionWarning || warning.Severity != SeverityWarning { + t.Fatalf("accepted source warning = %#v", warning) + } + } + + wantDestinations := map[string]string{ + canonicalTestPath(t, mainPath): "/service", + canonicalTestPath(t, firstPath): "/service/groups", + canonicalTestPath(t, secondPath): "/service/groups", + canonicalTestPath(t, nestedPath): "/service/groups/nested", + } + for _, source := range testLoaded(loaded).Sources() { + want, found := wantDestinations[source.Path] + if !found || source.Destination.String() != want { + t.Errorf("source %q destination = %q, want %q", source.Path, source.Destination.String(), want) + } + delete(wantDestinations, source.Path) + } + if len(wantDestinations) != 0 { + t.Errorf("sources not retained: %#v", wantDestinations) + } +} + +func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) { + t.Parallel() + + t.Run("rejected child is not hidden by the main declaration", func(t *testing.T) { + t.Parallel() + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n") + writeImportFile(t, directory, "child.yml", "version: rejected\n") + checks := 0 + _, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{ + Imports: true, SourceVersionMode: sourceVersionModeIfPresent, + Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), Check: func(version Version, _ *Validation) error { + checks++ + if version.Text == "rejected" { + return errors.New("rejected") + } + return nil + }, + }}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("rejected child error = %T %v, want *LoadError", err, err) + } + diagnostic := requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionHookFailed, "/version") + if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || checks != 2 { + t.Fatalf("rejected child diagnostic/callbacks = %#v, %d", diagnostic, checks) + } + }) + + t.Run("required child omission reports its nearest source location", func(t *testing.T) { + t.Parallel() + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n") + writeImportFile(t, directory, "child.yml", "name: child\n") + checks := 0 + _, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{ + Imports: true, 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("required child omission = %T %v, want *LoadError", err, err) + } + diagnostic := requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionError, "/version") + if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 || checks != 1 { + t.Fatalf("required omission diagnostic/callbacks = %#v, %d", diagnostic, checks) + } + }) + + t.Run("optional missing child remains absent", func(t *testing.T) { + t.Parallel() + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `version: main +configkit: + imports: + - path: missing.yml + optional: true +`) + checks := 0 + loaded, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{ + Imports: true, SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{ + Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil }, + }}, + }) + if err != nil || loaded == nil || checks != 1 || len(testLoaded(loaded).Sources()) != 1 { + t.Fatalf("optional missing source = %#v, checks %d, %v", loaded, checks, err) + } + }) +} + +func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n") + childPath := writeImportFile(t, directory, "child.yml", "version: accepted\nname: child\n") + main := parseImportMain(t, mainPath) + child, diagnostics := parseSource(File(childPath), "source-child", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatalf("child parse diagnostics = %#v", diagnostics) + } + rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }} + state := &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired} + wantNodes := countInputNodes(main.document) + countInputNodes(child.document) + wantBytes := main.bytes + child.bytes + limits := defaultImportLimits() + limits.bytes = wantBytes + limits.nodes = wantNodes + + accounting := &importAccounting{} + composed, sources, foundDiagnostics := loadImportedDocumentsTrackedWithVersion( + File(mainPath), nil, main, Path{}, rule, limits, accounting, nil, nil, state, + ) + if len(foundDiagnostics) != 0 || composed == nil || len(sources) != 2 || accounting.nodes != wantNodes || accounting.bytes != wantBytes { + t.Fatalf("exact raw byte/node accounting = %#v, %d/%d, want %d/%d, %#v", composed, accounting.bytes, accounting.nodes, wantBytes, wantNodes, foundDiagnostics) + } + if len(state.evidence) != 1 || state.evidence[0].sourceID != "source-2" || !state.evidence[0].checked || state.evidence[0].version.Text != "accepted" { + t.Fatalf("acquired child evidence = %#v", state.evidence) + } + if _, present := documentAtPath(composed.root, Keys("version")); present { + t.Fatalf("consumed child header remains in application document: %#v", composed.root) + } + + below := defaultImportLimits() + below.nodes = wantNodes - 1 + accounting = &importAccounting{} + composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion( + File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil, + &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}, + ) + if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded { + t.Fatalf("raw header nodes were not charged: %#v, %#v", composed, foundDiagnostics) + } + + below = defaultImportLimits() + below.bytes = wantBytes - 1 + accounting = &importAccounting{} + composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion( + File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil, + &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}, + ) + if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded { + t.Fatalf("raw header bytes were not charged: %#v, %#v", composed, foundDiagnostics) + } +} + func mustParseSourceVersionPath(t *testing.T, input string) Path { t.Helper() path, err := ParsePath(input)