Make analyze input resolution deterministic
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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 3198–3228 (ARC-001), 3280–3305
|
**Read first:** `audit-findings.md` lines 3198–3228 (ARC-001), 3280–3305
|
||||||
|
|||||||
@@ -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, ©, nil
|
return analyzeInputFound(resolved.Path, ©)
|
||||||
}
|
}
|
||||||
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, ©)
|
||||||
|
}
|
||||||
|
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, ©, 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 == "" {
|
||||||
|
|||||||
44
internal/stage/analyze_order_test.go
Normal file
44
internal/stage/analyze_order_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user