From 6abdd67bb57d7cf66281123540e3a7ca3aa217c9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 19:26:10 +0000 Subject: [PATCH] Add incremental analysis work planning --- docs/internal/stage-analyze.md | 16 +- docs/roadmap/implementation.md | 2 + internal/stage/analyze_plan.go | 231 +++++++++++++++++++ internal/stage/analyze_plan_test.go | 341 ++++++++++++++++++++++++++++ 4 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 internal/stage/analyze_plan.go create mode 100644 internal/stage/analyze_plan_test.go diff --git a/docs/internal/stage-analyze.md b/docs/internal/stage-analyze.md index ac787f1..25c6071 100644 --- a/docs/internal/stage-analyze.md +++ b/docs/internal/stage-analyze.md @@ -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` diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7ba3288..7d0b3aa 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/stage/analyze_plan.go b/internal/stage/analyze_plan.go new file mode 100644 index 0000000..198720a --- /dev/null +++ b/internal/stage/analyze_plan.go @@ -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 +} diff --git a/internal/stage/analyze_plan_test.go b/internal/stage/analyze_plan_test.go new file mode 100644 index 0000000..10ad1d9 --- /dev/null +++ b/internal/stage/analyze_plan_test.go @@ -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 +}