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.
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
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
and bypassed session templates are not traversed. This internal walker does not
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)
**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),
`visit`'s `WithNode` handling (lines 481–503), `visitCommand` (lines 675–690),
and `visitVariable` (lines 694–708).

View File

@@ -22,6 +22,7 @@ type dotIdentity uint8
const (
dotRoot dotIdentity = iota
dotNonRoot
dotUnknown
)
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 {
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:
return conditionEvaluation{value: node.Text, known: true}
case *parse.BoolNode:
@@ -183,6 +192,9 @@ func (walker *requirementWalker) evaluateValue(node parse.Node, reach requiremen
return conditionEvaluation{value: nil, known: true}
case *parse.FieldNode:
if dot != dotRoot || len(node.Ident) == 0 {
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
return conditionEvaluation{unsupported: true}
}
name := node.Ident[0]
@@ -197,7 +209,11 @@ func (walker *requirementWalker) evaluateValue(node parse.Node, reach requiremen
}
return conditionEvaluation{value: value, known: true}
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 {
walker.visitVariable(node, reach)
return conditionEvaluation{unsupported: true}
}
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 {
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 walker.visit(node.ElseList, requirementInactive, dot)
@@ -492,7 +509,8 @@ func (walker *requirementWalker) visit(node parse.Node, reach requirementReachab
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 {
bodyDot := walker.withDotIdentity(node.Pipe, dot, condition)
if err := walker.visit(node.List, thenReach, bodyDot); err != nil {
return err
}
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.")
}
}
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
case *parse.DotNode:
if dot == dotUnknown {
walker.unresolvedVariableScope(reach)
}
case *parse.VariableNode:
walker.visitVariable(node, reach)
case *parse.ChainNode:
@@ -668,6 +693,11 @@ func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach
walker.recordOptionalVariable(key, reach)
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 {
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) {
if len(variable.Ident) < 2 || variable.Ident[0] != "$" {
if len(variable.Ident) < 2 {
return
}
if variable.Ident[0] != "$" {
walker.unresolvedVariableScope(reach)
return
}
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 {
switch node := node.(type) {
switch node := unwrapSingleValue(node).(type) {
case *parse.DotNode:
return dot == dotRoot
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) {
evidence := walker.inputs[name]
if evidence == nil {

View File

@@ -11,6 +11,72 @@ import (
"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) {
description := "retained metadata"
definition := &domain.PromptDefinition{ID: "capture", Version: "v1"}