Enumerate prompt input and variable dependencies
This commit is contained in:
471
internal/prompt/requirements.go
Normal file
471
internal/prompt/requirements.go
Normal 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 ©
|
||||
}
|
||||
Reference in New Issue
Block a user