Resolve direct prompt input names
This commit is contained in:
@@ -70,10 +70,17 @@ dependencies do not by themselves make discovery incomplete. Recognizable
|
|||||||
references on known skipped paths remain optional inventory entries with an
|
references on known skipped paths remain optional inventory entries with an
|
||||||
inactive-use explanation, unless another applicable use establishes a
|
inactive-use explanation, unless another applicable use establishes a
|
||||||
stronger classification. Skipped expressions are scanned only for
|
stronger classification. Skipped expressions are scanned only for
|
||||||
syntactically recognizable names and are not evaluated. Invoked named
|
syntactically recognizable names and are not evaluated. A direct caller value
|
||||||
templates are scanned conservatively, and dependency-bearing range scopes make
|
or literal-key lookup can also supply an input name, including through simple
|
||||||
discovery incomplete. Uncalled definitions and bypassed session templates are
|
parentheses or a direct pipeline to `input`; names are used exactly as
|
||||||
not traversed. This internal walker does not change rendering behavior.
|
supplied. Unresolved names and unsupported dependency-bearing expressions
|
||||||
|
produce structured limitations while retaining recognizable references.
|
||||||
|
Where an unresolved name or scope could affect existing optional entries, those
|
||||||
|
entries are reported as conditional rather than inactive-only or definitively
|
||||||
|
optional. 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
|
## Profiles And Built-Ins
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,11 @@ Run `go test ./internal/prompt`; commit.
|
|||||||
|
|
||||||
## Stage 4 — Resolve Direct Names And Finish Limitation Reporting
|
## Stage 4 — Resolve Direct Names And Finish Limitation Reporting
|
||||||
|
|
||||||
|
**Status: Complete (2026-10-08).** Supported caller-derived input names resolve
|
||||||
|
exactly; unresolved names preserve recognizable dependencies and report
|
||||||
|
structured limitations, with affected optional entries conservatively marked
|
||||||
|
conditional.
|
||||||
|
|
||||||
**Outcome:** Simple supplied names resolve exactly; complicated names and
|
**Outcome:** Simple supplied names resolve exactly; complicated names and
|
||||||
constructs return useful partial inventories without speculative analysis.
|
constructs return useful partial inventories without speculative analysis.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type conditionEvaluation struct {
|
|||||||
value any
|
value any
|
||||||
known bool
|
known bool
|
||||||
unsupported bool
|
unsupported bool
|
||||||
|
inputNameUnknown bool
|
||||||
missingVariables map[string]struct{}
|
missingVariables map[string]struct{}
|
||||||
contentInputs map[string]struct{}
|
contentInputs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,9 @@ func (walker *requirementWalker) evaluateCondition(pipe *parse.PipeNode, reach r
|
|||||||
if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign {
|
if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign {
|
||||||
return walker.evaluateCommand(pipe.Cmds[0], reach, dot)
|
return walker.evaluateCommand(pipe.Cmds[0], reach, dot)
|
||||||
}
|
}
|
||||||
|
if name, ok := directInputPipeline(pipe); ok {
|
||||||
|
return walker.evaluateInputName(name, reach, dot)
|
||||||
|
}
|
||||||
before := walker.dependencies
|
before := walker.dependencies
|
||||||
visitReach := requirementPossible
|
visitReach := requirementPossible
|
||||||
if reach == requirementInactive {
|
if reach == requirementInactive {
|
||||||
@@ -86,19 +90,29 @@ func (walker *requirementWalker) evaluateCommand(command *parse.CommandNode, rea
|
|||||||
if isIdentifier {
|
if isIdentifier {
|
||||||
switch identifier.Ident {
|
switch identifier.Ident {
|
||||||
case "input":
|
case "input":
|
||||||
|
if reach == requirementInactive {
|
||||||
|
if len(command.Args) == 2 {
|
||||||
|
if name, ok := literalStringValue(command.Args[1]); ok {
|
||||||
|
walker.recordInput(name, requirementInactive)
|
||||||
|
return conditionEvaluation{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, argument := range command.Args[1:] {
|
||||||
|
_ = walker.visit(argument, requirementInactive, dot)
|
||||||
|
}
|
||||||
|
return conditionEvaluation{}
|
||||||
|
}
|
||||||
if len(command.Args) == 2 {
|
if len(command.Args) == 2 {
|
||||||
if name, ok := command.Args[1].(*parse.StringNode); ok {
|
if name, ok := command.Args[1].(*parse.StringNode); ok {
|
||||||
walker.recordInput(name.Text, reach)
|
walker.recordInput(name.Text, reach)
|
||||||
return conditionEvaluation{contentInputs: map[string]struct{}{name.Text: {}}}
|
return conditionEvaluation{contentInputs: map[string]struct{}{name.Text: {}}}
|
||||||
}
|
}
|
||||||
|
return walker.evaluateInputName(command.Args[1], reach, dot)
|
||||||
}
|
}
|
||||||
if reach == requirementInactive {
|
for _, argument := range command.Args[1:] {
|
||||||
_ = walker.visitCommand(command, reach, dot)
|
_ = walker.visit(argument, requirementPossible, dot)
|
||||||
return conditionEvaluation{}
|
|
||||||
}
|
}
|
||||||
before := walker.dependencies
|
return walker.unresolvedInputName(conditionEvaluation{unsupported: true})
|
||||||
_ = walker.visitCommand(command, requirementPossible, dot)
|
|
||||||
return conditionEvaluation{unsupported: walker.dependencies != before}
|
|
||||||
case "index":
|
case "index":
|
||||||
if len(command.Args) == 3 {
|
if len(command.Args) == 3 {
|
||||||
if key, ok := command.Args[2].(*parse.StringNode); ok && isRootMap(command.Args[1], dot, walker.rootKnown) {
|
if key, ok := command.Args[2].(*parse.StringNode); ok && isRootMap(command.Args[1], dot, walker.rootKnown) {
|
||||||
@@ -214,9 +228,83 @@ func (walker *requirementWalker) evaluateValue(node parse.Node, reach requiremen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (walker *requirementWalker) evaluateInputName(node parse.Node, reach requirementReachability, dot dotIdentity) conditionEvaluation {
|
||||||
|
evaluation := walker.evaluateValue(node, reach, dot)
|
||||||
|
if evaluation.known {
|
||||||
|
if name, ok := evaluation.value.(string); ok {
|
||||||
|
walker.recordInput(name, reach)
|
||||||
|
return conditionEvaluation{contentInputs: map[string]struct{}{name: {}}}
|
||||||
|
}
|
||||||
|
evaluation.unsupported = true
|
||||||
|
}
|
||||||
|
if evaluation.unsupported {
|
||||||
|
// Preserve directly recognizable references from a computed name without
|
||||||
|
// treating the computation itself as an evaluated dependency.
|
||||||
|
_ = walker.visit(node, requirementPossible, dot)
|
||||||
|
}
|
||||||
|
return walker.unresolvedInputName(evaluation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (walker *requirementWalker) unresolvedInputName(evaluation conditionEvaluation) conditionEvaluation {
|
||||||
|
for name := range evaluation.missingVariables {
|
||||||
|
walker.addIssueCode(domain.RequirementIssueMissingVariable, "", name, "A missing variable prevents resolving an input name.")
|
||||||
|
}
|
||||||
|
for name := range evaluation.contentInputs {
|
||||||
|
walker.addIssueCode(domain.RequirementIssueInputContent, name, "", "Captured input content affects an input name.")
|
||||||
|
}
|
||||||
|
walker.addIssueCode(domain.RequirementIssueDynamicInputName, "", "", "An input helper name is not statically available.")
|
||||||
|
walker.unknownInputEffect = true
|
||||||
|
evaluation.inputNameUnknown = true
|
||||||
|
return evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
func directInputPipeline(pipe *parse.PipeNode) (parse.Node, bool) {
|
||||||
|
if pipe == nil || len(pipe.Cmds) != 2 || len(pipe.Decl) != 0 || pipe.IsAssign {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
input := pipe.Cmds[1]
|
||||||
|
if len(input.Args) != 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
identifier, ok := input.Args[0].(*parse.IdentifierNode)
|
||||||
|
if !ok || identifier.Ident != "input" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
value := pipe.Cmds[0]
|
||||||
|
if len(value.Args) > 0 {
|
||||||
|
valueIdentifier, isValueIdentifier := value.Args[0].(*parse.IdentifierNode)
|
||||||
|
if isValueIdentifier && valueIdentifier.Ident == "input" && len(value.Args) == 2 {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(value.Args) == 1 {
|
||||||
|
return value.Args[0], true
|
||||||
|
}
|
||||||
|
if len(value.Args) == 3 {
|
||||||
|
function, ok := value.Args[0].(*parse.IdentifierNode)
|
||||||
|
if ok && function.Ident == "index" {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func literalStringValue(node parse.Node) (string, bool) {
|
||||||
|
switch node := node.(type) {
|
||||||
|
case *parse.StringNode:
|
||||||
|
return node.Text, true
|
||||||
|
case *parse.PipeNode:
|
||||||
|
if len(node.Cmds) == 1 && len(node.Cmds[0].Args) == 1 && len(node.Decl) == 0 && !node.IsAssign {
|
||||||
|
return literalStringValue(node.Cmds[0].Args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func mergeConditionEvaluations(left, right conditionEvaluation) conditionEvaluation {
|
func mergeConditionEvaluations(left, right conditionEvaluation) conditionEvaluation {
|
||||||
result := conditionEvaluation{
|
result := conditionEvaluation{
|
||||||
unsupported: left.unsupported || right.unsupported,
|
unsupported: left.unsupported || right.unsupported,
|
||||||
|
inputNameUnknown: left.inputNameUnknown || right.inputNameUnknown,
|
||||||
missingVariables: make(map[string]struct{}, len(left.missingVariables)+len(right.missingVariables)),
|
missingVariables: make(map[string]struct{}, len(left.missingVariables)+len(right.missingVariables)),
|
||||||
contentInputs: make(map[string]struct{}, len(left.contentInputs)+len(right.contentInputs)),
|
contentInputs: make(map[string]struct{}, len(left.contentInputs)+len(right.contentInputs)),
|
||||||
}
|
}
|
||||||
@@ -252,16 +340,18 @@ func branchReach(parent requirementReachability, known, truth, selected bool) re
|
|||||||
}
|
}
|
||||||
|
|
||||||
type requirementWalker struct {
|
type requirementWalker struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
vars map[string]string
|
vars map[string]string
|
||||||
inputs map[string]*inputEvidence
|
inputs map[string]*inputEvidence
|
||||||
variables map[string]*variableEvidence
|
variables map[string]*variableEvidence
|
||||||
issues map[requirementIssueKey]domain.RequirementIssue
|
issues map[requirementIssueKey]domain.RequirementIssue
|
||||||
complete bool
|
complete bool
|
||||||
rootKnown bool
|
rootKnown bool
|
||||||
dependencies int
|
unknownInputEffect bool
|
||||||
templateSet *template.Template
|
unknownVariableEffect bool
|
||||||
visitedTrees map[*parse.Tree]bool
|
dependencies int
|
||||||
|
templateSet *template.Template
|
||||||
|
visitedTrees map[*parse.Tree]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeRequirements collects the prompt dependencies recognizable from its
|
// AnalyzeRequirements collects the prompt dependencies recognizable from its
|
||||||
@@ -453,6 +543,7 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab
|
|||||||
walker.visitedTrees[called.Tree] = true
|
walker.visitedTrees[called.Tree] = true
|
||||||
rootKnown := walker.rootKnown
|
rootKnown := walker.rootKnown
|
||||||
walker.rootKnown = false
|
walker.rootKnown = false
|
||||||
|
walker.unknownVariableEffect = true
|
||||||
err := walker.visit(called.Tree.Root, requirementPossible, dotNonRoot)
|
err := walker.visit(called.Tree.Root, requirementPossible, dotNonRoot)
|
||||||
walker.rootKnown = rootKnown
|
walker.rootKnown = rootKnown
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -499,6 +590,17 @@ func (walker *requirementWalker) visitPipe(pipe *parse.PipeNode, reach requireme
|
|||||||
if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign {
|
if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign {
|
||||||
return walker.visitCommand(pipe.Cmds[0], reach, dot)
|
return walker.visitCommand(pipe.Cmds[0], reach, dot)
|
||||||
}
|
}
|
||||||
|
if name, ok := directInputPipeline(pipe); ok {
|
||||||
|
if reach == requirementInactive {
|
||||||
|
if literal, known := literalStringValue(name); known {
|
||||||
|
walker.recordInput(literal, requirementInactive)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return walker.visit(name, requirementInactive, dot)
|
||||||
|
}
|
||||||
|
walker.evaluateInputName(name, reach, dot)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
before := walker.dependencies
|
before := walker.dependencies
|
||||||
commandReach := requirementPossible
|
commandReach := requirementPossible
|
||||||
if reach == requirementInactive {
|
if reach == requirementInactive {
|
||||||
@@ -516,6 +618,7 @@ func (walker *requirementWalker) visitPipe(pipe *parse.PipeNode, reach requireme
|
|||||||
for _, declaration := range pipe.Decl {
|
for _, declaration := range pipe.Decl {
|
||||||
if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" {
|
if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" {
|
||||||
walker.rootKnown = false
|
walker.rootKnown = false
|
||||||
|
walker.unknownVariableEffect = true
|
||||||
walker.addIssue("", "", "The root template binding is reassigned.")
|
walker.addIssue("", "", "The root template binding is reassigned.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,24 +632,34 @@ func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach
|
|||||||
}
|
}
|
||||||
identifier, isIdentifier := command.Args[0].(*parse.IdentifierNode)
|
identifier, isIdentifier := command.Args[0].(*parse.IdentifierNode)
|
||||||
if isIdentifier && identifier.Ident == "input" {
|
if isIdentifier && identifier.Ident == "input" {
|
||||||
|
if reach == requirementInactive {
|
||||||
|
if len(command.Args) == 2 {
|
||||||
|
if name, ok := literalStringValue(command.Args[1]); ok {
|
||||||
|
walker.recordInput(name, reach)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, argument := range command.Args[1:] {
|
||||||
|
if err := walker.visit(argument, requirementInactive, dot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if len(command.Args) == 2 {
|
if len(command.Args) == 2 {
|
||||||
if name, ok := command.Args[1].(*parse.StringNode); ok {
|
if name, ok := command.Args[1].(*parse.StringNode); ok {
|
||||||
walker.recordInput(name.Text, reach)
|
walker.recordInput(name.Text, reach)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
walker.evaluateInputName(command.Args[1], reach, dot)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
for _, argument := range command.Args[1:] {
|
for _, argument := range command.Args[1:] {
|
||||||
argumentReach := requirementPossible
|
if err := walker.visit(argument, requirementPossible, dot); err != nil {
|
||||||
if reach == requirementInactive {
|
|
||||||
argumentReach = requirementInactive
|
|
||||||
}
|
|
||||||
if err := walker.visit(argument, argumentReach, dot); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reach != requirementInactive {
|
walker.unresolvedInputName(conditionEvaluation{unsupported: true})
|
||||||
walker.addIssue("", "", "An input name is not a direct string literal.")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if isIdentifier && identifier.Ident == "index" && len(command.Args) == 3 {
|
if isIdentifier && identifier.Ident == "index" && len(command.Args) == 3 {
|
||||||
@@ -584,6 +697,7 @@ func (walker *requirementWalker) visitVariable(variable *parse.VariableNode, rea
|
|||||||
}
|
}
|
||||||
if !walker.rootKnown {
|
if !walker.rootKnown {
|
||||||
if reach != requirementInactive {
|
if reach != requirementInactive {
|
||||||
|
walker.unknownVariableEffect = true
|
||||||
walker.addIssue("", "", "The root template binding is not known.")
|
walker.addIssue("", "", "The root template binding is not known.")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -736,7 +850,8 @@ func (walker *requirementWalker) recordConditionLimitation(condition conditionEv
|
|||||||
for name := range condition.contentInputs {
|
for name := range condition.contentInputs {
|
||||||
walker.addIssueCode(domain.RequirementIssueInputContent, name, "", "Captured input content affects branch selection.")
|
walker.addIssueCode(domain.RequirementIssueInputContent, name, "", "Captured input content affects branch selection.")
|
||||||
}
|
}
|
||||||
if condition.unsupported || (len(condition.missingVariables) == 0 && len(condition.contentInputs) == 0) {
|
if condition.unsupported && !condition.inputNameUnknown ||
|
||||||
|
(len(condition.missingVariables) == 0 && len(condition.contentInputs) == 0 && !condition.inputNameUnknown) {
|
||||||
walker.addIssue("", "", "A dependency-bearing branch condition is not resolved by the supported syntax analysis.")
|
walker.addIssue("", "", "A dependency-bearing branch condition is not resolved by the supported syntax analysis.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -745,6 +860,10 @@ func (walker *requirementWalker) result(result domain.RequirementInspection) dom
|
|||||||
result.Complete = walker.complete
|
result.Complete = walker.complete
|
||||||
result.Inputs = make([]domain.InputRequirement, 0, len(walker.inputs))
|
result.Inputs = make([]domain.InputRequirement, 0, len(walker.inputs))
|
||||||
for _, evidence := range walker.inputs {
|
for _, evidence := range walker.inputs {
|
||||||
|
if walker.unknownInputEffect && evidence.kind != domain.RequirementRequired {
|
||||||
|
evidence.kind = domain.RequirementConditional
|
||||||
|
evidence.explanation = "An unresolved input name could refer to this input."
|
||||||
|
}
|
||||||
result.Inputs = append(result.Inputs, domain.InputRequirement{
|
result.Inputs = append(result.Inputs, domain.InputRequirement{
|
||||||
Name: evidence.name, Kind: evidence.kind, Declared: evidence.declared,
|
Name: evidence.name, Kind: evidence.kind, Declared: evidence.declared,
|
||||||
ContentType: copyString(evidence.contentType), Description: copyString(evidence.description),
|
ContentType: copyString(evidence.contentType), Description: copyString(evidence.description),
|
||||||
@@ -754,6 +873,10 @@ func (walker *requirementWalker) result(result domain.RequirementInspection) dom
|
|||||||
sort.Slice(result.Inputs, func(i, j int) bool { return result.Inputs[i].Name < result.Inputs[j].Name })
|
sort.Slice(result.Inputs, func(i, j int) bool { return result.Inputs[i].Name < result.Inputs[j].Name })
|
||||||
result.Variables = make([]domain.VariableRequirement, 0, len(walker.variables))
|
result.Variables = make([]domain.VariableRequirement, 0, len(walker.variables))
|
||||||
for _, evidence := range walker.variables {
|
for _, evidence := range walker.variables {
|
||||||
|
if walker.unknownVariableEffect && evidence.kind != domain.RequirementRequired {
|
||||||
|
evidence.kind = domain.RequirementConditional
|
||||||
|
evidence.explanation = "An unresolved caller-variable scope could refer to this variable."
|
||||||
|
}
|
||||||
_, supplied := walker.vars[evidence.name]
|
_, supplied := walker.vars[evidence.name]
|
||||||
result.Variables = append(result.Variables, domain.VariableRequirement{
|
result.Variables = append(result.Variables, domain.VariableRequirement{
|
||||||
Name: evidence.name, Kind: evidence.kind, Supplied: supplied, Explanation: evidence.explanation,
|
Name: evidence.name, Kind: evidence.kind, Supplied: supplied, Explanation: evidence.explanation,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
@@ -360,6 +361,177 @@ func TestAnalyzeRequirementsRetainsInactiveEntriesAndPromotesActiveUses(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeRequirementsResolvesSimpleInputNames(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expression string
|
||||||
|
vars map[string]string
|
||||||
|
kind domain.RequirementKind
|
||||||
|
supplied bool
|
||||||
|
}{
|
||||||
|
{name: "direct dot", expression: `{{input .name}}`, vars: map[string]string{"name": " chosen "}, kind: domain.RequirementRequired, supplied: true},
|
||||||
|
{name: "explicit root", expression: `{{input $.name}}`, vars: map[string]string{"name": "chosen"}, kind: domain.RequirementRequired, supplied: true},
|
||||||
|
{name: "parenthesized direct", expression: `{{input (.name)}}`, vars: map[string]string{"name": "chosen"}, kind: domain.RequirementRequired, supplied: true},
|
||||||
|
{name: "literal lookup", expression: `{{input (index . "name")}}`, vars: map[string]string{"name": "chosen"}, kind: domain.RequirementOptional, supplied: true},
|
||||||
|
{name: "direct pipeline", expression: `{{.name | input}}`, vars: map[string]string{"name": "chosen"}, kind: domain.RequirementRequired, supplied: true},
|
||||||
|
{name: "empty supplied value", expression: `{{input .name}}`, vars: map[string]string{"name": ""}, kind: domain.RequirementRequired, supplied: true},
|
||||||
|
{name: "missing lookup has empty zero value", expression: `{{input (index . "name")}}`, vars: nil, kind: domain.RequirementOptional, supplied: false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{Role: "user", Content: test.expression}}}
|
||||||
|
snapshot := RequirementSnapshot{Templates: CompiledTemplates{Messages: []*template.Template{
|
||||||
|
compileRequirementTemplate(t, "message", test.expression),
|
||||||
|
}}}
|
||||||
|
result, err := AnalyzeRequirements(context.Background(), definition, snapshot, test.vars, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputName := "chosen"
|
||||||
|
if test.name == "direct dot" {
|
||||||
|
inputName = " chosen "
|
||||||
|
}
|
||||||
|
if test.name == "empty supplied value" || test.name == "missing lookup has empty zero value" {
|
||||||
|
inputName = ""
|
||||||
|
}
|
||||||
|
if !result.Complete || len(result.Issues) != 0 || len(result.Inputs) != 1 || result.Inputs[0].Name != inputName ||
|
||||||
|
result.Inputs[0].Kind != domain.RequirementRequired {
|
||||||
|
t.Fatalf("simple input name was not resolved exactly: %+v", result)
|
||||||
|
}
|
||||||
|
if len(result.Variables) != 1 || result.Variables[0].Name != "name" || result.Variables[0].Kind != test.kind || result.Variables[0].Supplied != test.supplied {
|
||||||
|
t.Fatalf("name-source caller key classification is incorrect: %+v", result.Variables)
|
||||||
|
}
|
||||||
|
artifacts := map[string]*domain.Artifact{inputName: {Name: inputName, Body: []byte("resolved")}}
|
||||||
|
rendered, err := NewGoRenderer().Render(context.Background(), definition, artifacts, test.vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rendered.Messages) != 1 || rendered.Messages[0].Content != "resolved" {
|
||||||
|
t.Fatalf("analyzed input name differs from renderer: %+v", rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeRequirementsReportsUnresolvedInputNamesAndPromotesUnknownEffects(t *testing.T) {
|
||||||
|
definition := &domain.PromptDefinition{Templates: []domain.PromptMessageTemplate{{
|
||||||
|
Role: "user", Content: `{{input .missing}}`,
|
||||||
|
}}}
|
||||||
|
result, err := AnalyzeRequirements(context.Background(), definition, RequirementSnapshot{
|
||||||
|
Inputs: []InputMetadata{{Name: "optional"}, {Name: "required", Required: true}},
|
||||||
|
Templates: CompiledTemplates{Messages: []*template.Template{
|
||||||
|
compileRequirementTemplate(t, "message", definition.Templates[0].Content),
|
||||||
|
}},
|
||||||
|
}, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Complete || len(result.Issues) != 2 || result.Issues[0].Code != domain.RequirementIssueDynamicInputName ||
|
||||||
|
result.Issues[1].Code != domain.RequirementIssueMissingVariable {
|
||||||
|
t.Fatalf("missing dynamic name did not report its limitations: %+v", result)
|
||||||
|
}
|
||||||
|
if result.Inputs[0].Name != "optional" || result.Inputs[0].Kind != domain.RequirementConditional || result.Inputs[0].Explanation == "" ||
|
||||||
|
result.Inputs[1].Name != "required" || result.Inputs[1].Kind != domain.RequirementRequired || len(result.Variables) != 1 ||
|
||||||
|
result.Variables[0].Name != "missing" || result.Variables[0].Kind != domain.RequirementRequired {
|
||||||
|
t.Fatalf("unknown name effects lost known classifications: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
computed := compileRequirementTemplate(t, "computed", `{{input (printf "%s" .prefix)}}`)
|
||||||
|
computedResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
||||||
|
Inputs: []InputMetadata{{Name: "declared"}}, Templates: CompiledTemplates{Messages: []*template.Template{computed}},
|
||||||
|
}, map[string]string{"prefix": "SENSITIVE-VALUE"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if computedResult.Complete || len(computedResult.Inputs) != 1 || computedResult.Inputs[0].Name != "declared" ||
|
||||||
|
computedResult.Inputs[0].Kind != domain.RequirementConditional || len(computedResult.Variables) != 1 ||
|
||||||
|
computedResult.Variables[0].Name != "prefix" || computedResult.Variables[0].Kind != domain.RequirementConditional {
|
||||||
|
t.Fatalf("computed-name uncertainty did not affect optional entries: %+v", computedResult)
|
||||||
|
}
|
||||||
|
for _, issue := range computedResult.Issues {
|
||||||
|
if strings.Contains(issue.Description, "SENSITIVE-VALUE") || issue.InputName == "SENSITIVE-VALUE" || issue.VariableName == "SENSITIVE-VALUE" {
|
||||||
|
t.Fatalf("input-name source value leaked into diagnostics: %+v", issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, input := range computedResult.Inputs {
|
||||||
|
if strings.Contains(input.Explanation, "SENSITIVE-VALUE") {
|
||||||
|
t.Fatalf("input-name source value leaked into an explanation: %+v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, variable := range computedResult.Variables {
|
||||||
|
if strings.Contains(variable.Explanation, "SENSITIVE-VALUE") {
|
||||||
|
t.Fatalf("input-name source value leaked into an explanation: %+v", variable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expression := range []string{`{{input (input "selector")}}`, `{{input "selector" | input}}`} {
|
||||||
|
t.Run(expression, func(t *testing.T) {
|
||||||
|
contentName := compileRequirementTemplate(t, "content-name", expression)
|
||||||
|
contentResult, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
||||||
|
Templates: CompiledTemplates{Messages: []*template.Template{contentName}},
|
||||||
|
}, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if contentResult.Complete || len(contentResult.Issues) != 2 ||
|
||||||
|
contentResult.Issues[0].Code != domain.RequirementIssueDynamicInputName ||
|
||||||
|
contentResult.Issues[1].Code != domain.RequirementIssueInputContent || contentResult.Issues[1].InputName != "selector" || len(contentResult.Inputs) != 1 ||
|
||||||
|
contentResult.Inputs[0].Name != "selector" || contentResult.Inputs[0].Kind != domain.RequirementRequired {
|
||||||
|
t.Fatalf("input-body-derived name did not preserve the known source dependency: %+v", contentResult)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeRequirementsDoesNotResolveSkippedComputedInputNames(t *testing.T) {
|
||||||
|
compiled := compileRequirementTemplate(t, "inactive-name", `{{if false}}{{input .name}}{{input (printf "%s" .other)}}{{"piped-literal" | input}}{{template "missing" .arg}}{{end}}`)
|
||||||
|
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
||||||
|
Templates: CompiledTemplates{Messages: []*template.Template{compiled}},
|
||||||
|
}, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !result.Complete || len(result.Issues) != 0 || len(result.Inputs) != 1 || result.Inputs[0].Name != "piped-literal" ||
|
||||||
|
result.Inputs[0].Kind != domain.RequirementOptional || result.Inputs[0].Explanation == "" || len(result.Variables) != 3 {
|
||||||
|
t.Fatalf("skipped computed expressions introduced uncertainty or names: %+v", result)
|
||||||
|
}
|
||||||
|
for _, variable := range result.Variables {
|
||||||
|
if variable.Kind != domain.RequirementOptional || variable.Explanation == "" {
|
||||||
|
t.Errorf("skipped variable %q was not retained as inactive optional: %+v", variable.Name, variable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeRequirementsPromotesOptionalVariablesWhenRootScopeIsUnknown(t *testing.T) {
|
||||||
|
compiled := compileRequirementTemplate(t, "scope", `{{index . "optional"}} {{.required}} {{template "called" .}}{{define "called"}}{{$.hidden}}{{end}}`)
|
||||||
|
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
||||||
|
Templates: CompiledTemplates{Messages: []*template.Template{compiled}},
|
||||||
|
}, map[string]string{"required": "value"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Complete || len(result.Variables) != 2 || result.Variables[0].Name != "optional" ||
|
||||||
|
result.Variables[0].Kind != domain.RequirementConditional || result.Variables[0].Explanation == "" ||
|
||||||
|
result.Variables[1].Name != "required" || result.Variables[1].Kind != domain.RequirementRequired {
|
||||||
|
t.Fatalf("unknown caller scope did not affect only optional known entries: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeRequirementsLeavesAssignmentDerivedNamesPartial(t *testing.T) {
|
||||||
|
compiled := compileRequirementTemplate(t, "assignment", `{{$alias := .source}}{{input $alias}}`)
|
||||||
|
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
||||||
|
Inputs: []InputMetadata{{Name: "known-optional"}}, Templates: CompiledTemplates{Messages: []*template.Template{compiled}},
|
||||||
|
}, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Complete || len(result.Issues) == 0 || len(result.Inputs) != 1 ||
|
||||||
|
result.Inputs[0].Name != "known-optional" || result.Inputs[0].Kind != domain.RequirementConditional ||
|
||||||
|
len(result.Variables) != 1 || result.Variables[0].Name != "source" || result.Variables[0].Kind != domain.RequirementConditional {
|
||||||
|
t.Fatalf("assignment-derived input name was interpreted or lost: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyzeRequirementsSupportsEmptyTemplatesAndCancellation(t *testing.T) {
|
func TestAnalyzeRequirementsSupportsEmptyTemplatesAndCancellation(t *testing.T) {
|
||||||
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
|
||||||
Templates: CompiledTemplates{Messages: []*template.Template{compileRequirementTemplate(t, "message", "plain text")}},
|
Templates: CompiledTemplates{Messages: []*template.Template{compileRequirementTemplate(t, "message", "plain text")}},
|
||||||
|
|||||||
Reference in New Issue
Block a user