From cc0b6183bffdba9dc5ff3c045d4f73ab4ebbed12 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 8 Oct 2026 20:25:17 +0000 Subject: [PATCH] Enumerate prompt input and variable dependencies --- docs/internal/overview.md | 6 +- docs/internal/sources.md | 9 + docs/roadmap/implementation.md | 4 + internal/domain/requirements.go | 58 ++++ internal/prompt/requirements.go | 471 +++++++++++++++++++++++++++ internal/prompt/requirements_test.go | 213 ++++++++++++ 6 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 internal/domain/requirements.go create mode 100644 internal/prompt/requirements.go create mode 100644 internal/prompt/requirements_test.go diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 87f21ec..cb7a78e 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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) | diff --git a/docs/internal/sources.md b/docs/internal/sources.md index deeba0e..926b32b 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 501b700..6347be4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/domain/requirements.go b/internal/domain/requirements.go new file mode 100644 index 0000000..9d981e9 --- /dev/null +++ b/internal/domain/requirements.go @@ -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 +} diff --git a/internal/prompt/requirements.go b/internal/prompt/requirements.go new file mode 100644 index 0000000..aa39858 --- /dev/null +++ b/internal/prompt/requirements.go @@ -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 © +} diff --git a/internal/prompt/requirements_test.go b/internal/prompt/requirements_test.go new file mode 100644 index 0000000..88a45f1 --- /dev/null +++ b/internal/prompt/requirements_test.go @@ -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 +}