Track the main version through sequence movement

This commit is contained in:
2026-10-24 01:11:28 +00:00
parent af84107905
commit 5bb4f364b7
8 changed files with 359 additions and 9 deletions

View File

@@ -209,7 +209,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
main := item("main", map[string]any{"id": "main", "schema": map[string]any{"version": "v1"}})
t.Run("same assigned main path is protected", func(t *testing.T) {
_, err := LoadLayered(main, testSecretBinding(), options(item("underlay", map[string]any{"id": "underlay", "schema": nil})))
_, err := LoadLayered(main, testSecretBinding(), options(item("underlay", map[string]any{"id": "main", "schema": nil})))
if got := groupedErrorCode(t, err); got != CodeUnderlayVersion {
t.Fatalf("protected item claim code = %s, want %s", got, CodeUnderlayVersion)
}

View File

@@ -27,11 +27,12 @@ func containsInheritanceDirective(root *document.Node) bool {
return visit(root)
}
func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic {
func projectInheritanceVersion(rule *VersionRule, expanded *inheritanceScope) (*VersionRule, *Diagnostic) {
if rule == nil || expanded == nil {
return nil
return rule, nil
}
for _, operation := range expanded.operations {
rule = projectVersionRule(rule, operation.sequences)
var location Location
var affected bool
if operation.control != nil {
@@ -50,13 +51,13 @@ func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope)
}
}
if affected {
return &Diagnostic{
return rule, &Diagnostic{
Code: CodeInheritanceVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "inheritance cannot change the schema-version path", Primary: &location,
}
}
}
return nil
return rule, nil
}
func inheritanceOperations(expanded *inheritanceScope) []inheritanceOperation {

View File

@@ -229,6 +229,8 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
}
}
if request.ordinary {
versionState.locateMainVersion(input.document)
mainOptions.Definition.Version = versionState.effectiveRule
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
} else {
input, diagnostics = assembleLayeredInput(request, input, accounting)
@@ -239,6 +241,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
request.effectiveVersion = versionState.effectiveRule
if request.ordinary && input.parsed.source != nil {
baseSources := make([]SourceInfo, 0, len(input.sources))
for _, source := range input.sources {
@@ -268,10 +271,13 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
accumulator.add(problems...)
return nil, accumulator.loadError()
}
if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil {
effectiveVersion, problem := projectInheritanceVersion(request.protectionVersion(), expanded)
if problem != nil {
accumulator.add(*problem)
return nil, accumulator.loadError()
}
request.effectiveVersion = effectiveVersion
versionState.effectiveRule = effectiveVersion
input.composition = composition
input.inheritance = expanded
input.document = expanded.root

View File

@@ -95,6 +95,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
var assembly *assemblyAccumulator
mainSourceCount := len(input.sources)
mainEvidence := input.versionState.evidenceSince(0)
projectVersion := sequenceVersionPath(request.protectionVersion())
for index, group := range request.underlays {
evidenceStart := len(input.versionState.evidenceSince(0))
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), accounting, input.versionState, request.sequencePolicy)
@@ -107,7 +108,13 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
if assembly != nil && assembly.current != nil {
lower = assembly.current.collectionPolicies
}
composition, diagnostics := composeUnderlayGroup(acquired, request.protectionVersion(), request.sequencePolicy, lower)
protectionRule := request.protectionVersion()
if projectVersion {
// The main item's final position is known only after tier assembly.
// Source-local checks still run during acquisition above.
protectionRule = nil
}
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, request.sequencePolicy, lower)
sourceStart := len(input.sources)
input.sources = append(input.sources, acquired.sources...)
if len(diagnostics) != 0 {
@@ -160,6 +167,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
}
if mainComposition != nil {
input.versionState.locateMainVersion(mainComposition.root)
if assembly == nil {
assembly = newAssemblyAccumulator(mainComposition)
} else {
@@ -175,6 +183,15 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
return input, append(versionDiagnostics, diagnostics...)
}
projectTierSourceDestinations(input.sources[:mainSourceCount], mainEvidence, operations)
input.versionState.projectMainVersion(operations)
}
}
if input.versionState != nil {
request.effectiveVersion = input.versionState.effectiveRule
}
if projectVersion && assembly != nil {
if diagnostic := projectedUnderlayVersionDiagnostic(assembly.current.applications, input.sources, request.protectionVersion()); diagnostic != nil {
return input, append(versionDiagnostics, *diagnostic)
}
}
if assembly == nil && activeOverlay(request.overlay) {

View File

@@ -172,7 +172,7 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
return sources, failures
}
overlayPayload := processed.payload
if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil {
if versionNode := fragmentVersionClaim(overlayPayload, rule); len(policies) == 0 && versionNode != nil {
location := publicLocation(versionNode.Location)
return sources, []Diagnostic{{
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
@@ -194,6 +194,18 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
if len(overlayDiagnostics) != 0 {
return sources, overlayDiagnostics
}
if len(policies) != 0 {
// The selected overlay retains one application with the actual item
// assignments. Raw list indexes cannot identify an upsert's destination.
application := base.current.applications[len(base.current.applications)-1]
if node := versionClaimFromApplication(application, rule); node != nil {
location := publicLocation(node.Location)
return sources, []Diagnostic{{
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
}}
}
}
base.current.inputBytes += int64(len(data))
base.current.inputNodes += nodeCount
return sources, nil

View File

@@ -0,0 +1,107 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func sequenceVersionPath(rule *VersionRule) bool {
if rule == nil {
return false
}
for _, segment := range rule.Path.segments {
if segment.kind == pathSegmentIndex {
return true
}
if segment.kind == pathSegmentParsed {
if _, index := schemaIndex(segment); index {
return true
}
}
}
return false
}
// Source-local checking and evidence keep their original coordinates. The
// effective rule follows the main declaration through structural placement.
func (state *sourceVersionState) locateMainVersion(root *document.Node) {
if state == nil || state.effectiveRule == nil {
return
}
path := state.effectiveRule.Path
if state.mainProcessing != nil && state.mainProcessing.evidence != nil {
path = state.mainProcessing.evidence.sensitivityPaths()[0]
} else if root != nil {
path = resolveSourceVersionPath(root, path).resolvedPath
}
rule := *state.effectiveRule
rule.Path = path.clone()
state.effectiveRule = &rule
}
func (state *sourceVersionState) projectMainVersion(operations []sequenceHistoryContribution) {
if state == nil {
return
}
state.effectiveRule = projectVersionRule(state.effectiveRule, operations)
}
func projectVersionRule(rule *VersionRule, operations []sequenceHistoryContribution) *VersionRule {
if rule == nil || len(operations) == 0 {
return rule
}
path, found := projectSequenceOperationPath(rule.Path, indexSequenceHistoryContributions(operations))
if !found {
return rule
}
projected := *rule
projected.Path = path
return &projected
}
func versionClaimFromApplication(application historyApplication, rule *VersionRule) *document.Node {
if rule == nil {
return nil
}
operation, affected := applicationOperationPath(application, rule.Path, nil)
if !affected {
return nil
}
local, found := historyApplicationLocalPath(application, operation)
if !found {
return nil
}
value, _ := documentAtPath(application.value, local)
return value
}
// Underlays precede the main tier, so missing fields are not removals of the
// later main declaration. Check authored claims in each source's assigned
// coordinates, without interpreting a whole-list initialization as a removal.
func projectedUnderlayVersionDiagnostic(applications []historyApplication, sources []SourceInfo, rule *VersionRule) *Diagnostic {
if rule == nil {
return nil
}
byID := make(map[string]SourceInfo, len(sources))
for _, source := range sources {
byID[source.ID] = source
}
for _, application := range applications {
if application.layer != LayerUnderlay || application.value == nil {
continue
}
var node *document.Node
if pathContains(rule.Path, application.path) {
node = application.value
} else if local, found := historyApplicationLocalPath(application, rule.Path); found {
node = fragmentVersionClaim(application.value, &VersionRule{Path: local})
}
if node == nil {
continue
}
code, message := CodeUnderlayVersion, "underlay inputs cannot contribute to the schema-version path"
if byID[application.value.Location.SourceID].ImportedFrom != nil {
code, message = CodeImportVersion, "imported fragments cannot contribute to the schema-version path"
}
location := publicLocation(node.Location)
return &Diagnostic{Code: code, Severity: SeverityError, Path: rule.Path.clone(), Message: message, Primary: &location}
}
return nil
}