Skip to content

Latest commit

 

History

History
38 lines (31 loc) · 10.2 KB

0036-edit-partial-coverage.md

File metadata and controls

38 lines (31 loc) · 10.2 KB

ADR-0036: --partial in edit — single-pair stays NO_MATCHES, multi-pair applies the matched ones

  • Status: Accepted

  • Date: 2026-06-15

  • Context: G117 (ADR-0026) introduced the 9-strategy fuzzy cascade for multi-pair edit and the PairResult { matched, strategy, similarity } per-pair report, but did not define the behavior of --partial in the single-pair case. The flag was added to EditArgs as a placeholder, documented in SKILL.md as "opt-in — applies matching pairs and reports the rest", and the v0.1.19 audit (GAP-2026-005b) confirmed that the help never rendered the flag because the field's clap declaration shipped without an implementation. The v0.1.20 closure of GAP-2026-005b is the implementation of --partial itself, and that implementation surfaces a semantic question: when a single-pair edit --old X --new Y --partial cannot find X in the file, what should the command do? Three answers are coherent: (a) silently succeed with zero pairs applied and exit 0 (the "permissive" reading), (b) return NO_MATCHES (exit 1) to mirror replace behavior, (c) return INVALID_INPUT (exit 65) to mirror the all-or-nothing multi-pair error path. The multi-pair case is unambiguous: --partial applies the matched pairs, reports the unmatched in pair_results, and exits 0 with edits < pairs_total. But a single-pair call with zero matches has nothing to apply — "applied 0 of 1" is a degenerate case that, depending on the caller's parser, can look identical to a successful no-op edit. Without a clear semantic, downstream local gates that grep for edits: 0 would silently pass a pipeline that should have failed.

  • Decision: Adopt the asymmetry explicitly: single-pair --partial with no match returns NO_MATCHES (exit 1); multi-pair --partial applies matched pairs and reports unmatched in pair_results (exit 0 with edits < pairs_total). Concretely:

    1. Single-pair (args.old.len() == 1): in edit_old_new_single at src/commands/edit.rs:438-462, when match_pair returns Err(_), the Err(_) if args.partial arm maps to AtomwriteError::NoMatches (exit 1, no write performed). Rationale: the same single-pair invocation without --partial would have returned INVALID_INPUT (exit 65) — the --partial flag does not relax the failure semantics for the degenerate single-pair case, because the caller did not actually request "apply the matching subset" (the subset is the empty set). Mirroring NO_MATCHES keeps the replace --no-matches semantic and gives local gates a single exit code to grep for ("if exit 1, the file was unchanged because nothing matched").
    2. Multi-pair (args.old.len() > 1): in edit_old_new_multi at src/commands/edit.rs:650-705, the per-pair loop accumulates pair_results; on match_pair error with args.partial set, the loop records a PairResult { matched: false, ... } and CONTINUES to the next pair instead of aborting. The write happens if and only if at least one pair matched. The exit is 0, the EditOutput envelope carries pairs_total, edits (the number actually applied), and the full pair_results array so the caller can audit which pairs were skipped.
    3. All-or-nothing default (no --partial): when the flag is absent, ANY unmatched pair in the multi-pair path aborts the whole batch with AtomwriteError::EditPairFailed { failed_pair_index, total, pair_results } (ADR-0026, exit 65). The single-pair path without --partial also aborts with INVALID_INPUT (exit 65). The default is strictly more conservative than --partial, so existing pipelines that did not opt in are unaffected.
    4. Zero-applied multi-pair edge case: when --partial is set and zero pairs match, the multi-pair path returns NO_MATCHES (exit 1, no write). This mirrors the single-pair case to keep the empty-result exit code consistent across pair counts.
    5. Schema discipline: the edit-output.schema.json mode field becomes fuzzy-multi(N) when any pair matched fuzzily and exact-multi(N) otherwise (ADR-0026 already pins this for the non-partial multi path); the --partial flag is not separately reflected in the schema, but pairs_total and edits together let a consumer detect the partial case by computing edits < pairs_total.
  • Consequences:

      • The semantic is explicit and documented: single-pair --partial is effectively a no-op-with-warning (exit 1) because there is only one pair to apply; multi-pair --partial is a true partial application. A reader of the SKILL section can answer the "what does --partial do?" question by counting the pairs.
      • local gates can rely on NO_MATCHES (exit 1) as the universal "no match found" signal across both edit and replace, regardless of --partial or pair count. The behavior is consistent.
      • The all-or-nothing default is preserved: agents that pipe edit into a pipeline without --partial get the same atomicity guarantee as before. The --partial opt-in is a strictly additive escape hatch.
      • Multi-pair --partial returns a structured pair_results array, so a caller that wants to know which specific pairs failed can audit without re-running the edit. The diagnostic value of G117 is preserved.
      • The single-pair --partial behavior is counterintuitive at first glance: a user who passes --partial to a single-pair command and gets exit 1 may file a bug. Mitigated by the SKILL section that explicitly states "single-pair --partial with no match exits 1; multi-pair --partial applies what matched".
      • The exit-code table grows by one row (exit 1 for the single-pair partial case in addition to the existing exit 1 for the multi-pair zero-applied case). Both share the NO_MATCHES code, so the schema does not change, but the narrative documentation must be precise.
      • A caller that wanted to detect "single-pair, no match, --partial set" specifically (as opposed to "any no-match case") would need to look at the mode field rather than the exit code. The mode field is a string discriminator that the existing pipelines already parse, so this is an additive dependency, not a breaking one.
  • Rationale: The single-pair case cannot be partial by definition — there is exactly one pair, so the matching subset is either the full set (matched) or the empty set (no match). The --partial flag in this degenerate case has no observable effect on the write, only on the error code: it downgrades INVALID_INPUT (exit 65, "the file is broken and your edit was structurally rejected") to NO_MATCHES (exit 1, "the file is fine but your old string was not present"). The downgrade is useful: an automated editor that retries on transient mismatches wants exit 1, not exit 65, so it can distinguish "try again with a different --old" from "abort the batch". The multi-pair case, by contrast, has a real partial semantics: "apply 7 of 10 pairs, report the 3 that didn't match". The asymmetry is therefore principled, not arbitrary. ADR-0026 already established that --partial defaults to off (all-or-nothing) to preserve atomicity; the v0.1.20 single-pair behavior is a natural consequence of that same all-or-nothing principle applied at the unit-of-failure level (one pair).

  • Examples:

    • atomwrite edit foo.rs --old "ALPHA" --new "beta": no match (ALPHA absent), --partial not set → INVALID_INPUT (exit 65), no write, error envelope on stdout.
    • atomwrite edit foo.rs --old "ALPHA" --new "beta" --partial: no match, --partial set → NO_MATCHES (exit 1), no write, error envelope on stdout. Caller sees exit 1 and can either retry with a different --old or treat the absence as expected.
    • atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y": A matches, B does not, --partial not set → INVALID_INPUT (exit 65), no write, envelope includes failed_pair_index: 2, total: 2, pair_results: [...].
    • atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y" --partial: A matches, B does not, --partial set → exit 0, write performed (only the A→X substitution), envelope pairs_total: 2, edits: 1, pair_results: [{matched: true, ...}, {matched: false, ...}].
    • atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y" --partial: NEITHER matches, --partial set → NO_MATCHES (exit 1), no write. Mirrors the single-pair case at the multi-pair level: zero applied pairs maps to the same exit code as one unmatched pair.
  • Alternatives considered:

    1. Make single-pair --partial exit 0 with edits: 0. Rejected: a successful exit with zero applied pairs is a footgun. local gates that grep for exit 0 would silently pass a pipeline that should have flagged the missing old string. NO_MATCHES (exit 1) is the conservative, grep-discoverable signal.
    2. Make single-pair --partial exit 0 and apply the new string at the end of the file. Rejected: an unprompted append is not an "edit", it is a write --append, and conflating the two violates the principle that verbs should match semantics. The edit verb implies "find X, replace with Y"; if X is absent, the operation is a no-op or a failure, never an append.
    3. Unify single and multi behavior under one rule: --partial always applies the matching subset, exit 0 if at least one pair applied, exit 1 if none. Rejected: the single-pair case has no "subset" — it has either 1 or 0 applications. Treating "applied 1 pair" identically to "applied 7 of 10 pairs" obscures the structural difference. The asymmetry is the cleanest model.
    4. Add a new exit code (e.g. exit 67 EDIT_PARTIAL_EMPTY) for the single-pair partial case. Rejected: 26 exit codes is already a maintenance burden; the v0.1.20 SKILL exit-code table would grow by one row for a degenerate case. The NO_MATCHES code is the existing canonical signal for "the operation found nothing to do" and the single-pair partial case is exactly that.
  • Trigger to revisit: If a future caller requests "single-pair --partial should still write the file even when nothing matched" (e.g. a templating tool that treats --old as a hint and --new as an unconditional append), revisit the semantic. If the multi-pair zero-applied case becomes a usability problem (the caller cannot tell whether --partial was set or the input was empty), add a pairs_applied: 0 discriminator to the error envelope. If a v0.2.0 release introduces a new exit code namespace, re-evaluate the NO_MATCHES reuse for the single-pair partial case.