Preserve caller scope during requirement inspection

This commit is contained in:
2026-10-08 21:15:56 +00:00
parent 21815ad7c0
commit e69a847cf2
4 changed files with 150 additions and 5 deletions

View File

@@ -77,7 +77,10 @@ supplied. Unresolved names and unsupported dependency-bearing expressions
produce structured limitations while retaining recognizable references. produce structured limitations while retaining recognizable references.
Where an unresolved name or scope could affect existing optional entries, those Where an unresolved name or scope could affect existing optional entries, those
entries are reported as conditional rather than inactive-only or definitively entries are reported as conditional rather than inactive-only or definitively
optional. Invoked named templates are scanned conservatively, and optional. Direct root-map `with` forms retain caller-map identity. Known scalar
values remain separate from unknown scopes; accesses through untracked local
maps report incomplete discovery rather than silently losing caller keys.
Invoked named templates are scanned conservatively, and
dependency-bearing range scopes make discovery incomplete. Uncalled definitions dependency-bearing range scopes make discovery incomplete. Uncalled definitions
and bypassed session templates are not traversed. This internal walker does not and bypassed session templates are not traversed. This internal walker does not
change rendering behavior. change rendering behavior.

View File

@@ -9,6 +9,11 @@ The existing ReleaseKit documentation finding is retained below.
### Finding 1 — Report Lost Caller-Map Scope As Incomplete (P2) ### Finding 1 — Report Lost Caller-Map Scope As Incomplete (P2)
**Status: Resolved.** Root-map `with` identity is retained; unknown local-map
accesses produce structured limitations and affect optional variable entries.
Focused regressions cover the reproduced forms, simple parentheses, and
inactive scopes. The package/race suite passed before completion of this unit.
Locations: [requirement walker](../../internal/prompt/requirements.go), Locations: [requirement walker](../../internal/prompt/requirements.go),
`visit`'s `WithNode` handling (lines 481–503), `visitCommand` (lines 675–690), `visit`'s `WithNode` handling (lines 481–503), `visitCommand` (lines 675–690),
and `visitVariable` (lines 694–708). and `visitVariable` (lines 694–708).

View File

