Files
puppet-catalog-tools/internal/impact/scan.go
T
unkin-agent 1c1df7f953 strip source quote-aware and resolve dynamic class references
- strip # comments with quote-state tracking so a hash in a string no
  longer truncates the line
- skip heredoc bodies in every @(EOT) form so documentation text is not
  indexed as a class reference
- take every literal name out of a selector, following a bare variable
  through the assignments in its own file
- record a class reference that stays unresolved against its file, with
  any literal prefix, instead of dropping it
- read a class name case insensitively in a title or type reference
- tag each edge as a declaration or an ordering reference
2026-09-12 17:18:56 +10:00

537 lines
13 KiB
Go

package impact
import (
"regexp"
"strings"
)
// refKind separates a construct that applies a class from one that only names
// it for ordering or to read a value out of it.
type refKind int
const (
refOrdering refKind = iota
refDeclare
)
// UnresolvedRef is a class reference the parser saw but could not reduce to
// literal names. Dropping one silently loses hosts, so it is recorded against
// its file and reported instead.
type UnresolvedRef struct {
File string `json:"file"`
Form string `json:"form"`
Expr string `json:"expr"`
Prefix string `json:"prefix,omitempty"`
}
// reserved are bare words an argument list or selector arm can hold that are
// values rather than class names.
var reserved = map[string]bool{
"undef": true, "true": true, "false": true, "default": true,
"class": true, "define": true,
}
// maxVarDepth caps how far a bare variable is followed through same-file
// assignments before the reference is called unresolved.
const maxVarDepth = 4
// assignRe finds `$name = ` while skipping `==`, `=~` and `=>`.
var assignRe = regexp.MustCompile(`(?:^|[^\w$.])\$([a-z_][a-zA-Z0-9_]*)\s*=[^=~>]`)
type refScan struct {
refs map[string]refKind
unresolved []UnresolvedRef
vars map[string]string
depth int
}
// scanReferences extracts every class or defined-type name a manifest body
// names, plus every class reference it could not resolve to literal names.
func scanReferences(body string) (map[string]refKind, []UnresolvedRef) {
s := &refScan{refs: map[string]refKind{}, vars: assignments(body)}
// include/contain/require: every name in the argument list.
for _, loc := range declFuncRe.FindAllStringIndex(body, -1) {
args, ok := callArguments(body, loc[1])
if !ok {
continue
}
s.args(callName(body[loc[0]:loc[1]]), refDeclare, args)
}
// class { ['a', 'b']: } declares one resource per title.
for _, loc := range classParamRe.FindAllStringIndex(body, -1) {
s.args("class {}", refDeclare, resourceTitles(body, loc[1]))
}
// Class['a', 'b'] names classes that are declared elsewhere; it orders
// against them rather than applying them.
for _, loc := range classRefRe.FindAllStringIndex(body, -1) {
inner, _, ok := balanced(body, loc[1]-1, '[', ']')
if !ok {
continue
}
s.args("Class[]", refOrdering, inner)
}
for _, re := range []*regexp.Regexp{inheritsRe, defineUseRe, createResRe} {
for _, m := range re.FindAllStringSubmatch(body, -1) {
s.add(m[1], refDeclare)
}
}
// A $mod::params::thing read and a Mod::Type['x'] reference both depend on
// another class without declaring it.
for _, m := range scopeVarRe.FindAllStringSubmatch(body, -1) {
s.add(m[1], refOrdering)
}
for _, m := range resourceRefRe.FindAllStringSubmatch(body, -1) {
s.add(strings.ToLower(m[1]), refOrdering)
}
return s.refs, dedupeUnresolved(s.unresolved)
}
// callName trims the separator declFuncRe matched ahead of the function name.
func callName(match string) string {
return strings.TrimLeftFunc(match, func(r rune) bool { return r < 'a' || r > 'z' })
}
func (s *refScan) add(name string, kind refKind) {
name = strings.TrimPrefix(name, "::")
if name == "" || reserved[name] {
return
}
if old, ok := s.refs[name]; !ok || kind > old {
s.refs[name] = kind
}
}
func (s *refScan) args(form string, kind refKind, args string) {
for _, arg := range splitArgs(args) {
s.arg(form, kind, arg)
}
}
// arg reduces one argument expression to class names, descending into arrays,
// parentheses and selector arms. Anything it cannot reduce becomes an
// unresolved reference rather than nothing.
func (s *refScan) arg(form string, kind refKind, arg string) {
arg = strings.TrimSpace(arg)
if arg == "" {
return
}
switch arg[0] {
case '[':
s.nested(form, kind, arg, '[', ']')
case '(':
s.nested(form, kind, arg, '(', ')')
default:
if arms, ok := selectorArms(arg); ok {
s.selector(form, kind, arg, arms)
return
}
s.literal(form, kind, arg)
}
}
func (s *refScan) nested(form string, kind refKind, arg string, opener, closer byte) {
inner, _, ok := balanced(arg, 0, opener, closer)
if !ok {
s.unresolvedAdd(form, arg, "")
return
}
s.args(form, kind, inner)
}
// selector takes every literal name out of every arm, which is strictly better
// than widening: a selector over role names lists all the classes it can pick.
func (s *refScan) selector(form string, kind refKind, arg, arms string) {
for _, arm := range splitArgs(arms) {
_, value, ok := cutRocket(arm)
if !ok {
if strings.TrimSpace(arm) != "" {
s.unresolvedAdd(form, arg, "")
}
continue
}
s.arg(form, kind, value)
}
}
func (s *refScan) literal(form string, kind refKind, arg string) {
lit, quoted := unquote(arg)
switch {
case classNameRe.MatchString(lit):
s.add(lit, kind)
case anyCaseNameRe.MatchString(lit):
// Class[Apache::Ssl] names the class in type form.
s.add(strings.ToLower(lit), kind)
case quoted:
// "profile::role_${role}" is a name only the compiler can finish.
s.unresolvedAdd(form, arg, literalPrefix(lit))
case strings.HasPrefix(arg, "$"):
s.variable(form, kind, arg)
default:
s.unresolvedAdd(form, arg, "")
}
}
// variable follows a bare $name through the assignments in the same file, and
// gives up loudly when that leads nowhere.
func (s *refScan) variable(form string, kind refKind, arg string) {
expr, ok := s.vars[strings.TrimPrefix(strings.TrimPrefix(arg, "$"), "::")]
if !ok || s.depth >= maxVarDepth {
s.unresolvedAdd(form, arg, "")
return
}
before := len(s.refs) + len(s.unresolved)
s.depth++
s.arg(form, kind, expr)
s.depth--
if len(s.refs)+len(s.unresolved) == before {
s.unresolvedAdd(form, arg, "")
}
}
func (s *refScan) unresolvedAdd(form, expr, prefix string) {
s.unresolved = append(s.unresolved, UnresolvedRef{Form: form, Expr: collapse(expr), Prefix: prefix})
}
func dedupeUnresolved(in []UnresolvedRef) []UnresolvedRef {
if len(in) == 0 {
return nil
}
seen := map[UnresolvedRef]bool{}
out := in[:0]
for _, u := range in {
if seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
// literalPrefix returns the part of an interpolated name that is fixed text,
// so the report can say what family of classes was missed.
func literalPrefix(s string) string {
i := strings.Index(s, "$")
if i <= 0 {
return ""
}
return s[:i]
}
// collapse folds whitespace and clips an expression to a length that reads in
// a report.
func collapse(s string) string {
out := strings.Join(strings.Fields(s), " ")
if len(out) > 80 {
out = out[:80] + "..."
}
return out
}
func unquote(s string) (string, bool) {
if len(s) > 1 && (s[0] == '\'' || s[0] == '"') && s[len(s)-1] == s[0] {
return s[1 : len(s)-1], true
}
return s, false
}
// selectorArms returns the arm text of a `<expr> ? { ... }` selector.
func selectorArms(s string) (string, bool) {
depth := 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case '?':
if depth != 0 {
continue
}
k := j + 1
for k < len(s) && isSpace(s[k]) {
k++
}
if k < len(s) && s[k] == '{' {
inner, _, ok := balanced(s, k, '{', '}')
return inner, ok
}
}
}
return "", false
}
// cutRocket splits a selector arm on its top-level `=>`.
func cutRocket(s string) (string, string, bool) {
depth := 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case '=':
if depth == 0 && j+1 < len(s) && s[j+1] == '>' {
return s[:j], s[j+2:], true
}
}
}
return "", "", false
}
// assignments maps a variable assigned in this file to the expression it was
// assigned, so `$p = $x ? {...}; include $p` still yields names.
func assignments(body string) map[string]string {
out := map[string]string{}
for _, loc := range assignRe.FindAllStringSubmatchIndex(body, -1) {
name := body[loc[2]:loc[3]]
if _, seen := out[name]; seen {
continue
}
out[name] = statementExpr(body, loc[1]-1)
}
return out
}
// statementExpr reads one expression starting at i, ending at the top-level
// semicolon, newline or closing delimiter that terminates the statement.
func statementExpr(s string, i int) string {
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; {
case c == '\'' || c == '"':
j = skipString(s, j) - 1
case c == '(' || c == '[' || c == '{':
depth++
case c == ')' || c == ']' || c == '}':
if depth == 0 {
return s[i:j]
}
depth--
case c == ';' && depth == 0:
return s[i:j]
case c == '\n' && depth == 0 && !continued(s[i:j]):
return s[i:j]
}
}
return s[i:]
}
// stripSource blanks out # comments and heredoc bodies. Both are text the
// class scan must not read: a `#` inside a string is not a comment, and an
// `include` in heredoc documentation is not an edge. Blanking rather than
// deleting keeps the line structure the statement scan relies on.
func stripSource(src string) string {
out := []byte(src)
blank := func(from, to int) {
for k := from; k < to && k < len(out); k++ {
if out[k] != '\n' {
out[k] = ' '
}
}
}
var pending []string
for i := 0; i < len(src); {
switch c := src[i]; {
case c == '\'' || c == '"':
i = skipString(src, i)
case c == '#':
j := i
for j < len(src) && src[j] != '\n' {
j++
}
blank(i, j)
i = j
case c == '@' && i+1 < len(src) && src[i+1] == '(':
tag, next, ok := heredocTag(src, i+1)
if !ok {
i++
continue
}
pending = append(pending, tag)
i = next
case c == '\n':
i++
for _, tag := range pending {
i = blankHeredoc(src, blank, i, tag)
}
pending = pending[:0]
default:
i++
}
}
return string(out)
}
// heredocTag reads the tag out of @(EOT), @("EOT"), @(EOT/L) and @(EOT:json/nt).
func heredocTag(s string, i int) (string, int, bool) {
j := i + 1
for j < len(s) && s[j] != ')' && s[j] != '\n' {
j++
}
if j >= len(s) || s[j] != ')' {
return "", i + 1, false
}
spec := s[i+1 : j]
if k := strings.IndexAny(spec, ":/"); k >= 0 {
spec = spec[:k]
}
spec = strings.Trim(strings.TrimSpace(spec), `"'`)
if spec == "" {
return "", j + 1, false
}
return spec, j + 1, true
}
// blankHeredoc blanks the lines from i up to and including the line that ends
// the heredoc, and returns the index just past it.
func blankHeredoc(s string, blank func(from, to int), i int, tag string) int {
for i < len(s) {
end := strings.IndexByte(s[i:], '\n')
if end < 0 {
blank(i, len(s))
return len(s)
}
line := s[i : i+end]
blank(i, i+end)
i += end + 1
if isHeredocEnd(line, tag) {
return i
}
}
return i
}
// isHeredocEnd matches the terminator line, with its optional `|` indentation
// marker and `-` trailing-newline marker.
func isHeredocEnd(line, tag string) bool {
t := strings.TrimSpace(line)
t = strings.TrimSpace(strings.TrimPrefix(t, "|"))
t = strings.TrimSpace(strings.TrimPrefix(t, "-"))
return t == tag
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// callArguments returns the argument text of the function call whose name ends
// at i, in either the parenthesised or the bare statement form. It reports
// false when the keyword is not a call at all, such as a require => attribute.
func callArguments(s string, i int) (string, bool) {
if i >= len(s) {
return "", false
}
if s[i] != '(' && s[i] != ' ' && s[i] != '\t' {
return "", false
}
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
if i >= len(s) {
return "", false
}
if s[i] == '(' {
inner, _, ok := balanced(s, i, '(', ')')
return inner, ok
}
if s[i] == '=' || s[i] == ',' || s[i] == ';' || s[i] == '\n' {
return "", false
}
return statementExpr(s, i), true
}
// splitArgs splits an argument list on its top-level commas.
func splitArgs(s string) []string {
var out []string
depth, start := 0, 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case ',':
if depth == 0 {
out = append(out, s[start:j])
start = j + 1
}
}
}
return append(out, s[start:])
}
// resourceTitles returns the title expression of a resource body whose opening
// brace ends at i, i.e. everything up to the colon that closes the title.
func resourceTitles(s string, i int) string {
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
if depth == 0 {
return ""
}
depth--
case ':':
if depth == 0 {
return s[i:j]
}
}
}
return ""
}
// balanced returns the text between the delimiter at i and its match.
func balanced(s string, i int, opener, closer byte) (string, int, bool) {
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case opener:
depth++
case closer:
depth--
if depth == 0 {
return s[i+1 : j], j + 1, true
}
}
}
return "", len(s), false
}
// skipString returns the index just past the quoted string starting at i.
func skipString(s string, i int) int {
quote := s[i]
for j := i + 1; j < len(s); j++ {
switch s[j] {
case '\\':
j++
case quote:
return j + 1
}
}
return len(s)
}
// continued reports whether an argument list so far ends on a comma, meaning
// the list carries on over the newline.
func continued(s string) bool {
s = strings.TrimRight(s, " \t\r")
return strings.HasSuffix(s, ",")
}