Architecture
Design decisions and module layout for axm-edit.
Design: 1 Tool, 1 JSON
The core design choice is a single batch_edit tool that handles replace, create, and delete operations in one atomic call. A refactor that modifies, creates, and deletes files in the same operation requires just 1 tool call instead of N.
Module layout
Text Only
src/axm_edit/
├── __init__.py # Package root
├── models/
│ └── operations.py # Pydantic models (Edit, ReplaceOp, CreateOp, DeleteOp, BatchResult)
├── core/
│ ├── engine.py # Validate-then-apply batch engine
│ └── checkpoint.py # Targeted per-path snapshot / rollback (no git)
├── services/
│ ├── lint.py # filter_ruff_lines — post-apply ruff diagnostic filtering
│ └── lint_diff.py # compute_lint_diffs / extract_rules_by_file — tagged lint diffs
├── tools/
│ ├── batch_edit.py # BatchEditTool (AXMTool protocol)
│ ├── batch_rollback.py # BatchRollbackTool (AXMTool protocol)
│ ├── read_file.py # ReadFileTool (AXMTool protocol)
│ ├── write_file.py # WriteFileTool (AXMTool protocol)
│ ├── edit_file.py # EditFileTool (AXMTool protocol)
│ ├── search_files.py # SearchFilesTool (AXMTool protocol)
│ ├── list_dir.py # ListDirTool (AXMTool protocol)
│ └── run_command.py # RunCommandTool (AXMTool protocol)
└── utils/
└── __init__.py # Shared utilities (is_binary)
The line-shift problem
When an edit at line 10 replaces 2 lines with 3 lines, every line number after 10 shifts by +1. If a second edit targets line 15 of the original file, applying it after the first edit would hit the wrong line.
Solution: All line numbers in edits reference the original file (the snapshot the agent read). The engine:
- Reads the file once (snapshot)
- Validates all
oldanchors against the snapshot - Sorts edits bottom-to-top (descending line number)
- Applies in that order — upper lines are never shifted by lower edits
- Writes the result
Atomicity
- Checkpoint: before applying, every path the batch will touch is snapshotted in-process — its prior existence plus original bytes — keyed by resolved relative path (no git involvement)
- Validation first: All checks pass before any file is touched
- Anchor re-verification (TOCTOU guard): validation resolves each replace edit's
oldanchor to a line range, but the file could change on disk before the apply phase splices it. Immediately before each splice, the apply step re-confirms that the resolved range still matchesold(using the same exact/dedented matchers as resolution). If the file drifted, the edit is not spliced at the now-stale location — the whole batch aborts, rolls back via the checkpoint, and returnsBatchResult(success=False)with a drift error - Best-effort apply with automatic rollback: atomicity is announced at validation, not at the OS level. Once validation passes the apply phase is best-effort: any exception raised mid-apply — anchor drift, a
write_text/unlink/mkdirfailure, a permission error — triggers a rollback to the pre-apply checkpoint and returnsBatchResult(success=False)with the error. If the rollback itself cannot fully restore every snapshotted path (a filesystem error while undoing), that is surfaced viaBatchResult.rollback_failed=True - Rollback is a strict inverse:
rollback(and thebatch_rollbacktool) restores only the snapshotted paths (rewrite original bytes, remove files that were absent before, recreate deleted files) and touches nothing else — nogit checkout/clean/stash. It is best-effort: every captured path is attempted even if an earlier one fails, and the outcome is returned as aRollbackResult(restored/unrestoredpath lists, plusok) so a partial rollback is detectable. Directory pruning is also strict: only the directories the batch itself created (recorded in the snapshot) are removed when empty — a pre-existing empty directory that merely contained a batch-created file is never deleted
Encoding & line endings
- EOL preservation: a replace reads the file with universal-newline translation for anchor matching, but the original end-of-line style (
\r\nvs\n) is detected up front and restored on write. Editing one line of a CRLF file leaves every untouched line as\r\n— only the spliced region changes — and an LF file stays LF.CreateOpcontent is written verbatim, so its embedded newlines are preserved as-authored. - Binary / non-UTF-8 rejection as a verdict: before any decode, the validation gate runs
is_binary()and decodes as UTF-8 inside a guarded read. A binary file (null bytes / mostly non-printable) or a file that is not valid UTF-8 is rejected as aValidationError, yieldingBatchResult(success=False)with a clear message — aUnicodeDecodeErrornever escapesbatch_apply, so callers (e.g.axm-anvil) can rely on the all-or-nothing contract.
Security
| Constraint | Reason |
|---|---|
| Relative paths only | No access outside the project |
../ blocked |
No path traversal |
old required for replace |
No blind modifications |
| Validation before write | Fail-fast, 0 files corrupted |
old re-checked at apply time |
Closes the validate→apply TOCTOU window; a drifted file aborts instead of a wrong-location splice |
| Targeted path snapshot | Rollback restores only what the batch touched, never destroys unrelated work |
agent_hint on tools |
LLM-optimized description propagates to MCP — agents see what each tool does without parsing docstrings |