Add incremental analysis work planning
This commit is contained in:
@@ -73,6 +73,19 @@ Supported source families:
|
||||
separately identifies manifest records removed from current configuration.
|
||||
A record is current only when its fingerprint version and value match and its
|
||||
configured output still passes manifest-authoritative evidence validation.
|
||||
- owns a read-only typed work planner. Its explicit targets are enabled
|
||||
artifacts by default or the exact normalized `--artifacts` selection when
|
||||
supplied. It closes targets over configured prerequisites, orders the closure
|
||||
topologically, reuses current members, and schedules every non-current member
|
||||
before its dependents.
|
||||
- force applies only to explicit targets. A current prerequisite is reused
|
||||
unless it is itself an explicit forced target; disabled prerequisites may be
|
||||
rebuilt when required, while unrelated disabled artifacts are excluded.
|
||||
- the work plan carries explicit targets, prerequisite-only work, deterministic
|
||||
execution and reuse lists, invalidated and removed records, and a cloned
|
||||
projected record collection. Valid unrelated configured records survive the
|
||||
projection, removed records are omitted, and legacy files never become
|
||||
current without regeneration.
|
||||
- resolves previous-session sources from local `previous/` cache only.
|
||||
- runs optional render-debug, then artifact execution.
|
||||
- validates non-empty output files and materializes canonical outputs.
|
||||
@@ -112,4 +125,5 @@ Supported source families:
|
||||
`internal/stage/analyze_fingerprint.go`,
|
||||
`internal/stage/analyze_fingerprint_test.go`,
|
||||
`internal/stage/analyze_reconciliation.go`, and
|
||||
`internal/stage/analyze_reconciliation_test.go`
|
||||
`internal/stage/analyze_reconciliation_test.go`,
|
||||
`internal/stage/analyze_plan.go`, and `internal/stage/analyze_plan_test.go`
|
||||
|
||||
@@ -541,6 +541,8 @@ fingerprint contract shared by resume validation, planning, and execution.
|
||||
|
||||
## Stage 11 — Incremental Analysis Work Planning
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
Turn reconciliation results, explicit selection, dependencies, and force into
|
||||
|
||||
231
internal/stage/analyze_plan.go
Normal file
231
internal/stage/analyze_plan.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
type analyzePlanRole string
|
||||
|
||||
const (
|
||||
analyzePlanTarget analyzePlanRole = "target"
|
||||
analyzePlanPrerequisite analyzePlanRole = "prerequisite"
|
||||
)
|
||||
|
||||
type analyzePlanAction string
|
||||
|
||||
const (
|
||||
analyzePlanExecute analyzePlanAction = "execute"
|
||||
analyzePlanReuse analyzePlanAction = "reuse"
|
||||
)
|
||||
|
||||
type analyzePlanItem struct {
|
||||
Key string
|
||||
Role analyzePlanRole
|
||||
Action analyzePlanAction
|
||||
Reason analyzeReconciliationReason
|
||||
Forced bool
|
||||
Dependencies []string
|
||||
ExpectedFingerprint string
|
||||
}
|
||||
|
||||
type analyzeWorkPlan struct {
|
||||
ExplicitTargets []string
|
||||
PrerequisiteWork []analyzePlanItem
|
||||
ExecutionOrder []analyzePlanItem
|
||||
ReusedCurrent []analyzePlanItem
|
||||
Invalidated []analyzePlanItem
|
||||
Removed []analyzePlanItem
|
||||
ProjectedRecords map[string]manifest.AnalyzeArtifactRecord
|
||||
}
|
||||
|
||||
// planAnalyzeWork turns selection and reconciliation into one deterministic,
|
||||
// read-only work plan. It never invokes adapters or mutates manifest state.
|
||||
func planAnalyzeWork(
|
||||
scriptoriumCfg *config.ScriptoriumConfig,
|
||||
selected []string,
|
||||
force bool,
|
||||
reconciliation analyzeReconciliation,
|
||||
) (analyzeWorkPlan, error) {
|
||||
if scriptoriumCfg == nil {
|
||||
return analyzeWorkPlan{}, nil
|
||||
}
|
||||
if err := config.ValidateScriptoriumArtifactDependencies(scriptoriumCfg.Artifacts); err != nil {
|
||||
return analyzeWorkPlan{}, err
|
||||
}
|
||||
effective, err := artifacts.ResolveEffectiveArtifactSet(
|
||||
artifacts.ConfiguredArtifactDefinitions(scriptoriumCfg.Artifacts),
|
||||
selected,
|
||||
)
|
||||
if err != nil {
|
||||
return analyzeWorkPlan{}, err
|
||||
}
|
||||
targets := effective.Keys()
|
||||
targetSet := stringSet(targets)
|
||||
closure, err := analyzeTargetDependencyClosure(scriptoriumCfg.Artifacts, targets)
|
||||
if err != nil {
|
||||
return analyzeWorkPlan{}, err
|
||||
}
|
||||
order, err := orderConfiguredAnalyzeArtifacts(scriptoriumCfg.Artifacts)
|
||||
if err != nil {
|
||||
return analyzeWorkPlan{}, err
|
||||
}
|
||||
|
||||
reconciled := make(map[string]analyzeArtifactReconciliation, len(reconciliation.Ordered))
|
||||
removed := make([]analyzePlanItem, 0)
|
||||
for _, item := range reconciliation.Ordered {
|
||||
if item.Reason == analyzeReconciliationRemoved {
|
||||
removed = append(removed, analyzePlanItem{Key: item.Key, Reason: item.Reason})
|
||||
continue
|
||||
}
|
||||
reconciled[item.Key] = item
|
||||
}
|
||||
sort.Slice(removed, func(i, j int) bool { return removed[i].Key < removed[j].Key })
|
||||
|
||||
plan := analyzeWorkPlan{
|
||||
ExplicitTargets: append([]string(nil), targets...),
|
||||
Removed: removed,
|
||||
ProjectedRecords: projectedAnalyzeRecords(scriptoriumCfg.Artifacts, reconciliation),
|
||||
}
|
||||
invalidated := make(map[string]struct{})
|
||||
for _, state := range reconciliation.Ordered {
|
||||
if _, configured := scriptoriumCfg.Artifacts[state.Key]; !configured || state.Stored == nil {
|
||||
continue
|
||||
}
|
||||
if state.Stored.Status == manifest.AnalyzeArtifactCurrent && state.Reason != analyzeReconciliationCurrent {
|
||||
plan.Invalidated = append(plan.Invalidated, analyzePlanItem{
|
||||
Key: state.Key, Reason: state.Reason, ExpectedFingerprint: state.ExpectedFingerprint,
|
||||
})
|
||||
invalidated[state.Key] = struct{}{}
|
||||
}
|
||||
}
|
||||
scheduled := make(map[string]bool, len(closure))
|
||||
for _, key := range order {
|
||||
if _, included := closure[key]; !included {
|
||||
continue
|
||||
}
|
||||
state, ok := reconciled[key]
|
||||
if !ok {
|
||||
return analyzeWorkPlan{}, fmt.Errorf("analysis reconciliation is missing configured artifact %q", key)
|
||||
}
|
||||
dependencies := normalizedAnalyzeDependencyKeys(scriptoriumCfg.Artifacts[key].DependsOn)
|
||||
dependencyWork := false
|
||||
for _, dependency := range dependencies {
|
||||
dependencyWork = dependencyWork || scheduled[dependency]
|
||||
}
|
||||
_, explicit := targetSet[key]
|
||||
forced := force && explicit
|
||||
needsWork := forced || state.Reason != analyzeReconciliationCurrent || dependencyWork
|
||||
item := analyzePlanItem{
|
||||
Key: key, Reason: state.Reason, Forced: forced,
|
||||
Dependencies: append([]string(nil), dependencies...),
|
||||
ExpectedFingerprint: state.ExpectedFingerprint,
|
||||
}
|
||||
if explicit {
|
||||
item.Role = analyzePlanTarget
|
||||
} else {
|
||||
item.Role = analyzePlanPrerequisite
|
||||
}
|
||||
if needsWork {
|
||||
item.Action = analyzePlanExecute
|
||||
scheduled[key] = true
|
||||
plan.ExecutionOrder = append(plan.ExecutionOrder, item)
|
||||
if !explicit {
|
||||
plan.PrerequisiteWork = append(plan.PrerequisiteWork, item)
|
||||
}
|
||||
if state.Stored != nil && state.Stored.Status == manifest.AnalyzeArtifactCurrent {
|
||||
if _, exists := invalidated[key]; !exists {
|
||||
plan.Invalidated = append(plan.Invalidated, item)
|
||||
invalidated[key] = struct{}{}
|
||||
}
|
||||
staleProjectedAnalyzeRecord(plan.ProjectedRecords, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
item.Action = analyzePlanReuse
|
||||
plan.ReusedCurrent = append(plan.ReusedCurrent, item)
|
||||
}
|
||||
sort.Slice(plan.Invalidated, func(i, j int) bool { return plan.Invalidated[i].Key < plan.Invalidated[j].Key })
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func analyzeTargetDependencyClosure(
|
||||
configured map[string]config.ScriptoriumArtifactConfig,
|
||||
targets []string,
|
||||
) (map[string]struct{}, error) {
|
||||
closure := make(map[string]struct{}, len(targets))
|
||||
var visit func(string) error
|
||||
visit = func(key string) error {
|
||||
if _, seen := closure[key]; seen {
|
||||
return nil
|
||||
}
|
||||
artifactCfg, ok := configured[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("selected artifact %q is not configured", key)
|
||||
}
|
||||
closure[key] = struct{}{}
|
||||
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
|
||||
if err := visit(dependency); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := visit(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return closure, nil
|
||||
}
|
||||
|
||||
func projectedAnalyzeRecords(
|
||||
configured map[string]config.ScriptoriumArtifactConfig,
|
||||
reconciliation analyzeReconciliation,
|
||||
) map[string]manifest.AnalyzeArtifactRecord {
|
||||
projected := make(map[string]manifest.AnalyzeArtifactRecord)
|
||||
for _, item := range reconciliation.Ordered {
|
||||
if item.Stored == nil || item.Reason == analyzeReconciliationRemoved {
|
||||
continue
|
||||
}
|
||||
if _, exists := configured[item.Key]; !exists {
|
||||
continue
|
||||
}
|
||||
record := *item.Stored
|
||||
if err := manifest.ValidateAnalyzeArtifactCollection(
|
||||
manifest.AnalyzeStateContractVersion,
|
||||
map[string]manifest.AnalyzeArtifactRecord{item.Key: record},
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
projected[item.Key] = record
|
||||
if item.Reason != analyzeReconciliationCurrent && record.Status == manifest.AnalyzeArtifactCurrent {
|
||||
staleProjectedAnalyzeRecord(projected, item.Key)
|
||||
}
|
||||
}
|
||||
return manifest.CloneAnalyzeArtifactCollection(projected)
|
||||
}
|
||||
|
||||
func staleProjectedAnalyzeRecord(records map[string]manifest.AnalyzeArtifactRecord, key string) {
|
||||
record, ok := records[key]
|
||||
if !ok || record.Status != manifest.AnalyzeArtifactCurrent {
|
||||
return
|
||||
}
|
||||
record.Status = manifest.AnalyzeArtifactStale
|
||||
record.Output = nil
|
||||
record.OutputSize = 0
|
||||
record.Error = ""
|
||||
records[key] = record
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
341
internal/stage/analyze_plan_test.go
Normal file
341
internal/stage/analyze_plan_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestPlanAnalyzeWorkConsumesSemanticReconciliation(t *testing.T) {
|
||||
env, m := currentAnalyzeReconciliationFixture(t)
|
||||
reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := planAnalyzeWork(env.Config.Pipeline.Scriptorium, nil, false, reconciled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAnalyzePlanKeys(t, "current execution", plan.ExecutionOrder, nil)
|
||||
assertAnalyzePlanKeys(t, "current reuse", plan.ReusedCurrent, []string{"session_recap"})
|
||||
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
recordPreparedAnalyzeInput(t, m, "narratio.input.players", filepath.Join(paths.InputsDir, "players.yml"), "players:\n - Hrank\n")
|
||||
reconciled, err = reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err = planAnalyzeWork(env.Config.Pipeline.Scriptorium, nil, false, reconciled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAnalyzePlanKeys(t, "changed execution", plan.ExecutionOrder, []string{"session_recap"})
|
||||
}
|
||||
|
||||
func TestPlanAnalyzeWorkSelectionAndDependencyClosure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configured map[string]config.ScriptoriumArtifactConfig
|
||||
selected []string
|
||||
force bool
|
||||
reasons map[string]analyzeReconciliationReason
|
||||
wantTargets []string
|
||||
wantExecution []string
|
||||
wantPrerequisiteWork []string
|
||||
wantReused []string
|
||||
wantForced []string
|
||||
}{
|
||||
{
|
||||
name: "default selection targets every enabled artifact",
|
||||
configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"alpha": {Enabled: true}, "zeta": {Enabled: true}, "disabled": {Enabled: false},
|
||||
},
|
||||
reasons: map[string]analyzeReconciliationReason{
|
||||
"alpha": analyzeReconciliationMissing, "zeta": analyzeReconciliationMissing, "disabled": analyzeReconciliationMissing,
|
||||
},
|
||||
wantTargets: []string{"alpha", "zeta"}, wantExecution: []string{"alpha", "zeta"},
|
||||
},
|
||||
{
|
||||
name: "partial selection closes over nested dependencies",
|
||||
configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"base": {Enabled: false}, "middle": {Enabled: false, DependsOn: []string{"base"}},
|
||||
"target": {Enabled: true, DependsOn: []string{"middle"}}, "unrelated": {Enabled: true},
|
||||
},
|
||||
selected: []string{"target"},
|
||||
reasons: map[string]analyzeReconciliationReason{
|
||||
"base": analyzeReconciliationMissing, "middle": analyzeReconciliationMissing,
|
||||
"target": analyzeReconciliationMissing, "unrelated": analyzeReconciliationMissing,
|
||||
},
|
||||
wantTargets: []string{"target"}, wantExecution: []string{"base", "middle", "target"},
|
||||
wantPrerequisiteWork: []string{"base", "middle"},
|
||||
},
|
||||
{
|
||||
name: "current prerequisite is reused",
|
||||
configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
|
||||
},
|
||||
selected: []string{"target"}, reasons: map[string]analyzeReconciliationReason{
|
||||
"base": analyzeReconciliationCurrent, "target": analyzeReconciliationStale,
|
||||
},
|
||||
wantTargets: []string{"target"}, wantExecution: []string{"target"}, wantReused: []string{"base"},
|
||||
},
|
||||
{
|
||||
name: "stale prerequisite schedules dependent after it",
|
||||
configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
|
||||
},
|
||||
selected: []string{"target"}, reasons: map[string]analyzeReconciliationReason{
|
||||
"base": analyzeReconciliationStale, "target": analyzeReconciliationCurrent,
|
||||
},
|
||||
wantTargets: []string{"target"}, wantExecution: []string{"base", "target"},
|
||||
wantPrerequisiteWork: []string{"base"},
|
||||
},
|
||||
{
|
||||
name: "force applies only to explicit target",
|
||||
configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
|
||||
},
|
||||
selected: []string{"target"}, force: true, reasons: map[string]analyzeReconciliationReason{
|
||||
"base": analyzeReconciliationCurrent, "target": analyzeReconciliationCurrent,
|
||||
},
|
||||
wantTargets: []string{"target"}, wantExecution: []string{"target"},
|
||||
wantReused: []string{"base"}, wantForced: []string{"target"},
|
||||
},
|
||||
{
|
||||
name: "explicit disabled target is valid",
|
||||
configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"disabled": {Enabled: false}, "unrelated": {Enabled: false},
|
||||
},
|
||||
selected: []string{"disabled"}, reasons: map[string]analyzeReconciliationReason{
|
||||
"disabled": analyzeReconciliationMissing, "unrelated": analyzeReconciliationMissing,
|
||||
},
|
||||
wantTargets: []string{"disabled"}, wantExecution: []string{"disabled"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reconciliation := analyzePlanningReconciliation(tt.configured, tt.reasons, nil)
|
||||
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: tt.configured}, tt.selected, tt.force, reconciliation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(plan.ExplicitTargets, tt.wantTargets) {
|
||||
t.Fatalf("targets = %#v, want %#v", plan.ExplicitTargets, tt.wantTargets)
|
||||
}
|
||||
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, tt.wantExecution)
|
||||
assertAnalyzePlanKeys(t, "prerequisite work", plan.PrerequisiteWork, tt.wantPrerequisiteWork)
|
||||
assertAnalyzePlanKeys(t, "reused", plan.ReusedCurrent, tt.wantReused)
|
||||
forced := make([]string, 0)
|
||||
for _, item := range plan.ExecutionOrder {
|
||||
if item.Forced {
|
||||
forced = append(forced, item.Key)
|
||||
}
|
||||
}
|
||||
if !(len(forced) == 0 && len(tt.wantForced) == 0) && !reflect.DeepEqual(forced, tt.wantForced) {
|
||||
t.Fatalf("forced = %#v, want %#v", forced, tt.wantForced)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAnalyzeWorkSchedulesEveryNonCurrentReason(t *testing.T) {
|
||||
reasons := []analyzeReconciliationReason{
|
||||
analyzeReconciliationStale,
|
||||
analyzeReconciliationMissing,
|
||||
analyzeReconciliationFailed,
|
||||
analyzeReconciliationLegacy,
|
||||
analyzeReconciliationNonResumable,
|
||||
}
|
||||
for _, reason := range reasons {
|
||||
t.Run(string(reason), func(t *testing.T) {
|
||||
configured := map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true}}
|
||||
plan, err := planAnalyzeWork(
|
||||
&config.ScriptoriumConfig{Artifacts: configured}, nil, false,
|
||||
analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{"target": reason}, nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
|
||||
if plan.ExecutionOrder[0].Reason != reason {
|
||||
t.Fatalf("reason = %q, want %q", plan.ExecutionOrder[0].Reason, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAnalyzeWorkRejectsUnknownSelectionAndInvalidGraphs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configured map[string]config.ScriptoriumArtifactConfig
|
||||
selected []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unknown selection", configured: map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true}},
|
||||
selected: []string{"missing"}, want: "not configured",
|
||||
},
|
||||
{
|
||||
name: "unknown dependency", configured: map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true, DependsOn: []string{"missing"}}},
|
||||
want: "is not configured",
|
||||
},
|
||||
{
|
||||
name: "cycle", configured: map[string]config.ScriptoriumArtifactConfig{
|
||||
"alpha": {Enabled: true, DependsOn: []string{"beta"}}, "beta": {Enabled: false, DependsOn: []string{"alpha"}},
|
||||
},
|
||||
want: "must not contain cycles",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: tt.configured}, tt.selected, false, analyzeReconciliation{})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testing.T) {
|
||||
configured := map[string]config.ScriptoriumArtifactConfig{
|
||||
"target": {Enabled: true}, "unrelated": {Enabled: true}, "invalid_unselected": {Enabled: true},
|
||||
}
|
||||
reasons := map[string]analyzeReconciliationReason{
|
||||
"target": analyzeReconciliationMissing, "unrelated": analyzeReconciliationCurrent,
|
||||
"invalid_unselected": analyzeReconciliationStale,
|
||||
}
|
||||
stored := map[string]*manifest.AnalyzeArtifactRecord{
|
||||
"unrelated": analyzePlanningCurrentRecord("unrelated"),
|
||||
"invalid_unselected": analyzePlanningCurrentRecord("invalid_unselected"),
|
||||
}
|
||||
reconciliation := analyzePlanningReconciliation(configured, reasons, stored)
|
||||
removed := analyzePlanningCurrentRecord("removed")
|
||||
reconciliation.Ordered = append(reconciliation.Ordered, analyzeArtifactReconciliation{
|
||||
Key: "removed", Reason: analyzeReconciliationRemoved, Stored: removed,
|
||||
})
|
||||
|
||||
before := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{
|
||||
"unrelated": *stored["unrelated"], "invalid_unselected": *stored["invalid_unselected"], "removed": *removed,
|
||||
})
|
||||
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := plan.ProjectedRecords["unrelated"].Status; got != manifest.AnalyzeArtifactCurrent {
|
||||
t.Fatalf("unrelated status = %q, want current", got)
|
||||
}
|
||||
if got := plan.ProjectedRecords["invalid_unselected"].Status; got != manifest.AnalyzeArtifactStale {
|
||||
t.Fatalf("invalid unselected status = %q, want stale", got)
|
||||
}
|
||||
if _, exists := plan.ProjectedRecords["removed"]; exists {
|
||||
t.Fatal("removed record survived projected authority")
|
||||
}
|
||||
assertAnalyzePlanKeys(t, "invalidated", plan.Invalidated, []string{"invalid_unselected"})
|
||||
assertAnalyzePlanKeys(t, "removed", plan.Removed, []string{"removed"})
|
||||
if !reflect.DeepEqual(before["unrelated"], *stored["unrelated"]) || !reflect.DeepEqual(before["invalid_unselected"], *stored["invalid_unselected"]) {
|
||||
t.Fatal("planning mutated stored records")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) {
|
||||
configured := map[string]config.ScriptoriumArtifactConfig{
|
||||
"target": {Enabled: true}, "legacy_unselected": {Enabled: true},
|
||||
}
|
||||
reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{
|
||||
"target": analyzeReconciliationLegacy, "legacy_unselected": analyzeReconciliationLegacy,
|
||||
}, nil)
|
||||
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
|
||||
if len(plan.ProjectedRecords) != 0 {
|
||||
t.Fatalf("legacy projection = %#v, want no current records", plan.ProjectedRecords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAnalyzeWorkOrderIsDeterministicAcrossMapInsertion(t *testing.T) {
|
||||
want := []string{"alpha", "middle", "zeta"}
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
configured := make(map[string]config.ScriptoriumArtifactConfig, 3)
|
||||
if attempt%2 == 0 {
|
||||
configured["zeta"] = config.ScriptoriumArtifactConfig{Enabled: true, DependsOn: []string{"middle"}}
|
||||
configured["middle"] = config.ScriptoriumArtifactConfig{Enabled: false, DependsOn: []string{"alpha"}}
|
||||
configured["alpha"] = config.ScriptoriumArtifactConfig{Enabled: false}
|
||||
} else {
|
||||
configured["alpha"] = config.ScriptoriumArtifactConfig{Enabled: false}
|
||||
configured["middle"] = config.ScriptoriumArtifactConfig{Enabled: false, DependsOn: []string{"alpha"}}
|
||||
configured["zeta"] = config.ScriptoriumArtifactConfig{Enabled: true, DependsOn: []string{"middle"}}
|
||||
}
|
||||
reasons := map[string]analyzeReconciliationReason{
|
||||
"alpha": analyzeReconciliationMissing, "middle": analyzeReconciliationMissing, "zeta": analyzeReconciliationMissing,
|
||||
}
|
||||
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, nil, false, analyzePlanningReconciliation(configured, reasons, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := analyzePlanKeys(plan.ExecutionOrder)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("attempt %d order = %#v, want %#v", attempt, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func analyzePlanningReconciliation(
|
||||
configured map[string]config.ScriptoriumArtifactConfig,
|
||||
reasons map[string]analyzeReconciliationReason,
|
||||
stored map[string]*manifest.AnalyzeArtifactRecord,
|
||||
) analyzeReconciliation {
|
||||
order, err := orderConfiguredAnalyzeArtifacts(configured)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
result := analyzeReconciliation{Ordered: make([]analyzeArtifactReconciliation, 0, len(order))}
|
||||
for _, key := range order {
|
||||
result.Ordered = append(result.Ordered, analyzeArtifactReconciliation{
|
||||
Key: key, Reason: reasons[key], ExpectedFingerprint: strings.Repeat(string(key[0]), 64), Stored: stored[key],
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func analyzePlanningCurrentRecord(key string) *manifest.AnalyzeArtifactRecord {
|
||||
return &manifest.AnalyzeArtifactRecord{
|
||||
Key: key, Status: manifest.AnalyzeArtifactCurrent,
|
||||
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||
Fingerprint: strings.Repeat("a", 64),
|
||||
Output: &manifest.ArtifactRecord{
|
||||
Kind: "scriptorium_artifact", SourceID: artifactpolicy.ConfiguredSourceID(key),
|
||||
LocalPath: "artifacts/" + key + ".md", ProducerRunID: "run-1", Checksum: strings.Repeat("b", 64),
|
||||
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
|
||||
},
|
||||
OutputSize: 1, ProducerRunID: "run-1", UpdatedAt: time.Unix(1, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func assertAnalyzePlanKeys(t *testing.T, label string, items []analyzePlanItem, want []string) {
|
||||
t.Helper()
|
||||
got := analyzePlanKeys(items)
|
||||
if len(got) == 0 && len(want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s = %#v, want %#v", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func analyzePlanKeys(items []analyzePlanItem) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.Key)
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user