add the catalog-impact binary #2

Merged
benvin merged 10 commits from benvin/catalog-impact into main 2026-09-12 18:19:04 +10:00
Member

Catalog testing has no way to know which hosts a puppet-prod change reaches, so a branch is compiled against the whole estate or against a guess.

  • add the catalog-impact binary plus internal/impact
  • map hieradata paths onto fact selectors from the hiera hierarchy
  • trace manifest classes back to role classes through include/contain/require, inherits, class {}, Class[], create_resources, scope variables, defined types and hiera_include
  • strip comments quote-aware, skip heredoc bodies, and read selector arms and same-file variables as class names
  • report any class reference that stays unresolved and widen through it
  • fetch the estate fact table from PuppetDB in one query, and widen to every host for a path that matches no rule

Requires #1.

Catalog testing has no way to know which hosts a puppet-prod change reaches, so a branch is compiled against the whole estate or against a guess. - add the catalog-impact binary plus internal/impact - map hieradata paths onto fact selectors from the hiera hierarchy - trace manifest classes back to role classes through include/contain/require, inherits, class {}, Class[], create_resources, scope variables, defined types and hiera_include - strip comments quote-aware, skip heredoc bodies, and read selector arms and same-file variables as class names - report any class reference that stays unresolved and widen through it - fetch the estate fact table from PuppetDB in one query, and widen to every host for a path that matches no rule Requires #1.
Author
Member

Reviewed the diff against benvin/scaffold-catalog-diff, verified the mapping rules against the real puppet-prod checkout, and ran make test/make lint (both pass: race-enabled tests green, 0 lint issues).

Blocking

  1. Reference regexes silently drop comma-separated names. internal/impact/index.go's includeRe/classRefRe match one identifier per keyword occurrence, not a list. include foo::bar, baz::qux (valid Puppet, and legal for contain/Class['a','b'] too) indexes only foo::bar — baz::qux is never added to includedBy, so a change to whatever declares baz::qux reports zero hosts instead of whatever hosts baz::qux reaches. Not currently present in puppet-prod (checked), but it's a direct violation of the stated over-include bias and will silently under-report the day someone writes include a, b. Needs the regexes (or a post-match split) to walk the whole comma list.

  2. .woodpecker/release.yaml was not updated: for name in catalog-diff; do (line 30) and ASSETS="catalog-diff-linux-amd64 ..." (line 130) still only build/attach catalog-diff's cross-platform binaries to the Gitea release. packaging/nfpm.yaml and scripts/build-rpm.sh were correctly updated for catalog-impact, so the RPM ships it, but the standalone release binaries won't. Given the PR's own framing ("add the catalog-impact binary" as a full peer of catalog-diff), this looks like a missed spot rather than intentional.

Not blocking (would have done differently)

  • The hiera hierarchy shape (roles/<t1..3>, country/.../region/..., os/<name>/<name><major>.<minor>, etc.) is hand-encoded in paths.go/hieradataSelector rather than derived from hiera.yaml itself. It matches the real file today, but nothing ties the two together — a hierarchy edit in puppet-prod won't be caught by anything short of this tool quietly mis-mapping paths.
  • PR body undersells the diff: it advertises tracing "through include, contain, class {} and hiera_include" but the index also picks up inherits, require, Class[...] ordering refs, create_resources(), scope-variable reads and bare defined-type declarations. All in service of the one stated goal, so not scope creep, just worth a mention next time.

Verified correct (spot-checked against puppet-prod, not just the fixtures): the hiera_include-only trap (jellyfin module, applied solely via hieradata/roles/apps/media/jellyfin.yaml, zero include in site/) is resolved through the listing file's hierarchy position, not through class reverse-lookup — matches real repo structure exactly. Tier-prefix selection, cycle termination (BFS with a seen set, not recursive — no stack-overflow risk), hiera_exclude subtraction, and os name/major/minor matching all check out. Output ordering is deterministic despite Go map iteration because every map-derived list is sorted before being written out.

