Skip to content

Lint

lint

Claude subprocess auto-fix for remaining ruff errors.

After ruff --fix resolves what it can, this module spawns a Claude subprocess to attempt fixing the remaining diagnostics — one call per file (parallel), max one retry cycle.

apply_edits(file_path, edits)

Apply old→new string replacements on file content.

Returns True if at least one edit matched, False otherwise. Writes back only if at least one replacement was applied.

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def apply_edits(file_path: Path, edits: list[dict[str, str]]) -> bool:
    """Apply old→new string replacements on file content.

    Returns True if at least one edit matched, False otherwise.
    Writes back only if at least one replacement was applied.
    """
    if not edits:
        return False
    content = file_path.read_text()
    # Normalize CRLF to LF for matching
    content = content.replace("\r\n", "\n")
    matched = False
    for edit in edits:
        old = edit["old"]
        new = edit["new"]
        if old in content:
            content = content.replace(old, new, 1)
            matched = True
    if matched:
        file_path.write_text(content)
    return matched

build_prompt(file_path, file_errors)

Build a Claude prompt with error details and targeted snippets.

Files ≤ _MAX_FULL_FILE lines get the full file content. Larger files get header + error snippets.

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def build_prompt(file_path: Path, file_errors: list[str]) -> str:
    """Build a Claude prompt with error details and targeted snippets.

    Files ≤ _MAX_FULL_FILE lines get the full file content.
    Larger files get header + error snippets.
    """
    total_lines = len(file_path.read_text().splitlines())
    if total_lines <= _MAX_FULL_FILE:
        return _build_full_file_prompt(file_path, file_errors)
    error_block = "\n".join(file_errors)
    snippets = build_snippets(file_path, file_errors)
    return (
        f"File: {file_path.name}\n\n"
        f"Ruff errors:\n{error_block}\n\n"
        f"Code snippets (with line numbers):\n{snippets}\n\n"
        f"Return ONLY a JSON array of edits. Each edit is an object with "
        f'"old" (exact text to find) and "new" (replacement text) keys.\n'
        f'Example: [{{"old": "except:\\n    pass", '
        f'"new": "except Exception:\\n    logging.exception(\\"Unexpected error\\")"}}]'
    )

build_snippets(file_path, file_errors)

Build targeted code snippets around each error (±N lines context).

For large files (> _MAX_FULL_FILE), prepends the file header (imports and module-level code before the first class/def) to ensure Claude always sees existing imports.

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def build_snippets(file_path: Path, file_errors: list[str]) -> str:
    """Build targeted code snippets around each error (±N lines context).

    For large files (> _MAX_FULL_FILE), prepends the file header (imports
    and module-level code before the first class/def) to ensure Claude
    always sees existing imports.
    """
    all_lines = file_path.read_text().splitlines()
    total = len(all_lines)
    error_lines = _extract_error_lines(file_errors)

    # Merge overlapping ranges
    ranges: list[tuple[int, int]] = []
    for line_no in sorted(set(error_lines)):
        start = max(0, line_no - 1 - _SNIPPET_CONTEXT)
        end = min(total, line_no - 1 + _SNIPPET_CONTEXT + 1)
        if ranges and start <= ranges[-1][1]:
            ranges[-1] = (ranges[-1][0], max(ranges[-1][1], end))
        else:
            ranges.append((start, end))

    # Prepend header range for large files
    header_end = find_header_end(all_lines)
    if header_end > 0:
        header_range = (0, header_end)
        if ranges and header_range[1] >= ranges[0][0]:
            # Header overlaps with first snippet — merge
            ranges[0] = (0, max(ranges[0][1], header_end))
        else:
            ranges.insert(0, header_range)

    # Build snippet text with line numbers
    parts: list[str] = []
    for start, end in ranges:
        snippet_lines = [f"{i + 1}: {all_lines[i]}" for i in range(start, end)]
        parts.append("\n".join(snippet_lines))

    return "\n...\n".join(parts)

claude_fix(root, errors, *, warnings=None)

Use Claude subprocess to auto-fix remaining ruff errors.

Groups errors by file and spawns one claude -p call per file in parallel. Max one attempt per file — if the fix fails or produces invalid output, the original errors are returned.

