Resolve direct prompt input names

This commit is contained in:
2026-10-08 20:36:17 +00:00
parent 93e8874813
commit 089e824a92
4 changed files with 336 additions and 29 deletions

View File

@@ -70,10 +70,17 @@ 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.
syntactically recognizable names and are not evaluated. A direct caller value
or literal-key lookup can also supply an input name, including through simple
parentheses or a direct pipeline to `input`; names are used exactly as
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

View File

@@ -305,6 +305,11 @@ Run `go test ./internal/prompt`; commit.
## 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
constructs return useful partial inventories without speculative analysis.

View File

@@ -51,6 +51,7 @@ type conditionEvaluation struct {
value any
known bool
unsupported bool
inputNameUnknown bool
missingVariables 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 {
return walker.evaluateCommand(pipe.Cmds[0], reach, dot)
}
if name, ok := directInputPipeline(pipe); ok {
return walker.evaluateInputName(name, reach, dot)
}
before := walker.dependencies
visitReach := requirementPossible
if reach == requirementInactive {
@@ -86,19 +90,29 @@ func (walker *requirementWalker) evaluateCommand(command *parse.CommandNode, rea
if isIdentifier {
switch identifier.Ident {
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 name, ok := command.Args[1].(*parse.StringNode); ok {
walker.recordInput(name.Text, reach)
return conditionEvaluation{contentInputs: map[string]struct{}{name.Text: {}}}
}
return walker.evaluateInputName(command.Args[1], reach, dot)
}
if reach == requirementInactive {
_ = walker.visitCommand(command, reach, dot)
return conditionEvaluation{}
for _, argument := range command.Args[1:] {
_ = walker.visit(argument, requirementPossible, dot)
}
before := walker.dependencies
_ = walker.visitCommand(command, requirementPossible, dot)
return conditionEvaluation{unsupported: walker.dependencies != before}
return walker.unresolvedInputName(conditionEvaluation{unsupported: true})
case "index":
if len(command.Args) == 3 {
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 {
result := conditionEvaluation{
unsupported: left.unsupported || right.unsupported,
inputNameUnknown: left.inputNameUnknown || right.inputNameUnknown,
missingVariables: make(map[string]struct{}, len(left.missingVariables)+len(right.missingVariables)),
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 {
ctx context.Context
vars map[string]string
inputs map[string]*inputEvidence
variables map[string]*variableEvidence
issues map[requirementIssueKey]domain.RequirementIssue
complete bool
rootKnown bool
dependencies int
templateSet *template.Template
visitedTrees map[*parse.Tree]bool
ctx context.Context
vars map[string]string
inputs map[string]*inputEvidence
variables map[string]*variableEvidence
issues map[requirementIssueKey]domain.RequirementIssue
complete bool
rootKnown bool
unknownInputEffect bool
unknownVariableEffect bool
dependencies int
templateSet *template.Template
visitedTrees map[*parse.Tree]bool
}
// 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
rootKnown := walker.rootKnown
walker.rootKnown = false
walker.unknownVariableEffect = true
err := walker.visit(called.Tree.Root, requirementPossible, dotNonRoot)
walker.rootKnown = rootKnown
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 {
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
commandReach := requirementPossible
if reach == requirementInactive {
@@ -516,6 +618,7 @@ func (walker *requirementWalker) visitPipe(pipe *parse.PipeNode, reach requireme
for _, declaration := range pipe.Decl {
if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" {
walker.rootKnown = false
walker.unknownVariableEffect = true
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)
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 name, ok := command.Args[1].(*parse.StringNode); ok {
walker.recordInput(name.Text, reach)
return nil
}
walker.evaluateInputName(command.Args[1], reach, dot)
return nil
}
for _, argument := range command.Args[1:] {
argumentReach := requirementPossible
if reach == requirementInactive {
argumentReach = requirementInactive
}
if err := walker.visit(argument, argumentReach, dot); err != nil {
if err := walker.visit(argument, requirementPossible, dot); err != nil {
return err
}
}
if reach != requirementInactive {
walker.addIssue("", "", "An input name is not a direct string literal.")
}
walker.unresolvedInputName(conditionEvaluation{unsupported: true})
return nil
}
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 reach != requirementInactive {
walker.unknownVariableEffect = true
walker.addIssue("", "", "The root template binding is not known.")
}
return
@@ -736,7 +850,8 @@ func (walker *requirementWalker) recordConditionLimitation(condition conditionEv
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) {
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.")
}
}
@@ -745,6 +860,10 @@ func (walker *requirementWalker) result(result domain.RequirementInspection) dom
result.Complete = walker.complete
result.Inputs = make([]domain.InputRequirement, 0, len(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{
Name: evidence.name, Kind: evidence.kind, Declared: evidence.declared,
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 })
result.Variables = make([]domain.VariableRequirement, 0, len(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]
result.Variables = append(result.Variables, domain.VariableRequirement{
Name: evidence.name, Kind: evidence.kind, Supplied: supplied, Explanation: evidence.explanation,

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"reflect"
"strings"
"testing"
"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) {
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{compileRequirementTemplate(t, "message", "plain text")}},