diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 6e67540..569538d 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -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. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index a7ed58b..57ffefe 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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). diff --git a/internal/prompt/requirements.go b/internal/prompt/requirements.go index 978aed2..c73fc98 100644 --- a/internal/prompt/requirements.go +++ b/internal/prompt/requirements.go @@ -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 { diff --git a/internal/prompt/requirements_test.go b/internal/prompt/requirements_test.go index c9c722f..e898243 100644 --- a/internal/prompt/requirements_test.go +++ b/internal/prompt/requirements_test.go @@ -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"}