Parameters:

Name Type Description Default
root Path

Project root directory.

required
errors list[str]

Ruff diagnostic lines (file:line:col: CODE msg).

required
warnings list[str] | None

Optional list to collect warning messages into.

None

Returns:

Type Description
list[str]

List of remaining errors after attempted fixes. Empty if all fixed.

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def claude_fix(
    root: Path,
    errors: list[str],
    *,
    warnings: list[str] | None = None,
) -> list[str]:
    """Use Claude subprocess to auto-fix remaining ruff errors.

    Groups errors by file and spawns one ``claude -p`` call per file
    in parallel. Max one attempt per file — if the fix fails or
    produces invalid output, the original errors are returned.

    Args:
        root: Project root directory.
        errors: Ruff diagnostic lines (``file:line:col: CODE msg``).
        warnings: Optional list to collect warning messages into.

    Returns:
        List of remaining errors after attempted fixes. Empty if all fixed.
    """
    if not errors:
        return []

    if not _has_claude:
        if warnings is not None:
            warnings.append("claude not found, auto-fix skipped")
        return errors

    grouped = _group_errors_by_file(errors)
    remaining: list[str] = []

    with ThreadPoolExecutor() as executor:
        futures = {
            executor.submit(_fix_single_file, root, filename, file_errors): filename
            for filename, file_errors in grouped.items()
        }
        for future in as_completed(futures):
            file_remaining, file_warnings = future.result()
            remaining.extend(file_remaining)
            if warnings is not None:
                warnings.extend(file_warnings)

    return remaining

fabricates_definition(edit)

Return True if edit introduces a new def or class.

Catches the pathological case where Claude invents a stub function or class to silence an F821/F822 (undefined name) error instead of fixing the reference site.

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def fabricates_definition(edit: dict[str, str]) -> bool:
    """Return True if *edit* introduces a new ``def`` or ``class``.

    Catches the pathological case where Claude invents a stub function
    or class to silence an F821/F822 (undefined name) error instead of
    fixing the reference site.
    """
    old_defs = len(_DEF_PATTERN.findall(edit["old"]))
    new_defs = len(_DEF_PATTERN.findall(edit["new"]))
    return new_defs > old_defs

filter_ruff_lines(stdout)

Keep real diagnostic lines, dropping ruff summary noise.

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def filter_ruff_lines(stdout: str) -> list[str]:
    """Keep real diagnostic lines, dropping ruff summary noise."""
    return [
        line
        for line in stdout.strip().splitlines()
        if line.strip() and not line.startswith(_RUFF_NOISE_PREFIXES)
    ]

find_header_end(all_lines)

Return 0-indexed line number of first class/def at indent 0.

If no class/def found, returns len(all_lines) (whole file is header).

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def find_header_end(all_lines: list[str]) -> int:
    """Return 0-indexed line number of first class/def at indent 0.

    If no class/def found, returns len(all_lines) (whole file is header).
    """
    for i, line in enumerate(all_lines):
        if re.match(r"^(class |def |async def )", line):
            return i
    return len(all_lines)

parse_edits(output)

Parse Claude's JSON output into a list of old/new edit pairs.

Strips markdown code fences before parsing. Returns an empty list on any parse failure (graceful degradation).

Source code in packages/axm-edit/src/axm_edit/services/lint.py
Python
def parse_edits(output: str) -> list[dict[str, str]]:
    """Parse Claude's JSON output into a list of old/new edit pairs.

    Strips markdown code fences before parsing. Returns an empty list
    on any parse failure (graceful degradation).
    """
    text = output.strip()
    # Strip markdown code fences if present
    if text.startswith("```"):
        lines = text.splitlines()
        # Remove first line (```json or ```) and last line (```)
        if lines[-1].strip() == "```":
            text = "\n".join(lines[1:-1])
    try:
        data = json.loads(text)
    except (json.JSONDecodeError, ValueError, TypeError):
        return []
    if not isinstance(data, list):
        return []
    for entry in data:
        if not isinstance(entry, dict) or "old" not in entry or "new" not in entry:
            return []
    return data