diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 926b32b..3fa465d 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -60,11 +60,20 @@ cover resource origins, context containment, capture, and selection. The `internal/prompt` requirement walker uses the captured parse trees and declaration metadata without executing templates or reading artifacts. It collects literal `input` names, direct root caller keys, and literal-key -`index` lookups. Branch-body references are conservatively conditional until -condition analysis is applied; invoked named templates are scanned -conservatively, and dependency-bearing range scopes make discovery incomplete. -Uncalled definitions and bypassed session templates are not traversed. This -internal walker does not change rendering behavior. +`index` lookups. For `if` and `with`, it classifies branches when their +conditions are simple supported values: direct values, literals, negation, or +two-string equality/inequality. It uses Go template truthiness, so a supplied +nonempty string such as `false` is true. Missing decision values and +content-dependent conditions leave dependency-bearing branches conditional +and report incomplete discovery; unresolved conditions with no branch +dependencies do not by themselves make discovery incomplete. Recognizable +references on known skipped paths remain optional inventory entries with an +inactive-use explanation, unless another applicable use establishes a +stronger classification. Skipped expressions are scanned only for +syntactically recognizable names and are not evaluated. Invoked named +templates are scanned conservatively, and dependency-bearing range scopes make +discovery incomplete. Uncalled definitions and bypassed session templates are +not traversed. This internal walker does not change rendering behavior. ## Profiles And Built-Ins diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6347be4..049f14a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -270,6 +270,10 @@ Run `go test ./internal/prompt ./internal/domain`; commit. ## Stage 3 — Classify Simple Branches And Inactive References +**Status: Complete (2026-10-08).** Supported `if`/`with` conditions classify +selected paths, while recognizable skipped-path references remain optional +inventory entries without introducing uncertainty. + **Outcome:** Ordinary supplied-context branches give useful classifications; inactive inputs and variables remain optional inventory. diff --git a/internal/prompt/requirements.go b/internal/prompt/requirements.go index aa39858..90fbf09 100644 --- a/internal/prompt/requirements.go +++ b/internal/prompt/requirements.go @@ -14,6 +14,7 @@ type requirementReachability uint8 const ( requirementDefinite requirementReachability = iota requirementPossible + requirementInactive ) type dotIdentity uint8 @@ -27,6 +28,7 @@ type inputEvidence struct { name string kind domain.RequirementKind declared bool + applicable bool contentType *string description *string explanation string @@ -35,6 +37,7 @@ type inputEvidence struct { type variableEvidence struct { name string kind domain.RequirementKind + applicable bool explanation string } @@ -44,6 +47,210 @@ type requirementIssueKey struct { variableName string } +type conditionEvaluation struct { + value any + known bool + unsupported bool + missingVariables map[string]struct{} + contentInputs map[string]struct{} +} + +func (evaluation conditionEvaluation) truth() bool { + truth, ok := template.IsTrue(evaluation.value) + return evaluation.known && ok && truth +} + +func (walker *requirementWalker) evaluateCondition(pipe *parse.PipeNode, reach requirementReachability, dot dotIdentity) conditionEvaluation { + if pipe == nil { + return conditionEvaluation{unsupported: true} + } + if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign { + return walker.evaluateCommand(pipe.Cmds[0], reach, dot) + } + before := walker.dependencies + visitReach := requirementPossible + if reach == requirementInactive { + visitReach = requirementInactive + } + if err := walker.visitPipe(pipe, visitReach, dot); err != nil { + return conditionEvaluation{unsupported: true} + } + return conditionEvaluation{unsupported: walker.dependencies != before} +} + +func (walker *requirementWalker) evaluateCommand(command *parse.CommandNode, reach requirementReachability, dot dotIdentity) conditionEvaluation { + if command == nil || len(command.Args) == 0 { + return conditionEvaluation{unsupported: true} + } + identifier, isIdentifier := command.Args[0].(*parse.IdentifierNode) + if isIdentifier { + switch identifier.Ident { + case "input": + if len(command.Args) == 2 { + if name, ok := command.Args[1].(*parse.StringNode); ok { + walker.recordInput(name.Text, reach) + return conditionEvaluation{contentInputs: map[string]struct{}{name.Text: {}}} + } + } + if reach == requirementInactive { + _ = walker.visitCommand(command, reach, dot) + return conditionEvaluation{} + } + before := walker.dependencies + _ = walker.visitCommand(command, requirementPossible, dot) + return conditionEvaluation{unsupported: walker.dependencies != before} + case "index": + if len(command.Args) == 3 { + if key, ok := command.Args[2].(*parse.StringNode); ok && isRootMap(command.Args[1], dot, walker.rootKnown) { + walker.recordOptionalVariable(key.Text, reach) + value, supplied := walker.vars[key.Text] + if !supplied { + value = "" + } + return conditionEvaluation{value: value, known: true} + } + } + case "not": + if len(command.Args) == 2 { + evaluation := walker.evaluateValue(command.Args[1], reach, dot) + if evaluation.known { + truth, ok := template.IsTrue(evaluation.value) + if ok { + evaluation.value = !truth + } else { + evaluation.known = false + evaluation.unsupported = true + } + } + return evaluation + } + case "eq", "ne": + if len(command.Args) == 3 { + left := walker.evaluateValue(command.Args[1], reach, dot) + right := walker.evaluateValue(command.Args[2], reach, dot) + result := mergeConditionEvaluations(left, right) + if left.known && right.known { + leftString, leftOK := left.value.(string) + rightString, rightOK := right.value.(string) + if leftOK && rightOK { + equal := leftString == rightString + if identifier.Ident == "ne" { + equal = !equal + } + result.value = equal + result.known = true + return result + } + result.unsupported = true + } + return result + } + } + } + if !isIdentifier && len(command.Args) == 1 { + return walker.evaluateValue(command.Args[0], reach, dot) + } + if reach == requirementInactive { + _ = walker.visitCommand(command, reach, dot) + return conditionEvaluation{} + } + before := walker.dependencies + _ = walker.visitCommand(command, requirementPossible, dot) + return conditionEvaluation{unsupported: walker.dependencies != before} +} + +func (walker *requirementWalker) evaluateValue(node parse.Node, reach requirementReachability, dot dotIdentity) conditionEvaluation { + switch node := node.(type) { + case *parse.StringNode: + return conditionEvaluation{value: node.Text, known: true} + case *parse.BoolNode: + return conditionEvaluation{value: node.True, known: true} + case *parse.NilNode: + return conditionEvaluation{value: nil, known: true} + case *parse.FieldNode: + if dot != dotRoot || len(node.Ident) == 0 { + return conditionEvaluation{unsupported: true} + } + name := node.Ident[0] + walker.recordVariable(name, reach) + if len(node.Ident) > 1 { + walker.addIssue("", name, "A chained field access is outside the direct-key scope.") + return conditionEvaluation{unsupported: true} + } + value, supplied := walker.vars[name] + if !supplied { + return conditionEvaluation{missingVariables: map[string]struct{}{name: {}}} + } + return conditionEvaluation{value: value, known: true} + case *parse.VariableNode: + if len(node.Ident) < 2 || node.Ident[0] != "$" || !walker.rootKnown { + return conditionEvaluation{unsupported: true} + } + name := node.Ident[1] + walker.recordVariable(name, reach) + if len(node.Ident) > 2 { + walker.addIssue("", name, "A chained field access is outside the direct-key scope.") + return conditionEvaluation{unsupported: true} + } + value, supplied := walker.vars[name] + if !supplied { + return conditionEvaluation{missingVariables: map[string]struct{}{name: {}}} + } + return conditionEvaluation{value: value, known: true} + case *parse.CommandNode: + return walker.evaluateCommand(node, reach, dot) + case *parse.PipeNode: + return walker.evaluateCondition(node, reach, dot) + case *parse.ChainNode: + before := walker.dependencies + visitReach := requirementPossible + if reach == requirementInactive { + visitReach = requirementInactive + } + _ = walker.visit(node.Node, visitReach, dot) + return conditionEvaluation{unsupported: walker.dependencies != before} + default: + return conditionEvaluation{unsupported: true} + } +} + +func mergeConditionEvaluations(left, right conditionEvaluation) conditionEvaluation { + result := conditionEvaluation{ + unsupported: left.unsupported || right.unsupported, + missingVariables: make(map[string]struct{}, len(left.missingVariables)+len(right.missingVariables)), + contentInputs: make(map[string]struct{}, len(left.contentInputs)+len(right.contentInputs)), + } + for name := range left.missingVariables { + result.missingVariables[name] = struct{}{} + } + for name := range right.missingVariables { + result.missingVariables[name] = struct{}{} + } + for name := range left.contentInputs { + result.contentInputs[name] = struct{}{} + } + for name := range right.contentInputs { + result.contentInputs[name] = struct{}{} + } + return result +} + +func branchReach(parent requirementReachability, known, truth, selected bool) requirementReachability { + if parent == requirementInactive { + return requirementInactive + } + if !known { + return requirementPossible + } + if truth == selected { + if parent == requirementPossible { + return requirementPossible + } + return requirementDefinite + } + return requirementInactive +} + type requirementWalker struct { ctx context.Context vars map[string]string @@ -158,39 +365,82 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab case *parse.ActionNode: return walker.visit(node.Pipe, reach, dot) case *parse.IfNode: - if err := walker.visit(node.Pipe, reach, dot); err != nil { + if reach == requirementInactive { + if err := walker.visit(node.Pipe, requirementInactive, dot); err != nil { + return err + } + if err := walker.visit(node.List, requirementInactive, dot); err != nil { + return err + } + return walker.visit(node.ElseList, requirementInactive, dot) + } + condition := walker.evaluateCondition(node.Pipe, reach, dot) + before := walker.dependencies + thenReach := branchReach(reach, condition.known, condition.truth(), true) + elseReach := branchReach(reach, condition.known, condition.truth(), false) + if err := walker.visit(node.List, thenReach, dot); err != nil { return err } - if err := walker.visit(node.List, requirementPossible, dot); err != nil { + if err := walker.visit(node.ElseList, elseReach, dot); err != nil { return err } - return walker.visit(node.ElseList, requirementPossible, dot) + if !condition.known && walker.dependencies != before { + walker.recordConditionLimitation(condition) + } + return nil case *parse.WithNode: - if err := walker.visit(node.Pipe, reach, dot); err != nil { + if reach == requirementInactive { + if err := walker.visit(node.Pipe, requirementInactive, dot); err != nil { + return err + } + if err := walker.visit(node.List, requirementInactive, dotNonRoot); err != nil { + return err + } + return walker.visit(node.ElseList, requirementInactive, dot) + } + condition := walker.evaluateCondition(node.Pipe, reach, dot) + before := walker.dependencies + thenReach := branchReach(reach, condition.known, condition.truth(), true) + elseReach := branchReach(reach, condition.known, condition.truth(), false) + if err := walker.visit(node.List, thenReach, dotNonRoot); err != nil { return err } - if err := walker.visit(node.List, requirementPossible, dotNonRoot); err != nil { + if err := walker.visit(node.ElseList, elseReach, dot); err != nil { return err } - return walker.visit(node.ElseList, requirementPossible, dot) + if !condition.known && walker.dependencies != before { + walker.recordConditionLimitation(condition) + } + return nil case *parse.RangeNode: if err := walker.visit(node.Pipe, reach, dot); err != nil { return err } before := walker.dependencies - if err := walker.visit(node.List, requirementPossible, dotNonRoot); err != nil { + bodyReach := requirementPossible + if reach == requirementInactive { + bodyReach = requirementInactive + } + if err := walker.visit(node.List, bodyReach, dotNonRoot); err != nil { return err } - if err := walker.visit(node.ElseList, requirementPossible, dot); err != nil { + if err := walker.visit(node.ElseList, bodyReach, dot); err != nil { return err } - if walker.dependencies != before { + if reach != requirementInactive && walker.dependencies != before { walker.addIssue("", "", "Dependencies inside a range are only conservatively enumerated.") } case *parse.TemplateNode: - if err := walker.visit(node.Pipe, requirementPossible, dot); err != nil { + pipeReach := requirementPossible + if reach == requirementInactive { + pipeReach = requirementInactive + } + if err := walker.visit(node.Pipe, pipeReach, dot); err != nil { return err } + if reach == requirementInactive { + return nil + } called := walker.templateSet.Lookup(node.Name) if called == nil || called.Tree == nil || called.Tree.Root == nil { walker.addIssue("", "", "An invoked named template is unavailable for syntax inspection.") @@ -216,7 +466,7 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab case *parse.FieldNode: if dot == dotRoot && len(node.Ident) > 0 { walker.recordVariable(node.Ident[0], reach) - if len(node.Ident) > 1 { + if len(node.Ident) > 1 && reach != requirementInactive { walker.addIssue("", node.Ident[0], "A chained field access is outside the direct-key scope.") } } @@ -224,14 +474,20 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab walker.visitVariable(node, reach) case *parse.ChainNode: before := walker.dependencies - if err := walker.visit(node.Node, requirementPossible, dot); err != nil { + chainReach := requirementPossible + if reach == requirementInactive { + chainReach = requirementInactive + } + if err := walker.visit(node.Node, chainReach, dot); err != nil { return err } - if walker.dependencies != before { + if reach != requirementInactive && walker.dependencies != before { walker.addIssue("", "", "A chained expression is not interpreted.") } case *parse.BreakNode, *parse.ContinueNode: - walker.addIssue("", "", "Loop control is outside the supported syntax scope.") + if reach != requirementInactive { + walker.addIssue("", "", "Loop control is outside the supported syntax scope.") + } } return nil } @@ -244,18 +500,24 @@ func (walker *requirementWalker) visitPipe(pipe *parse.PipeNode, reach requireme return walker.visitCommand(pipe.Cmds[0], reach, dot) } before := walker.dependencies + commandReach := requirementPossible + if reach == requirementInactive { + commandReach = requirementInactive + } for _, command := range pipe.Cmds { - if err := walker.visitCommand(command, requirementPossible, dot); err != nil { + if err := walker.visitCommand(command, commandReach, dot); err != nil { return err } } - if walker.dependencies != before { + if reach != requirementInactive && walker.dependencies != before { walker.addIssue("", "", "A pipeline or variable assignment is not interpreted.") } - for _, declaration := range pipe.Decl { - if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" { - walker.rootKnown = false - walker.addIssue("", "", "The root template binding is reassigned.") + if reach != requirementInactive { + for _, declaration := range pipe.Decl { + if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" { + walker.rootKnown = false + walker.addIssue("", "", "The root template binding is reassigned.") + } } } return nil @@ -274,11 +536,17 @@ func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach } } for _, argument := range command.Args[1:] { - if err := walker.visit(argument, requirementPossible, dot); err != nil { + argumentReach := requirementPossible + if reach == requirementInactive { + argumentReach = requirementInactive + } + if err := walker.visit(argument, argumentReach, dot); err != nil { return err } } - walker.addIssue("", "", "An input name is not a direct string literal.") + if reach != requirementInactive { + walker.addIssue("", "", "An input name is not a direct string literal.") + } return nil } if isIdentifier && identifier.Ident == "index" && len(command.Args) == 3 { @@ -296,11 +564,15 @@ func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach if isIdentifier && index == 0 { continue } - if err := walker.visit(argument, requirementPossible, dot); err != nil { + argumentReach := requirementPossible + if reach == requirementInactive { + argumentReach = requirementInactive + } + if err := walker.visit(argument, argumentReach, dot); err != nil { return err } } - if walker.dependencies != before { + if reach != requirementInactive && walker.dependencies != before { walker.addIssue("", "", "A function call or computed expression is not interpreted.") } return nil @@ -311,11 +583,13 @@ func (walker *requirementWalker) visitVariable(variable *parse.VariableNode, rea return } if !walker.rootKnown { - walker.addIssue("", "", "The root template binding is not known.") + if reach != requirementInactive { + walker.addIssue("", "", "The root template binding is not known.") + } return } walker.recordVariable(variable.Ident[1], reach) - if len(variable.Ident) > 2 { + if len(variable.Ident) > 2 && reach != requirementInactive { walker.addIssue("", variable.Ident[1], "A chained field access is outside the direct-key scope.") } } @@ -340,14 +614,22 @@ func (walker *requirementWalker) recordInput(name string, reach requirementReach } walker.inputs[name] = evidence } - walker.promoteInput(evidence, kindForReach(reach)) - if evidence.kind == domain.RequirementRequired { - evidence.explanation = "" - } else if reach == requirementPossible { - evidence.explanation = "This reference is not known to be reached on every execution path." - walker.addIssue(name, "", "A dependency is conservatively classified.") + if reach == requirementInactive { + if !evidence.applicable && evidence.kind != domain.RequirementRequired { + evidence.explanation = "This input is referenced only in a skipped template path." + } + } else { + evidence.applicable = true + walker.promoteInput(evidence, kindForReach(reach)) + if evidence.kind == domain.RequirementRequired { + evidence.explanation = "" + } else if reach == requirementPossible { + evidence.explanation = "This reference is not known to be reached on every execution path." + } + } + if reach != requirementInactive { + walker.dependencies++ } - walker.dependencies++ } func (walker *requirementWalker) recordVariable(name string, reach requirementReachability) { @@ -356,26 +638,43 @@ func (walker *requirementWalker) recordVariable(name string, reach requirementRe evidence = &variableEvidence{name: name, kind: domain.RequirementOptional} walker.variables[name] = evidence } - walker.promoteVariable(evidence, kindForReach(reach)) - if evidence.kind == domain.RequirementRequired { - evidence.explanation = "" - } else if reach == requirementPossible { - evidence.explanation = "This reference is not known to be reached on every execution path." - walker.addIssue("", name, "A dependency is conservatively classified.") + if reach == requirementInactive { + if !evidence.applicable { + evidence.explanation = "This variable is referenced only in a skipped template path." + } + } else { + evidence.applicable = true + walker.promoteVariable(evidence, kindForReach(reach)) + if evidence.kind == domain.RequirementRequired { + evidence.explanation = "" + } else if reach == requirementPossible { + evidence.explanation = "This reference is not known to be reached on every execution path." + } + } + if reach != requirementInactive { + walker.dependencies++ } - walker.dependencies++ } -func (walker *requirementWalker) recordOptionalVariable(name string, _ requirementReachability) { +func (walker *requirementWalker) recordOptionalVariable(name string, reach requirementReachability) { evidence := walker.variables[name] if evidence == nil { evidence = &variableEvidence{name: name, kind: domain.RequirementOptional} walker.variables[name] = evidence } - if evidence.kind == domain.RequirementOptional { + if reach == requirementInactive { + if !evidence.applicable { + evidence.explanation = "This variable is referenced only in a skipped template path." + } + } else { + evidence.applicable = true + } + if evidence.kind == domain.RequirementOptional && reach != requirementInactive { evidence.explanation = "This key is read through an optional map lookup." } - walker.dependencies++ + if reach != requirementInactive { + walker.dependencies++ + } } func (walker *requirementWalker) promoteInput(evidence *inputEvidence, kind domain.RequirementKind) { @@ -415,9 +714,13 @@ func requirementRank(kind domain.RequirementKind) int { } func (walker *requirementWalker) addIssue(inputName, variableName, description string) { + walker.addIssueCode(domain.RequirementIssueUnsupportedConstruct, inputName, variableName, description) +} + +func (walker *requirementWalker) addIssueCode(code domain.RequirementIssueCode, inputName, variableName, description string) { walker.complete = false issue := domain.RequirementIssue{ - Code: domain.RequirementIssueUnsupportedConstruct, + Code: code, InputName: inputName, VariableName: variableName, Description: description, @@ -426,6 +729,18 @@ func (walker *requirementWalker) addIssue(inputName, variableName, description s walker.issues[key] = issue } +func (walker *requirementWalker) recordConditionLimitation(condition conditionEvaluation) { + for name := range condition.missingVariables { + walker.addIssueCode(domain.RequirementIssueMissingVariable, "", name, "A missing variable prevents selecting a dependency-bearing branch.") + } + for name := range condition.contentInputs { + walker.addIssueCode(domain.RequirementIssueInputContent, name, "", "Captured input content affects branch selection.") + } + if condition.unsupported || (len(condition.missingVariables) == 0 && len(condition.contentInputs) == 0) { + walker.addIssue("", "", "A dependency-bearing branch condition is not resolved by the supported syntax analysis.") + } +} + func (walker *requirementWalker) result(result domain.RequirementInspection) domain.RequirementInspection { result.Complete = walker.complete result.Inputs = make([]domain.InputRequirement, 0, len(walker.inputs)) diff --git a/internal/prompt/requirements_test.go b/internal/prompt/requirements_test.go index 88a45f1..d9b6688 100644 --- a/internal/prompt/requirements_test.go +++ b/internal/prompt/requirements_test.go @@ -93,7 +93,7 @@ func TestAnalyzeRequirementsSeparatesRootAndLocalTemplateValues(t *testing.T) { if result.Variables[0].Kind != domain.RequirementRequired || result.Variables[1].Kind != domain.RequirementConditional || result.Complete { t.Fatalf("with scope and unresolved branch should be conservative: %+v", result) } - if len(result.Issues) != 1 || result.Issues[0].VariableName != "root" { + if len(result.Issues) != 1 || result.Issues[0].Code != domain.RequirementIssueMissingVariable || result.Issues[0].VariableName != "context" { t.Fatalf("conditional root use should have one associated limitation: %+v", result.Issues) } } @@ -115,7 +115,7 @@ func TestAnalyzeRequirementsMarksBranchAndUnsupportedScopeReferences(t *testing. if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantVars) { t.Fatalf("variable names = %v, want %v", got, wantVars) } - if result.Complete || len(result.Issues) < 3 { + if result.Complete || len(result.Issues) < 2 { t.Fatalf("branch/range limitations were not reported: %+v", result) } for _, input := range result.Inputs { @@ -169,6 +169,197 @@ func TestAnalyzeRequirementsScansInvokedDefinitionsConservatively(t *testing.T) } } +func TestAnalyzeRequirementsClassifiesKnownConditions(t *testing.T) { + tests := []struct { + name string + condition string + vars map[string]string + wantActive string + wantSkipped string + }{ + {name: "string false is truthy", condition: `.flag`, vars: map[string]string{"flag": "false"}, wantActive: "active", wantSkipped: "inactive"}, + {name: "empty direct value is false", condition: `.flag`, vars: map[string]string{"flag": ""}, wantActive: "inactive", wantSkipped: "active"}, + {name: "simple equality", condition: `eq .mode "active"`, vars: map[string]string{"mode": "active"}, wantActive: "active", wantSkipped: "inactive"}, + {name: "simple inequality", condition: `ne .mode "active"`, vars: map[string]string{"mode": "other"}, wantActive: "active", wantSkipped: "inactive"}, + {name: "not direct value", condition: `not .flag`, vars: map[string]string{"flag": ""}, wantActive: "active", wantSkipped: "inactive"}, + {name: "literal true", condition: `true`, wantActive: "active", wantSkipped: "inactive"}, + {name: "literal false", condition: `false`, wantActive: "inactive", wantSkipped: "active"}, + {name: "missing index is empty", condition: `index . "toggle"`, wantActive: "inactive", wantSkipped: "active"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{ + Role: "user", Content: `{{if ` + test.condition + `}}{{input "active"}}{{else}}{{input "inactive"}}{{end}}`, + }}} + snapshot := RequirementSnapshot{Templates: CompiledTemplates{Messages: []*template.Template{ + compileRequirementTemplate(t, "message", definition.Templates[0].Content), + }}} + result, err := AnalyzeRequirements(context.Background(), definition, snapshot, test.vars, false) + if err != nil { + t.Fatal(err) + } + if !result.Complete || len(result.Issues) != 0 { + t.Fatalf("known condition should produce a complete result: %+v", result) + } + inputs := make(map[string]domain.InputRequirement, len(result.Inputs)) + for _, input := range result.Inputs { + inputs[input.Name] = input + } + if inputs[test.wantActive].Kind != domain.RequirementRequired || inputs[test.wantSkipped].Kind != domain.RequirementOptional || inputs[test.wantSkipped].Explanation == "" { + t.Fatalf("selected/skipped inputs were misclassified: %+v", result.Inputs) + } + if test.condition == `index . "toggle"` { + if len(result.Variables) != 1 || result.Variables[0].Name != "toggle" || result.Variables[0].Kind != domain.RequirementOptional || result.Variables[0].Supplied { + t.Fatalf("missing index key was not retained as optional: %+v", result.Variables) + } + } + + artifacts := map[string]*domain.Artifact{ + "active": {Name: "active", Body: []byte("active")}, + "inactive": {Name: "inactive", Body: []byte("inactive")}, + } + rendered, err := NewGoRenderer().Render(context.Background(), definition, artifacts, test.vars) + if err != nil { + t.Fatal(err) + } + selected := "active" + if inputs[selected].Kind != domain.RequirementRequired { + selected = "inactive" + } + if len(rendered.Messages) != 1 || rendered.Messages[0].Content != selected { + t.Fatalf("analysis and renderer disagree: result=%+v rendered=%+v", result, rendered) + } + }) + } +} + +func TestAnalyzeRequirementsHandlesWithDotAndNestedReachability(t *testing.T) { + definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{ + Role: "user", Content: `{{with .context}}{{input "inside"}} {{.child}} {{$.root}}{{else}}{{input "empty"}}{{end}}`, + }}} + snapshot := RequirementSnapshot{Templates: CompiledTemplates{Messages: []*template.Template{ + compileRequirementTemplate(t, "message", definition.Templates[0].Content), + }}} + result, err := AnalyzeRequirements(context.Background(), definition, snapshot, map[string]string{"context": "value", "root": "root"}, false) + if err != nil { + t.Fatal(err) + } + if !result.Complete || len(result.Inputs) != 2 || len(result.Variables) != 2 { + t.Fatalf("known with condition should have a complete inventory: %+v", result) + } + if result.Inputs[0].Name != "empty" || result.Inputs[0].Kind != domain.RequirementOptional || + result.Inputs[1].Name != "inside" || result.Inputs[1].Kind != domain.RequirementRequired { + t.Fatalf("with body and else reachability is incorrect: %+v", result.Inputs) + } + if result.Variables[0].Name != "context" || result.Variables[0].Kind != domain.RequirementRequired || + result.Variables[1].Name != "root" || result.Variables[1].Kind != domain.RequirementRequired { + t.Fatalf("with dot was confused with the root caller map: %+v", result.Variables) + } + + nested := compileRequirementTemplate(t, "nested", `{{if .outer}}{{if .inner}}{{input "inner"}}{{else}}{{input "inner_else"}}{{end}}{{else}}{{input "outer_else"}}{{end}}`) + nestedResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ + Templates: CompiledTemplates{Messages: []*template.Template{nested}}, + }, map[string]string{"outer": "yes", "inner": "yes"}, false) + if err != nil { + t.Fatal(err) + } + if !nestedResult.Complete || nestedResult.Inputs[0].Name != "inner" || nestedResult.Inputs[0].Kind != domain.RequirementRequired || + nestedResult.Inputs[1].Name != "inner_else" || nestedResult.Inputs[1].Kind != domain.RequirementOptional || + nestedResult.Inputs[2].Name != "outer_else" || nestedResult.Inputs[2].Kind != domain.RequirementOptional { + t.Fatalf("known nested conditions lost selected or inactive reachability: %+v", nestedResult) + } +} + +func TestAnalyzeRequirementsReportsUnresolvedDecisionAndContentConditions(t *testing.T) { + missing := compileRequirementTemplate(t, "missing", `{{if eq .mode "active"}}{{input "yes"}}{{else}}{{input "no"}}{{end}}`) + missingResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ + Templates: CompiledTemplates{Messages: []*template.Template{missing}}, + }, nil, false) + if err != nil { + t.Fatal(err) + } + if missingResult.Complete || len(missingResult.Issues) != 1 || missingResult.Issues[0].Code != domain.RequirementIssueMissingVariable || + missingResult.Issues[0].VariableName != "mode" || missingResult.Inputs[0].Kind != domain.RequirementConditional || + missingResult.Inputs[1].Kind != domain.RequirementConditional { + t.Fatalf("missing decision value did not produce conditional dependencies: %+v", missingResult) + } + + content := compileRequirementTemplate(t, "content", `{{if input "selector"}}{{input "yes"}}{{else}}{{input "no"}}{{end}}`) + contentResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ + Templates: CompiledTemplates{Messages: []*template.Template{content}}, + }, nil, false) + if err != nil { + t.Fatal(err) + } + contentInputs := make(map[string]domain.InputRequirement, len(contentResult.Inputs)) + for _, input := range contentResult.Inputs { + contentInputs[input.Name] = input + } + if contentResult.Complete || len(contentResult.Issues) != 1 || contentResult.Issues[0].Code != domain.RequirementIssueInputContent || + contentResult.Issues[0].InputName != "selector" || contentInputs["selector"].Kind != domain.RequirementRequired || + contentInputs["yes"].Kind != domain.RequirementConditional || contentInputs["no"].Kind != domain.RequirementConditional { + t.Fatalf("content-dependent condition did not preserve known dependencies: %+v", contentResult) + } + + textOnly := compileRequirementTemplate(t, "text-only", `{{if .missing}}plain text{{else}}also plain text{{end}}`) + textResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ + Templates: CompiledTemplates{Messages: []*template.Template{textOnly}}, + }, nil, false) + if err != nil || !textResult.Complete || len(textResult.Issues) != 0 { + t.Fatalf("unresolved text-only branch should not affect completeness: %+v, %v", textResult, err) + } +} + +func TestAnalyzeRequirementsRetainsInactiveEntriesAndPromotesActiveUses(t *testing.T) { + description := "declared for a reason" + definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{ + Role: "user", Content: `{{if false}}{{input "shared"}} {{.shared_var}} {{input "declared"}}{{input .dynamic}}{{and .left .right}}{{end}}{{input "shared"}} {{.shared_var}}`, + }}} + snapshot := RequirementSnapshot{ + Inputs: []InputMetadata{{Name: "declared", Required: true, Description: &description}}, + Templates: CompiledTemplates{Messages: []*template.Template{ + compileRequirementTemplate(t, "message", definition.Templates[0].Content), + }}, + } + result, err := AnalyzeRequirements(context.Background(), definition, snapshot, map[string]string{"shared_var": "present"}, false) + if err != nil { + t.Fatal(err) + } + if !result.Complete || len(result.Issues) != 0 { + t.Fatalf("skipped unsupported expressions introduced uncertainty: %+v", result) + } + inputs := make(map[string]domain.InputRequirement, len(result.Inputs)) + for _, input := range result.Inputs { + inputs[input.Name] = input + } + if len(inputs) != 2 || inputs["shared"].Kind != domain.RequirementRequired || inputs["shared"].Explanation != "" || + inputs["declared"].Kind != domain.RequirementRequired || inputs["declared"].Description == nil || *inputs["declared"].Description != description { + t.Fatalf("active or declared requirements did not override inactivity: %+v", result.Inputs) + } + variables := make(map[string]domain.VariableRequirement, len(result.Variables)) + for _, variable := range result.Variables { + variables[variable.Name] = variable + } + for _, name := range []string{"dynamic", "left", "right", "shared_var"} { + if _, ok := variables[name]; !ok { + t.Errorf("inactive caller key %q was not inventoried", name) + } + } + if variables["shared_var"].Kind != domain.RequirementRequired || variables["shared_var"].Explanation != "" || + variables["dynamic"].Kind != domain.RequirementOptional || variables["dynamic"].Explanation == "" { + t.Fatalf("inactive caller metadata was not promoted or explained: %+v", result.Variables) + } + + allOptional := compileRequirementTemplate(t, "optional", `{{index . "lookup"}}`) + optionalResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ + Inputs: []InputMetadata{{Name: "optional"}}, Templates: CompiledTemplates{Messages: []*template.Template{allOptional}}, + }, nil, false) + if err != nil || !optionalResult.Complete || len(optionalResult.Issues) != 0 || optionalResult.Inputs[0].Kind != domain.RequirementOptional || + optionalResult.Variables[0].Kind != domain.RequirementOptional { + t.Fatalf("all-optional inventory should be complete without artifacts: %+v, %v", optionalResult, err) + } +} + func TestAnalyzeRequirementsSupportsEmptyTemplatesAndCancellation(t *testing.T) { result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{ Templates: CompiledTemplates{Messages: []*template.Template{compileRequirementTemplate(t, "message", "plain text")}},