Reviewed the diff against `benvin/scaffold-catalog-diff`, verified the mapping rules against the real `puppet-prod` checkout, and ran `make test`/`make lint` (both pass: race-enabled tests green, 0 lint issues). **Blocking** 1. Reference regexes silently drop comma-separated names. `internal/impact/index.go`'s `includeRe`/`classRefRe` match one identifier per keyword occurrence, not a list. `include foo::bar, baz::qux` (valid Puppet, and legal for `contain`/`Class['a','b']` too) indexes only `foo::bar` — `baz::qux` is never added to `includedBy`, so a change to whatever declares `baz::qux` reports zero hosts instead of whatever hosts `baz::qux` reaches. Not currently present in `puppet-prod` (checked), but it's a direct violation of the stated over-include bias and will silently under-report the day someone writes `include a, b`. Needs the regexes (or a post-match split) to walk the whole comma list. 2. `.woodpecker/release.yaml` was not updated: `for name in catalog-diff; do` (line 30) and `ASSETS="catalog-diff-linux-amd64 ..."` (line 130) still only build/attach `catalog-diff`'s cross-platform binaries to the Gitea release. `packaging/nfpm.yaml` and `scripts/build-rpm.sh` were correctly updated for `catalog-impact`, so the RPM ships it, but the standalone release binaries won't. Given the PR's own framing ("add the catalog-impact binary" as a full peer of catalog-diff), this looks like a missed spot rather than intentional. **Not blocking (would have done differently)** - The hiera hierarchy shape (`roles/<t1..3>`, `country/.../region/...`, `os/<name>/<name><major>.<minor>`, etc.) is hand-encoded in `paths.go`/`hieradataSelector` rather than derived from `hiera.yaml` itself. It matches the real file today, but nothing ties the two together — a hierarchy edit in puppet-prod won't be caught by anything short of this tool quietly mis-mapping paths. - PR body undersells the diff: it advertises tracing "through include, contain, class {} and hiera_include" but the index also picks up `inherits`, `require`, `Class[...]` ordering refs, `create_resources()`, scope-variable reads and bare defined-type declarations. All in service of the one stated goal, so not scope creep, just worth a mention next time. **Verified correct** (spot-checked against `puppet-prod`, not just the fixtures): the `hiera_include`-only trap (`jellyfin` module, applied solely via `hieradata/roles/apps/media/jellyfin.yaml`, zero `include` in `site/`) is resolved through the listing file's hierarchy position, not through class reverse-lookup — matches real repo structure exactly. Tier-prefix selection, cycle termination (BFS with a `seen` set, not recursive — no stack-overflow risk), `hiera_exclude` subtraction, and os name/major/minor matching all check out. Output ordering is deterministic despite Go map iteration because every map-derived list is sorted before being written out.
Author
Member

Review at head 6551ab9 (base benvin/scaffold-catalog-diff). Verified against a real puppet-prod checkout, ran the parser adversarially, and exercised the full build/test/rpm chain.

Blocking — under-include (parser can silently miss a real reference, no fallback signal):

  1. A selector as the include/contain/require argument drops every branch's name. include $facts['role'] ? { 'web' => 'profile::web', default => 'profile::base' } → references() returns neither name. Same for the indirected form $profile = $x ? {...}; include $profile.
  2. commentRe = (?m)#.*$ doesn't track quote state. A literal # inside a string (hex color, MOTD text, ticket ref, URL fragment) truncates the rest of that physical line — including any include/contain/require that follows on the same line. Repro: $x = "port #1" ; include profile::afterhash → afterhash never appears. Same via a selector holding a color value: $x = $y ? { 'a' => '#ff0000', default => '#00ff00' } ; include profile::afterselect → dropped.
  3. Interpolated class names vanish silently: include "profile::role_${role}" → nothing captured.

None of these are live in puppet-prod today (no include $var in the repo; the one #-in-string line has nothing after it on the same line), but there's no equivalent of the path-level "matches no rule, widened to the whole estate" loud fallback for a class the parser can't statically resolve — it just disappears, which is the one failure mode this tool is designed never to have.

Not blocking (over-inclusion / noise, consistent with the stated safe-direction bias):

  1. Heredocs aren't excluded from the class scan. content => @(EOT)\n include profile::secret_admin\n EOT produces a bogus reference from doc prose. 10 files in puppet-prod use heredocs; none trigger a live false positive today, but site/profiles/manifests/selinux/frr.pp's SELinux-policy heredoc (a literal require { ... } CIL block, ;-terminated lines) walks callArguments's depth-tracker straight past the heredoc's own closing brace and only stops at a bare ; deep inside the policy body — it happens to yield nothing only because the resulting blob fails the strict identifier regex. Works by luck of content, not by heredoc awareness.
  2. require => Class['foo'] is indexed as a real dependency via the standalone Class[...] regex, even though Puppet's ordering metaparameters (require/before/notify/subscribe) never themselves cause inclusion. This looks deliberate (there's a test asserting it), and it's safe-direction, but --explain will describe it as "why" the class is pulled in when it's really just an ordering constraint.

Verified correct:

  • The hiera_include-only trap: jellyfin (module class with zero include anywhere in site/, applied only via hiera_include: - jellyfin in hieradata/roles/apps/media/jellyfin.yaml) resolves correctly to just the apps::media::jellyfin hosts.
  • Tier-prefix / country-region / structured-os selectors checked against puppet-prod's actual hiera.yaml and hieradata tree (all real path shapes matched; the two hierarchy variants unused today are still handled).
  • Determinism (sorted throughout, no map-iteration leak) and cycle/fixed-point termination.
  • Build chain: make build, make completions, make rpm all succeed at this head; the RPM ships both binaries plus bash/zsh/fish completions at correct paths/modes, no hardcoded binary name left anywhere.
  • go test -race ./... all green, golangci-lint run ./... 0 issues.
  • PR body meets the size/format convention.

Not flagging atomicity: the Makefile/nfpm/release.yaml rework is coupled to this PR only because catalog-impact is the second binary and the old hardcoded-single-name scheme can't express that.

