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/CommentedSeqround-trip types are explicitly banned from leaking into the codebase. PyYAML'ssafe_dumpdiscards 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).
fixis already named there as the motivating case foradd_subparsers— "a destructive variant with its own flag set." That ADR supersedes the roadmap's older--fix --applyflag spelling:fixis a subcommand, not a flag oncheck. - SARIF history (#168).
result.fixes[]was removed in 0.6.0 because SARIF 2.1.0 requiresartifactChangeson every fix object, andFinding.fixis 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.fixis 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.
- Edit mechanism — surgical text patching, not re-serialization.
fixcomputes 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. - 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 SARIFartifactChanges. - CLI shape —
compose-lint fix [FILE ...], a subcommand per ADR-011. Dry-run is the default and prints a unified diff to stdout;--applywrites in place.--only CL-XXXX(repeatable) narrows the rule set. - 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).
- 1.0 relationship —
fixdoes not gate 1.0. Perdocs/RELEASING.md, 1.0 is a contract freeze, and the contract (CLI, config, JSON/SARIF) is ready independently offix. 1.0 ships on stability;fixis promoted as the headline of a later MINOR (target 1.1). The roadmap's remediation thesis is satisfied byfixshipping in 1.x, not by it landing in1.0.0specifically. - Release strategy — ship undocumented and experimental, gated in stages.
fixlands on main behind a hidden subcommand (argparse.SUPPRESS) with a loud stderr experimental warning, explicitly excluded from the SemVer contract, and gated behindCOMPOSE_LINT_EXPERIMENTAL=1in 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. - 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,
fixleaves the file untouched and reports the finding as manual-only. A wrong fix is worse than no fix. - SARIF — reintroduce
artifactChangesrendered from theTextEditmodel, 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 --applyand re-runningfixmust 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 singleapply_edits(text, edits) -> strin 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 setcaveatto 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
--checkmode (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
fixexit 1. Consistent withinit(ADR-011): forfix, findings are the input, not the failure signal. Usecheckfor 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 incompose-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.mdis 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 whilefixmatures 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=1andSUPPRESSed — 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 bringfixunder 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:
- All six safe rules implemented, each with the corpus regression gate green.
- Corpus run: every auto-fixable finding either fixed-and-clean or explicitly refused; zero fixed files fail to re-parse; zero non-idempotent fixes.
- Refusal policy (Part 6) exercised by tests for anchors, merge keys, and flow style.
fix --applyround-trips: fixed file re-lints with the targeted rules silenced and no new findings introduced.- Full-corpus soak:
fixruns 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:
- Invalid file — the patch is not valid Compose, so
docker composerefuses to start. Caught by the parse net (reparse_or_error): re-parse the candidate; refuse and write nothing if it does not parse. - 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 outsideservices, 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). - Self-inconsistent fix — the patch is valid and confined but does not
settle: a second
fixpass would edit again, or the fix introduced a finding the original lacked. Caught byverify_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--applycarries the guarantee the gate only proved over the corpus. - 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, andos.replaceit 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_composereads 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.replaceis 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(the127.0.0.1:rewrite has different semantics there), and CL-0007 when a service has notmpfs: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, à lablack --check) — a CI-gate convenience; additive, defer until requested.--backup/.bakfiles — 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: registerfixunder the ADR-011 subparser withhelp=argparse.SUPPRESS; emit the experimental stderr warning in its handler. Guard registration onCOMPOSE_LINT_EXPERIMENTALfor 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 callsrule.fix(...)for each finding whose rule advertises a fixer. - Reuse
LineLoaderpositions; where a fixer needs a column the loader doesn't retain, extend the sidecar rather than re-parsing. - Diff rendering: stdlib
difflib.unified_diffover original vs. patched text. No new dependency. - SARIF: a
region(startLine/startColumn/endLine/endColumn) plusartifactChanges[].replacements[]built from eachTextEdit; 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/fixargv 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
fixdeliberately carved out as experimental, andfixpromotes 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_editscentralizes the risk in one tested function.