Skip to content

ADR-014: fix — Automated Remediation of Auto-Fixable Findings

Status: Accepted — Phase 3 reached in 0.11.0. fix is promoted to the documented, SemVer-covered surface: it lists in --help, structured SARIF fixes[] ship unconditionally, and the per-invocation experimental warning and COMPOSE_LINT_EXPERIMENTAL gate are removed. All five promotion criteria in Part 5 are met, including the full-corpus soak (~6.4k files: zero re-parse failures, zero non-idempotent fixes, zero new findings introduced).

Context: compose-lint tells a user what is wrong and how to fix it, but today the fix is prose the user applies by hand. Milestone 3 of the roadmap (docs/ROADMAP.md) proposes turning the unambiguous subset of findings into edits the tool applies itself. This is the project's strongest differentiation against KICS/Checkov/Trivy, which report Compose issues but do not remediate them.

Three prior decisions constrain how fix can be built, and one open tension shapes how it should ship:

  • ADR-003 (no ruamel.yaml). The runtime depends only on PyYAML, and CommentedMap/CommentedSeq round-trip types are explicitly banned from leaking into the codebase. PyYAML's safe_dump discards comments, reflows quoting, and reorders keys — so the "parse → mutate object → re-serialize" remediation strategy is not available. A fixer must not destroy the parts of the file it isn't fixing.
  • ADR-011 (subcommand model). fix is already named there as the motivating case for add_subparsers — "a destructive variant with its own flag set." That ADR supersedes the roadmap's older --fix --apply flag spelling: fix is a subcommand, not a flag on check.
  • SARIF history (#168). result.fixes[] was removed in 0.6.0 because SARIF 2.1.0 requires artifactChanges on every fix object, and Finding.fix is human prose, not a machine-applicable patch. Re-introducing SARIF fixes requires producing real structured edits — the same artifact a fixer produces to write to disk.
  • 1.0 tension. Per docs/RELEASING.md, 1.0 is a contract freeze (CLI, config schema, JSON/SARIF shape), not a feature checklist. fix is additive and correctness-risky. We do not want a half-built, destructive fixer to become part of the frozen 1.0 surface, nor do we want to block 1.0 on it.

The intent of this ADR is to decide the mechanism, edit model, CLI shape, safe-rule set, refusal policy, and release strategy for fix. The exact text each rule emits (placement within a service block, comment annotations) is left to implementation, except where it forces a decision here.

Decision: Eight decisions.

  1. Edit mechanism — surgical text patching, not re-serialization. fix computes minimal byte-range replacements against the original file text and splices them in. It never re-dumps the document. Everything outside a touched span is preserved byte-for-byte.
  2. Edit model — a shared TextEdit. A rule's fixer returns structured edits (start_line, start_col, end_line, end_col, replacement) (1-indexed, SARIF region convention). The same model renders three ways: a unified diff (dry-run), an in-place write (--apply), and SARIF artifactChanges.
  3. CLI shape — compose-lint fix [FILE ...], a subcommand per ADR-011. Dry-run is the default and prints a unified diff to stdout; --apply writes in place. --only CL-XXXX (repeatable) narrows the rule set.
  4. Safe-rule set — every finding with a mechanically safe fix (Part 4): a single rewrite, determined entirely by the file, that resolves the finding and leaves valid Compose — by adding a hardening directive or deleting a security-weakening override. All 21 rules were evaluated; six qualify: CL-0003, CL-0005, CL-0007, CL-0009, CL-0014, CL-0015. CL-0003 is hardening-only; the other five change runtime behavior and ship a mandatory per-fix caveat in the dry-run (Parts 3–4). Behavioral risk is carried to the user via the caveat + experimental warning, not by excluding the fix. The remaining fifteen stay report-only (ambiguous value, external lookup, granted resource, secret relocation, or architectural).
  5. 1.0 relationship — fix does not gate 1.0. Per docs/RELEASING.md, 1.0 is a contract freeze, and the contract (CLI, config, JSON/SARIF) is ready independently of fix. 1.0 ships on stability; fix is promoted as the headline of a later MINOR (target 1.1). The roadmap's remediation thesis is satisfied by fix shipping in 1.x, not by it landing in 1.0.0 specifically.
  6. Release strategy — ship undocumented and experimental, gated in stages. fix lands on main behind a hidden subcommand (argparse.SUPPRESS) with a loud stderr experimental warning, explicitly excluded from the SemVer contract, and gated behind COMPOSE_LINT_EXPERIMENTAL=1 in its first (single-rule) phase. The gate relaxes in three stages (Part 5) as corpus evidence accrues. This lets it merge incrementally and be dogfooded against the corpus without making promises or blocking 1.0.
  7. Refusal policy — refuse, never guess. When a finding sits on an anchored/merged service, in flow style that can't be edited unambiguously, or anywhere the correct edit is not unique, fix leaves the file untouched and reports the finding as manual-only. A wrong fix is worse than no fix.
  8. SARIF — reintroduce artifactChanges rendered from the TextEdit model, restoring (correctly) what #168 removed.

Part 1 — Edit mechanism

Option A — Surgical text patching (chosen)

Treat the file as text; compute minimal (region → replacement) edits from the finding's location and splice them into the original bytes.

Pros: - Honors ADR-003. No new dependency, no CommentedMap leakage, no need to revisit the parser library decision. - Non-destructive by construction. Comments, key order, quoting style, blank lines, and trailing whitespace outside the edited span are preserved. The resulting diff is the diff a careful human would have written. - Reuses existing groundwork. LineLoader already captures per-key and per-sequence-item positions (parser.py); fix is the write-side mirror of that read-side tracking. - Produces the structured edit SARIF needs anyway (Part 7). One artifact, three renderings.

Cons: - Indentation, flow-vs-block style, and insertion-point must be inferred from surrounding text rather than handed to us by a serializer. This is real work and the source of most of the test surface. - Each fixer carries some text-shaping logic instead of just mutating a dict. Mitigated by a shared helper layer (indent inference, list-item insertion, scalar replacement) so individual rules stay small.

Option B — Parse, mutate, re-serialize with PyYAML

Pros: - Fixers are trivial: mutate the dict, dump.

Cons: - Destroys comments, reorders keys, reflows quoting and string styles across the entire file, not just the fixed span. The diff is enormous and the user cannot trust it. Disqualifying on its own. - Cannot represent "add read_only: true here" — only "the whole file now looks like this."

Option C — Adopt ruamel.yaml round-trip mode

Pros: - Purpose-built for comment-preserving round-trips.

Cons: - Directly violates ADR-003 and the CLAUDE.md "no ruamel.yaml" rule (packaging instability, round-trip types leaking into rule code). Reopening that decision is out of scope and unjustified when text patching covers the safe-rule set.

Rationale (mechanism)

The no-ruamel constraint already decided this; the ADR's job is to make it explicit and name the consequence: fix is a text-patching engine, not a YAML emitter. The cost (style inference) is bounded and testable; the benefit (trustworthy minimal diffs) is the whole point of an auto-fixer a user will run against files they care about.


Part 2 — Edit model and the fixer interface

A fixer is an optional capability a rule advertises. The proposed interface:

@dataclass(frozen=True)
class TextEdit:
    start_line: int      # 1-indexed, SARIF region convention
    start_col: int       # 1-indexed
    end_line: int
    end_col: int
    replacement: str     # may be multi-line; "" for pure deletion
    caveat: str | None = None   # behavioral note; set on behavior-changing fixes

class BaseRule:
    def fix(self, finding: Finding, data, lines, text: str) -> list[TextEdit] | None:
        """Return edits that remediate `finding`, or None if not auto-fixable
        / not safe to fix in this file (see refusal policy)."""
        return None  # default: rule is report-only

Binding properties:

  • Idempotent. Applying the output of fix --apply and re-running fix must produce zero edits. Re-linting a fixed file must not re-fire the same rule. Both are asserted in the corpus regression gate (Part 6).
  • Non-overlapping within a file. Edits are collected across all rules, sorted by position, and checked for overlap before application. Overlapping edits on the same region are a refusal, not a merge.
  • Applied bottom-up. Edits are spliced from the last position to the first so earlier offsets stay valid as later text changes length.
  • Engine-owned application. Rules produce TextEdits; a single apply_edits(text, edits) -> str in the engine performs the splice. Rules never write files.
  • Behavior-changing fixes carry a caveat. A fixer whose edit alters what the container does at runtime — not merely its hardening posture — MUST set caveat to the failure mode it introduces (what can break, and how to avoid it). The dry-run renderer surfaces it and marks the hunk (Part 3); hardening- only fixes leave it unset.

Rationale: a single position-based edit model is the common denominator of the three things we must produce — a diff, a write, and SARIF artifactChanges — and keeps the destructive operation in one auditable place rather than smeared across 21 rules.

Multi-rule coordination

A few findings are jointly fixable even though each rule's per-finding fixer must refuse in isolation. The canonical case: a security_opt whose entries are all profile-disables (so the service also lacks no-new-privileges). CL-0009 alone would have to empty the block and CL-0003 alone would have to append into a block of disables — each non-idempotent (a second pass would re-fire the other rule). The engine runs a coordination pass before the per-finding pass: it groups a service's findings and, when it recognizes such a pattern, synthesizes one merged edit (here: replace every disable item with a single - no-new-privileges:true) that resolves all the grouped findings at once and re-lints clean. Consumed findings skip their own fixers.

This does not weaken "non-overlapping within a file": the coordinator emits a single coherent edit, so apply_edits still never merges overlapping edits — the merge is decided up front by an engine pass that owns the cross-rule knowledge, not guessed at splice time. A coordinated group is also the unit of conflict resolution: if its edit conflicts with another unit's, the whole group is refused. Coordination only fires when every rule it spans is in scope (--only) and unsuppressed, so it never applies a change the user took out of scope. It refuses the same conditions the underlying fixers do (anchored/merged service, flow style) plus anything its whole-span rewrite would silently drop, such as a comment interleaved among the items.


Part 3 — CLI shape and write semantics

compose-lint fix [FILE ...], registered alongside check and init via the add_subparsers work ADR-011 already calls for.

Flags (v-experimental):

Flag Behavior
(none) Dry-run. Print a unified diff of proposed edits to stdout; status to stderr. Writes nothing.
--apply Write edits in place.
--only CL-XXXX Restrict to the named rule(s); repeatable.
--config PATH Honor .compose-lint.yml so suppressed/excluded findings are not "fixed" (see Part 6).

Behavior-changing fixes are flagged in the diff

A fix that only tightens posture without changing what the container does at runtime (CL-0003) renders as an ordinary hunk. A fix that changes runtime behavior — CL-0005 alters network reachability, CL-0007 makes the rootfs unwritable — renders with a ⚠ behavior-changing marker and its caveat printed inline above the hunk:

⚠ behavior-changing · CL-0007: read_only:true breaks the container if it writes
  to its root filesystem. Declare writable paths via tmpfs/volumes first.
--- docker-compose.yml
+++ docker-compose.yml
@@ services.web @@
     image: nginx:1.27
+    read_only: true

This is in addition to the global experimental warning (Part 5). The intent: a reader of the dry-run sees at a glance which edits are pure hardening and which could break their workload, before deciding to --apply. The same caveat text rides into SARIF as a note-level message on the fix (Part 7) so Code Scanning consumers see it too.

Write-flag naming — --apply (chosen)

ADR-011 floated --in-place; the roadmap wrote --apply. This ADR settles on --apply and supersedes ADR-011's illustrative spelling.

Pros: - Pairs with the dry-run mental model: preview the diff, then apply it. - Avoids sed's -i/--in-place connotation of unconditional, backup-free mutation.

Cons: - -w/--write (gofmt, prettier) is a common alternative spelling some users will reach for first. Acceptable; documented in fix --help.

Exit codes (extends ADR-006)

  • Dry-run: 0 on success (diff printed or nothing to do), 2 on usage/parse error. It does not exit 1 when edits are available — a deferred --check mode (Out of scope) is the right home for "fail CI if unfixed."
  • --apply: 0 on a successful write (the findings were remediated), 2 on usage/parse/write error.
  • Residual non-auto-fixable findings do not make fix exit 1. Consistent with init (ADR-011): for fix, findings are the input, not the failure signal. Use check for the pass/fail gate.

Rationale: fix is an operation that produces an artifact (a diff or a modified file), like init, not a gate like check. Its exit codes follow the artifact model, and stdout/stderr follow the CLAUDE.md split (diff = data on stdout, status = stderr) — the second stdout-emitting mode CLAUDE.md and ADR-011 anticipated. This is the point at which the text-mode banner gate in cli.py must either extend to cover fix or status lines move to stderr permanently; fix chooses the latter for its own output (no banner; diff only).


Part 4 — Safe-rule set

Auto-fix is offered for every finding with a mechanically safe fix:

a single rewrite, determined entirely by the file (no external lookup, no value the author must choose), that resolves the finding and leaves a valid Compose file — either by adding a hardening directive or by deleting a security-weakening override so the platform's secure default re-applies.

Behavioral risk is not handled by narrowing this set; it is handled by surfacing a caveat per fix and the global experimental warning (Parts 3, 5). Six rules qualify:

Rule Edit primitive Runtime behavior Caveat
CL-0007 read-only fs Insert read_only: true Changes — rootfs becomes unwritable; breaks containers that write to it Required
CL-0003 no-new-privileges Append no-new-privileges:true to security_opt:, or create the list Hardening-only — blocks setuid escalation; near-zero breakage None
CL-0005 unbound ports Short syntax: prepend 127.0.0.1: to the host side. Long syntax: add host_ip: 127.0.0.1 as a sibling key, or retarget a wildcard one Changes — drops non-local reachability; breaks intended LAN/remote access Required
CL-0009 unconfined profile Delete the seccomp:unconfined / apparmor:unconfined entry Changes — default seccomp/AppArmor profile re-applies; a workload needing a blocked syscall may fail Required
CL-0014 logging disabled Delete driver: none Changes — default logging driver re-enabled; logs collected again (disk/IO) Required
CL-0015 healthcheck disabled Delete the healthcheck disable Changes — image's default healthcheck re-enabled; an unhealthy status can affect depends_on/orchestration Required

Only CL-0003 is hardening-only; the other five change runtime behavior and ship a mandatory dry-run caveat (Part 3). Implementation groups by edit primitive, not severity: insertion (CL-0007) is the vertical slice, then the deletions (CL-0009/0014/0015 — structurally simplest, see below), then list-append/create (CL-0003), then the in-scalar edit (CL-0005, the riskiest to parse). CL-0005's long-syntax handling (insert/retarget host_ip on a port mapping) was added afterward as coverage completion — same rule, a second edit primitive.

Why these six and not the other deletion rules. The line is revert a guardrail vs. revoke a granted resource:

  • CL-0009/0014/0015 turn a platform security default back on. seccomp, logging, and healthchecks apply whether or not the file mentions them; the finding is an explicit opt-out, and deleting it restores the default. No compensating change is needed for the service to stay well-formed.
  • CL-0002 (privileged), CL-0008 (host network), CL-0010 (host namespaces), CL-0011 (cap_add), CL-0013 (sensitive mount), CL-0016 (devices), CL-0017 (mount propagation) grant the container access to a host resource or capability it otherwise would not have. Deleting that strips function the author added on purpose, the secure "default" restores no equivalent, and a compensating change (specific caps, explicit port maps) is usually required. They fail the definition and stay report-only.

Deletion fixers must leave a valid block or remove it whole. Removing the sole entry of security_opt: / logging: must drop the now-empty parent key, not leave security_opt: []. If a deletion would leave a structurally partial block it cannot fully resolve (e.g. logging.driver: none alongside logging.options), the fixer refuses (Part 6) rather than emit a broken file.

Out of auto-fix scope (report-only). The other fifteen rules fail the definition:

  • Ambiguous value / external lookup: CL-0004 (which version?), CL-0019 (digest needs a registry), CL-0006 (which capabilities? — issue #4), CL-0012 (which limit?), CL-0018 (which user? — deletion may not even resolve it).
  • Revokes a granted resource (above): CL-0002, CL-0008, CL-0010, CL-0011, CL-0013, CL-0016, CL-0017.
  • Context-dependent secret relocation: CL-0020, CL-0021.
  • Architectural: CL-0001 (socket-proxy sidecar).

Rationale: "mechanically safe" is a property of the edit, not the risk — every qualifying fix is unambiguous and leaves a valid file. Where the edit also changes behavior, the caveat carries that risk to the user rather than hiding the fix from them. Rules whose correct fix needs information the file doesn't contain stay report-only, where prose guidance already serves.


Part 5 — Release strategy: undocumented and experimental

fix is destructive and correctness-risky, and we want it on main early for incremental review and corpus dogfooding without (a) advertising a half-built fixer, (b) making SemVer promises about it, or (c) entangling it with the 1.0 contract freeze. The decision is to ship it hidden and experimental, then promote.

Option A — Hidden subcommand + experimental warning, excluded from contract (chosen)

  • Registered with help=argparse.SUPPRESS, so it does not appear in compose-lint --help. Absent from README and docs (except this ADR).
  • Every invocation prints a one-line stderr warning: warning: 'fix' is experimental and unstable; output and flags may change without notice. Review the diff before --apply.
  • docs/RELEASING.md is amended to state that experimental subcommands are not part of the SemVer contract — their behavior, flags, and existence may change in any release, including patch. 1.0 can freeze the rest of the surface while fix matures behind this carve-out.
  • Exposure relaxes in three stages as corpus evidence accrues, never the reverse:
  • Phase 1 (engine + CL-0007): registration is gated behind COMPOSE_LINT_EXPERIMENTAL=1 and SUPPRESSed — it cannot be discovered or invoked by accident at all.
  • Phase 2 (all six rules, corpus regression green): drop the env gate; keep the subcommand hidden and the stderr warning.
  • Phase 3 (promotion criteria met): remove SUPPRESS, document in README and --help, enable SARIF fixes, announce in CHANGELOG, and bring fix under the SemVer contract.

Pros: - Mergeable in small slices (Part 6 phasing) with real CI and corpus testing, not stranded on a long-lived branch. - No user relies on unfinished behavior; the curious can opt in; the contract is untouched. - Cleanly decouples fix from the 1.0 decision — answers the open tension directly: 1.0 ships when the contract is ready, fix promotes when it's ready, independently.

Cons: - A hidden command still exists in shipped artifacts; a determined user can find and run it. The stderr warning and (optional) env gate are the mitigations — acceptable for a non-default, dry-run-by-default operation. - Two states to track (experimental vs. promoted) and a documented promotion step. Lightweight; the promotion criteria below make it mechanical.

Option B — Documented from day one, marked "experimental" in the docs

Pros: honest discoverability; users can find and try it.

Cons: invites bug reports and CI adoption against a moving target; pressure to keep early flag spellings; risks the surface drifting into the 1.0 freeze before it's ready. Premature for a destructive feature.

Option C — Long-lived feature branch until complete

Pros: zero exposure.

Cons: no CI on main, painful rebases, no incremental dogfooding against the corpus, big-bang merge risk. Worst option for a correctness-sensitive feature that benefits most from running against real files early.

Promotion criteria (experimental → documented + contract-covered)

fix graduates — removing SUPPRESS, adding README/--help docs, enabling SARIF fixes (Part 7), announcing in CHANGELOG — when all hold:

  1. All six safe rules implemented, each with the corpus regression gate green.
  2. Corpus run: every auto-fixable finding either fixed-and-clean or explicitly refused; zero fixed files fail to re-parse; zero non-idempotent fixes.
  3. Refusal policy (Part 6) exercised by tests for anchors, merge keys, and flow style.
  4. fix --apply round-trips: fixed file re-lints with the targeted rules silenced and no new findings introduced.
  5. Full-corpus soak: fix runs clean against at least one fresh full corpus pull (the ~6.4k-file fetch, not just the committed snapshot fixtures). The long-tail file shapes the snapshot can't capture must be exercised — and refusal rate measured — before promotion.

Until then, treat fix like an internal tool that happens to ship in the wheel.


Part 6 — Refusal policy and the corpus gate

Refuse (leave untouched, report as manual-only) when:

  • The finding's service inherits via merge key (<<:) or YAML anchor/alias, so the edit's correct target (anchor vs. service) is ambiguous.
  • The relevant block is in flow style (security_opt: [...], ports: ["8080:80"]) and the minimal edit isn't unambiguous.
  • Two fixers want overlapping regions.
  • ${VAR} interpolation sits inside the span to be edited (the resolved value is unknown; CL-0005's port host could be a variable).

Respect suppression. A finding suppressed or service-excluded via .compose-lint.yml (ADR-010) is never fixed — suppression is a deliberate human decision. fix loads config like check does.

Corpus regression gate (new test, runs against ~/.cache/compose-lint-corpus/ fixtures, mirrors test_corpus_snapshot): for every file, fix → assert it still parses, re-lints with the targeted rules cleared, and a second fix is a no-op. This is the safety net that makes shipping fix defensible, and the reason experimental-on-main (Part 5) beats a branch.


Apply-time safety layers

The corpus gate (Part 6) proves the invariants hold across ~1.5k real files before release. It cannot run on a user's machine against a file it has never seen. A fix can still break a running service four ways, and --apply defends each in turn — the first three at apply time, the corpus gate behind them:

  1. Invalid file — the patch is not valid Compose, so docker compose refuses to start. Caught by the parse net (reparse_or_error): re-parse the candidate; refuse and write nothing if it does not parse.
  2. Collateral mutation — the patch is valid Compose but a miscomputed splice span dropped or mangled a key the fixer never meant to touch. The parse net waves this through. Caught by verify_apply's structure check: every service the fix did not touch, and every top-level key outside services, must parse identically before and after. This is the cheap form — it confirms untouched config is unchanged, not that the touched services changed in exactly the intended way (see Deferred, below).
  3. Self-inconsistent fix — the patch is valid and confined but does not settle: a second fix pass would edit again, or the fix introduced a finding the original lacked. Caught by verify_apply's converge + no-new-finding checks — the same two invariants the corpus gate enforces (Part 6), now enforced at apply time so a live --apply carries the guarantee the gate only proved over the corpus.
  4. Operational write hazard — the content is correct but the write itself corrupts the file (interrupted write, full disk leaving a truncated file). Caught by the atomic write: write a temp file in the same directory, fsync, carry over the original's mode, and os.replace it in. A reader sees either the old file or the complete new one, never a mix.

A failure at layer 1–3 is a fixer bug, not user error: --apply refuses the whole file, writes nothing, prints the diff for diagnosis, and exits 2.

Deferred

  • Strong structural proof. The interim structure check (layer 2) only asserts untouched config is unchanged. The strong form has each fixer declare its intended semantic delta (e.g. +services.web.read_only, -services.web.security_opt[seccomp:unconfined]) and the engine verify the actual parsed delta equals the declared one — catching a fixer that mutates its own service in an unintended way, not just a neighbour. Needs a delta declaration on the fixer interface; defer until a real divergence motivates it.
  • CRLF / BOM read-path fidelity. The atomic write emits the computed text verbatim, but load_compose reads with newline normalization, so a CRLF-authored file is rewritten with LF endings. Preserving the original line ending (and a leading BOM) requires the read path to retain them, not just the write path — a separate change.
  • Concurrency (TOCTOU). A fix bases its edits on the file as read; a concurrent edit between read and os.replace is silently clobbered. Re-checking the on-disk content is unchanged just before the swap would refuse rather than overwrite. Cheap, but a distinct concern from durability.
  • Context-aware gating for behavior-changing fixers. Some caveats could become refusals when the same file shows a clear breakage signal: CL-0005 under network_mode: host (the 127.0.0.1: rewrite has different semantics there), and CL-0007 when a service has no tmpfs: or writable mount (a read-only rootfs is then very likely to break it — a candidate for a stronger, context-specific caveat rather than an outright refusal).

Out of scope for this ADR

  • fix --check (exit 1 if edits would be made, à la black --check) — a CI-gate convenience; additive, defer until requested.
  • --backup / .bak files — rely on the user's VCS for v-experimental; revisit if requested.
  • Expanding the safe-rule set beyond CL-0003/0005/0007 — each addition is its own decision against the Part 4 bar.
  • Capability profiles for CL-0006 (issue #4) — a prerequisite for ever auto-fixing CL-0006, tracked separately.
  • Interactive / per-finding confirmation (fix -i) — adds a TTY dependency story; the dry-run-then-apply flow covers the need.
  • Exact emitted text and comment annotations per rule — implementation detail, except the style-matching constraints stated above.

Implementation notes (non-binding)

  • cli.py: register fix under the ADR-011 subparser with help=argparse.SUPPRESS; emit the experimental stderr warning in its handler. Guard registration on COMPOSE_LINT_EXPERIMENTAL for the first (single-rule) phase; drop the env guard at Phase 2 (see Part 5 exposure stages).
  • Engine: add apply_edits(text, edits) -> str (sort, overlap-check, splice bottom-up) and an edit-collection pass that calls rule.fix(...) for each finding whose rule advertises a fixer.
  • Reuse LineLoader positions; where a fixer needs a column the loader doesn't retain, extend the sidecar rather than re-parsing.
  • Diff rendering: stdlib difflib.unified_diff over original vs. patched text. No new dependency.
  • SARIF: a region (startLine/startColumn/endLine/endColumn) plus artifactChanges[].replacements[] built from each TextEdit; gate emission on promotion so experimental edits don't leak into a contract-shaped artifact.
  • Phasing: (1) edit engine + CL-0007 behind the env gate; (2) deletions CL-0009/0014/0015 + CL-0003; (3) CL-0005; (4) corpus gate; (5) SARIF + promotion.
  • Tests: per-rule fix fixtures (block & flow style, 2- and 4-space indent, present/absent target block), idempotency, refusal cases (anchor, merge key, ${VAR} in span), suppression respected, bare/check/fix argv routing, exit codes.

Consequences

  • compose-lint gains remediation — the roadmap's strongest differentiator — without a new runtime dependency and without reopening ADR-003.
  • The work merges to main in reviewable slices and is continuously validated against real-world files, rather than accumulating on a branch.
  • 1.0 is unblocked: the contract can freeze with fix deliberately carved out as experimental, and fix promotes on its own timeline via a documented, mechanical criteria check.
  • A new destructive code path exists. It is dry-run by default, hidden, warned-on, refusal-first, and corpus-gated — the mitigations are proportionate, and apply_edits centralizes the risk in one tested function.