Make analyze input resolution deterministic

This commit is contained in:
2026-08-10 22:36:21 +00:00
parent af492c9e97
commit 131ffd9887
5 changed files with 260 additions and 139 deletions

View File

@@ -41,7 +41,7 @@ ID.
`ArtifactCatalog` tracks: `ArtifactCatalog` tracks:
- `planned`: source registered for run context; - `planned`: source registered for run context;
- `executable`: selected and enabled for analyze execution; - `executable`: included in the effective analyze artifact set;
- `available`: local file exists and validates; - `available`: local file exists and validates;
- `provenance`: availability source. - `provenance`: availability source.
@@ -92,7 +92,7 @@ Validation by content type:
`CollectPreviousArtifactRequirements`: `CollectPreviousArtifactRequirements`:
- scans enabled configured artifacts only; - scans the effective configured artifact set;
- extracts only canonical previous-session sources; - extracts only canonical previous-session sources;
- deduplicates by artifact key; - deduplicates by artifact key;
- merges required and optional references (required wins); - merges required and optional references (required wins);

View File

@@ -985,6 +985,8 @@ semantics, deterministic diagnostics, and executable remediation guidance.
mixed custom/built-in artifacts, repeated randomized map insertion, exact guidance, mixed custom/built-in artifacts, repeated randomized map insertion, exact guidance,
context construction, and final prompt inputs. Run deterministic tests repeatedly. context construction, and final prompt inputs. Run deterministic tests repeatedly.
**Status:** Completed.
## Stage 30 — Remove misleading contracts and align Audita ownership ## Stage 30 — Remove misleading contracts and align Audita ownership
**Read first:** `audit-findings.md` lines 31983228 (ARC-001), 32803305 **Read first:** `audit-findings.md` lines 31983228 (ARC-001), 32803305

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -46,6 +45,31 @@ type analyzeArtifactExecutionResult struct {
ReusedArtifacts []map[string]any ReusedArtifacts []map[string]any
} }
type analyzeExecutionContext struct {
Env *Env
Manifest *manifest.Manifest
Paths artifacts.SessionPaths
RunLayout runStageLayout
SessionID string
TranscriptRefs analyzeTranscriptInputs
Catalog *artifacts.ArtifactCatalog
}
type analyzeInputResolutionState uint8
const (
analyzeInputPresent analyzeInputResolutionState = iota
analyzeInputAbsent
analyzeInputError
)
type analyzeInputResolution struct {
State analyzeInputResolutionState
Path string
Artifact *artifacts.ResolvedSessionArtifact
Err error
}
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
if env == nil || env.Config == nil { if env == nil || env.Config == nil {
return nil, fmt.Errorf("analyze: stage environment config is required") return nil, fmt.Errorf("analyze: stage environment config is required")
@@ -118,8 +142,15 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}}, nil }}, nil
} }
transcriptRefs := discoverAnalyzeTranscriptRefs(m, paths) execution := analyzeExecutionContext{
sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath)) Env: env,
Manifest: m,
Paths: paths,
RunLayout: runLayout,
SessionID: sessionID,
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
Catalog: runtimeCatalog,
}
outputs := make([]artifacts.Ref, 0, len(plans)) outputs := make([]artifacts.Ref, 0, len(plans))
logs := []string{} logs := []string{}
@@ -129,18 +160,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
reusedSeen := map[string]struct{}{} reusedSeen := map[string]struct{}{}
for _, plan := range plans { for _, plan := range plans {
artifactResult, err := executeAnalyzeArtifact( artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan)
ctx,
env,
m,
paths,
runLayout,
sessionID,
sessionDir,
plan,
transcriptRefs,
runtimeCatalog,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -229,14 +249,17 @@ func orderSelectedScriptoriumArtifacts(
catalog *artifacts.ArtifactCatalog, catalog *artifacts.ArtifactCatalog,
) ([]string, error) { ) ([]string, error) {
selectedSet := map[string]struct{}{} selectedSet := map[string]struct{}{}
for _, key := range effective.Keys() { selected := effective.Keys()
for _, key := range selected {
selectedSet[key] = struct{}{} selectedSet[key] = struct{}{}
} }
for selectedKey := range selectedSet { dependencyErrors := []string{}
for _, selectedKey := range selected {
cfg, ok := artifactsCfg[selectedKey] cfg, ok := artifactsCfg[selectedKey]
if !ok { if !ok {
return nil, fmt.Errorf("selected artifact %q is not configured", selectedKey) dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey))
continue
} }
for _, dep := range cfg.DependsOn { for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep) trimmedDep := strings.TrimSpace(dep)
@@ -248,21 +271,25 @@ func orderSelectedScriptoriumArtifacts(
} }
sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep) sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep)
if !ok { if !ok {
return nil, fmt.Errorf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep) dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep))
continue
} }
entry, ok := catalog.Lookup(sourceID) entry, ok := catalog.Lookup(sourceID)
if !ok || !entry.Available { if !ok || !entry.Available {
return nil, fmt.Errorf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID) dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID))
} }
} }
} }
if len(dependencyErrors) > 0 {
return nil, errors.New(strings.Join(dependencyErrors, "; "))
}
indegree := map[string]int{} indegree := map[string]int{}
edges := map[string][]string{} edges := map[string][]string{}
for key := range selectedSet { for _, key := range selected {
indegree[key] = 0 indegree[key] = 0
} }
for key := range selectedSet { for _, key := range selected {
cfg := artifactsCfg[key] cfg := artifactsCfg[key]
for _, dep := range cfg.DependsOn { for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep) trimmedDep := strings.TrimSpace(dep)
@@ -308,16 +335,14 @@ func orderSelectedScriptoriumArtifacts(
func executeAnalyzeArtifact( func executeAnalyzeArtifact(
ctx context.Context, ctx context.Context,
env *Env, execution analyzeExecutionContext,
m *manifest.Manifest,
paths artifacts.SessionPaths,
runLayout runStageLayout,
sessionID string,
sessionDir string,
plan analyzeArtifactExecutionPlan, plan analyzeArtifactExecutionPlan,
transcriptRefs analyzeTranscriptInputs,
runtimeCatalog *artifacts.ArtifactCatalog,
) (*analyzeArtifactExecutionResult, error) { ) (*analyzeArtifactExecutionResult, error) {
env := execution.Env
paths := execution.Paths
runLayout := execution.RunLayout
sessionID := execution.SessionID
transcriptRefs := execution.TranscriptRefs
artifactName := plan.Name artifactName := plan.Name
artifactCfg := plan.Cfg artifactCfg := plan.Cfg
@@ -328,24 +353,27 @@ func executeAnalyzeArtifact(
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs) inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
for _, inputName := range inputNames { for _, inputName := range inputNames {
inputCfg := artifactCfg.Inputs[inputName] inputCfg := artifactCfg.Inputs[inputName]
resolvedPath, resolved, resolvedArtifact, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog) resolution := resolveScriptoriumInput(inputCfg, execution)
if resolveErr != nil { switch resolution.State {
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolveErr) case analyzeInputError:
} return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolution.Err)
if !resolved { case analyzeInputAbsent:
if inputCfg.Required { if inputCfg.Required {
return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName) return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName)
} }
omittedOptionalInputs = append(omittedOptionalInputs, inputName) omittedOptionalInputs = append(omittedOptionalInputs, inputName)
continue continue
case analyzeInputPresent:
inputPaths[inputName] = resolution.Path
default:
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
} }
inputPaths[inputName] = resolvedPath if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
reusedArtifacts = append(reusedArtifacts, map[string]any{ reusedArtifacts = append(reusedArtifacts, map[string]any{
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID), "name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
"source_id": resolvedArtifact.ID, "source_id": resolution.Artifact.ID,
"path": resolvedArtifact.Path, "path": resolution.Artifact.Path,
"provenance": resolvedArtifact.Provenance, "provenance": resolution.Artifact.Provenance,
}) })
} }
} }
@@ -603,91 +631,96 @@ func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPat
return resolved.Path, resolved.Provenance return resolved.Path, resolved.Provenance
} }
func resolveScriptoriumInput( func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution analyzeExecutionContext) analyzeInputResolution {
inputName string,
inputCfg config.ScriptoriumInputConfig,
m *manifest.Manifest,
paths artifacts.SessionPaths,
sessionDir string,
runtimeCatalog *artifacts.ArtifactCatalog,
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
source := strings.TrimSpace(inputCfg.Source) source := strings.TrimSpace(inputCfg.Source)
descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source) descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
if describeErr != nil { if describeErr != nil {
return "", false, nil, describeErr return analyzeInputFailure(describeErr)
} }
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput { if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, paths) resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
if err != nil { if err != nil {
if inputCfg.Required { if inputCfg.Required {
return "", false, nil, err return analyzeInputFailure(err)
} }
return "", false, nil, nil return analyzeInputMissing()
} }
return resolvedPath, ok, nil, nil if !ok {
return analyzeInputMissing()
}
return analyzeInputFound(resolvedPath, nil)
} }
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
if err == nil { if err == nil {
copy := resolved copy := resolved
return resolved.Path, true, &copy, nil return analyzeInputFound(resolved.Path, &copy)
} }
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if inputCfg.Required { if inputCfg.Required {
return "", false, nil, fmt.Errorf( return analyzeInputFailure(fmt.Errorf(
"required previous-session input source %q is unavailable; run narratio run-stage --force prepare", "required previous-session input source %q is unavailable; run narratio run-stage prepare %s --force",
source, source,
) execution.SessionID,
))
} }
return "", false, nil, nil return analyzeInputMissing()
} }
return "", false, nil, err return analyzeInputFailure(err)
} }
switch source {
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
if err == nil {
copy := resolved
return analyzeInputFound(resolved.Path, &copy)
}
if !errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
return analyzeInputFailure(err)
}
if !inputCfg.Required {
return analyzeInputMissing()
}
switch descriptor.Source.Kind {
case artifactpolicy.SourceKindExtraction:
return analyzeInputFailure(fmt.Errorf(
"required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then run narratio run-stage extract %s --force",
source,
descriptor.Source.ConfiguredKey,
execution.SessionID,
))
case artifactpolicy.SourceKindConfiguredArtifact:
return analyzeInputFailure(fmt.Errorf("configured artifact source %q is unavailable", source))
default: default:
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) return analyzeInputFailure(requiredBuiltInInputError(descriptor.Source.ID, execution))
if err == nil {
copy := resolved
return resolved.Path, true, &copy, nil
}
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if descriptor.Source.Kind == artifactpolicy.SourceKindExtraction {
if inputCfg.Required {
return "", false, nil, fmt.Errorf(
"required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then rerun extract with --force",
source,
descriptor.Source.ConfiguredKey,
)
}
return "", false, nil, nil
}
if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
if inputCfg.Required {
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
}
return "", false, nil, nil
}
switch descriptor.Source.ID {
case artifacts.ArtifactTranscriptPolished:
return "", false, nil, nil
case artifacts.ArtifactTranscriptFinal:
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
case artifacts.ArtifactTranscriptFinalTrimmed:
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
case artifacts.ArtifactTranscriptFinalMarkdown, artifacts.ArtifactTranscriptFinalTrimmedMarkdown:
return "", false, nil, fmt.Errorf(
"rendered markdown transcript input is unavailable for source %q; run narratio run-stage render %s --force",
descriptor.Source.ID,
paths.SessionID,
)
default:
return "", false, nil, nil
}
}
return "", false, nil, err
} }
} }
func analyzeInputFound(path string, artifact *artifacts.ResolvedSessionArtifact) analyzeInputResolution {
return analyzeInputResolution{State: analyzeInputPresent, Path: path, Artifact: artifact}
}
func analyzeInputMissing() analyzeInputResolution {
return analyzeInputResolution{State: analyzeInputAbsent}
}
func analyzeInputFailure(err error) analyzeInputResolution {
return analyzeInputResolution{State: analyzeInputError, Err: err}
}
func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error {
entry, ok := execution.Catalog.Lookup(source)
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
return fmt.Errorf("required built-in source %q is unavailable", source)
}
return fmt.Errorf(
"required built-in source %q is unavailable; run narratio run-stage %s %s --force",
source,
entry.ProducerStage,
execution.SessionID,
)
}
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) { func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
filename, ok := preparedStableInputFilename(sourceID) filename, ok := preparedStableInputFilename(sourceID)
if !ok { if !ok {
@@ -760,27 +793,6 @@ func buildAnalyzeRuntimeArtifactCatalog(
return catalog, nil return catalog, nil
} }
func resolveInputPathForRead(paths artifacts.SessionPaths, sessionDir, pathValue string) string {
trimmed := strings.TrimSpace(pathValue)
if trimmed == "" {
return ""
}
if filepath.IsAbs(trimmed) {
return filepath.Clean(trimmed)
}
candidates := []string{}
if strings.TrimSpace(sessionDir) != "" {
candidates = append(candidates, filepath.Clean(filepath.Join(sessionDir, trimmed)))
}
candidates = append(candidates, filepath.Clean(artifacts.ResolveSessionLocalPathForRead(paths, trimmed)))
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
return c
}
}
return candidates[0]
}
func resolveScriptoriumOutputPath(paths artifacts.SessionPaths, configured string) (string, error) { func resolveScriptoriumOutputPath(paths artifacts.SessionPaths, configured string) (string, error) {
outputPath := strings.TrimSpace(configured) outputPath := strings.TrimSpace(configured)
if outputPath == "" { if outputPath == "" {

View File

@@ -0,0 +1,44 @@
package stage
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestOrderSelectedScriptoriumArtifactsReportsUnavailableDependenciesDeterministically(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true, DependsOn: []string{"alpha_dep"}},
"alpha_dep": {Enabled: false, OutputPath: "artifacts/alpha_dep.md"},
"zeta": {Enabled: true, DependsOn: []string{"zeta_dep"}},
"zeta_dep": {Enabled: false, OutputPath: "artifacts/zeta_dep.md"},
}
effective, err := artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(configured),
[]string{"alpha", "zeta"},
)
if err != nil {
t.Fatalf("ResolveEffectiveArtifactSet() error = %v", err)
}
catalog, err := artifacts.BootstrapRuntimeCatalog(
artifacts.ConfiguredArtifactDefinitions(configured),
effective,
nil,
)
if err != nil {
t.Fatalf("BootstrapRuntimeCatalog() error = %v", err)
}
for i := 0; i < 100; i++ {
_, err := orderSelectedScriptoriumArtifacts(configured, effective, catalog)
if err == nil {
t.Fatal("orderSelectedScriptoriumArtifacts() error = nil, want unavailable dependency")
}
want := `artifact "alpha" depends on "alpha_dep", but "narratio.artifact.alpha_dep" is unavailable; artifact "zeta" depends on "zeta_dep", but "narratio.artifact.zeta_dep" is unavailable`
if !strings.Contains(err.Error(), want) {
t.Fatalf("attempt %d error = %q, want %q", i, err, want)
}
}
}

View File

@@ -445,8 +445,8 @@ func TestAnalyzeFailsWhenRequiredCanonicalPreviousRecapMissing(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "run narratio run-stage --force prepare") { if !strings.Contains(err.Error(), "narratio run-stage prepare 2026-05-03 --force") {
t.Fatalf("error = %q, want guidance to run force prepare", err.Error()) t.Fatalf("error = %q, want executable prepare guidance", err.Error())
} }
} }
@@ -767,8 +767,8 @@ func TestAnalyzeRequiredPreviousSessionArtifactInputGuidesPrepareForce(t *testin
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "run narratio run-stage --force prepare") { if !strings.Contains(err.Error(), "narratio run-stage prepare 2026-05-03 --force") {
t.Fatalf("error = %q, want guidance to run force prepare", err.Error()) t.Fatalf("error = %q, want executable prepare guidance", err.Error())
} }
} }
@@ -920,11 +920,77 @@ func TestAnalyzeFailsWhenTrimmedTranscriptMissing(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "trimmed transcript input is unavailable") { if !strings.Contains(err.Error(), "narratio.transcript.final_trimmed") || !strings.Contains(err.Error(), "narratio run-stage trim 2026-05-03 --force") {
t.Fatalf("error = %q, want missing trimmed transcript context", err.Error()) t.Fatalf("error = %q, want executable trimmed transcript guidance", err.Error())
} }
if !strings.Contains(err.Error(), "run trim stage first") { }
t.Fatalf("error = %q, want guidance to run trim stage first", err.Error())
func TestAnalyzeMissingOptionalBuiltInInputsAreOmitted(t *testing.T) {
for _, source := range []string{
artifacts.ArtifactTranscriptBase,
artifacts.ArtifactTranscriptPolished,
artifacts.ArtifactTranscriptFinal,
artifacts.ArtifactTranscriptFinalTrimmed,
artifacts.ArtifactTranscriptFinalMarkdown,
artifacts.ArtifactTranscriptFinalTrimmedMarkdown,
artifacts.ArtifactBoundsSession,
} {
t.Run(source, func(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs = map[string]config.ScriptoriumInputConfig{
"optional": {Source: source, Required: false},
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if _, ok := fake.RunRequests[0].InputPaths["optional"]; ok {
t.Fatalf("optional input = %q, want omission", fake.RunRequests[0].InputPaths["optional"])
}
})
}
}
func TestAnalyzeMissingRequiredBuiltInInputsProvideExecutableGuidance(t *testing.T) {
tests := []struct {
source string
stage string
}{
{source: artifacts.ArtifactTranscriptBase, stage: "merge"},
{source: artifacts.ArtifactTranscriptPolished, stage: "polish"},
{source: artifacts.ArtifactTranscriptFinal, stage: "normalize"},
{source: artifacts.ArtifactTranscriptFinalTrimmed, stage: "trim"},
{source: artifacts.ArtifactTranscriptFinalMarkdown, stage: "render"},
{source: artifacts.ArtifactTranscriptFinalTrimmedMarkdown, stage: "render"},
{source: artifacts.ArtifactBoundsSession, stage: "trim"},
}
for _, tt := range tests {
t.Run(tt.source, func(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs = map[string]config.ScriptoriumInputConfig{
"required": {Source: tt.source, Required: true},
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("Run() error = nil, want required input error")
}
command := "narratio run-stage " + tt.stage + " 2026-05-03 --force"
if !strings.Contains(err.Error(), tt.source) || !strings.Contains(err.Error(), command) {
t.Fatalf("Run() error = %q, want source %q and command %q", err, tt.source, command)
}
if len(fake.RunRequests) != 0 {
t.Fatalf("run requests = %d, want 0", len(fake.RunRequests))
}
})
} }
} }
@@ -1189,11 +1255,8 @@ func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "normalized transcript input is unavailable") { if !strings.Contains(err.Error(), "narratio.transcript.final") || !strings.Contains(err.Error(), "narratio run-stage normalize 2026-05-03 --force") {
t.Fatalf("error = %q, want missing normalized transcript context", err.Error()) t.Fatalf("error = %q, want executable normalized transcript guidance", err.Error())
}
if !strings.Contains(err.Error(), "run normalize stage first") {
t.Fatalf("error = %q, want guidance to run normalize stage first", err.Error())
} }
if len(fake.RunRequests) != 0 { if len(fake.RunRequests) != 0 {
t.Fatalf("run requests = %d, want 0 on missing normalized transcript", len(fake.RunRequests)) t.Fatalf("run requests = %d, want 0 on missing normalized transcript", len(fake.RunRequests))
@@ -1409,7 +1472,7 @@ func TestAnalyzeRequiredUnavailableExtractionFailsWithGuidance(t *testing.T) {
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m) _, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "pipeline.notarius output \"encounters\"") || !strings.Contains(err.Error(), "rerun extract with --force") { if err == nil || !strings.Contains(err.Error(), "pipeline.notarius output \"encounters\"") || !strings.Contains(err.Error(), "narratio run-stage extract 2026-05-03 --force") {
t.Fatalf("Run() error = %v, want actionable extraction guidance", err) t.Fatalf("Run() error = %v, want actionable extraction guidance", err)
} }
if len(fake.RunRequests) != 0 { if len(fake.RunRequests) != 0 {