Skip to content

fix(analyzer)!: a refusal that can be fixed must not call itself unsupported, and one encoder for the mirror - #290

Merged
sunib merged 4 commits into
mainfrom
fix/analyzer-refusal-doc-and-single-encoder
Jul 30, 2026
Merged

fix(analyzer)!: a refusal that can be fixed must not call itself unsupported, and one encoder for the mirror#290
sunib merged 4 commits into
mainfrom
fix/analyzer-refusal-doc-and-single-encoder

Conversation

@sunib

@sunib sunib commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes the consumer's Tier 0 asks (#22) and the one Tier 3 ask that was too silly to keep around (#11), and reconciles the work queue with what the attribution branch already shipped.

#22 — the analyzer contract said three things that are not true

Three sentences, each misleading a consumer that reads our source as the contract.

  • ReasonRefusedStructural advertised itself as "the permanent support boundary". It has not been permanent since Solvable shipped: the same code answers both ways, and correctly — a root refused for a configMapGenerator is nobody's to solve, one whose kustomization.yaml does not parse is one commit from adoptable. The consumer wrote Permanent = true off that sentence and shipped "this can never be synced" to the second author. The doc now points at Solvable.
  • The refusal detail had one stem for both branches, so a solvable refusal read kustomization uses unsupported feature(s): unparseable — the opposite of what Solvable said beside it. The stem now derives from the same classification that sets Solvable, so the two cannot drift the way the doc comment did. The not-solvable stem is byte-identical to before, which is why the corpus baseline moves on exactly one refusal: the one that was lying. The nested-kustomization message shares that stem and stops listing every construct it might have been in favour of the ones it found.
  • Actor now states which scans can report which values — and the answer is better than the doc line that was asked for. InScope gates only IssueOutOfScope; IssueUnresolvedKRM carries the same actor and is gated on a type registry that has seen a cluster. Cluster-awareness is the real gate, which is what makes the guarantee safe to state rather than merely true today, and a corpus test pins that no structure-only scan names the platform operator.

#11 — one encoder, and the "cosmetic" label was wrong

A create serialized through sigs.k8s.io/yaml and an update through gopkg.in/yaml.v3, so sequences landed at the parent key's column in one and two columns deeper in the other. The first update after a create rewrote every list line in the file to carry one changed field. For a product whose entire output is a Git diff, that is illegibility, not ugliness.

Built in the order the queue asked for: the byte-for-byte pinning test first, then the fix wherever it failed. Three things came out of doing it —

  • The direction was forced, not chosen. yaml.v3 always indents a sequence under its mapping key, so the create style cannot be produced by the encoder that edits documents in place. Create moved to yaml.v3, which is also what every already-updated file in a repository looks like.
  • The fix is a package, not a shared constant. Two encoders agreeing on an indent width are one refactor from diverging again, silently, because both emit valid YAML and nothing fails. internal/yamlstyle is the only place the write path constructs an encoder, and a source-scanning test refuses a second one.
  • One behaviour difference had to be absorbed. yaml.v3 raises cannot marshal type by panicking with a bare string, which escapes its own recover, where the JSON path returned an error. A write path that returns an error retries and reports; one that panics takes the process down.

sigs.k8s.io/yaml stays where it parses or round-trips a Go struct through its JSON tags (the analyzer report, the in-memory kustomization copies handed to kustomize) — none of those bytes reach a mirror. One style authority for Git output, not one library for the tree.

Adding a key still differs between create and update, deliberately: a patch appends it where the document's own order puts it while a canonical render sorts keys, and preserving a human's key order is what the in-place editor is for. The test says so, so nobody reads it as a gap.

The queue

docs/design/open-asks-priority.md is reconciled to reality. The fact stream, ask #23, the name tier and metrics Phase 1 shipped and are struck from it; the residue they leave is ranked (the removal-wait decision, the head-of-line block on the shard, the aggregated create). #5 loses one of its two arguments and says so — "audit cannot attribute a finalized delete at all" is false now that the sticky removal pointer does exactly that. It also answers what "Event" meant in the inference-deletion open question: a real corev1.Event, which is not nearly free there because placement runs on the branch worker rather than in a controller, must attach to the GitTarget rather than to an object that may live in a remote cluster, and is a notification rather than a record — the durable half belongs to B2.

Compatibility

Two reworded strings, both documented as unstable and neither switched on by any code: the refusal detail for a solvable render-root refusal, and the unsupported-kustomize issue message. No API, CRD, or metric change.

No new diff churn from the encoder change: files are re-serialized only when they are written, and an update already re-indented them. What changes is that a create no longer plants a file its own next update will rewrite.

Validation

task lint, task test (coverage 78.5%, baseline held), task lint-docs, and the corpus baseline regenerated (task gitops-layouts-baseline, two lines moved). task test-e2e result is reported in a comment below.

🤖 Generated with Claude Code