Review at head 6551ab9 (base benvin/scaffold-catalog-diff). Verified against a real puppet-prod checkout, ran the parser adversarially, and exercised the full build/test/rpm chain. **Blocking — under-include (parser can silently miss a real reference, no fallback signal):** 1. A selector as the include/contain/require argument drops every branch's name. `include $facts['role'] ? { 'web' => 'profile::web', default => 'profile::base' }` → `references()` returns neither name. Same for the indirected form `$profile = $x ? {...}; include $profile`. 2. `commentRe = (?m)#.*$` doesn't track quote state. A literal `#` inside a string (hex color, MOTD text, ticket ref, URL fragment) truncates the rest of that physical line — including any include/contain/require that follows on the same line. Repro: `$x = "port #1" ; include profile::afterhash` → afterhash never appears. Same via a selector holding a color value: `$x = $y ? { 'a' => '#ff0000', default => '#00ff00' } ; include profile::afterselect` → dropped. 3. Interpolated class names vanish silently: `include "profile::role_${role}"` → nothing captured. None of these are live in puppet-prod today (no `include $var` in the repo; the one `#`-in-string line has nothing after it on the same line), but there's no equivalent of the path-level "matches no rule, widened to the whole estate" loud fallback for a class the parser can't statically resolve — it just disappears, which is the one failure mode this tool is designed never to have. **Not blocking (over-inclusion / noise, consistent with the stated safe-direction bias):** 4. Heredocs aren't excluded from the class scan. `content => @(EOT)\n include profile::secret_admin\n EOT` produces a bogus reference from doc prose. 10 files in puppet-prod use heredocs; none trigger a live false positive today, but `site/profiles/manifests/selinux/frr.pp`'s SELinux-policy heredoc (a literal `require { ... }` CIL block, `;`-terminated lines) walks `callArguments`'s depth-tracker straight past the heredoc's own closing brace and only stops at a bare `;` deep inside the policy body — it happens to yield nothing only because the resulting blob fails the strict identifier regex. Works by luck of content, not by heredoc awareness. 5. `require => Class['foo']` is indexed as a real dependency via the standalone `Class[...]` regex, even though Puppet's ordering metaparameters (require/before/notify/subscribe) never themselves cause inclusion. This looks deliberate (there's a test asserting it), and it's safe-direction, but `--explain` will describe it as "why" the class is pulled in when it's really just an ordering constraint. **Verified correct:** - The hiera_include-only trap: `jellyfin` (module class with zero `include` anywhere in site/, applied only via `hiera_include: - jellyfin` in hieradata/roles/apps/media/jellyfin.yaml) resolves correctly to just the apps::media::jellyfin hosts. - Tier-prefix / country-region / structured-os selectors checked against puppet-prod's actual hiera.yaml and hieradata tree (all real path shapes matched; the two hierarchy variants unused today are still handled). - Determinism (sorted throughout, no map-iteration leak) and cycle/fixed-point termination. - Build chain: `make build`, `make completions`, `make rpm` all succeed at this head; the RPM ships both binaries plus bash/zsh/fish completions at correct paths/modes, no hardcoded binary name left anywhere. - `go test -race ./...` all green, `golangci-lint run ./...` 0 issues. - PR body meets the size/format convention. Not flagging atomicity: the Makefile/nfpm/release.yaml rework is coupled to this PR only because catalog-impact is the second binary and the old hardcoded-single-name scheme can't express that.
Author
Member

Targeted review of the latest work only: internal/impact/scan.go, the unresolved-reference widening it feeds, and merge commit f34dacc. Not re-litigating hieradata path mapping or packaging.

Ran: parser over the full real puppet-prod checkout, go test -race ./..., make lint, make pre-commit, 25s/~800k execs of FuzzScanReferences — all clean, no crashes. Verdict: not mergeable as-is — one confirmed silent-loss bug and one confirmed dead-safety-net bug, both in the exact mechanism this PR exists to guarantee. Both are small, targeted fixes.

Blocking

1. stripSource/scan.go's quote handling can silently drop a class reference outright — not resolved, not reported unresolved.

Root cause: every string-aware helper (skipString, balanced, splitArgs, statementExpr, selectorArms, cutRocket, resourceTitles) tracks "am I inside a string" by toggling on any unescaped quote character equal to whatever opened the current would-be string — it has no concept of quote-type nesting. That's fine for the very common case of a single-quoted hash key inside a double-quoted interpolation ("${facts['role']}" works correctly, verified). It breaks when a nested, differently-quoted substring itself contains a literal, unescaped instance of the outer delimiter followed by a structural character (), ], }, ,) before the true closing quote:

body := "class p {\n  include(\"${sprintf('a\"a)b\"c', $x)}\", profile::real)\n}\n"

scanReferences(stripSource(body)) produces refs = map[] and unresolved = [{Form: "include", Expr: "${sprintf('a"a}]. profile::real appears nowhere — not resolved, not reported unresolved. The literal ) inside the single-quoted string desyncs the toggle and is read as the real closing paren of include(...), truncating the argument list before profile::real. A related, non-losing but noisy variant (an embedded comma instead of a paren) just garbles one unresolved entry into two nonsensical fragments, polluting --explain output — same root cause.

This directly contradicts the PR's stated invariant ("anything that still won't reduce to literal names becomes an UnresolvedRef... which both reports loudly and widens") — here nothing is even recognized as unresolved, because the tokenizer itself desyncs before the scanner ever sees there was an argument to fail on. Confirmed via isolated unit test, reproducible, not a fluke of my harness (checked stripSource didn't touch the body — it doesn't need to, no comments/heredocs involved).

