Classify simple prompt requirement branches
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")}},
|
||||
|
||||
Reference in New Issue
Block a user