Enumerate prompt input and variable dependencies

This commit is contained in:
2026-10-08 20:25:17 +00:00
parent bf472dad8a
commit cc0b6183bf
6 changed files with 758 additions and 3 deletions

View File

@@ -17,9 +17,9 @@ contributor workflow and validation.
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `internal/backend` | Holds a defensive, immutable lookup and capacity-policy index for final backend definitions validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
| `internal/catalog` | Selects released keyed catalog assets and PromptKit-owned profile definitions as ConfigKit underlays; effective validation belongs to the configuration loader. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) |
| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values, captures indexed prompt definitions and referenced message content, constructs backend and presence-aware profile snapshots, and retains an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values, captures indexed prompt definitions, referenced message content, compiled templates, and input metadata snapshots, constructs backend and presence-aware profile snapshots, and retains an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/engine` | Assembles selected catalog and caller configuration into one snapshot, then constructs capacity, rendering, validation, generation, repair, and the use-case runner for each engine. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) |
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, and attempt observations. | [Internal generation coordination](generation.md) |
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
@@ -27,7 +27,7 @@ contributor workflow and validation.
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
| `internal/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) |
| `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) |
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data, and conservatively enumerates dependencies from captured template syntax. | [Go-template renderer](../../internal/prompt/go_renderer.go), [requirement discovery](../../internal/prompt/requirements.go) |
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |

View File

@@ -57,6 +57,15 @@ captured presence state. Runtime rendering still checks request variables and
input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
cover resource origins, context containment, capture, and selection.
The `internal/prompt` requirement walker uses the captured parse trees and
declaration metadata without executing templates or reading artifacts. It
collects literal `input` names, direct root caller keys, and literal-key
`index` lookups. Branch-body references are conservatively conditional until
condition analysis is applied; 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
`internal/catalog` selects the released keyed OpenRouter and Rakestrawhome

View File

@@ -237,6 +237,10 @@ commit.
## Stage 2 — Enumerate Literal Inputs And Caller Keys
**Status: Complete (2026-10-08).** The internal walker enumerates declarations,
literal input references, and supported caller keys with conservative branch
and unsupported-scope classifications.
**Outcome:** An internal syntax walker produces deduplicated inventories and
does not confuse caller variables with template locals.

View File

@@ -0,0 +1,58 @@
package domain
// RequirementKind classifies how a prompt may depend on an input or variable.
type RequirementKind string
const (
RequirementRequired RequirementKind = "required"
RequirementConditional RequirementKind = "conditional"
RequirementOptional RequirementKind = "optional"
)
// InputRequirement describes one declared or template-referenced input.
type InputRequirement struct {
Name string
Kind RequirementKind
Declared bool
ContentType *string
Description *string
Explanation string
}
// VariableRequirement describes one caller variable referenced by a prompt.
type VariableRequirement struct {
Name string
Kind RequirementKind
Supplied bool
Explanation string
}
// RequirementIssueCode identifies a limit that may affect requirement
// discovery.
type RequirementIssueCode string
const (
RequirementIssueMissingVariable RequirementIssueCode = "missing_variable"
RequirementIssueInputContent RequirementIssueCode = "input_content"
RequirementIssueDynamicInputName RequirementIssueCode = "dynamic_input_name"
RequirementIssueUnsupportedConstruct RequirementIssueCode = "unsupported_construct"
)
// RequirementIssue describes one limitation on requirement discovery.
type RequirementIssue struct {
Code RequirementIssueCode
InputName string
VariableName string
Description string
}
// RequirementInspection contains the inventories discovered for one prompt.
type RequirementInspection struct {
PromptID string
PromptVersion string
ProfileID string
Inputs []InputRequirement
Variables []VariableRequirement
Complete bool
Issues []RequirementIssue
}

View File

@@ -0,0 +1,471 @@
package prompt
import (
"context"
"sort"
"text/template"
"text/template/parse"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
type requirementReachability uint8
const (
requirementDefinite requirementReachability = iota
requirementPossible
)
type dotIdentity uint8
const (
dotRoot dotIdentity = iota
dotNonRoot
)
type inputEvidence struct {
name string
kind domain.RequirementKind
declared bool
contentType *string
description *string
explanation string
}
type variableEvidence struct {
name string
kind domain.RequirementKind
explanation string
}
type requirementIssueKey struct {
code domain.RequirementIssueCode
inputName string
variableName string
}
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
}
// AnalyzeRequirements collects the prompt dependencies recognizable from its
// captured syntax. Branch bodies remain conditional until their conditions are
// classified; unsupported dependency-bearing syntax makes the result partial.
func AnalyzeRequirements(
ctx context.Context,
definition *domain.PromptDefinition,
snapshot RequirementSnapshot,
vars map[string]string,
sessionBypassed bool,
) (domain.RequirementInspection, error) {
if err := ctx.Err(); err != nil {
return domain.RequirementInspection{}, err
}
result := domain.RequirementInspection{Complete: true}
if definition != nil {
result.PromptID = definition.ID
result.PromptVersion = definition.Version
}
walker := &requirementWalker{
ctx: ctx,
vars: vars,
inputs: make(map[string]*inputEvidence, len(snapshot.Inputs)),
variables: make(map[string]*variableEvidence),
issues: make(map[requirementIssueKey]domain.RequirementIssue),
complete: true,
rootKnown: true,
}
for _, input := range snapshot.Inputs {
if err := ctx.Err(); err != nil {
return domain.RequirementInspection{}, err
}
kind := domain.RequirementOptional
if input.Required {
kind = domain.RequirementRequired
}
walker.inputs[input.Name] = &inputEvidence{
name: input.Name,
kind: kind,
declared: true,
contentType: copyString(input.ContentType),
description: copyString(input.Description),
}
if kind == domain.RequirementOptional {
walker.inputs[input.Name].explanation = "Declared as an optional input."
}
}
for _, message := range snapshot.Templates.Messages {
if err := walker.walkTemplate(message); err != nil {
return domain.RequirementInspection{}, err
}
}
if !sessionBypassed && snapshot.Templates.Session != nil {
if err := walker.walkTemplate(snapshot.Templates.Session); err != nil {
return domain.RequirementInspection{}, err
}
}
if err := ctx.Err(); err != nil {
return domain.RequirementInspection{}, err
}
return walker.result(result), nil
}
func (walker *requirementWalker) walkTemplate(compiled *template.Template) error {
if err := walker.ctx.Err(); err != nil {
return err
}
if compiled == nil || compiled.Tree == nil || compiled.Tree.Root == nil {
walker.addIssue("", "", "A captured template has no parsed root.")
return nil
}
previousSet := walker.templateSet
previousVisited := walker.visitedTrees
walker.templateSet = compiled
walker.visitedTrees = map[*parse.Tree]bool{compiled.Tree: true}
walker.rootKnown = true
err := walker.visit(compiled.Tree.Root, requirementDefinite, dotRoot)
walker.templateSet = previousSet
walker.visitedTrees = previousVisited
return err
}
func (walker *requirementWalker) visit(node parse.Node, reach requirementReachability, dot dotIdentity) error {
if err := walker.ctx.Err(); err != nil {
return err
}
if node == nil {
return nil
}
switch node := node.(type) {
case *parse.ListNode:
if node == nil {
return nil
}
for _, child := range node.Nodes {
if err := walker.visit(child, reach, dot); err != nil {
return err
}
}
case *parse.ActionNode:
return walker.visit(node.Pipe, reach, dot)
case *parse.IfNode:
if err := walker.visit(node.Pipe, reach, dot); err != nil {
return err
}
if err := walker.visit(node.List, requirementPossible, dot); err != nil {
return err
}
return walker.visit(node.ElseList, requirementPossible, dot)
case *parse.WithNode:
if err := walker.visit(node.Pipe, reach, dot); err != nil {
return err
}
if err := walker.visit(node.List, requirementPossible, dotNonRoot); err != nil {
return err
}
return walker.visit(node.ElseList, requirementPossible, dot)
case *parse.RangeNode:
if err := walker.visit(node.Pipe, reach, dot); err != nil {
return err
}
before := walker.dependencies
if err := walker.visit(node.List, requirementPossible, dotNonRoot); err != nil {
return err
}
if err := walker.visit(node.ElseList, requirementPossible, dot); err != nil {
return err
}
if walker.dependencies != before {
walker.addIssue("", "", "Dependencies inside a range are only conservatively enumerated.")
}
case *parse.TemplateNode:
if err := walker.visit(node.Pipe, requirementPossible, dot); err != nil {
return err
}
called := walker.templateSet.Lookup(node.Name)
if called == nil || called.Tree == nil || called.Tree.Root == nil {
walker.addIssue("", "", "An invoked named template is unavailable for syntax inspection.")
return nil
}
if walker.visitedTrees[called.Tree] {
walker.addIssue("", "", "An invoked named template is scanned conservatively.")
return nil
}
walker.visitedTrees[called.Tree] = true
rootKnown := walker.rootKnown
walker.rootKnown = false
err := walker.visit(called.Tree.Root, requirementPossible, dotNonRoot)
walker.rootKnown = rootKnown
if err != nil {
return err
}
walker.addIssue("", "", "An invoked named template is scanned conservatively.")
case *parse.PipeNode:
return walker.visitPipe(node, reach, dot)
case *parse.CommandNode:
return walker.visitCommand(node, reach, dot)
case *parse.FieldNode:
if dot == dotRoot && len(node.Ident) > 0 {
walker.recordVariable(node.Ident[0], reach)
if len(node.Ident) > 1 {
walker.addIssue("", node.Ident[0], "A chained field access is outside the direct-key scope.")
}
}
case *parse.VariableNode:
walker.visitVariable(node, reach)
case *parse.ChainNode:
before := walker.dependencies
if err := walker.visit(node.Node, requirementPossible, dot); err != nil {
return err
}
if walker.dependencies != before {
walker.addIssue("", "", "A chained expression is not interpreted.")
}
case *parse.BreakNode, *parse.ContinueNode:
walker.addIssue("", "", "Loop control is outside the supported syntax scope.")
}
return nil
}
func (walker *requirementWalker) visitPipe(pipe *parse.PipeNode, reach requirementReachability, dot dotIdentity) error {
if pipe == nil {
return nil
}
if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign {
return walker.visitCommand(pipe.Cmds[0], reach, dot)
}
before := walker.dependencies
for _, command := range pipe.Cmds {
if err := walker.visitCommand(command, requirementPossible, dot); err != nil {
return err
}
}
if walker.dependencies != before {
walker.addIssue("", "", "A pipeline or variable assignment is not interpreted.")
}
for _, declaration := range pipe.Decl {
if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" {
walker.rootKnown = false
walker.addIssue("", "", "The root template binding is reassigned.")
}
}
return nil
}
func (walker *requirementWalker) visitCommand(command *parse.CommandNode, reach requirementReachability, dot dotIdentity) error {
if command == nil || len(command.Args) == 0 {
return nil
}
identifier, isIdentifier := command.Args[0].(*parse.IdentifierNode)
if isIdentifier && identifier.Ident == "input" {
if len(command.Args) == 2 {
if name, ok := command.Args[1].(*parse.StringNode); ok {
walker.recordInput(name.Text, reach)
return nil
}
}
for _, argument := range command.Args[1:] {
if err := walker.visit(argument, requirementPossible, dot); err != nil {
return err
}
}
walker.addIssue("", "", "An input name is not a direct string literal.")
return nil
}
if isIdentifier && identifier.Ident == "index" && len(command.Args) == 3 {
if _, ok := command.Args[2].(*parse.StringNode); ok && isRootMap(command.Args[1], dot, walker.rootKnown) {
key := command.Args[2].(*parse.StringNode).Text
walker.recordOptionalVariable(key, reach)
return nil
}
}
if !isIdentifier && len(command.Args) == 1 {
return walker.visit(command.Args[0], reach, dot)
}
before := walker.dependencies
for index, argument := range command.Args {
if isIdentifier && index == 0 {
continue
}
if err := walker.visit(argument, requirementPossible, dot); err != nil {
return err
}
}
if walker.dependencies != before {
walker.addIssue("", "", "A function call or computed expression is not interpreted.")
}
return nil
}
func (walker *requirementWalker) visitVariable(variable *parse.VariableNode, reach requirementReachability) {
if len(variable.Ident) < 2 || variable.Ident[0] != "$" {
return
}
if !walker.rootKnown {
walker.addIssue("", "", "The root template binding is not known.")
return
}
walker.recordVariable(variable.Ident[1], reach)
if len(variable.Ident) > 2 {
walker.addIssue("", variable.Ident[1], "A chained field access is outside the direct-key scope.")
}
}
func isRootMap(node parse.Node, dot dotIdentity, rootKnown bool) bool {
switch node := node.(type) {
case *parse.DotNode:
return dot == dotRoot
case *parse.VariableNode:
return rootKnown && len(node.Ident) == 1 && node.Ident[0] == "$"
default:
return false
}
}
func (walker *requirementWalker) recordInput(name string, reach requirementReachability) {
evidence := walker.inputs[name]
if evidence == nil {
evidence = &inputEvidence{
name: name, kind: domain.RequirementOptional,
explanation: "Declared as an optional input.",
}
walker.inputs[name] = evidence
}
walker.promoteInput(evidence, kindForReach(reach))
if evidence.kind == domain.RequirementRequired {
evidence.explanation = ""
} else if reach == requirementPossible {
evidence.explanation = "This reference is not known to be reached on every execution path."
walker.addIssue(name, "", "A dependency is conservatively classified.")
}
walker.dependencies++
}
func (walker *requirementWalker) recordVariable(name string, reach requirementReachability) {
evidence := walker.variables[name]
if evidence == nil {
evidence = &variableEvidence{name: name, kind: domain.RequirementOptional}
walker.variables[name] = evidence
}
walker.promoteVariable(evidence, kindForReach(reach))
if evidence.kind == domain.RequirementRequired {
evidence.explanation = ""
} else if reach == requirementPossible {
evidence.explanation = "This reference is not known to be reached on every execution path."
walker.addIssue("", name, "A dependency is conservatively classified.")
}
walker.dependencies++
}
func (walker *requirementWalker) recordOptionalVariable(name string, _ requirementReachability) {
evidence := walker.variables[name]
if evidence == nil {
evidence = &variableEvidence{name: name, kind: domain.RequirementOptional}
walker.variables[name] = evidence
}
if evidence.kind == domain.RequirementOptional {
evidence.explanation = "This key is read through an optional map lookup."
}
walker.dependencies++
}
func (walker *requirementWalker) promoteInput(evidence *inputEvidence, kind domain.RequirementKind) {
if requirementRank(kind) > requirementRank(evidence.kind) {
evidence.kind = kind
}
if evidence.kind == domain.RequirementRequired {
evidence.explanation = ""
}
}
func (walker *requirementWalker) promoteVariable(evidence *variableEvidence, kind domain.RequirementKind) {
if requirementRank(kind) > requirementRank(evidence.kind) {
evidence.kind = kind
}
if evidence.kind == domain.RequirementRequired {
evidence.explanation = ""
}
}
func kindForReach(reach requirementReachability) domain.RequirementKind {
if reach == requirementPossible {
return domain.RequirementConditional
}
return domain.RequirementRequired
}
func requirementRank(kind domain.RequirementKind) int {
switch kind {
case domain.RequirementRequired:
return 3
case domain.RequirementConditional:
return 2
default:
return 1
}
}
func (walker *requirementWalker) addIssue(inputName, variableName, description string) {
walker.complete = false
issue := domain.RequirementIssue{
Code: domain.RequirementIssueUnsupportedConstruct,
InputName: inputName,
VariableName: variableName,
Description: description,
}
key := requirementIssueKey{code: issue.Code, inputName: inputName, variableName: variableName}
walker.issues[key] = issue
}
func (walker *requirementWalker) result(result domain.RequirementInspection) domain.RequirementInspection {
result.Complete = walker.complete
result.Inputs = make([]domain.InputRequirement, 0, len(walker.inputs))
for _, evidence := range walker.inputs {
result.Inputs = append(result.Inputs, domain.InputRequirement{
Name: evidence.name, Kind: evidence.kind, Declared: evidence.declared,
ContentType: copyString(evidence.contentType), Description: copyString(evidence.description),
Explanation: evidence.explanation,
})
}
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 {
_, supplied := walker.vars[evidence.name]
result.Variables = append(result.Variables, domain.VariableRequirement{
Name: evidence.name, Kind: evidence.kind, Supplied: supplied, Explanation: evidence.explanation,
})
}
sort.Slice(result.Variables, func(i, j int) bool { return result.Variables[i].Name < result.Variables[j].Name })
result.Issues = make([]domain.RequirementIssue, 0, len(walker.issues))
for _, issue := range walker.issues {
result.Issues = append(result.Issues, issue)
}
sort.Slice(result.Issues, func(i, j int) bool {
left, right := result.Issues[i], result.Issues[j]
if left.Code != right.Code {
return left.Code < right.Code
}
if left.InputName != right.InputName {
return left.InputName < right.InputName
}
return left.VariableName < right.VariableName
})
return result
}
func copyString(value *string) *string {
if value == nil {
return nil
}
copy := *value
return &copy
}

View File

@@ -0,0 +1,213 @@
package prompt
import (
"context"
"errors"
"reflect"
"testing"
"text/template"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
func TestAnalyzeRequirementsCollectsDeclaredAndLiteralInputs(t *testing.T) {
description := "retained metadata"
definition := &domain.PromptDefinition{ID: "capture", Version: "v1"}
snapshot := RequirementSnapshot{
Inputs: []InputMetadata{
{Name: "optional", ContentType: stringPointerForTest("text/plain"), Description: &description},
{Name: "unused", Required: true},
{Name: "transcript", Required: true},
},
Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", `{{input "transcript"}} {{input "extra"}} {{input "extra"}}`),
}},
}
result, err := AnalyzeRequirements(context.Background(), definition, snapshot, nil, false)
if err != nil {
t.Fatal(err)
}
if result.PromptID != "capture" || result.PromptVersion != "v1" || !result.Complete || len(result.Issues) != 0 {
t.Fatalf("unexpected result identity or completeness: %+v", result)
}
wantNames := []string{"extra", "optional", "transcript", "unused"}
if got := requirementInputNames(result.Inputs); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("input names = %v, want %v", got, wantNames)
}
if got := result.Inputs[0]; got.Kind != domain.RequirementRequired || got.Declared || got.ContentType != nil || got.Description != nil {
t.Fatalf("undeclared literal input was not required independently: %+v", got)
}
if got := result.Inputs[1]; got.Kind != domain.RequirementOptional || !got.Declared || got.ContentType == nil || *got.ContentType != "text/plain" ||
got.Description == nil || *got.Description != description || got.Explanation == "" {
t.Fatalf("optional declaration metadata was not retained: %+v", got)
}
if result.Inputs[2].Kind != domain.RequirementRequired || !result.Inputs[2].Declared || result.Inputs[3].Kind != domain.RequirementRequired {
t.Fatalf("required declarations or literal use were lost: %+v", result.Inputs)
}
*result.Inputs[1].Description = "caller mutation"
if *snapshot.Inputs[0].Description != description {
t.Fatal("result metadata pointer aliases the captured snapshot")
}
}
func TestAnalyzeRequirementsDistinguishesDirectAndOptionalCallerKeys(t *testing.T) {
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", `{{.missing}} {{$.present}} {{index . "lookup"}}`),
}},
}, map[string]string{"present": ""}, false)
if err != nil {
t.Fatal(err)
}
if !result.Complete || len(result.Issues) != 0 {
t.Fatalf("plain root and literal lookup syntax should be complete: %+v", result)
}
wantNames := []string{"lookup", "missing", "present"}
if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("variable names = %v, want %v", got, wantNames)
}
if result.Variables[0].Kind != domain.RequirementOptional || result.Variables[0].Supplied || result.Variables[0].Explanation == "" {
t.Errorf("literal index lookup should be optional and absent: %+v", result.Variables[0])
}
if result.Variables[1].Kind != domain.RequirementRequired || result.Variables[1].Supplied {
t.Errorf("missing direct variable should be required but not supplied: %+v", result.Variables[1])
}
if result.Variables[2].Kind != domain.RequirementRequired || !result.Variables[2].Supplied {
t.Errorf("empty supplied direct variable was not detected by presence: %+v", result.Variables[2])
}
}
func TestAnalyzeRequirementsSeparatesRootAndLocalTemplateValues(t *testing.T) {
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", `{{$local := "local"}}{{with .context}}{{$local}} {{.child}} {{$.root}}{{end}}`),
}},
}, nil, false)
if err != nil {
t.Fatal(err)
}
wantNames := []string{"context", "root"}
if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("root variable names = %v, want %v", got, wantNames)
}
if result.Variables[0].Kind != domain.RequirementRequired || result.Variables[1].Kind != domain.RequirementConditional || result.Complete {
t.Fatalf("with scope and unresolved branch should be conservative: %+v", result)
}
if len(result.Issues) != 1 || result.Issues[0].VariableName != "root" {
t.Fatalf("conditional root use should have one associated limitation: %+v", result.Issues)
}
}
func TestAnalyzeRequirementsMarksBranchAndUnsupportedScopeReferences(t *testing.T) {
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{
compileRequirementTemplate(t, "message", `{{if .enabled}}{{input "maybe"}}{{end}}{{range .items}}{{input "inside"}} {{.local}} {{$.root}}{{end}}`),
}},
}, nil, false)
if err != nil {
t.Fatal(err)
}
wantInputs := []string{"inside", "maybe"}
wantVars := []string{"enabled", "items", "root"}
if got := requirementInputNames(result.Inputs); !reflect.DeepEqual(got, wantInputs) {
t.Fatalf("input names = %v, want %v", got, wantInputs)
}
if got := requirementVariableNames(result.Variables); !reflect.DeepEqual(got, wantVars) {
t.Fatalf("variable names = %v, want %v", got, wantVars)
}
if result.Complete || len(result.Issues) < 3 {
t.Fatalf("branch/range limitations were not reported: %+v", result)
}
for _, input := range result.Inputs {
if input.Kind != domain.RequirementConditional {
t.Errorf("input %q should be conservative conditional: %+v", input.Name, input)
}
}
if result.Variables[0].Kind != domain.RequirementRequired || result.Variables[1].Kind != domain.RequirementRequired ||
result.Variables[2].Kind != domain.RequirementConditional {
t.Errorf("root and loop-scope variables were misclassified: %+v", result.Variables)
}
}
func TestAnalyzeRequirementsExcludesUnusedDefinitionsAndBypassedSession(t *testing.T) {
message := compileRequirementTemplate(t, "message", `{{define "unused"}}{{input "unused"}}{{end}}main`)
session := compileRequirementTemplate(t, "session", `{{input "session_only"}}`)
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{message}, Session: session},
}, nil, true)
if err != nil {
t.Fatal(err)
}
if !result.Complete || len(result.Inputs) != 0 {
t.Fatalf("unused definitions or bypassed session were included: %+v", result)
}
result, err = AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{message}, Session: session},
}, nil, false)
if err != nil {
t.Fatal(err)
}
if len(result.Inputs) != 1 || result.Inputs[0].Name != "session_only" || result.Inputs[0].Kind != domain.RequirementRequired {
t.Fatalf("applicable session template was not visited: %+v", result)
}
}
func TestAnalyzeRequirementsScansInvokedDefinitionsConservatively(t *testing.T) {
message := compileRequirementTemplate(t, "message", `{{define "unused"}}{{input "unused"}}{{end}}{{define "called"}}{{input "called"}} {{.local}}{{end}}main {{template "called" .}}`)
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{message}},
}, nil, false)
if err != nil {
t.Fatal(err)
}
if result.Complete || len(result.Inputs) != 1 || result.Inputs[0].Name != "called" ||
result.Inputs[0].Kind != domain.RequirementConditional || len(result.Variables) != 0 {
t.Fatalf("called definition was not scanned conservatively: %+v", result)
}
if len(result.Issues) < 1 || result.Issues[0].Code != domain.RequirementIssueUnsupportedConstruct {
t.Fatalf("named-template limitation was not reported: %+v", result.Issues)
}
}
func TestAnalyzeRequirementsSupportsEmptyTemplatesAndCancellation(t *testing.T) {
result, err := AnalyzeRequirements(context.Background(), &domain.PromptDefinition{}, RequirementSnapshot{
Templates: CompiledTemplates{Messages: []*template.Template{compileRequirementTemplate(t, "message", "plain text")}},
}, nil, false)
if err != nil || !result.Complete || len(result.Inputs) != 0 || len(result.Variables) != 0 {
t.Fatalf("input-free template should have a complete empty inventory: %+v, %v", result, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := AnalyzeRequirements(ctx, &domain.PromptDefinition{}, RequirementSnapshot{}, nil, false); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled context returned %v, want context.Canceled", err)
}
}
func compileRequirementTemplate(t *testing.T, name, source string) *template.Template {
t.Helper()
compiled, err := CompileTemplate(name, source)
if err != nil {
t.Fatalf("compile template: %v", err)
}
return compiled
}
func requirementInputNames(inputs []domain.InputRequirement) []string {
names := make([]string, len(inputs))
for index, input := range inputs {
names[index] = input.Name
}
return names
}
func requirementVariableNames(variables []domain.VariableRequirement) []string {
names := make([]string, len(variables))
for index, variable := range variables {
names[index] = variable.Name
}
return names
}
func stringPointerForTest(value string) *string {
return &value
}