diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 3fa465d..6e67540 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 049f14a..da224b4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/prompt/requirements.go b/internal/prompt/requirements.go index 90fbf09..978aed2 100644 --- a/internal/prompt/requirements.go +++ b/internal/prompt/requirements.go @@ -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, diff --git a/internal/prompt/requirements_test.go b/internal/prompt/requirements_test.go index d9b6688..c9c722f 100644 --- a/internal/prompt/requirements_test.go +++ b/internal/prompt/requirements_test.go @@ -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")}},