Preserve caller scope during requirement inspection
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user