@@ -22,6 +22,7 @@ type dotIdentity uint8
const ( const (
dotRoot dotIdentity = iota dotRoot dotIdentity = iota
dotNonRoot dotNonRoot
dotUnknown
) )
type inputEvidence struct { type inputEvidence struct {
@@ -175,6 +176,14 @@ func (walker *requirementWalker) evaluateCommand(command *parse.CommandNode, rea
func (walker *requirementWalker) evaluateValue(node parse.Node, reach requirementReachability, dot dotIdentity) conditionEvaluation { func (walker *requirementWalker) evaluateValue(node parse.Node, reach requirementReachability, dot dotIdentity) conditionEvaluation {
switch node := node.(type) { switch node := node.(type) {
case *parse.DotNode:
if dot == dotRoot {
return conditionEvaluation{value: walker.vars, known: true}
}
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
return conditionEvaluation{unsupported: true}
case *parse.StringNode: case *parse.StringNode:
return conditionEvaluation{value: node.Text, known: true} return conditionEvaluation{value: node.Text, known: true}
case *parse.BoolNode: case *parse.BoolNode:
@@ -183,6 +192,9 @@ func (walker *requirementWalker) evaluateValue(node parse.Node, reach requiremen
return conditionEvaluation{value: nil, known: true} return conditionEvaluation{value: nil, known: true}
case *parse.FieldNode: case *parse.FieldNode:
if dot != dotRoot || len(node.Ident) == 0 { if dot != dotRoot || len(node.Ident) == 0 {
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
return conditionEvaluation{unsupported: true} return conditionEvaluation{unsupported: true}
} }
name := node.Ident[0] name := node.Ident[0]
@@ -197,7 +209,11 @@ func (walker *requirementWalker) evaluateValue(node parse.Node, reach requiremen
} }
return conditionEvaluation{value: value, known: true} return conditionEvaluation{value: value, known: true}
case *parse.VariableNode: case *parse.VariableNode:
if len(node.Ident) == 1 && node.Ident[0] == "$" && walker.rootKnown {
return conditionEvaluation{value: walker.vars, known: true}
}
if len(node.Ident) < 2 || node.Ident[0] != "$" || !walker.rootKnown { if len(node.Ident) < 2 || node.Ident[0] != "$" || !walker.rootKnown {
walker.visitVariable(node, reach)
return conditionEvaluation{unsupported: true} return conditionEvaluation{unsupported: true}
} }
name := node.Ident[1] name := node.Ident[1]
@@ -483,7 +499,8 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab
if err := walker.visit(node.Pipe, requirementInactive, dot); err != nil { if err := walker.visit(node.Pipe, requirementInactive, dot); err != nil {
return err return err
} }
if err := walker.visit(node.List, requirementInactive, dotNonRoot); err != nil { bodyDot := walker.withDotIdentity(node.Pipe, dot, conditionEvaluation{})
if err := walker.visit(node.List, requirementInactive, bodyDot); err != nil {
return err return err
} }
return walker.visit(node.ElseList, requirementInactive, dot) return walker.visit(node.ElseList, requirementInactive, dot)
@@ -492,7 +509,8 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab
before := walker.dependencies before := walker.dependencies
thenReach := branchReach(reach, condition.known, condition.truth(), true) thenReach := branchReach(reach, condition.known, condition.truth(), true)
elseReach := branchReach(reach, condition.known, condition.truth(), false) elseReach := branchReach(reach, condition.known, condition.truth(), false)
if err := walker.visit(node.List, thenReach, dotNonRoot); err != nil { bodyDot := walker.withDotIdentity(node.Pipe, dot, condition)
if err := walker.visit(node.List, thenReach, bodyDot); err != nil {
return err return err
} }
if err := walker.visit(node.ElseList, elseReach, dot); err != nil { if err := walker.visit(node.ElseList, elseReach, dot); err != nil {
@@ -561,6 +579,13 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab
walker.addIssue("", node.Ident[0], "A chained field access is outside the direct-key scope.") walker.addIssue("", node.Ident[0], "A chained field access is outside the direct-key scope.")
} }
} }
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
case *parse.DotNode:
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
case *parse.VariableNode: case *parse.VariableNode:
walker.visitVariable(node, reach) walker.visitVariable(node, reach)
case *parse.ChainNode: case *parse.ChainNode:
@@ -668,6 +693,11 @@ func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach
walker.recordOptionalVariable(key, reach) walker.recordOptionalVariable(key, reach)
return nil return nil
} }
// A local map may alias the caller root even though its value is not
// tracked. A lookup on it must not vanish from complete discovery.
if variable, ok := unwrapSingleValue(command.Args[1]).(*parse.VariableNode); ok && len(variable.Ident) == 1 {
walker.unresolvedVariableScope(reach)
}
} }
if !isIdentifier && len(command.Args) == 1 { if !isIdentifier && len(command.Args) == 1 {
return walker.visit(command.Args[0], reach, dot) return walker.visit(command.Args[0], reach, dot)
@@ -692,7 +722,11 @@ func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach
} }
func (walker *requirementWalker) visitVariable(variable *parse.VariableNode, reach requirementReachability) { func (walker *requirementWalker) visitVariable(variable *parse.VariableNode, reach requirementReachability) {
if len(variable.Ident) < 2 || variable.Ident[0] != "$" { if len(variable.Ident) < 2 {
return
}
if variable.Ident[0] != "$" {
walker.unresolvedVariableScope(reach)
return return
} }
if !walker.rootKnown { if !walker.rootKnown {
@@ -708,8 +742,35 @@ func (walker *requirementWalker) visitVariable(variable *parse.VariableNode, rea
} }
} }
func (walker *requirementWalker) unresolvedVariableScope(reach requirementReachability) {
if reach != requirementInactive {
walker.unknownVariableEffect = true
walker.addIssue("", "", "A caller-variable scope is not statically known.")
}
}
func (walker *requirementWalker) withDotIdentity(pipe *parse.PipeNode, dot dotIdentity, condition conditionEvaluation) dotIdentity {
if pipe != nil && len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign && len(pipe.Cmds[0].Args) == 1 &&
isRootMap(pipe.Cmds[0].Args[0], dot, walker.rootKnown) {
return dotRoot
}
if condition.known {
if _, root := condition.value.(map[string]string); root {
return dotRoot
}
return dotNonRoot
}
if condition.inputNameUnknown ||
!condition.unsupported && (len(condition.missingVariables) > 0 || len(condition.contentInputs) > 0) {
// Supported caller values and input bodies are strings; supported
// comparisons and negation return booleans, never the root map.
return dotNonRoot
}
return dotUnknown
}
func isRootMap(node parse.Node, dot dotIdentity, rootKnown bool) bool { func isRootMap(node parse.Node, dot dotIdentity, rootKnown bool) bool {
switch node := node.(type) { switch node := unwrapSingleValue(node).(type) {
case *parse.DotNode: case *parse.DotNode:
return dot == dotRoot return dot == dotRoot
case *parse.VariableNode: case *parse.VariableNode:
@@ -719,6 +780,16 @@ func isRootMap(node parse.Node, dot dotIdentity, rootKnown bool) bool {
} }
} }
func unwrapSingleValue(node parse.Node) parse.Node {
for {
pipe, ok := node.(*parse.PipeNode)
if !ok || len(pipe.Cmds) != 1 || len(pipe.Decl) != 0 || pipe.IsAssign || len(pipe.Cmds[0].Args) != 1 {
return node
}
node = pipe.Cmds[0].Args[0]
}
}
func (walker *requirementWalker) recordInput(name string, reach requirementReachability) { func (walker *requirementWalker) recordInput(name string, reach requirementReachability) {
evidence := walker.inputs[name] evidence := walker.inputs[name]
if evidence == nil { if evidence == nil {

View File

@@ -11,6 +11,72 @@ import (
"gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/domain"
) )
func TestAnalyzeRequirementsDoesNotHideCallerMapScopes(t *testing.T) {
for _, source := range []string{
`{{with .}}{{.audience}}{{end}}`,
`{{with $}}{{.audience}}{{end}}`,
`{{with (.)}}{{.audience}}{{end}}`,
`{{$alias := .}}{{with $alias}}{{.audience}}{{end}}`,
`{{with .}}{{index . "audience"}}{{end}}`,
`{{$alias := .}}{{index $alias "audience"}}`,
`{{$alias := .}}{{index ($alias) "audience"}}`,
`{{$alias := .}}{{$alias.audience}}`,
} {
t.Run(source, func(t *testing.T) {
compiled := compileRequirementTemplate(t, "scope", source)
vars := map[string]string{"audience": "visible"}
var rendered strings.Builder
if err := compiled.Execute(&rendered, vars); err != nil || rendered.String() != "visible" {
t.Fatalf("caller-map reference did not render: %q, %v", rendered.String(), err)
}
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{compiled}},
}, vars, false)
if err != nil {
t.Fatal(err)
}
if result.Complete {
if len(result.Variables) != 1 || result.Variables[0].Name != "audience" {
t.Fatalf("complete discovery lost the caller key: %+v", result)
}
} else if len(result.Issues) == 0 {
t.Fatalf("partial discovery did not explain its limitation: %+v", result)
}
})
}
}
func TestAnalyzeRequirementsUnknownScopesAffectOptionalEntriesOnlyWhenActive(t *testing.T) {
for _, access := range []string{`{{with $alias}}{{.audience}}{{end}}`, `{{index $alias "audience"}}`, `{{index ($alias) "audience"}}`, `{{$alias.audience}}`} {
for _, active := range []bool{false, true} {
source := `{{if false}}{{.optional}}{{end}}{{$alias := .}}` + access
if !active {
source = `{{if false}}` + source + `{{end}}`
}
compiled := compileRequirementTemplate(t, "unknown-scope", source)
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{compiled}},
}, map[string]string{"audience": "visible"}, false)
if err != nil {
t.Fatal(err)
}
wantKind := domain.RequirementOptional
if active {
wantKind = domain.RequirementConditional
}
if result.Complete == active || len(result.Variables) != 1 || result.Variables[0].Name != "optional" || result.Variables[0].Kind != wantKind {
t.Fatalf("active=%v, source=%s, result=%+v", active, source, result)
}
if active && (len(result.Issues) == 0 || result.Issues[0].Code != domain.RequirementIssueUnsupportedConstruct) {
t.Fatalf("active unknown scope lacks a structured issue: %+v", result)
}
if !active && len(result.Issues) != 0 {
t.Fatalf("skipped unknown scope introduced uncertainty: %+v", result)
}
}
}
}
func TestAnalyzeRequirementsCollectsDeclaredAndLiteralInputs(t *testing.T) { func TestAnalyzeRequirementsCollectsDeclaredAndLiteralInputs(t *testing.T) {
description := "retained metadata" description := "retained metadata"
definition := &domain.PromptDefinition{ID: "capture", Version: "v1"} definition := &domain.PromptDefinition{ID: "capture", Version: "v1"}