979 lines
32 KiB
Go
979 lines
32 KiB
Go
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
|
|
requirementInactive
|
|
)
|
|
|
|
type dotIdentity uint8
|
|
|
|
const (
|
|
dotRoot dotIdentity = iota
|
|
dotNonRoot
|
|
dotUnknown
|
|
)
|
|
|
|
type inputEvidence struct {
|
|
name string
|
|
kind domain.RequirementKind
|
|
declared bool
|
|
applicable bool
|
|
contentType *string
|
|
description *string
|
|
explanation string
|
|
}
|
|
|
|
type variableEvidence struct {
|
|
name string
|
|
kind domain.RequirementKind
|
|
applicable bool
|
|
explanation string
|
|
}
|
|
|
|
type requirementIssueKey struct {
|
|
code domain.RequirementIssueCode
|
|
inputName string
|
|
variableName string
|
|
}
|
|
|
|
type conditionEvaluation struct {
|
|
value any
|
|
known bool
|
|
unsupported bool
|
|
inputNameUnknown bool
|
|
missingVariables map[string]struct{}
|
|
contentInputs map[string]struct{}
|
|
}
|
|
|
|
func (evaluation conditionEvaluation) truth() bool {
|
|
truth, ok := template.IsTrue(evaluation.value)
|
|
return evaluation.known && ok && truth
|
|
}
|
|
|
|
func (walker *requirementWalker) evaluateCondition(pipe *parse.PipeNode, reach requirementReachability, dot dotIdentity) conditionEvaluation {
|
|
if pipe == nil {
|
|
return conditionEvaluation{unsupported: true}
|
|
}
|
|
if len(pipe.Cmds) == 1 && len(pipe.Decl) == 0 && !pipe.IsAssign {
|
|
return walker.evaluateCommand(pipe.Cmds[0], reach, dot)
|
|
}
|
|
if name, ok := directInputPipeline(pipe); ok {
|
|
return walker.evaluateInputName(name, reach, dot)
|
|
}
|
|
before := walker.dependencies
|
|
visitReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
visitReach = requirementInactive
|
|
}
|
|
if err := walker.visitPipe(pipe, visitReach, dot); err != nil {
|
|
return conditionEvaluation{unsupported: true}
|
|
}
|
|
return conditionEvaluation{unsupported: walker.dependencies != before}
|
|
}
|
|
|
|
func (walker *requirementWalker) evaluateCommand(command *parse.CommandNode, reach requirementReachability, dot dotIdentity) conditionEvaluation {
|
|
if command == nil || len(command.Args) == 0 {
|
|
return conditionEvaluation{unsupported: true}
|
|
}
|
|
identifier, isIdentifier := command.Args[0].(*parse.IdentifierNode)
|
|
if isIdentifier {
|
|
switch identifier.Ident {
|
|
case "input":
|
|
if reach == requirementInactive {
|
|
if len(command.Args) == 2 {
|
|
if name, ok := literalStringValue(command.Args[1]); ok {
|
|
walker.recordInput(name, requirementInactive)
|
|
return conditionEvaluation{}
|
|
}
|
|
}
|
|
for _, argument := range command.Args[1:] {
|
|
_ = walker.visit(argument, requirementInactive, dot)
|
|
}
|
|
return conditionEvaluation{}
|
|
}
|
|
if len(command.Args) == 2 {
|
|
if name, ok := command.Args[1].(*parse.StringNode); ok {
|
|
walker.recordInput(name.Text, reach)
|
|
return conditionEvaluation{contentInputs: map[string]struct{}{name.Text: {}}}
|
|
}
|
|
return walker.evaluateInputName(command.Args[1], reach, dot)
|
|
}
|
|
for _, argument := range command.Args[1:] {
|
|
_ = walker.visit(argument, requirementPossible, dot)
|
|
}
|
|
return walker.unresolvedInputName(conditionEvaluation{unsupported: true})
|
|
case "index":
|
|
if len(command.Args) == 3 {
|
|
if key, ok := command.Args[2].(*parse.StringNode); ok && isRootMap(command.Args[1], dot, walker.rootKnown) {
|
|
walker.recordOptionalVariable(key.Text, reach)
|
|
value, supplied := walker.vars[key.Text]
|
|
if !supplied {
|
|
value = ""
|
|
}
|
|
return conditionEvaluation{value: value, known: true}
|
|
}
|
|
}
|
|
case "not":
|
|
if len(command.Args) == 2 {
|
|
evaluation := walker.evaluateValue(command.Args[1], reach, dot)
|
|
if evaluation.known {
|
|
truth, ok := template.IsTrue(evaluation.value)
|
|
if ok {
|
|
evaluation.value = !truth
|
|
} else {
|
|
evaluation.known = false
|
|
evaluation.unsupported = true
|
|
}
|
|
}
|
|
return evaluation
|
|
}
|
|
case "eq", "ne":
|
|
if len(command.Args) == 3 {
|
|
left := walker.evaluateValue(command.Args[1], reach, dot)
|
|
right := walker.evaluateValue(command.Args[2], reach, dot)
|
|
result := mergeConditionEvaluations(left, right)
|
|
if left.known && right.known {
|
|
leftString, leftOK := left.value.(string)
|
|
rightString, rightOK := right.value.(string)
|
|
if leftOK && rightOK {
|
|
equal := leftString == rightString
|
|
if identifier.Ident == "ne" {
|
|
equal = !equal
|
|
}
|
|
result.value = equal
|
|
result.known = true
|
|
return result
|
|
}
|
|
result.unsupported = true
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
if !isIdentifier && len(command.Args) == 1 {
|
|
return walker.evaluateValue(command.Args[0], reach, dot)
|
|
}
|
|
if reach == requirementInactive {
|
|
_ = walker.visitCommand(command, reach, dot)
|
|
return conditionEvaluation{}
|
|
}
|
|
before := walker.dependencies
|
|
_ = walker.visitCommand(command, requirementPossible, dot)
|
|
return conditionEvaluation{unsupported: walker.dependencies != before}
|
|
}
|
|
|
|
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:
|
|
return conditionEvaluation{value: node.True, known: true}
|
|
case *parse.NilNode:
|
|
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]
|
|
walker.recordVariable(name, reach)
|
|
if len(node.Ident) > 1 {
|
|
walker.addIssue("", name, "A chained field access is outside the direct-key scope.")
|
|
return conditionEvaluation{unsupported: true}
|
|
}
|
|
value, supplied := walker.vars[name]
|
|
if !supplied {
|
|
return conditionEvaluation{missingVariables: map[string]struct{}{name: {}}}
|
|
}
|
|
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]
|
|
walker.recordVariable(name, reach)
|
|
if len(node.Ident) > 2 {
|
|
walker.addIssue("", name, "A chained field access is outside the direct-key scope.")
|
|
return conditionEvaluation{unsupported: true}
|
|
}
|
|
value, supplied := walker.vars[name]
|
|
if !supplied {
|
|
return conditionEvaluation{missingVariables: map[string]struct{}{name: {}}}
|
|
}
|
|
return conditionEvaluation{value: value, known: true}
|
|
case *parse.CommandNode:
|
|
return walker.evaluateCommand(node, reach, dot)
|
|
case *parse.PipeNode:
|
|
return walker.evaluateCondition(node, reach, dot)
|
|
case *parse.ChainNode:
|
|
before := walker.dependencies
|
|
visitReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
visitReach = requirementInactive
|
|
}
|
|
_ = walker.visit(node.Node, visitReach, dot)
|
|
return conditionEvaluation{unsupported: walker.dependencies != before}
|
|
default:
|
|
return conditionEvaluation{unsupported: true}
|
|
}
|
|
}
|
|
|
|
func (walker *requirementWalker) evaluateInputName(node parse.Node, reach requirementReachability, dot dotIdentity) conditionEvaluation {
|
|
evaluation := walker.evaluateValue(node, reach, dot)
|
|
if evaluation.known {
|
|
if name, ok := evaluation.value.(string); ok {
|
|
walker.recordInput(name, reach)
|
|
return conditionEvaluation{contentInputs: map[string]struct{}{name: {}}}
|
|
}
|
|
evaluation.unsupported = true
|
|
}
|
|
// Value analysis has already visited recognizable references, including
|
|
// the conservative fallback for unsupported expressions. Do not rescan
|
|
// the same subtree: nested computed names would multiply that work.
|
|
return walker.unresolvedInputName(evaluation)
|
|
}
|
|
|
|
func (walker *requirementWalker) unresolvedInputName(evaluation conditionEvaluation) conditionEvaluation {
|
|
for name := range evaluation.missingVariables {
|
|
walker.addIssueCode(domain.RequirementIssueMissingVariable, "", name, "A missing variable prevents resolving an input name.")
|
|
}
|
|
for name := range evaluation.contentInputs {
|
|
walker.addIssueCode(domain.RequirementIssueInputContent, name, "", "Captured input content affects an input name.")
|
|
}
|
|
walker.addIssueCode(domain.RequirementIssueDynamicInputName, "", "", "An input helper name is not statically available.")
|
|
walker.unknownInputEffect = true
|
|
evaluation.inputNameUnknown = true
|
|
return evaluation
|
|
}
|
|
|
|
func directInputPipeline(pipe *parse.PipeNode) (parse.Node, bool) {
|
|
if pipe == nil || len(pipe.Cmds) != 2 || len(pipe.Decl) != 0 || pipe.IsAssign {
|
|
return nil, false
|
|
}
|
|
input := pipe.Cmds[1]
|
|
if len(input.Args) != 1 {
|
|
return nil, false
|
|
}
|
|
identifier, ok := input.Args[0].(*parse.IdentifierNode)
|
|
if !ok || identifier.Ident != "input" {
|
|
return nil, false
|
|
}
|
|
value := pipe.Cmds[0]
|
|
if len(value.Args) > 0 {
|
|
valueIdentifier, isValueIdentifier := value.Args[0].(*parse.IdentifierNode)
|
|
if isValueIdentifier && valueIdentifier.Ident == "input" && len(value.Args) == 2 {
|
|
return value, true
|
|
}
|
|
}
|
|
if len(value.Args) == 1 {
|
|
return value.Args[0], true
|
|
}
|
|
if len(value.Args) == 3 {
|
|
function, ok := value.Args[0].(*parse.IdentifierNode)
|
|
if ok && function.Ident == "index" {
|
|
return value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func literalStringValue(node parse.Node) (string, bool) {
|
|
switch node := node.(type) {
|
|
case *parse.StringNode:
|
|
return node.Text, true
|
|
case *parse.PipeNode:
|
|
if len(node.Cmds) == 1 && len(node.Cmds[0].Args) == 1 && len(node.Decl) == 0 && !node.IsAssign {
|
|
return literalStringValue(node.Cmds[0].Args[0])
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func mergeConditionEvaluations(left, right conditionEvaluation) conditionEvaluation {
|
|
result := conditionEvaluation{
|
|
unsupported: left.unsupported || right.unsupported,
|
|
inputNameUnknown: left.inputNameUnknown || right.inputNameUnknown,
|
|
missingVariables: make(map[string]struct{}, len(left.missingVariables)+len(right.missingVariables)),
|
|
contentInputs: make(map[string]struct{}, len(left.contentInputs)+len(right.contentInputs)),
|
|
}
|
|
for name := range left.missingVariables {
|
|
result.missingVariables[name] = struct{}{}
|
|
}
|
|
for name := range right.missingVariables {
|
|
result.missingVariables[name] = struct{}{}
|
|
}
|
|
for name := range left.contentInputs {
|
|
result.contentInputs[name] = struct{}{}
|
|
}
|
|
for name := range right.contentInputs {
|
|
result.contentInputs[name] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func branchReach(parent requirementReachability, known, truth, selected bool) requirementReachability {
|
|
if parent == requirementInactive {
|
|
return requirementInactive
|
|
}
|
|
if !known {
|
|
return requirementPossible
|
|
}
|
|
if truth == selected {
|
|
if parent == requirementPossible {
|
|
return requirementPossible
|
|
}
|
|
return requirementDefinite
|
|
}
|
|
return requirementInactive
|
|
}
|
|
|
|
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
|
|
unknownInputEffect bool
|
|
unknownVariableEffect 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 = copyString(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 reach == requirementInactive {
|
|
if err := walker.visit(node.Pipe, requirementInactive, dot); err != nil {
|
|
return err
|
|
}
|
|
if err := walker.visit(node.List, requirementInactive, dot); err != nil {
|
|
return err
|
|
}
|
|
return walker.visit(node.ElseList, requirementInactive, dot)
|
|
}
|
|
condition := walker.evaluateCondition(node.Pipe, reach, dot)
|
|
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, dot); err != nil {
|
|
return err
|
|
}
|
|
if err := walker.visit(node.ElseList, elseReach, dot); err != nil {
|
|
return err
|
|
}
|
|
if !condition.known && walker.dependencies != before {
|
|
walker.recordConditionLimitation(condition)
|
|
}
|
|
return nil
|
|
case *parse.WithNode:
|
|
if reach == requirementInactive {
|
|
if err := walker.visit(node.Pipe, requirementInactive, dot); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
}
|
|
condition := walker.evaluateCondition(node.Pipe, reach, dot)
|
|
before := walker.dependencies
|
|
thenReach := branchReach(reach, condition.known, condition.truth(), true)
|
|
elseReach := branchReach(reach, condition.known, condition.truth(), false)
|
|
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 {
|
|
return err
|
|
}
|
|
if !condition.known && walker.dependencies != before {
|
|
walker.recordConditionLimitation(condition)
|
|
}
|
|
return nil
|
|
case *parse.RangeNode:
|
|
if err := walker.visit(node.Pipe, reach, dot); err != nil {
|
|
return err
|
|
}
|
|
before := walker.dependencies
|
|
bodyReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
bodyReach = requirementInactive
|
|
}
|
|
if err := walker.visit(node.List, bodyReach, dotNonRoot); err != nil {
|
|
return err
|
|
}
|
|
if err := walker.visit(node.ElseList, bodyReach, dot); err != nil {
|
|
return err
|
|
}
|
|
if reach != requirementInactive && walker.dependencies != before {
|
|
walker.addIssue("", "", "Dependencies inside a range are only conservatively enumerated.")
|
|
}
|
|
case *parse.TemplateNode:
|
|
pipeReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
pipeReach = requirementInactive
|
|
}
|
|
if err := walker.visit(node.Pipe, pipeReach, dot); err != nil {
|
|
return err
|
|
}
|
|
if reach == requirementInactive {
|
|
return nil
|
|
}
|
|
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
|
|
walker.unknownVariableEffect = true
|
|
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 && reach != requirementInactive {
|
|
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:
|
|
before := walker.dependencies
|
|
chainReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
chainReach = requirementInactive
|
|
}
|
|
if err := walker.visit(node.Node, chainReach, dot); err != nil {
|
|
return err
|
|
}
|
|
if reach != requirementInactive && walker.dependencies != before {
|
|
walker.addIssue("", "", "A chained expression is not interpreted.")
|
|
}
|
|
case *parse.BreakNode, *parse.ContinueNode:
|
|
if reach != requirementInactive {
|
|
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)
|
|
}
|
|
if name, ok := directInputPipeline(pipe); ok {
|
|
if reach == requirementInactive {
|
|
if literal, known := literalStringValue(name); known {
|
|
walker.recordInput(literal, requirementInactive)
|
|
return nil
|
|
}
|
|
return walker.visit(name, requirementInactive, dot)
|
|
}
|
|
walker.evaluateInputName(name, reach, dot)
|
|
return nil
|
|
}
|
|
before := walker.dependencies
|
|
commandReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
commandReach = requirementInactive
|
|
}
|
|
for _, command := range pipe.Cmds {
|
|
if err := walker.visitCommand(command, commandReach, dot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if reach != requirementInactive && walker.dependencies != before {
|
|
walker.addIssue("", "", "A pipeline or variable assignment is not interpreted.")
|
|
}
|
|
if reach != requirementInactive {
|
|
for _, declaration := range pipe.Decl {
|
|
if len(declaration.Ident) > 0 && declaration.Ident[0] == "$" {
|
|
walker.rootKnown = false
|
|
walker.unknownVariableEffect = true
|
|
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 reach == requirementInactive {
|
|
if len(command.Args) == 2 {
|
|
if name, ok := literalStringValue(command.Args[1]); ok {
|
|
walker.recordInput(name, reach)
|
|
return nil
|
|
}
|
|
}
|
|
for _, argument := range command.Args[1:] {
|
|
if err := walker.visit(argument, requirementInactive, dot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if len(command.Args) == 2 {
|
|
if name, ok := command.Args[1].(*parse.StringNode); ok {
|
|
walker.recordInput(name.Text, reach)
|
|
return nil
|
|
}
|
|
walker.evaluateInputName(command.Args[1], reach, dot)
|
|
return nil
|
|
}
|
|
for _, argument := range command.Args[1:] {
|
|
if err := walker.visit(argument, requirementPossible, dot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
walker.unresolvedInputName(conditionEvaluation{unsupported: true})
|
|
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
|
|
}
|
|
// 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)
|
|
}
|
|
before := walker.dependencies
|
|
for index, argument := range command.Args {
|
|
if isIdentifier && index == 0 {
|
|
continue
|
|
}
|
|
argumentReach := requirementPossible
|
|
if reach == requirementInactive {
|
|
argumentReach = requirementInactive
|
|
}
|
|
if err := walker.visit(argument, argumentReach, dot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if reach != requirementInactive && 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 {
|
|
return
|
|
}
|
|
if variable.Ident[0] != "$" {
|
|
walker.unresolvedVariableScope(reach)
|
|
return
|
|
}
|
|
if !walker.rootKnown {
|
|
if reach != requirementInactive {
|
|
walker.unknownVariableEffect = true
|
|
walker.addIssue("", "", "The root template binding is not known.")
|
|
}
|
|
return
|
|
}
|
|
walker.recordVariable(variable.Ident[1], reach)
|
|
if len(variable.Ident) > 2 && reach != requirementInactive {
|
|
walker.addIssue("", variable.Ident[1], "A chained field access is outside the direct-key scope.")
|
|
}
|
|
}
|
|
|
|
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 := unwrapSingleValue(node).(type) {
|
|
case *parse.DotNode:
|
|
return dot == dotRoot
|
|
case *parse.VariableNode:
|
|
return rootKnown && len(node.Ident) == 1 && node.Ident[0] == "$"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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 {
|
|
evidence = &inputEvidence{
|
|
name: name, kind: domain.RequirementOptional,
|
|
explanation: "Declared as an optional input.",
|
|
}
|
|
walker.inputs[name] = evidence
|
|
}
|
|
if reach == requirementInactive {
|
|
if !evidence.applicable && evidence.kind != domain.RequirementRequired {
|
|
evidence.explanation = "This input is referenced only in a skipped template path."
|
|
}
|
|
} else {
|
|
evidence.applicable = true
|
|
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."
|
|
}
|
|
}
|
|
if reach != requirementInactive {
|
|
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
|
|
}
|
|
if reach == requirementInactive {
|
|
if !evidence.applicable {
|
|
evidence.explanation = "This variable is referenced only in a skipped template path."
|
|
}
|
|
} else {
|
|
evidence.applicable = true
|
|
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."
|
|
}
|
|
}
|
|
if reach != requirementInactive {
|
|
walker.dependencies++
|
|
}
|
|
}
|
|
|
|
func (walker *requirementWalker) recordOptionalVariable(name string, reach requirementReachability) {
|
|
evidence := walker.variables[name]
|
|
if evidence == nil {
|
|
evidence = &variableEvidence{name: name, kind: domain.RequirementOptional}
|
|
walker.variables[name] = evidence
|
|
}
|
|
if reach == requirementInactive {
|
|
if !evidence.applicable {
|
|
evidence.explanation = "This variable is referenced only in a skipped template path."
|
|
}
|
|
} else {
|
|
evidence.applicable = true
|
|
}
|
|
if evidence.kind == domain.RequirementOptional && reach != requirementInactive {
|
|
evidence.explanation = "This key is read through an optional map lookup."
|
|
}
|
|
if reach != requirementInactive {
|
|
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.addIssueCode(domain.RequirementIssueUnsupportedConstruct, inputName, variableName, description)
|
|
}
|
|
|
|
func (walker *requirementWalker) addIssueCode(code domain.RequirementIssueCode, inputName, variableName, description string) {
|
|
walker.complete = false
|
|
issue := domain.RequirementIssue{
|
|
Code: code,
|
|
InputName: inputName,
|
|
VariableName: variableName,
|
|
Description: description,
|
|
}
|
|
key := requirementIssueKey{code: issue.Code, inputName: inputName, variableName: variableName}
|
|
walker.issues[key] = issue
|
|
}
|
|
|
|
func (walker *requirementWalker) recordConditionLimitation(condition conditionEvaluation) {
|
|
for name := range condition.missingVariables {
|
|
walker.addIssueCode(domain.RequirementIssueMissingVariable, "", name, "A missing variable prevents selecting a dependency-bearing branch.")
|
|
}
|
|
for name := range condition.contentInputs {
|
|
walker.addIssueCode(domain.RequirementIssueInputContent, name, "", "Captured input content affects branch selection.")
|
|
}
|
|
if condition.unsupported && !condition.inputNameUnknown ||
|
|
(len(condition.missingVariables) == 0 && len(condition.contentInputs) == 0 && !condition.inputNameUnknown) {
|
|
walker.addIssue("", "", "A dependency-bearing branch condition is not resolved by the supported syntax analysis.")
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if walker.unknownInputEffect && evidence.kind != domain.RequirementRequired {
|
|
evidence.kind = domain.RequirementConditional
|
|
evidence.explanation = "An unresolved input name could refer to this input."
|
|
}
|
|
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 {
|
|
if walker.unknownVariableEffect && evidence.kind != domain.RequirementRequired {
|
|
evidence.kind = domain.RequirementConditional
|
|
evidence.explanation = "An unresolved caller-variable scope could refer to this variable."
|
|
}
|
|
_, 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 ©
|
|
}
|