refactor(adapter): typed FileOp.Action and an explicit OpCleanup kind (#230) - #249
Merged
Merged
Conversation
`FileOp.Action` was a string with a documented `"" == "write"` convention: `render.Plan`, `render.Apply` and `render.PreviewApply` each rewrote "" to "write" at intake so their guards (which matched the literal "write") could not be dodged, and every downstream reader carried a comment saying whether its ops were plan-normalized or raw adapter output. It is now `adapter.Action` (`ActionWrite`, `ActionDelete`) with `ActionWrite` as the zero value, so a FileOp built without an action writes, there is no empty spelling, and the three normalizations plus their comment tax are deleted. `String()` keeps the dry-run label and `DispatchOps`' error text byte-identical for both valid values; an out-of-range value reads "action(<n>)". Every production producer now sets `Action: adapter.ActionWrite` / `adapter.ActionDelete` explicitly (a pure token swap at 41 adapter Render sites plus the five pipeline/CLI sites). The four `!= "" && != "write"` guards and reconcile's `!= "write"` collapse to `!= adapter.ActionWrite`, which is exactly behaviour-preserving for every plan `render.Plan` can build (all its ops were already spelled "write"). The `OwnedKeys` field comment is corrected while here: it is populated by `render.Plan` from state when Plan has one, not by Apply; a Render-set value is only a fallback. Tests: the two normalization tests become `TestPlan_ZeroActionIsWriteAtTheGuards` / `TestApply_ZeroActionForCallerBuiltPlans` (same containment assertions; the ops are deliberately built without an Action so the zero-value contract stays pinned), `TestDispatchOps` gains a zero-value case and an out-of-range case, and the new `internal/adapter/action_test.go` pins the zero value and `String()`. No golden changes: the #229 characterization harness differs only in its two fixture constructors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…ops (#230) An orphan-cleanup op — the empty key-merge write `render.Plan` synthesizes when a source section empties, whose only work is pruning that section's owned keys through the merge path — was recognised by `apply` via three copies of a shape sniff (`IsKeyMerge && TrimSpace(Content) == "{}" && len(OwnedKeys) > 0`) plus a pinning test asserting no adapter could ever render that shape for a populated section. The op now carries its identity: `FileOp.Kind` (`adapter.OpKind`) is `OpRender` (the zero value) for an ordinary rendered op and `OpCleanup` for a cleanup, orthogonal to `Action` — a cleanup op stays an `ActionWrite`, since the merge performs the removal. The three sniffs in `printPlannedOp`, `planSyncCounts` and `removalCounts` become `op.Kind == adapter.OpCleanup`. Both synthesis sites — `render.orphanCleanupOps` and `agent disable --purge`'s pointer prune — build the op through the single constructor `adapter.CleanupOp(path, strategy, owned)`, the only producer of `OpCleanup`, so the kind cannot be missed at either site and no production code hand-rolls the `{}`+OwnedKeys literal any more. `TestAdapters_NeverRenderEmptyObjectForPopulatedSection` keeps its name and body but is now an adapter-fidelity guard only (an adapter rendering `{}` for a populated section has silently dropped it); nothing consumes the `{}`-uniqueness property any more. New: `TestOrphanCleanupOps_StampsOpCleanup` (render) pins the stamp through the pipeline path, and `TestCleanupOp` (adapter) pins every field the constructor sets, so dropping the stamp fails in three packages — those two plus `TestApplyDryRun_CleanupOpNotCountedToWrite` (cli). No user-visible behaviour changes; the #229 characterization goldens are untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…kind (#230) Document the typed `adapter.FileOp` in the contract pages: §3 gains a "`FileOp` is typed too" paragraph next to the "Skips are typed" precedent — `Action` with `ActionWrite` as the zero value and no intake normalization; `Kind` saying why an op exists, orthogonal to `Action`; `adapter.CleanupOp` as the only producer of `OpCleanup`, called from `render.orphanCleanupOps` and `agent disable --purge`. `docs/components.md`'s `internal/adapter` key list and `internal/render` orphan-reclaim sentence say the same, and the CHANGELOG gets one internal-only bullet under [Unreleased] > Changed. The website's contract pages are generated from these files at build time (website/scripts/sync-docs.mjs) and its authored pages mention neither `FileOp` nor an action string, so no website edit is needed; the dry-run labels the user guide, README and CLI reference describe are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…AST guard, anchored label pin (#230) Round 1 (four lenses on 21b7d14) found two ISSUEs and a set of NITs: - The "only producer of OpCleanup" promise was asserted, not enforced. Add TestEveryCleanupLiteralUsesNewCleanupOp (sibling of TestEverySkipLiteralSetsKind): any production FileOp literal — direct or an elided slice element — whose Content is a static "{}" outside NewCleanupOp's body fails, with a matcher self-test alongside. - TestApplyDryRun_CleanupOpNotCountedToWrite's label check matched the "Removals: … will remove these" summary line, so printPlannedOp's Kind branch was unpinned. The assertion is anchored to the op's own line now (" remove ", never "write"/"synced"). - CleanupOp → NewCleanupOp (state.NewFileKey precedent; the old name read as a conversion and was one word-swap from OpCleanup). - Docs: OpCleanup is identified by kind, never "defined by its shape"; OpKind.String has no production consumer and says so; OwnedKeys is overwritten on every key-merge op; Kind is why the op exists, not which source produced its content; MergeStrategy stays a string, deferred to #250; the scope-honesty caveat on applyPlan's containment backstop is restored. - Tests: a dest-already-gone subtest for orphanCleanupOps; the skipkind AST fixture no longer spells Action: "write". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…eanup shape in both directions, headline pin (#230) Round 2 (four lenses on cfa9d2e) was NIT-only; all taken: - The AST guard now keys on the cleanup SHAPE — an empty-object Content headed for a key-merge destination (it names OwnedKeys, or a MergeStrategy other than "replace") — so a legitimate whole-file "{}" write is not misdirected to NewCleanupOp; it also flags a literal that stamps Kind: OpCleanup by hand (the other way around the constructor) and a positional literal it cannot read, in the safe direction like its Skip sibling; "{}" matches ignoring all whitespace. The per-file scan is extracted so TestCleanupOpStaticGuardScan can pin the NewCleanupOp allow-window on snippets (inside: exempt; another function or another package's NewCleanupOp: flagged; widening it to the whole file fails a row). The matched-literal count is logged; the prose said ~47, the scan matches 45. - TestApplyDryRun_CleanupOpNotCountedToWrite checks the label with the dest path trimmed off (t.TempDir embeds the test's own name, which contains "Write"), and now runs the real apply to pin the headline ("removed: 1 key(s)", no "applied:") that removalCounts' appliedOps-- exists to produce — the one behaviour whose deletion the suite missed. - docs/architecture.md no longer says the kind "cannot be missed" (the guard is literal-only): a hand-rolled cleanup literal cannot ship unstamped. The MergeStrategy deferral sentence moves to the end of the key-merge block it belongs to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…elision walk, headline-line pin (#230) Round 3 (four lenses on 9576712, targeted at the round-2 changes) was NIT-only; all taken: - The guard's elided-element walk is container-generic: a type-elided FileOp element is reached through a slice, array or map — nested containers included — and an element that spells its own type is left to the plain walk, so nothing is counted twice. New scan rows pin the typed-element-counted-once case, a map value, a nested slice, and a method named NewCleanupOp (which grants no allow-window; that condition was the one unpinned by round 3's mutation sweep). - The shape message names the escape hatch: a whole-file write of "{}" names neither MergeStrategy nor OwnedKeys and is not flagged. The guard docs also say post-construction assignment is out of scope, and why an unquotable literal leans the way it does. - TestApplyDryRun_CleanupOpNotCountedToWrite's real-apply pin is anchored to the "removed: 1 key(s)" line itself — the same fix round 2 made to the label check — so a future zero-ops hint elsewhere in the output cannot fail it, and "applied: 0 ops, removed: 1 key(s)" still does. - Docs: a hand-rolled cleanup literal cannot ship "unstamped or hand-stamped"; the moved MergeStrategy sentence names "the published Adapter interface" again; the CHANGELOG says "hand-rolled cleanup literals". #250 carries a note to teach the guard the typed replace constant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #230. Third PR in the #226 code-quality series (after #240, #244, #247). No behaviour change.
1.
adapter.FileOp.Actionis a typed enum whose zero value is write (commit 1,f11bccc).Actionwas astringwith a documented"" == "write"convention thatrender.Plan,render.Applyandrender.PreviewApplyeach rewrote at intake, and that every downstream reader re-asserted in a "plan-normalized vs raw adapter output" comment. It is nowadapter.Action(ActionWrite= zero value,ActionDelete,String()), so there is no empty spelling to normalize: the three intake rewrites and the four reader comments are deleted. The containment backstops inPlanandapplyPlanstay — they were always the load-bearing half, and their tests are rewritten to prove the property that survives: there is no spelling that writes while dodging a guard (the fixtures are built without anActionon purpose, and say so).%q/ui.Padsurfaces go throughString(), so the dry-run label andDispatchOps' error text are unchanged for every value a producer can make. 41 adapterAction: "write"sites (38 files) are a pure token swap.2. Orphan-cleanup ops carry an explicit
Kind: adapter.OpCleanup, built by one constructor (commit 2,0207296).applyused to identify the synthesized empty key-merge write that prunes an emptied section's owned keys by sniffingIsKeyMerge && TrimSpace(Content) == "{}" && len(OwnedKeys) > 0— three copies inapply.go, plus a registry-wide test whose stated job was to keep that signature unique. The op is now stamped at synthesis, by the single produceradapter.NewCleanupOp(path, strategy, owned)called from bothrender.orphanCleanupOpsandagent disable --purge; the three sniffs readop.Kind.Kindis orthogonal toAction— a cleanup op is anActionWrite(the merge path performs the removal viaOwnedKeys), so it is not a third action and no== ActionWritesite had to grow an|| cleanup. "Only producer" is enforced in both directions, not asserted:TestEveryCleanupLiteralUsesNewCleanupOp(sibling ofTestEverySkipLiteralSetsKind) fails any productionFileOpliteral — direct, or a type-elided element of any slice/array/map nesting — that hand-rolls the cleanup shape (a static{}into a key-merge destination) or stampsKind: OpCleanupby hand outside the constructor; a whole-file{}write is not the shape and passes. The pinning test keeps its name and body with its rationale rewritten to the contract it still enforces (adapter fidelity: rendering{}for a populated section is a silent drop).3. The
OwnedKeysfield comment was wrong on both counts and is corrected (doc only): it is populated byrender.Planfrom state (scoped to the op's own top-level sections) on every key-merge op when Plan is given a state, not byApply; and three adapters do set it inRenderas a fallback for Apply-without-the-pipeline, whichPlanoverwrites.Deliberately out of scope: typing
MergeStrategy→ #250. The cheap partial is only ≈7 prod + 2 test files, ~40 lines — but it leaves a half-typed API (a typed field fed by astringinterface accessor through explicit conversions), and the full version changes the publishedAdapterinterface. That belongs with its own issue; the CHANGELOG and architecture doc say so, and #250 carries the one guard-side follow-up (teach the shape exemption the typed replace constant).Why this is behaviour-preserving, measured rather than argued.
FileOpis never serialized (nojson:tags, no--jsonpayload carries one;planItemis pinned all-unexported), so there is no wire form to preserve.fileOp/keyOpfixture constructors — and zero golden edits.FileOpliteral already setActionexplicitly (the""default was documented but never used), andActionDeletenever enters a plan'sOps(orphan deletes are synthesized insideapplyPlan; purge and the drift walk build their own slices), so the walk's!= ActionWritefilter stays as belt-and-braces.renderedset rejected""while the walk accepted it — collapses to one predicate. On the old code, respelling only the harness constructors to""fails exactly one fixture (T-19, the reconcilerenderedprojection), so that set is a live oracle; applying the collapse to those same""fixtures is fully green. The only thing that could move T-19 is the""spelling, which no fixture uses and which the typed model makes unrepresentable.Reading the diff. Commit 1 is 67 files, 41 of them the one-line adapter swap; commit 2 is 7 files and is where the thought is; commit 3 is docs; commits 4–6 close the three review-loop rounds. The plan and execution spec were reviewed by a fresh reviewer before execution.
Review loop (three rounds, four read-only lenses each — correctness, adversarial, API design, test rigor). Round 1 on
21b7d14: two ISSUEs —printPlannedOp's Kind branch was unpinned (the dry-run test'sContains(dry, "remove")matched the summary line) and "only producer ofOpCleanup" was asserted, not enforced — plus prose nits; fixed incfa9d2e. Round 2 oncfa9d2e: NIT-only, three lenses ship-it; all taken in9576712(guard narrowed to the cleanup shape and made two-directional, the allow-window pinned on parsed snippets, the real-apply headline pinned, doc sentences). Round 3 on9576712(targeted at the round-2 changes): NIT-only, three lenses ship-it, a 16-mutation sweep of the guard found one unpinned condition; all taken ina886a45(container-generic elision walk, the method-receiver row, the headline pin anchored to its line, the escape-hatch clause, doc wording). Severity trace ISSUE → NIT → NIT.Type of change
Test plan
New tests:
internal/adapter/action_test.go—TestFileOpEnums_ZeroValues(the one explicit pin that the zero values are write/render, via barevar),TestFileOpEnums_String(String()and%qfor both enums incl. the out-of-range fallback),TestNewCleanupOp(every field the constructor sets).internal/adapter/cleanupop_guard_test.go—TestEveryCleanupLiteralUsesNewCleanupOp(AST walk of every production file underinternal/; aFileOpliteral, direct or a type-elided element of any slice/array/map nesting, is flagged if it is positional, stampsKind: OpCleanupby hand, or hand-rolls the cleanup shape — all outsideNewCleanupOp's own body; anti-vacuity floor, matched count logged),TestCleanupOpStaticGuardScan(the per-file scan on thirteen parsed snippets: the allow-window inside/outside/other-package/method-receiver, typed element counted once, elided slice/map/nested elements, the whole-file{}negative, the positional case) andTestCleanupOpStaticGuardMatchers(16-row matcher self-test).internal/render/pipeline_cleanup_internal_test.go—TestOrphanCleanupOps_StampsOpCleanup: the realorphanCleanupOps; with the dest on disk the op isActionWrite/OpCleanup/{}/the owned pointer; with the dest already gone, no op.Rewritten (not retired):
TestPlan_ZeroActionIsWriteAtTheGuards,TestApply_ZeroActionForCallerBuiltPlans, twoTestDispatchOpscases,TestWalkPlanItems/action-not-write-is-skipped— all built without anActionso the zero-value contract is pinned;TestAdapters_NeverRenderEmptyObjectForPopulatedSection(rationale only);TestApplyDryRun_CleanupOpNotCountedToWrite(label assertion anchored to the op's own line with the path trimmed; the real apply'sremoved: 1 key(s)headline line pinned to carry noappliedpartition).Break-verifies run before commit:
ActionDelete→ 7 test functions fail in 3 packages. Converting those fixtures to an explicitActionWritewould drop that to 2, which is why they are zero-value on purpose.Kind: OpCleanupline insideNewCleanupOp→ exactlyTestNewCleanupOp(adapter),TestOrphanCleanupOps_StampsOpCleanup(render),TestApplyDryRun_CleanupOpNotCountedToWrite(cli) fail.printPlannedOp's Kind check, or swap its label to "write" → the dry-run test fails on the op line. DropremovalCounts'appliedOps--, or printapplied: 0 ops, removed: …→ it fails on the headline line.orphanCleanupOps(direct, or with"{ }") or inpurgeAgentDests(elided slice element) → the guard namesrender/pipeline.go:330/cli/agent.go:715; hand-stampKind: adapter.OpCleanupthere → flagged as a hand stamp; a positional bare literal inadapter.go→ flagged; a whole-file{}write with no strategy → not flagged. Widen the allow-window to the whole file, drop the receiver condition, count typed elements twice, or drop map support → a snippet row fails each.Gates, each commit independently and again on the head:
go build,go vet,gofmt -lempty, gofumpt,AGENTSYNC_TEST_IN_CONTAINER=1 go test ./...(29 packages ok),go test -race ./internal/cli/...,go test -tags=e2e ./test/e2e/...,go test -tags=bdd ./test/bdd/...,GOTOOLCHAIN=go1.26.2 golangci-lint@v2.12.2 run ./...→ 0 issues.just test-releaseis green (the release bar) —justis not installable in this session; every layer the recipe orchestrates (vet → build → race → e2e → bdd) was run directly as listed above, and CI's hermetictest-releasejob is green on the PR.just lintis clean — run directly with the pinned toolchain;go.mod/go.sumuntouched.Checklist
fix(secrets): …).internal/secrets,internal/capture, or anysource.Write*path, I've re-read the secret-handling invariants inCLAUDE.md/SECURITY.mdand not weakened them. — Not touched: no file underinternal/secrets,internal/captureorinternal/sourcechanges;walkSecretFieldsnever visitsFileOp.docs/architecture.md,docs/components.md,CHANGELOG.md; the website contract pages are generated from those, and no authored page mentionsFileOpor an action string.🤖 Generated with Claude Code
https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG