Skip to content

refactor(adapter): typed FileOp.Action and an explicit OpCleanup kind (#230) - #249

Merged
spxrogers merged 6 commits into
mainfrom
claude/issue-230-typed-fileop-action
Sep 5, 2026
Merged

refactor(adapter): typed FileOp.Action and an explicit OpCleanup kind (#230)#249
spxrogers merged 6 commits into
mainfrom
claude/issue-230-typed-fileop-action

Conversation

@spxrogers

@spxrogers spxrogers commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #230. Third PR in the #226 code-quality series (after #240, #244, #247). No behaviour change.

1. adapter.FileOp.Action is a typed enum whose zero value is write (commit 1, f11bccc).
Action was a string with a documented "" == "write" convention that render.Plan, render.Apply and render.PreviewApply each rewrote at intake, and that every downstream reader re-asserted in a "plan-normalized vs raw adapter output" comment. It is now adapter.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 in Plan and applyPlan stay — 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 an Action on purpose, and say so). %q/ui.Pad surfaces go through String(), so the dry-run label and DispatchOps' error text are unchanged for every value a producer can make. 41 adapter Action: "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).
apply used to identify the synthesized empty key-merge write that prunes an emptied section's owned keys by sniffing IsKeyMerge && TrimSpace(Content) == "{}" && len(OwnedKeys) > 0 — three copies in apply.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 producer adapter.NewCleanupOp(path, strategy, owned) called from both render.orphanCleanupOps and agent disable --purge; the three sniffs read op.Kind. Kind is orthogonal to Action — a cleanup op is an ActionWrite (the merge path performs the removal via OwnedKeys), so it is not a third action and no == ActionWrite site had to grow an || cleanup. "Only producer" is enforced in both directions, not asserted: TestEveryCleanupLiteralUsesNewCleanupOp (sibling of TestEverySkipLiteralSetsKind) fails any production FileOp literal — 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 stamps Kind: OpCleanup by 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 OwnedKeys field comment was wrong on both counts and is corrected (doc only): it is populated by render.Plan from state (scoped to the op's own top-level sections) on every key-merge op when Plan is given a state, not by Apply; and three adapters do set it in Render as a fallback for Apply-without-the-pipeline, which Plan overwrites.

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 a string interface accessor through explicit conversions), and the full version changes the published Adapter interface. 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.

  • FileOp is never serialized (no json: tags, no --json payload carries one; planItem is pinned all-unexported), so there is no wire form to preserve.
  • The Extract a shared plan→drift-classification walk for status/diff/reconcile/explain #229 characterization harness (27 fixtures × status/diff/reconcile/explain goldens) needed exactly two lines — the fileOp/keyOp fixture constructors — and zero golden edits.
  • Every production FileOp literal already set Action explicitly (the "" default was documented but never used), and ActionDelete never enters a plan's Ops (orphan deletes are synthesized inside applyPlan; purge and the drift walk build their own slices), so the walk's != ActionWrite filter stays as belt-and-braces.
  • The one place two spellings of "is this a write" disagreed — reconcile's rendered set 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 reconcile rendered projection), 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's Contains(dry, "remove") matched the summary line) and "only producer of OpCleanup" was asserted, not enforced — plus prose nits; fixed in cfa9d2e. Round 2 on cfa9d2e: NIT-only, three lenses ship-it; all taken in 9576712 (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 on 9576712 (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 in a886a45 (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

  • Bug fix
  • New feature / enhancement
  • Refactor (no behavior change)
  • Docs
  • Tests / CI / tooling

Test plan

New tests:

  • internal/adapter/action_test.goTestFileOpEnums_ZeroValues (the one explicit pin that the zero values are write/render, via bare var), TestFileOpEnums_String (String() and %q for both enums incl. the out-of-range fallback), TestNewCleanupOp (every field the constructor sets).
  • internal/adapter/cleanupop_guard_test.goTestEveryCleanupLiteralUsesNewCleanupOp (AST walk of every production file under internal/; a FileOp literal, direct or a type-elided element of any slice/array/map nesting, is flagged if it is positional, stamps Kind: OpCleanup by hand, or hand-rolls the cleanup shape — all outside NewCleanupOp'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) and TestCleanupOpStaticGuardMatchers (16-row matcher self-test).
  • internal/render/pipeline_cleanup_internal_test.goTestOrphanCleanupOps_StampsOpCleanup: the real orphanCleanupOps; with the dest on disk the op is ActionWrite/OpCleanup/{}/the owned pointer; with the dest already gone, no op.

Rewritten (not retired): TestPlan_ZeroActionIsWriteAtTheGuards, TestApply_ZeroActionForCallerBuiltPlans, two TestDispatchOps cases, TestWalkPlanItems/action-not-write-is-skipped — all built without an Action so 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's removed: 1 key(s) headline line pinned to carry no applied partition).

Break-verifies run before commit:

  • Swap the iota order so the zero value is ActionDelete7 test functions fail in 3 packages. Converting those fixtures to an explicit ActionWrite would drop that to 2, which is why they are zero-value on purpose.
  • Delete the Kind: OpCleanup line inside NewCleanupOp → exactly TestNewCleanupOp (adapter), TestOrphanCleanupOps_StampsOpCleanup (render), TestApplyDryRun_CleanupOpNotCountedToWrite (cli) fail.
  • Invert printPlannedOp's Kind check, or swap its label to "write" → the dry-run test fails on the op line. Drop removalCounts' appliedOps--, or print applied: 0 ops, removed: … → it fails on the headline line.
  • Hand-roll the shape in orphanCleanupOps (direct, or with "{ }") or in purgeAgentDests (elided slice element) → the guard names render/pipeline.go:330 / cli/agent.go:715; hand-stamp Kind: adapter.OpCleanup there → flagged as a hand stamp; a positional bare literal in adapter.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.
  • Disable the dest-exists gate → the "dest already gone" subtest fails.

Gates, each commit independently and again on the head: go build, go vet, gofmt -l empty, 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-release is green (the release bar) — just is not installable in this session; every layer the recipe orchestrates (vet → build → race → e2e → bdd) was run directly as listed above, and CI's hermetic test-release job is green on the PR.
  • just lint is clean — run directly with the pinned toolchain; go.mod/go.sum untouched.

Checklist

  • Conventional commit messages with a scope (e.g. fix(secrets): …).
  • Tests added/updated for the behavior changed.
  • If this touches internal/secrets, internal/capture, or any
    source.Write* path, I've re-read the secret-handling invariants in
    CLAUDE.md / SECURITY.md and not weakened them. — Not touched: no file under internal/secrets, internal/capture or internal/source changes; walkSecretFields never visits FileOp.
  • Docs updated if behavior, CLI surface, or capability coverage changed. — docs/architecture.md, docs/components.md, CHANGELOG.md; the website contract pages are generated from those, and no authored page mentions FileOp or an action string.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG

`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
@spxrogers
spxrogers merged commit fca1c26 into main Sep 5, 2026
7 checks passed
@spxrogers
spxrogers deleted the claude/issue-230-typed-fileop-action branch September 5, 2026 01:26
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.

FileOp modeling: typed Action and an explicit cleanup-op kind

2 participants