Not found live in the real puppet-prod checkout (see below — 0 unresolved refs today), and the fuzzer's 25s run (~800k execs) didn't hit it either since the seed corpus has no multi-quote-type nesting. But it's a real, constructible input, and the whole point of this tool is that such inputs must never silently disappear.

2. UnresolvedRef.Prefix is never lowercased, so the widening safety net silently stops working for exactly the mixed-case idiom this repo already uses.

literalPrefix() (scan.go) takes the raw-case text before the first $ in an interpolated string literal and stores it verbatim as UnresolvedRef.Prefix. Every resolved class name flowing through the index (IncludedBy, known, the resolver's BFS queue) is normalized to lowercase via anyCaseNameRe — but this one code path is the sole exception. Index.UnresolvedIncluders then does a case-sensitive strings.HasPrefix(class, u.Ref.Prefix).

Concretely: include "APP::${team}_role" yields UnresolvedRef{Prefix: "APP::"} (mixed case, untouched). Since every class string passed into UnresolvedIncluders is guaranteed lowercase, strings.HasPrefix("app::web_role", "APP::") is false — the widening that's supposed to catch "this reference might have named app::web_role" never fires. That's a silent regression in the exact fallback mechanism whose entire job is to never drop coverage.

This isn't hypothetical style: the repo already contains this exact case-insensitive idiom today, just not interpolated — site/profiles/manifests/sql/galera_member.pp:76,187,195 has both Class['Mysql::Server'] and Class['Mysql::server'], and the PR correctly folds both to mysql::server (verified). An interpolated version of the same idiom, Class["Mysql::${x}"], would silently lose its widening safety net. One-line fix: lowercase the prefix in literalPrefix (or at the unresolvedAdd call site) to match how every other class-name comparison in this file is normalized.

Neither of the above shows up on the real puppet-prod checkout: BuildIndex(puppet-prod) — 349 known classes indexed, 0 UnresolvedRefs produced anywhere in the repo today. So both bugs are latent against the current corpus, confirmed only via targeted unit tests against the parser's own logic — not evidence of a live incident, but real gaps against a stated hard invariant.

Non-blocking

3. The fuzzer doesn't assert the property it's there for. FuzzScanReferences only checks len(stripped) == len(body). The comment on stripSource (and the separate, non-fuzzed TestStripSourcePreservesLineCount) says what actually matters is that newline positions survive, since "the statement scanner reads newlines." By inspection the current implementation can't violate that (blank() explicitly skips \n, and the buffer is never resized), so nothing is hidden today — but the fuzz property is strictly weaker than the invariant it's meant to guard, so a future edit that broke newline placement while somehow preserving length wouldn't be caught by 800k fuzz execs. Cheap fix: fold the same newline-position check from TestStripSourcePreservesLineCount into the fuzz function.

4. Widening via an empty Prefix (item 2 in the brief) — confirmed as designed, not a bug, but a sharp edge worth flagging. strings.HasPrefix(class, "") is always true, so an UnresolvedRef with no recoverable prefix matches every class queried in every resolve() walk — intentional given "unknown could be anything." Confirmed the BFS still terminates safely (bounded by the seen map — no loop, no blowup) and the via chain text stays coherent (reads as "X might apply Y via this unresolved reference in Z", not garbage). Not exercised at all today (0 unresolved refs in puppet-prod), but the mechanism means: the day any commonly-included file (e.g. profiles::base) picks up even one bare-variable include $x-style reference, most future changes will resolve to "whole estate affected" via that one file, silently regardless of what actually changed. Not asking for a redesign — the over-include bias is correct — just flagging it as worth watching for in review of future PRs that touch widely-included profiles.

5. Client.Warn gap (item 5 in the brief) — assessed: non-blocking, and narrower than framed. Confirmed cmd/catalog-impact/main.go builds a pdbmux.Client without setting .Warn, unlike catalog-diff/main.go:257. But catalog-impact's only fact-fetch path (impact.LoadNodes → Client.FactsByName) never calls fold() and never calls Warn at all — FactsByName decodes straight into []FactRecord{Value any}, which for a well-formed JSON body essentially never fails to decode. So wiring .Warn today would be a no-op. The actual (much narrower) silent-drop on this path is in internal/impact/node.go: LoadNodes drops any record with an empty Certname with zero logging (~line 70), and applyFact's os-fact handling silently no-ops on a shape mismatch. Neither is part of this PR's new work, and neither has an existing hook to report through — worth a follow-up ticket to give LoadNodes its own loud-drop path, but not a reason to block this PR.

Merge commit f34dacc — verified clean

Diffed both parent tips against the merge for the two files it touched:

  • internal/pdbmux/pdbmux_test.go: all of scaffold-catalog-diff's new fold/decodeFactsets tests (bare-object fallback, array-with-match, array-no-match, empty array, skip-undecodable, nothing-decodable) are present, followed by all of catalog-impact's FactsByName tests (query shape, empty names, server error) — no duplicates, nothing dropped from either side.
  • AGENTS.md: both sides' additions (pdbmux factset-shape note, catalog gotchas, new catalog-impact gotchas section) are present together; nothing reverted.
  • FactsByName (pdbmux.go) is a self-contained raw AST-query path independent of fold/decodeFactsets, so it doesn't regress or depend on the parent's factset rework — verified by reading the implementation, not just by "it compiles." go test -race ./... passes.

Also checked, no issues found

  • Reporting fires on all three output paths: plain mode via warn() → stderr, --json via unresolved_references + summary.resolution_incomplete, --explain via both a summary line and a full section.
  • RenderJSON nil-guards Hosts/ChangedFiles/UnmappedFiles/UnresolvedRefs to [], so --json stays well-formed (no null) with zero unresolved references.
  • Determinism: Result.UnresolvedRefs/hosts/reasons are all explicitly sorted before rendering; map iteration order (scan.go's refs, index.go's includedBy) never leaks into output ordering.
  • Case-insensitivity is otherwise consistently applied: literal() lowercases both the classNameRe and anyCaseNameRe branches before add(), and the resourceRefRe match is lowercased at its call site. The one gap is finding #2 above.

Happy to point at exact line numbers for the fixes if useful — both are small (a lowercase call, and either a real interpolation-aware tokenizer or at minimum quote-type-aware nesting in the shared string-skipping helpers).

Targeted review of the latest work only: `internal/impact/scan.go`, the unresolved-reference widening it feeds, and merge commit `f34dacc`. Not re-litigating hieradata path mapping or packaging. Ran: parser over the full real `puppet-prod` checkout, `go test -race ./...`, `make lint`, `make pre-commit`, 25s/~800k execs of `FuzzScanReferences` — all clean, no crashes. Verdict: **not mergeable as-is** — one confirmed silent-loss bug and one confirmed dead-safety-net bug, both in the exact mechanism this PR exists to guarantee. Both are small, targeted fixes. ## Blocking **1. `stripSource`/`scan.go`'s quote handling can silently drop a class reference outright — not resolved, not reported unresolved.** Root cause: every string-aware helper (`skipString`, `balanced`, `splitArgs`, `statementExpr`, `selectorArms`, `cutRocket`, `resourceTitles`) tracks "am I inside a string" by toggling on *any* unescaped quote character equal to whatever opened the current would-be string — it has no concept of quote-*type* nesting. That's fine for the very common case of a single-quoted hash key inside a double-quoted interpolation (`"${facts['role']}"` works correctly, verified). It breaks when a nested, differently-quoted substring itself contains a *literal, unescaped* instance of the outer delimiter followed by a structural character (`)`, `]`, `}`, `,`) before the true closing quote: ```go body := "class p {\n include(\"${sprintf('a\"a)b\"c', $x)}\", profile::real)\n}\n" ``` `scanReferences(stripSource(body))` produces `refs = map[]` and `unresolved = [{Form: "include", Expr: `"${sprintf('a"a`}]`. **`profile::real` appears nowhere** — not resolved, not reported unresolved. The literal `)` inside the single-quoted string desyncs the toggle and is read as the real closing paren of `include(...)`, truncating the argument list before `profile::real`. A related, non-losing but noisy variant (an embedded comma instead of a paren) just garbles one unresolved entry into two nonsensical fragments, polluting `--explain` output — same root cause. This directly contradicts the PR's stated invariant ("anything that still won't reduce to literal names becomes an `UnresolvedRef`... which both reports loudly and widens") — here nothing is even recognized as unresolved, because the tokenizer itself desyncs before the scanner ever sees there was an argument to fail on. Confirmed via isolated unit test, reproducible, not a fluke of my harness (checked `stripSource` didn't touch the body — it doesn't need to, no comments/heredocs involved). Not found live in the real `puppet-prod` checkout (see below — 0 unresolved refs today), and the fuzzer's 25s run (~800k execs) didn't hit it either since the seed corpus has no multi-quote-type nesting. But it's a real, constructible input, and the whole point of this tool is that such inputs must never silently disappear. **2. `UnresolvedRef.Prefix` is never lowercased, so the widening safety net silently stops working for exactly the mixed-case idiom this repo already uses.** `literalPrefix()` (scan.go) takes the raw-case text before the first `$` in an interpolated string literal and stores it verbatim as `UnresolvedRef.Prefix`. Every *resolved* class name flowing through the index (`IncludedBy`, `known`, the resolver's BFS queue) is normalized to lowercase via `anyCaseNameRe` — but this one code path is the sole exception. `Index.UnresolvedIncluders` then does a case-sensitive `strings.HasPrefix(class, u.Ref.Prefix)`. Concretely: `include "APP::${team}_role"` yields `UnresolvedRef{Prefix: "APP::"}` (mixed case, untouched). Since every `class` string passed into `UnresolvedIncluders` is guaranteed lowercase, `strings.HasPrefix("app::web_role", "APP::")` is `false` — the widening that's supposed to catch "this reference might have named `app::web_role`" never fires. That's a silent regression in the exact fallback mechanism whose entire job is to never drop coverage. This isn't hypothetical style: the repo already contains this exact case-insensitive idiom today, just not interpolated — `site/profiles/manifests/sql/galera_member.pp:76,187,195` has both `Class['Mysql::Server']` and `Class['Mysql::server']`, and the PR correctly folds both to `mysql::server` (verified). An interpolated version of the same idiom, `Class["Mysql::${x}"]`, would silently lose its widening safety net. One-line fix: lowercase the prefix in `literalPrefix` (or at the `unresolvedAdd` call site) to match how every other class-name comparison in this file is normalized. Neither of the above shows up on the real `puppet-prod` checkout: `BuildIndex(puppet-prod)` — 349 known classes indexed, **0 `UnresolvedRef`s produced** anywhere in the repo today. So both bugs are latent against the current corpus, confirmed only via targeted unit tests against the parser's own logic — not evidence of a live incident, but real gaps against a stated hard invariant. ## Non-blocking **3. The fuzzer doesn't assert the property it's there for.** `FuzzScanReferences` only checks `len(stripped) == len(body)`. The comment on `stripSource` (and the separate, non-fuzzed `TestStripSourcePreservesLineCount`) says what actually matters is that newline *positions* survive, since "the statement scanner reads newlines." By inspection the current implementation can't violate that (`blank()` explicitly skips `\n`, and the buffer is never resized), so nothing is hidden today — but the fuzz property is strictly weaker than the invariant it's meant to guard, so a future edit that broke newline placement while somehow preserving length wouldn't be caught by 800k fuzz execs. Cheap fix: fold the same newline-position check from `TestStripSourcePreservesLineCount` into the fuzz function. **4. Widening via an empty `Prefix` (item 2 in the brief) — confirmed as designed, not a bug, but a sharp edge worth flagging.** `strings.HasPrefix(class, "")` is always true, so an `UnresolvedRef` with no recoverable prefix matches every class queried in every `resolve()` walk — intentional given "unknown could be anything." Confirmed the BFS still terminates safely (bounded by the `seen` map — no loop, no blowup) and the `via` chain text stays coherent (reads as "X might apply Y via this unresolved reference in Z", not garbage). Not exercised at all today (0 unresolved refs in `puppet-prod`), but the mechanism means: the day any commonly-included file (e.g. `profiles::base`) picks up even one bare-variable `include $x`-style reference, most future changes will resolve to "whole estate affected" via that one file, silently regardless of what actually changed. Not asking for a redesign — the over-include bias is correct — just flagging it as worth watching for in review of *future* PRs that touch widely-included profiles. **5. `Client.Warn` gap (item 5 in the brief) — assessed: non-blocking, and narrower than framed.** Confirmed `cmd/catalog-impact/main.go` builds a `pdbmux.Client` without setting `.Warn`, unlike `catalog-diff/main.go:257`. But `catalog-impact`'s only fact-fetch path (`impact.LoadNodes` → `Client.FactsByName`) never calls `fold()` and never calls `Warn` at all — `FactsByName` decodes straight into `[]FactRecord{Value any}`, which for a well-formed JSON body essentially never fails to decode. So wiring `.Warn` today would be a no-op. The actual (much narrower) silent-drop on this path is in `internal/impact/node.go`: `LoadNodes` drops any record with an empty `Certname` with zero logging (~line 70), and `applyFact`'s `os`-fact handling silently no-ops on a shape mismatch. Neither is part of this PR's new work, and neither has an existing hook to report through — worth a follow-up ticket to give `LoadNodes` its own loud-drop path, but not a reason to block this PR. ## Merge commit `f34dacc` — verified clean Diffed both parent tips against the merge for the two files it touched: - `internal/pdbmux/pdbmux_test.go`: all of `scaffold-catalog-diff`'s new fold/`decodeFactsets` tests (bare-object fallback, array-with-match, array-no-match, empty array, skip-undecodable, nothing-decodable) are present, followed by all of `catalog-impact`'s `FactsByName` tests (query shape, empty names, server error) — no duplicates, nothing dropped from either side. - `AGENTS.md`: both sides' additions (pdbmux factset-shape note, catalog gotchas, new `catalog-impact gotchas` section) are present together; nothing reverted. - `FactsByName` (pdbmux.go) is a self-contained raw AST-query path independent of `fold`/`decodeFactsets`, so it doesn't regress or depend on the parent's factset rework — verified by reading the implementation, not just by "it compiles." `go test -race ./...` passes. ## Also checked, no issues found - Reporting fires on all three output paths: plain mode via `warn()` → stderr, `--json` via `unresolved_references` + `summary.resolution_incomplete`, `--explain` via both a summary line and a full section. - `RenderJSON` nil-guards `Hosts`/`ChangedFiles`/`UnmappedFiles`/`UnresolvedRefs` to `[]`, so `--json` stays well-formed (no `null`) with zero unresolved references. - Determinism: `Result.UnresolvedRefs`/hosts/reasons are all explicitly sorted before rendering; map iteration order (scan.go's `refs`, index.go's `includedBy`) never leaks into output ordering. - Case-insensitivity is otherwise consistently applied: `literal()` lowercases both the `classNameRe` and `anyCaseNameRe` branches before `add()`, and the `resourceRefRe` match is lowercased at its call site. The one gap is finding #2 above. Happy to point at exact line numbers for the fixes if useful — both are small (a lowercase call, and either a real interpolation-aware tokenizer or at minimum quote-type-aware nesting in the shared string-skipping helpers).
Author
Member

Reviewed commits ae948c7 (parser rewrite) and a5f2734 (merge of benvin/scaffold-catalog-diff).

Blocking

cmd/catalog-impact/main.go builds its client with puppetdb.New(opts.puppetdb, opts.timeout) and never sets .Warn (contrast cmd/catalog-diff/main.go, which does: facts.Warn = func(msg string) {...}). The merge's consolidated FactsByName now drops-and-warns undecodable fact records through that same c.Warn callback — previously FactsByName decoded straight to any and could only hard-fail the whole estate fetch. Since Warn is nil here, if len(skipped) > 0 && c.Warn != nil { c.Warn(...) } never fires: the warning string is built and thrown away.

Concrete repro: if every requested fact for one host (enc_role/country/os/virtual/...) fails to decode — e.g. a record with no value key at all, len(r.Value)==0 — that host is excluded from out inside FactsByName, never reaches LoadNodes's byName map, and disappears from the entire estate table with zero trace: no stderr line, no node_warnings entry, no --explain section, summary.resolution_incomplete stays false. That's a silent under-include — the one thing the design bias doc says must never happen, and the exact failure mode this stack was supposed to make visible (see TestLoadNodesReportsRecordsWithNoCertname, which does correctly surface the no-certname case end to end).

Fix: wire Warn in cmd/catalog-impact/main.go the same way catalog-diff does, and ideally route the message into Result.NodeWarnings/node_warnings too (the channel anonymousWarnings already built for exactly this class of problem) rather than stderr-only, so it shows up in --json/--explain as well as plain mode. TestFactsByNameSkipsUndecodableRecords sets c.Warn manually and passes — that masked the fact that the real CLI never wires it up; worth a CLI-level (or Analyzer-level) test that fails without the fix.

Non-blocking / clean

  • Traced the walkSource/balanced/skipString/skipInterpolation unification against all six original scanners (splitArgs, statementExpr, resourceTitles, selectorArms, cutRocket, balanced): the depth semantics line up exactly (pre-decrement check on close, pairDelim scoped to balanced's own bracket pair so an unmatched brace elsewhere still can't unbalance it, anyDelim for the rest matching the original's generic three-bracket counting).
  • Attacked the new interpolation handling directly: nested interpolation, unterminated ${, an interpolation containing heredoc-marker-looking text, and interpolation nested inside a heredoc body all resolve safely (to unresolved, never a wrong or silently-dropped answer, never a hang/panic) — verified with a 20s/~172k-exec fuzz run (no new crashers) plus targeted manual cases against the built package.
  • literalPrefix lowering is a genuine correctness fix, not just refactor churn: index.go's widening check (strings.HasPrefix(class, u.Ref.Prefix)) compares against a lowercase index, so an unlowered prefix from a mixed-case interpolated name (e.g. Class["Mysql::${x}"]) would previously never widen-match — a silent under-include. Covered by TestUnresolvedPrefixIsLowercased.
  • The merge cleanly consolidates queryFacts/decodeRecords/decodeValue for both Facts and FactsByName. The --pdb→--puppetdb rename is fully propagated (grepped for pdb_url/PDBURL/opts.pdb/CATALOG_DIFF_PDB — no leftovers), and FactsByName picks up BOM-tolerance as a side effect it didn't have pre-merge.
  • Proxy-neutrality and estate-data greps are clean on the merged tree; only permitted pdbmux.*unkin.net occurrences remain (default URL + README mirror).
  • node_warnings follows the same nil→[] guard as the other --json fields (RenderJSON), so --json stays well-formed in the new empty/warning cases.

go test -race ./..., make lint, make pre-commit all green on this branch.

Reviewed commits `ae948c7` (parser rewrite) and `a5f2734` (merge of `benvin/scaffold-catalog-diff`). **Blocking** `cmd/catalog-impact/main.go` builds its client with `puppetdb.New(opts.puppetdb, opts.timeout)` and never sets `.Warn` (contrast `cmd/catalog-diff/main.go`, which does: `facts.Warn = func(msg string) {...}`). The merge's consolidated `FactsByName` now drops-and-warns undecodable fact records through that same `c.Warn` callback — previously `FactsByName` decoded straight to `any` and could only hard-fail the whole estate fetch. Since `Warn` is nil here, `if len(skipped) > 0 && c.Warn != nil { c.Warn(...) }` never fires: the warning string is built and thrown away. Concrete repro: if every requested fact for one host (enc_role/country/os/virtual/...) fails to decode — e.g. a record with no `value` key at all, `len(r.Value)==0` — that host is excluded from `out` inside `FactsByName`, never reaches `LoadNodes`'s `byName` map, and disappears from the entire estate table with zero trace: no stderr line, no `node_warnings` entry, no `--explain` section, `summary.resolution_incomplete` stays `false`. That's a silent under-include — the one thing the design bias doc says must never happen, and the exact failure mode this stack was supposed to make visible (see `TestLoadNodesReportsRecordsWithNoCertname`, which does correctly surface the no-certname case end to end). Fix: wire `Warn` in `cmd/catalog-impact/main.go` the same way `catalog-diff` does, and ideally route the message into `Result.NodeWarnings`/`node_warnings` too (the channel `anonymousWarnings` already built for exactly this class of problem) rather than stderr-only, so it shows up in `--json`/`--explain` as well as plain mode. `TestFactsByNameSkipsUndecodableRecords` sets `c.Warn` manually and passes — that masked the fact that the real CLI never wires it up; worth a CLI-level (or `Analyzer`-level) test that fails without the fix. **Non-blocking / clean** - Traced the `walkSource`/`balanced`/`skipString`/`skipInterpolation` unification against all six original scanners (`splitArgs`, `statementExpr`, `resourceTitles`, `selectorArms`, `cutRocket`, `balanced`): the depth semantics line up exactly (pre-decrement check on close, `pairDelim` scoped to `balanced`'s own bracket pair so an unmatched brace elsewhere still can't unbalance it, `anyDelim` for the rest matching the original's generic three-bracket counting). - Attacked the new interpolation handling directly: nested interpolation, unterminated `${`, an interpolation containing heredoc-marker-looking text, and interpolation nested inside a heredoc body all resolve safely (to `unresolved`, never a wrong or silently-dropped answer, never a hang/panic) — verified with a 20s/~172k-exec fuzz run (no new crashers) plus targeted manual cases against the built package. - `literalPrefix` lowering is a genuine correctness fix, not just refactor churn: `index.go`'s widening check (`strings.HasPrefix(class, u.Ref.Prefix)`) compares against a lowercase index, so an unlowered prefix from a mixed-case interpolated name (e.g. `Class["Mysql::${x}"]`) would previously never widen-match — a silent under-include. Covered by `TestUnresolvedPrefixIsLowercased`. - The merge cleanly consolidates `queryFacts`/`decodeRecords`/`decodeValue` for both `Facts` and `FactsByName`. The `--pdb`→`--puppetdb` rename is fully propagated (grepped for `pdb_url`/`PDBURL`/`opts.pdb`/`CATALOG_DIFF_PDB` — no leftovers), and `FactsByName` picks up BOM-tolerance as a side effect it didn't have pre-merge. - Proxy-neutrality and estate-data greps are clean on the merged tree; only permitted `pdbmux.*unkin.net` occurrences remain (default URL + README mirror). - `node_warnings` follows the same nil→`[]` guard as the other `--json` fields (`RenderJSON`), so `--json` stays well-formed in the new empty/warning cases. `go test -race ./...`, `make lint`, `make pre-commit` all green on this branch.
benvin changed target branch from benvin/scaffold-catalog-diff to main 2026-09-12 18:18:40 +10:00
benvin added 10 commits 2026-09-12 18:18:40 +10:00
add the catalog-impact binary
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
8093933500
Work out which hosts a puppet-prod change can affect, so a catalog test can
fan out over exactly those hosts instead of the whole estate.

- add cmd/catalog-impact and internal/impact
- map hieradata paths onto fact selectors from the hiera hierarchy
- trace manifest classes back to role classes through include, contain,
  class {} and hiera_include, to a fixed point
- fetch the estate fact table from pdbmux in one query via FactsByName
- widen to all hosts, loudly, for any path that matches no rule
- ship the binary and its completions in the RPM
catch four more class reference forms
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/build Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
9919676836
A params.pp is only ever reached by inherits and $class::var, so changing one
resolved to no hosts at all.

- index inherits, $class::var reads and create_resources()
- match a defined type declared part-way along a line
report changed files that reach no host
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
9ff9edefa8
A hieradata file for a role with no members produces an empty host list, which
is indistinguishable from a mapping failure.

- list files that mapped cleanly but selected nobody in --explain
include/contain/require take a list of names; parse the argument list so
the second and later names are indexed too.
ship every cmd binary in the release
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
6551ab9f13
Take the binary list from cmd/ in the Makefile, the RPM packaging and the
release pipeline so a new tool ships without a pipeline edit.
- 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
report unresolved class references instead of resolving to silence
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
c4d3ed24a9
- walk the classes holding an unresolved reference when tracing any class
  it could have named, so a dropped edge cannot lose hosts
- list unresolved references in --explain, in unresolved_references in
  --json and on stderr, and flag summary.resolution_incomplete
- name an ordering reference as such in the --explain chain
merge benvin/scaffold-catalog-diff into benvin/catalog-impact
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
f34dacc818
track the opening quote and scan interpolations as code
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ae948c7f04
A differently quoted substring inside a ${...} interpolation ended the
outer string early and truncated the argument list, dropping every name
after it. One shared walker now backs every source scan.

Lowercase the recovered prefix of an unresolved reference, and every
class name as it is added, so a mixed-case interpolated name still
widens.

Report fact records with no certname instead of dropping them, and
assert stripSource keeps newline positions rather than just length.
merge benvin/scaffold-catalog-diff into benvin/catalog-impact
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
a5f27348ae
Move FactsByName onto the renamed puppetdb client and share the facts
query and record decoding with the single-node fetch.
benvin merged commit bdc8f51994 into main 2026-09-12 18:19:04 +10:00
benvin deleted branch benvin/catalog-impact 2026-09-12 18:19:05 +10:00
Sign in to join this conversation.