sunib added 4 commits July 29, 2026 21:26
The queue was built bottom-up: Tier 0 and Tier 1 are still unbuilt while the
largest Tier 2 item shipped anyway, together with a consumer ask (#23) that was
filed and fixed before it was ever ranked. Record that rather than smoothing it
into a re-ranking.

Struck: the fact stream, #23, the name tier, metrics Phase 1. Ranked in their
place: the removal-wait decision (F then C, Tier 1, because a watched type the
audit policy excludes is #15's failure mode one level down), the head-of-line
block on the shard goroutine, the aggregated create, and the stream's two
capacity questions.

#5 loses one of its two arguments and says so: "audit cannot attribute a
finalized delete at all" is false now that the sticky removal pointer does
exactly that. The first argument — a hosted control plane will not give you an
audit webhook — carries it alone.

Also answers what "Event" meant in the inference deletion's open question: a
real corev1.Event, which is not nearly free there because placement runs on the
branch worker rather than in a controller, must attach to the GitTarget rather
than to an object that may live in a remote cluster, and is a notification
rather than a record. The durable half belongs to B2.
…pported

Consumer ask #22, all three parts. Each one was a sentence, and each one was
misleading a consumer that reads our source as the contract.

ReasonRefusedStructural documented itself as "the permanent support boundary".
It has not been permanent since Solvable shipped: the same code answers both
ways, correctly, because a root refused for a configMapGenerator is nobody's to
solve while one whose kustomization.yaml does not parse is one commit from
adoptable. A consumer wrote Permanent = true off that sentence and shipped "this
can never be synced" to the second author. The doc now points at Solvable.

The refusal detail had one stem for both branches, so a solvable refusal read
"kustomization uses unsupported feature(s): unparseable" — the opposite of what
Solvable said beside it. The stem now comes from the same classification that
sets Solvable, so the two cannot drift the way the doc comment did. The
not-solvable stem is byte-identical to what it was, which is why the corpus
baseline moves on exactly one refusal: the one that was lying.

The nested-kustomization message shares that stem, and stops listing every
construct it might have been in favour of the ones it found.

Actor now states which scans can report which values. The ask asked whether a
structure-only scan can name the platform operator; it cannot, and the reason is
not the one reported. InScope gates only IssueOutOfScope; IssueUnresolvedKRM
carries the same actor and is gated on a type registry that has seen a cluster.
Cluster-awareness is the real gate, which is what makes the guarantee safe to
state, and a corpus test pins it.

BREAKING CHANGE: the refusal detail for a solvable render-root refusal and the
message on an unsupported-kustomize issue are reworded. Both are documented as
unstable strings; no code switches on them.
…field

Consumer ask #11, filed as cosmetic and accepted as not. A create serialized
through sigs.k8s.io/yaml and an update through gopkg.in/yaml.v3, so sequences
landed at the parent key's column in one and two columns deeper in the other,
and the first update after a create rewrote every list line in the file to carry
one changed field. For a product whose entire output is a Git diff, that is not
ugliness, it is illegibility.

Built in the order the queue asked for: a test pinning create and update output
byte-for-byte first, then the fix wherever it failed.

The direction was forced rather than chosen. yaml.v3 always indents a sequence
under its mapping key, so the create style cannot be produced by the encoder
that edits documents in place; create moved to yaml.v3. It is also what every
file in a repository the operator has already updated once looks like.

The fix is a package and not a shared constant: two encoders agreeing on an
indent width are one refactor from diverging again, silently, because both emit
valid YAML and nothing fails. internal/yamlstyle is the only place the write
path constructs an encoder, and a source-scanning test refuses a second one.

It also contains one behaviour difference that had to be absorbed: yaml.v3
raises "cannot marshal type" by panicking with a bare string, which escapes its
own recover, where the JSON path returned an error. A write path that returns an
error retries and reports; one that panics takes the process down.

Adding a key still differs between the two paths, deliberately: a patch appends
it where the document's own order puts it while a canonical render sorts keys,
and preserving a human's key order is what the in-place editor is for.
Tier 0 and the one Tier 3 entry worth doing beside it are built, so they leave
the queue. Both entries keep their argument and gain what the work found:

#22's Actor answer was better than the doc line asked for — cluster-awareness is
the gate, not AcceptancePolicy.InScope, and the second raise site the consumer's
trace missed is the one that proves it.

#11 records three things that only came out of doing it: the direction was
forced by yaml.v3's sequence indentation rather than chosen, the fix had to be a
package rather than a shared constant, and one panic-versus-error difference had
to be absorbed at the seam.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sunib, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fce669ce-1826-4041-bb18-6c9a7d6a755e

📥 Commits

Reviewing files that changed from the base of the PR and between fcdc3bc and f492e9f.

📒 Files selected for processing (16)
  • docs/INDEX.md
  • docs/TODO.md
  • docs/design/open-asks-priority.md
  • internal/git/manifestedit/patch.go
  • internal/manifestanalyzer/acceptance.go
  • internal/manifestanalyzer/scan_repo.go
  • internal/manifestanalyzer/solvable.go
  • internal/manifestanalyzer/solvable_test.go
  • internal/manifestreport/render_contract_test.go
  • internal/sanitize/marshal.go
  • internal/yamlstyle/yamlstyle.go
  • internal/yamlstyle/yamlstyle_test.go
  • pkg/manifestanalyzer/folder.go
  • pkg/manifestanalyzer/repo.go
  • pkg/manifestanalyzer/testdata/folder-report.golden.yaml
  • test/fixtures/gitops-layouts/support-today.md

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.15942% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/sanitize/marshal.go 50.0% 5 Missing and 4 partials ⚠️
internal/manifestanalyzer/scan_repo.go 75.0% 1 Missing and 1 partial ⚠️
internal/yamlstyle/yamlstyle.go 92.5% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@sunib
sunib merged commit aed4af9 into main Jul 30, 2026
19 checks passed
@sunib
sunib deleted the fix/analyzer-refusal-doc-and-single-encoder branch July 30, 2026 06:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant