Cursor Tutorial: Lint AI Edits, 13 Steps, 40 Min [2026]

Cursor changed how it bills on October 12, 2026, when it announced its move into The X Subscription. On the new Premium, Super, and Ultra plans, models such as Claude, GPT, and Gemini bill at API list prices from prepaid credits. Every token an edit regenerates now carries a price you can read on a meter. Cost is only half the problem, though. A rewritten file can also come back with comments gone, line endings flipped, or a stray placeholder sitting in the middle of a class.

This Cursor tutorial builds editlint, a Python tool of about 290 lines that compares every changed file against its last commit and flags silent damage. It takes 13 steps and roughly 40 minutes, and it needs no third-party packages. If you first want to steer Cursor toward smaller patches, start with the Cursor rules walkthrough for diff versus rewrite edits and then come back here to verify what actually landed on disk.

Most Cursor tutorial content tunes the editing behavior, benchmarks it, or reviews it by hand. This one is a linter. It runs on every change, whether the edit came from Composer, from an agent run, or from a teammate pasting a chat answer. You will finish with a tested tool, a Cursor rule file, a pre-commit hook that blocks damaged commits, and a CI job that does the same on pull requests.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

How Cursor Turns a Chat Answer Into a File Change

Before writing a single check, it helps to know where damage enters the pipeline. Cursor splits a hard edit into two jobs. A frontier model plans the change in chat, and a second step applies it to the file on disk. Most of the damage this tutorial hunts for happens in that second step, or in the gap between what the plan sketched and what the apply step was asked to reproduce.

Planning and applying are separate jobs

Cursor’s engineering team explained the design in a May 2024 post titled “Editing Files at 1000 Tokens per Second.” The post is more than two years old, so read it as the history of the design, not as a spec for today’s build. It still frames the trade-off better than anything else. The team wrote: “By default, we have language models generate the fully rewritten file conditioned on the current file, the conversation history, and the current code block.”

The same post explains why Cursor did not simply ask models for diffs. In its words, “We found that language models struggle with diff-formatted edits for a few possible reasons.” The team listed fewer output tokens to think with, diffs being rarer than whole files in training data, and the trouble models have counting line numbers. Its middle path borrowed from Aider. “Instead of a standard diff format, the model proposes diff hunks as search/replace blocks.”

The post also records two habits of general-purpose models that matter for this project. Some models left out code and marked the gap with “…” or a comment. Others deleted commented-out code and tidied unrelated lines while they were in there. Both habits are exactly what a linter can catch, and both are why the first two checks you will write look for leaked markers and lost comments.

What about today? Cursor’s docs and release notes, as retrieved for this article, do not publish a per-edit rule for when a rewrite or a search/replace patch gets used. The model picker has grown a lot since 2024. Composer 2.5 shipped on May 18, 2026, and a September 2026 rundown of Cursor’s model lineup lists Grok 4.7 and Composer 2.5 next to a picker of third-party options. Cursor’s own release notes added Claude Sonnet 5.5 and an opt-in Claude Haiku 5.5 in the weeks before this article. With that many models, edit behavior can differ run to run. So this tutorial treats the strategy as unknown and measures the outcome instead. To see search/replace application written from scratch, the fast-apply patch engine tutorial builds one step by step.

Why the diff view can mislead you

Cursor shows you a green-and-red diff after an edit, and it is tempting to read that as proof the model touched only those lines. A secondary source dated October 22, 2026 warns against that reading. It says the visible diff may be only a presentation layer, so a full-file regeneration can still render as a tidy line-level diff. Treat that as a single-source claim, but it matches how diffing works. The editor computes a diff between two file states and does not know how the second state was produced.

A Cursor forum report describes a concrete version of the problem. According to that report, full-file rewrites could strip comments and corrupt CRLF line endings. Whatever your build does today, the lesson stands. The interface cannot vouch for the bytes. Cursor’s release notes did add a shortcut to collapse or expand every file in a diff, per AI Polix’s coverage of the release, which helps with review. It does not replace a machine check.

What a wasted rewrite costs now

Rewrites also burn output tokens, and output tokens cost more than input tokens on every model below. The table lists per-million-token prices for models you can pick in Cursor, taken from Cursor’s published model pricing and its Grok 4.5 announcement. Prices on the new plans apply to usage beyond what a plan includes, and third-party models bill at their API list rates.

ModelInput per 1M tokensOutput per 1M tokensNote
Composer 2.5 (standard)$0.50$2.50Released May 18, 2026, based on Moonshot’s Kimi K2.5 checkpoint
Composer 2.5 (fast variant)$3.00$15.00Fast mode is the default
Grok 4.7 (prompts under 200K tokens)$2.00$6.00$0.50 per 1M cached input, 500K-token context window
Grok 4.7 (prompts over 200K tokens)$4.00$12.00$1.00 per 1M cached input
Grok 4.5$2.00$6.00Available in Cursor
Grok 4.5 (fast variant)$4.00$18.00Listed in Cursor’s Grok 4.5 post

One more wrinkle: a Cursor forum thread titled “Other Models % hits 100% while usage-export at published API rates only reconstructs ~65%” shows users struggling to reconcile the meter with published prices (the thread is public). Your own diff statistics give you a second, independent view of how much text each edit regenerated. The linter you are about to build records exactly that number.

Prerequisites and Versions

The tool uses only the Python standard library, so setup is short. The table lists what was installed on the machine used to build and test every snippet in this article. Newer patch releases of each tool should behave the same way.

RequirementVersion testedWhy you need it
Python3.12.3Runs editlint, the AST scope check, and the unit tests
Git2.43.0Supplies the pre-edit version of each file via git show
Cursor desktopCurrent release, with Composer 2.5 or another model selectableProduces the edits you will lint
Shellsh or bash (Git Bash on Windows)Runs the pre-commit hook
Disk spaceUnder 5 MBDemo repo plus fixtures
TimeAbout 40 minutesReading, typing, and running every step

New to the editor itself? The Cursor AI walkthrough in 12 steps covers installation and the basics. You also need a git repository you can commit to freely. Do all of this in a throwaway demo repo first. Once the checks behave the way you expect, copy editlint.py into a real project. Any Cursor model works, because the linter looks at results rather than at the model that produced them.

Here is what each check hunts for and why that kind of damage appears in the first place. You will build them in this order, one or two per step.

CheckWhat it catchesDefault severityTypical cause
placeholder-leakText such as “… existing code …” written into the fileErrorApply step copies a sketch literally
comment-lossTwo or more comment lines vanishErrorModel regenerates a file and skips comments
line-endingsCRLF flipped to LF, or a file that now mixes bothErrorRewrite normalizes endings
bom, final-newlineByte order mark or trailing newline added or droppedWarningWhole-file regeneration
blast-radiusMore than 50% of lines changedWarningRewrite-equivalent edit for a small request
format-churnFive or more whitespace-only line changesWarningModel reformats while editing
out-of-scope, symbol-removedA function or class changed that you did not allowErrorUnrequested cleanup of nearby code
syntaxFile no longer parses as PythonErrorTruncated output or a bad merge of hunks

Steps 1 to 3: Create the Project, Commit a Baseline, Add Fixtures

Step 1: create the folder and initialize git. The whole method rests on having a clean commit to compare against, so git comes first. Set a local identity too, otherwise the baseline commit fails on a fresh machine.

mkdir editlint-demo && cd editlint-demo
git init -q
git config user.email [email protected]
git config user.name "Your Name"
python3 --version   # expect 3.12.3 or newer
git --version       # expect 2.43.0 or newer

Step 2: write a realistic file with comments worth protecting. The fixture below is a small shopping cart. Its comments carry business rules, such as “discount first, then tax,” which is the kind of knowledge that disappears when a model regenerates a file without them. Save it as shop/cart.py.

"""Shopping cart helpers."""
from decimal import Decimal

# Tax is applied after discounts, never before.
TAX_RATE = Decimal("0.0825")


class Cart:
    def __init__(self):
        # Items are stored as (sku, unit_price, qty) tuples.
        self.items = []

    def add(self, sku, unit_price, qty=1):
        # Quantity must be positive, the API layer relies on this error.
        if qty < 1:
            raise ValueError("qty must be >= 1")
        self.items.append((sku, Decimal(unit_price), qty))

    def subtotal(self):
        # Sum in Decimal to avoid float drift.
        return sum(price * qty for _, price, qty in self.items)

    def total(self, discount=Decimal("0")):
        # Discount first, then tax (see TAX_RATE note above).
        taxable = self.subtotal() - discount
        return (taxable * (1 + TAX_RATE)).quantize(Decimal("0.01"))


def format_money(value):
    # Always two decimals, always a dollar sign.
    return f"${value:.2f}"

Step 3: add a CRLF file and a shell script, then commit. Real repos contain files with unusual endings, and those are the ones a rewrite damages first. The snippet writes a legacy report module with Windows line endings on purpose, then adds a short POSIX deploy script. Commit everything, including editlint.py once you have it, before you let any AI edit touch the tree.

python3 - <<'EOF'
src = '''"""Legacy nightly report."""
# Written on Windows, CRLF on purpose, the vendor parser needs it.
def build_report(rows):
    # Header first.
    out = ["sku,qty"]
    for sku, qty in rows:
        # One row per line.
        out.append(f"{sku},{qty}")
    return "\\r\\n".join(out)
'''
open("shop/legacy_report.py", "wb").write(src.replace("\n", "\r\n").encode())
EOF

printf '#!/bin/sh\n# Deploy script, keep it POSIX.\nset -eu\n# Build first.\nmake build\n# Then ship.\nmake ship\n' > shop/deploy.sh
git add -A && git commit -qm "baseline"
file shop/*

The last command should report that shop/legacy_report.py has “CRLF line terminators.” If it does not, your editor or git config normalized the file, and the CRLF checks later will have nothing to protect. The troubleshooting section covers that case. With the baseline committed, any difference you see from now on is a difference an edit introduced.

Steps 4 and 5: The Findings Model and the Placeholder Check

Step 4: define what a finding looks like. Every check returns a list of Finding objects with a path, a check name, a severity, a message, and an optional line number. A second dataclass, Options, carries the thresholds so tests can change them without touching globals. Start editlint.py with the imports, the lookup tables, and these two classes.

#!/usr/bin/env python3
"""editlint: flag silent damage in files edited by an AI code editor."""
from __future__ import annotations

import argparse
import ast
import copy
import difflib
import fnmatch
import json
import re
import subprocess
import sys
from dataclasses import dataclass, asdict
from pathlib import Path

LINE_COMMENT = {
    ".py": "#", ".sh": "#", ".rb": "#", ".yml": "#", ".yaml": "#", ".toml": "#",
    ".js": "//", ".jsx": "//", ".ts": "//", ".tsx": "//", ".go": "//",
    ".java": "//", ".c": "//", ".cpp": "//", ".rs": "//", ".cs": "//",
}

PLACEHOLDERS = [
    re.compile(r"^\s*(//|#|/\*|<!--)?\s*\.\.\.\s*(existing|rest of|remaining|previous|other|unchanged)", re.I),
    re.compile(r"^\s*(//|#)\s*(rest of (the )?(file|code|function|class)|remaining code|same as before|keep existing|unchanged)", re.I),
    re.compile(r"^\s*\.\.\.\s*$"),
]

BOM = b"\xef\xbb\xbf"
FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)


@dataclass
class Finding:
    path: str
    check: str
    severity: str  # error | warn | info
    message: str
    line: int | None = None


@dataclass
class Options:
    allow: tuple = ()
    max_blast: float = 0.5
    min_comment_loss: int = 2
    churn_lines: int = 5

The three PLACEHOLDERS patterns cover the usual offenders. The first matches lines such as “… existing code …” in any comment style. The second matches prose markers like “rest of the file” or “same as before.” The third matches a bare ellipsis line. That last one needs care, because Python uses ... legitimately in stubs and protocol classes.

Step 5: write the placeholder check with a before-and-after count. The trick is to count markers in the old file and the new file, and flag only the surplus. A repo that already contains legitimate ellipsis lines will not trigger false alarms, while a fresh “… existing code …” line pushes the count up and gets reported with its line number.

def count_placeholders(lines):
    hits = []
    for i, text in enumerate(lines, 1):
        if any(p.search(text) for p in PLACEHOLDERS):
            hits.append(i)
    return hits


def check_placeholders(path, old_lines, new_lines, _opts):
    before, after = count_placeholders(old_lines), count_placeholders(new_lines)
    if len(after) <= len(before):
        return []
    extra = after[len(before):] if len(before) else after
    return [Finding(path, "placeholder-leak", "error",
                    f"elision marker written into the file: {new_lines[n - 1].strip()[:60]!r}", n)
            for n in extra]

Why does this one rank first? A leaked placeholder is the loudest failure mode and often the most expensive. The file still looks plausible in the diff view. It may even compile, because a comment marker is valid syntax. Your function bodies are gone, and nothing complains until a test or a customer finds the missing behavior. The same applies in JavaScript, Go, or Rust, and the regexes already cover // comments.

Also, a quick word on reading the output you will get later. A placeholder finding carries a line number, so you can jump straight to the spot. The other checks work on whole files and leave the line blank. That keeps the report short enough to read at a glance in a pre-commit hook, where long output gets ignored.

Steps 6 and 7: Comment Loss and Byte-Level Checks

Step 6: count comment lines before and after. Comments are the first casualty of a regenerated file, and they are also the hardest thing to restore, because nothing in the new file hints that they ever existed. The check below maps each file extension to its line-comment prefix, counts lines that begin with it, and raises an error when two or more disappear. For C-style languages it also counts lines starting with /* or *.

def comment_lines(lines, suffix):
    prefix = LINE_COMMENT.get(suffix)
    if not prefix:
        return 0
    n = 0
    for text in lines:
        s = text.strip()
        if s.startswith(prefix) or (prefix == "//" and (s.startswith("/*") or s.startswith("*"))):
            n += 1
    return n


def check_comments(path, old_lines, new_lines, opts):
    suffix = Path(path).suffix
    if suffix not in LINE_COMMENT:
        return []
    before, after = comment_lines(old_lines, suffix), comment_lines(new_lines, suffix)
    lost = before - after
    if lost >= opts.min_comment_loss:
        return [Finding(path, "comment-loss", "error",
                        f"comment lines dropped from {before} to {after}")]
    return []

Two comment lines is a deliberate default. A single deleted comment is usually an intentional cleanup, while two or more in one edit points to a regeneration. The threshold lives in Options.min_comment_loss and in a command-line flag you will add in step 10. Python docstrings are not counted, since they are string literals rather than comments. If your team leans on docstrings, add a count of triple-quoted strings to the same function.

Step 7: compare raw bytes, not decoded text. Python’s text mode quietly translates line endings, so any check that reads files as text would miss a CRLF flip. Work on bytes instead. The function counts \r\n pairs and bare \n characters, derives the dominant style for the old and new content, and compares the two. It also notes a changed byte order mark and a changed final newline.

def line_endings(raw: bytes):
    crlf = raw.count(b"\r\n")
    lf = raw.count(b"\n") - crlf
    return crlf, lf


def check_bytes(path, old: bytes, new: bytes, _opts):
    out = []
    o_crlf, o_lf = line_endings(old)
    n_crlf, n_lf = line_endings(new)
    old_style = "crlf" if o_crlf > o_lf else "lf"
    new_style = "crlf" if n_crlf > n_lf else "lf"
    if old_style != new_style:
        out.append(Finding(path, "line-endings", "error",
                           f"line endings flipped from {old_style.upper()} to {new_style.upper()}"))
    elif n_crlf and n_lf and not (o_crlf and o_lf):
        out.append(Finding(path, "line-endings", "error",
                           f"file now mixes endings ({n_crlf} CRLF, {n_lf} LF)"))
    if old.startswith(BOM) != new.startswith(BOM):
        out.append(Finding(path, "bom", "warn", "byte order mark added or removed"))
    if old.endswith(b"\n") != new.endswith(b"\n"):
        out.append(Finding(path, "final-newline", "warn", "trailing newline added or removed"))
    return out

Look at the mixed-endings branch. A file that was pure LF and now contains a handful of CRLF lines gets flagged, but a file that was already mixed does not. That keeps the check quiet on messy legacy files and loud on new damage. This is also the check that backs up the August forum report about CRLF corruption. If you work across Windows and Linux machines, pair it with the gitattributes setup for Cursor diffs so git itself stops renormalizing endings behind your back.

Steps 8 and 9: Blast Radius, Format Churn, and Symbol Scope

Step 8: measure how much of the file changed. The blast-radius check uses difflib.SequenceMatcher to count removed and added lines, divides the larger of the two by the old line count, and counts hunks with zero lines of context. A ratio above the limit, 50% by default, raises a warning that the edit was rewrite-equivalent. Below the limit it reports an info line, so you always see the size of what changed.

def blast(old_lines, new_lines):
    sm = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
    removed = added = 0
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag != "equal":
            removed += i2 - i1
            added += j2 - j1
    hunks = len(list(sm.get_grouped_opcodes(0)))
    ratio = max(removed, added) / max(len(old_lines), 1)
    return sm, removed, added, hunks, ratio


def check_blast(path, old_lines, new_lines, opts):
    _, removed, added, hunks, ratio = blast(old_lines, new_lines)
    if ratio > opts.max_blast:
        return [Finding(path, "blast-radius", "warn",
                        f"{ratio:.0%} of lines changed in {hunks} hunk(s): rewrite-equivalent edit")]
    if removed or added:
        return [Finding(path, "blast-radius", "info",
                        f"{ratio:.0%} of lines changed in {hunks} hunk(s): under the {opts.max_blast:.0%} limit")]
    return []


def check_churn(path, old_lines, new_lines, opts):
    sm = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
    churn = 0
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == "replace" and i2 - i1 == j2 - j1:
            for a, b in zip(old_lines[i1:i2], new_lines[j1:j2]):
                if a != b and "".join(a.split()) == "".join(b.split()):
                    churn += 1
    if churn >= opts.churn_lines:
        return [Finding(path, "format-churn", "warn",
                        f"{churn} lines changed only in whitespace")]
    return []

The churn check sits beside it. It looks at replaced blocks of equal length and counts lines that differ only in whitespace. Five of those is enough for a warning, since it usually means the model reformatted code while making an unrelated change. Whitespace churn is cheap to produce and expensive to review, because it buries the real change in noise. It also wrecks git blame.

Step 9: check which functions and classes changed. This is the most useful check in the tool, and it only works for Python. It parses the old and new source with ast, builds a dictionary of qualified symbol names (like Cart.add) to their dumped syntax trees, and compares the two. Comments and formatting do not appear in an AST dump, so only real logic changes register. Classes are compared without their methods, which keeps one edited method from marking the whole class as changed.

def symbol_map(source: str):
    tree = ast.parse(source)
    out = {}

    def walk(body, prefix):
        for node in body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                name = prefix + node.name
                if isinstance(node, ast.ClassDef):
                    shell = copy.copy(node)
                    shell.body = [n for n in node.body if not isinstance(n, FUNCS)]
                    out[name] = ast.dump(shell)
                    walk(node.body, name + ".")
                else:
                    out[name] = ast.dump(node)

    walk(tree.body, "")
    return out


def allowed(name, patterns):
    return any(fnmatch.fnmatch(name, p) or name.startswith(p + ".") for p in patterns)


def check_symbols(path, old_text, new_text, opts):
    if Path(path).suffix != ".py":
        return []
    try:
        new_map = symbol_map(new_text)
    except SyntaxError as exc:
        return [Finding(path, "syntax", "error", f"file no longer parses: {exc.msg}", exc.lineno)]
    try:
        old_map = symbol_map(old_text)
    except SyntaxError:
        return []
    out = []
    for name in sorted(set(old_map) | set(new_map)):
        if name not in new_map:
            state = "removed"
        elif name not in old_map:
            state = "added"
        elif old_map[name] != new_map[name]:
            state = "changed"
        else:
            continue
        if opts.allow and not allowed(name, opts.allow):
            out.append(Finding(path, "out-of-scope", "error", f"{name} was {state} but is not in --allow"))
        elif state == "removed":
            out.append(Finding(path, "symbol-removed", "error", f"{name} was deleted"))
        else:
            out.append(Finding(path, "symbol-" + state, "info", f"{name} {state}"))
    return out

The --allow list is how you state intent. Ask Cursor to fix format_money, run the linter with --allow format_money, and any other changed, added, or removed symbol becomes an out-of-scope error. Entries support globs, so Cart.* allows every method on the class. Without an allow list, symbol changes show up as info lines and only deletions count as errors. A file that stops parsing is reported with the syntax error and its line.

Prompting and linting work well as a pair. The prompt tells the model what to touch, and the allow list verifies that it listened. If you also run agent sessions that edit many files, the audit tool for Cursor Composer rewrites takes a complementary approach by classifying each edit after the fact.

Step 10: Wire Up Git and the Command Line

Step 10: connect the checks to real files. Three small functions do the plumbing. lint_pair runs every check on one old and new pair. changed_paths asks git which tracked files were modified, and lint_repo fetches the old bytes with git show from the base ref and the new bytes from the working tree or the index. Binary files are skipped when a NUL byte shows up near the start of the file.

def lint_pair(path, old: bytes, new: bytes, opts: Options):
    old_text = old.decode("utf-8", "replace")
    new_text = new.decode("utf-8", "replace")
    old_lines, new_lines = old_text.splitlines(), new_text.splitlines()
    findings = []
    findings += check_bytes(path, old, new, opts)
    findings += check_placeholders(path, old_lines, new_lines, opts)
    findings += check_comments(path, old_lines, new_lines, opts)
    findings += check_churn(path, old_lines, new_lines, opts)
    findings += check_blast(path, old_lines, new_lines, opts)
    findings += check_symbols(path, old_text, new_text, opts)
    return findings


def git(*args) -> bytes:
    return subprocess.run(["git", *args], capture_output=True, check=True).stdout


def changed_paths(staged: bool, base: str):
    cmd = ["diff", "--name-only", "--diff-filter=M"]
    if staged:
        cmd.append("--cached")
    cmd.append(base)
    return [p for p in git(*cmd).decode().split("\n") if p]


def lint_repo(staged, base, opts):
    findings = []
    for path in changed_paths(staged, base):
        old = git("show", f"{base}:{path}")
        new = git("show", f":{path}") if staged else Path(path).read_bytes()
        if b"\x00" in old[:8000] or b"\x00" in new[:8000]:
            continue
        findings += lint_pair(path, old, new, opts)
    return findings

The command-line layer is plain argparse. It supports three modes. With no arguments it compares the working tree to HEAD, which suits a quick check after an agent run. With --staged it compares the index, which is what a pre-commit hook needs. With --files OLD NEW it compares two arbitrary files, handy for pasted chat output. The exit code is 1 when any error exists, or any warning under --strict.

def render(findings, as_json):
    if as_json:
        return json.dumps([asdict(f) for f in findings], indent=2)
    lines = []
    for f in findings:
        where = f"{f.path}:{f.line}" if f.line else f.path
        lines.append(f"[{f.severity.upper():5}] {where:<24} {f.check:<15} {f.message}")
    errors = sum(f.severity == "error" for f in findings)
    warns = sum(f.severity == "warn" for f in findings)
    lines.append(f"-- {errors} error(s), {warns} warning(s)")
    return "\n".join(lines)


def main(argv=None):
    ap = argparse.ArgumentParser(description="Lint AI-edited files for silent damage")
    ap.add_argument("--base", default="HEAD", help="git ref holding the pre-edit files")
    ap.add_argument("--staged", action="store_true", help="compare the index instead of the working tree")
    ap.add_argument("--files", nargs=2, metavar=("OLD", "NEW"), help="compare two files directly")
    ap.add_argument("--allow", default="", help="comma-separated Python symbols the edit may touch")
    ap.add_argument("--max-blast", type=float, default=0.5)
    ap.add_argument("--min-comment-loss", type=int, default=2)
    ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
    ap.add_argument("--quiet", action="store_true", help="hide info findings")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args(argv)

    opts = Options(allow=tuple(s for s in args.allow.split(",") if s), max_blast=args.max_blast,
                   min_comment_loss=args.min_comment_loss)
    if args.files:
        old, new = (Path(p).read_bytes() for p in args.files)
        findings = lint_pair(args.files[1], old, new, opts)
    else:
        findings = lint_repo(args.staged, args.base, opts)

    shown = [f for f in findings if not (args.quiet and f.severity == "info")]
    print(render(shown, args.json))
    bad = {"error", "warn"} if args.strict else {"error"}
    return 1 if any(f.severity in bad for f in findings) else 0

Run python3 editlint.py --help and you should see the flags listed. Nothing has changed in the tree yet, so a plain run prints only the summary line “0 error(s), 0 warning(s)” and exits 0. If it crashes instead, the cause is nearly always a missing baseline commit, which the troubleshooting list below covers. Once that clean run works, you can trust that later errors come from edits and not from the tool.

Step 11: Run It Against a Good Patch and a Bad Rewrite

Step 11: simulate both outcomes and compare. First apply a targeted patch by hand, the kind a search/replace edit would produce. Change only format_money to add a thousands separator. Then run the linter with that function on the allow list.

python3 - <<'EOF'
p = "shop/cart.py"
s = open(p).read()
s = s.replace('return f"${value:.2f}"', 'return f"${value:,.2f}"')
open(p, "w").write(s)
EOF
python3 editlint.py --allow format_money; echo "exit=$?"
[INFO ] shop/cart.py             blast-radius    3% of lines changed in 1 hunk(s): under the 50% limit
[INFO ] shop/cart.py             symbol-changed  format_money changed
-- 0 error(s), 0 warning(s)
exit=0

Three percent of the file in one hunk, one allowed symbol, exit code 0. That is what a clean edit looks like. Reset it with git checkout shop/cart.py. Now write the damage a careless full-file regeneration produces. Overwrite cart.py with a version that has no comments, drops subtotal, loses the quantity check in add, and leaves a placeholder line behind. Convert legacy_report.py to LF and strip the final newline from deploy.sh.

python3 editlint.py --allow format_money; echo "exit=$?"
[ERROR] shop/cart.py:13          placeholder-leak elision marker written into the file: '# ... existing code ...'
[ERROR] shop/cart.py             comment-loss    comment lines dropped from 6 to 1
[INFO ] shop/cart.py             blast-radius    39% of lines changed in 7 hunk(s): under the 50% limit
[ERROR] shop/cart.py             out-of-scope    Cart.add was changed but is not in --allow
[ERROR] shop/cart.py             out-of-scope    Cart.subtotal was removed but is not in --allow
[INFO ] shop/cart.py             symbol-changed  format_money changed
[WARN ] shop/deploy.sh           final-newline   trailing newline added or removed
[ERROR] shop/legacy_report.py    line-endings    line endings flipped from CRLF to LF
-- 5 error(s), 1 warning(s)
exit=1

Read that output closely, because it shows why the byte and AST checks matter. The blast radius is only 39%, so a pure size check would have called this edit acceptable. The out-of-scope errors tell the real story: Cart.add lost its quantity guard and Cart.subtotal vanished, and neither was in the request. The CRLF file and the shell script would never have appeared in a diff you were reading for cart.py.

ScenarioErrorsWarningsExit codeWhat it shows
Targeted patch to format_money0003% of lines, one hunk, one allowed symbol
Careless rewrite across three files511Placeholder, lost comments, deleted method, CRLF flip
Whole file replaced (pairwise mode)711100% of lines changed, every symbol deleted
Same rewrite committed with the staged hook411 (commit blocked)Hook output hides info lines and skips the allow list

The pairwise mode result in the third row came from python3 editlint.py --files old_cart.py new_cart.py where the new file was a two-line stub. It reported comment loss, a 100% blast radius, and every top-level symbol as deleted. The staged hook in the fourth row printed four errors and one warning, and git refused the commit. These numbers come from real runs on the demo repo, not from estimates.

Step 12: Lock Behavior In With Unit Tests

Step 12: write tests before you tune thresholds. Without tests, every threshold tweak risks silently breaking an older check. The suite uses unittest from the standard library, so nothing needs installing. Each test builds a small old and new byte string, calls lint_pair, and asserts on the set of check names that fired.

import unittest

from editlint import Options, lint_pair

OPTS = Options()
OLD = b"# keep me\n# and me\ndef a():\n    return 1\n\n\ndef b():\n    return 2\n"


def checks(findings, severity="error"):
    return {f.check for f in findings if f.severity == severity}


class EditLintTests(unittest.TestCase):
    def test_clean_patch_passes(self):
        new = OLD.replace(b"return 2", b"return 3")
        self.assertEqual(checks(lint_pair("m.py", OLD, new, OPTS)), set())

    def test_placeholder_leak(self):
        new = OLD.replace(b"def b():\n    return 2\n", b"# ... existing code ...\n")
        self.assertIn("placeholder-leak", checks(lint_pair("m.py", OLD, new, OPTS)))

    def test_bare_ellipsis_in_old_file_is_not_a_leak(self):
        old = b"class P:\n    def f(self): ...\n    ...\n"
        self.assertNotIn("placeholder-leak", checks(lint_pair("m.py", old, old, OPTS)))

    def test_comment_loss(self):
        new = OLD.replace(b"# keep me\n# and me\n", b"")
        self.assertIn("comment-loss", checks(lint_pair("m.py", OLD, new, OPTS)))

    def test_crlf_flip(self):
        old = OLD.replace(b"\n", b"\r\n")
        self.assertIn("line-endings", checks(lint_pair("m.py", old, OLD, OPTS)))

    def test_mixed_endings(self):
        new = OLD.replace(b"return 2", b"return 3").replace(b"\n", b"\r\n", 1)
        self.assertIn("line-endings", checks(lint_pair("m.py", OLD, new, OPTS)))

    def test_out_of_scope_symbol(self):
        new = OLD.replace(b"return 1", b"return 9")
        opts = Options(allow=("b",))
        self.assertIn("out-of-scope", checks(lint_pair("m.py", OLD, new, opts)))

    def test_allowed_symbol_passes(self):
        new = OLD.replace(b"return 2", b"return 9")
        opts = Options(allow=("b",))
        self.assertNotIn("out-of-scope", checks(lint_pair("m.py", OLD, new, opts)))

    def test_syntax_error(self):
        new = OLD + b"def broken(:\n"
        self.assertIn("syntax", checks(lint_pair("m.py", OLD, new, OPTS)))

    def test_rewrite_sized_edit_warns(self):
        new = b"def z():\n    return 0\n"
        self.assertIn("blast-radius", checks(lint_pair("m.py", OLD, new, OPTS), "warn"))


if __name__ == "__main__":
    unittest.main()

Run the whole suite with python3 -m unittest -v. Ten tests should pass in a few milliseconds, including one that confirms a bare ellipsis already present in the old file does not count as a leak. That test pins down the before-and-after counting from step 5. If you extend the tool, add the failing test first, watch it fail, and then write the check.

python3 -m unittest -v 2>&1 | tail -8
# Ran 10 tests in 0.003s
# OK

Why bother when the tool is small? Because the checks interact. A change to the comment counter can shift what the blast-radius check sees, and a stricter regex can start flagging legitimate code. The tests catch both, and they double as examples of every failure the linter understands. New teammates can read them in five minutes and know exactly what the tool promises.

Step 13: Add a Cursor Rule, a Pre-Commit Hook, and a CI Job

Step 13: enforce the checks in three places. This last part of the Cursor tutorial is where the linter stops being a script and becomes a habit. A rule tells the model what you expect, a hook stops damaged commits on your machine, and CI catches anything that slipped through on a teammate’s. Start with the rule. Cursor reads project rules from .cursor/rules, and an alwaysApply rule goes into every session. Save the file below as .cursor/rules/targeted-edits.mdc.

---
description: Keep AI edits small and lossless
alwaysApply: true
---
- Change only the functions named in the request. Leave every other line byte-for-byte alone.
- Never write placeholder text such as "... existing code ..." into a file.
- Keep every comment and docstring, and keep each file's existing line endings.
- Before finishing, run `python3 editlint.py --allow <functions you touched>` and fix every error.

The last bullet turns the linter into part of the agent loop. An agent that can run shell commands will see the errors and repair its own edit. For a test bed that shows how different rule files change edit behavior, the Cursor Composer setup guide for diff versus rewrite testing goes further than this four-line file. Rules are advice, though, and models skip advice. The hook is the part that does not negotiate.

#!/bin/sh
# Install: cp pre-commit.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
exec python3 editlint.py --staged --quiet

With the hook installed, a commit containing the careless rewrite from step 11 stops with four errors and one warning, and git log shows no new commit. Bypass it only on purpose with git commit --no-verify, and treat that flag the way you would treat disabling a test. Now add the CI job so pull requests get the same treatment. Fetch full history, find the merge base with your main branch, and lint against it.

# .github/workflows/editlint.yml
name: editlint
on: pull_request
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Lint edits against the merge base
        run: |
          BASE=$(git merge-base origin/${{ github.base_ref }} HEAD)
          python3 editlint.py --base "$BASE" --quiet

This workflow is a starting point and was not run in the demo repo, so test it on a scratch branch before you rely on it. Using the merge base matters. Linting against the tip of main instead would flag every file that changed on main since your branch started, producing noise that teaches people to ignore the job.

Common Pitfalls When Linting AI Edits

Six mistakes account for most of the confusion people run into with this kind of tool. Each one is easy to avoid once you know it exists.

  • Linting without a clean baseline. If the working tree already held uncommitted changes before Cursor touched it, the linter blames the edit for all of them. Commit or stash first, every time.
  • Trusting the diff view. The editor draws a diff from two file states. It cannot tell you whether the model regenerated the whole file, and a secondary source from October 22, 2026 warns that a full rewrite can look like a small patch. Check bytes.
  • Reading text mode instead of bytes. Python’s universal newlines hide CRLF differences. The line-ending check only works because it reads bytes. Do not “simplify” it into open(path).read().
  • Setting the allow list too wide. Writing --allow "*" turns the scope check off. Name the functions the request mentions and add helpers only when the model genuinely needs them.
  • Ignoring warnings forever. Final-newline and blast-radius warnings look minor, and they pile up. Run CI with --strict on repos where formatting churn hurts, and fix the rule that caused it.
  • Linting the linter by accident. If editlint.py is tracked and you edit it, the tool reports its own symbols as out of scope. Commit tool changes on their own, as this tutorial’s own demo had to.

A seventh trap is conceptual. The linter detects damage patterns it knows about. A model can still introduce a wrong constant, flip a comparison, or call the wrong function, and none of those register as damage. Pair the linter with tests and normal code review. It narrows what a reviewer must read, and it does not replace the reviewer.

Troubleshooting

These are the failures most likely to show up on a first run, with the fix for each.

  1. The hook never runs. Confirm .git/hooks/pre-commit exists and is executable with ls -l. If you set core.hooksPath, git ignores the default folder, so copy the script to the configured path instead.
  2. “fatal: path exists on disk, but not in HEAD.” A file is new and has no baseline. The tool filters to modified files with --diff-filter=M, so this error means you changed that filter. Restore it, or treat new files as clean.
  3. Every Python file reports a line-ending flip on Windows. Git is converting endings on checkout because of core.autocrlf. Add a .gitattributes file that pins endings per file type, as in the gitattributes tutorial linked earlier, and re-run.
  4. A legitimate stub triggers placeholder-leak. The count only rises when you add more bare ... lines than the old file had. If you really added a protocol method, extend the allowlist or add a trailing comment so the third regex stops matching.
  5. comment-loss fires on an intentional cleanup. Pass --min-comment-loss 9 for that run, or raise the default. The demo run with that flag silenced the comment error and still reported everything else.
  6. “file no longer parses” on a template or Python 2 file. The AST check cannot read it. Exclude those paths by lint-ing only *.py files you own, or catch the SyntaxError for old files, which the tool already does for the baseline side.
  7. out-of-scope fires for a helper the model had to rename. Add the new name to --allow, or use a glob such as Cart.*. Renames show as one removal plus one addition, so allow both names.
  8. The run is slow on a very large file. SequenceMatcher can get much slower as the line count grows. Skip blast radius for generated files, or compute it from git diff --numstat instead.
  9. Cursor edited the file but the linter shows nothing. You may have already staged and committed, or the agent worked in a separate git worktree. Run the linter inside the same directory the agent used, and compare against the right base ref.
  10. The CI job lints every file in the repo. The base ref was probably wrong. Print git merge-base in the job log and check that fetch-depth: 0 is set, otherwise the checkout is too shallow to find a common ancestor.

Advanced Tips

Once the basic tool works, a few extensions pay off quickly. None of them need new dependencies.

Record every run to a CSV. Append one line per file per run with the date, the check name, the model you used, and the blast ratio. After a few weeks you can see whether Composer 2.5, Grok 4.7, or a Claude model tends to regenerate more text for the same request. The CLI benchmark for diff versus rewrite edits shows a more controlled way to compare models. Your CSV complements it with real working data.

Price the damage. Changed lines times a tokens-per-line estimate times the model’s output price gives a rough dollar figure for each edit. Measure your own tokens-per-line ratio on your own code first, because languages differ. The sketch below takes prices from the model table earlier in this article.

PRICES = {"composer-2.5": (0.50, 2.50), "grok-4.7": (2.00, 6.00)}  # USD per 1M tokens (input, output)

def edit_cost(model, output_lines, tokens_per_line=12):
    _, out_price = PRICES[model]
    return output_lines * tokens_per_line * out_price / 1_000_000

Treat 12 tokens per line as a placeholder to replace with a measurement. The point is relative cost. A rewrite of a whole file always outputs far more lines than a patch of the same change, so the ratio between the two is stable even when the absolute figure is off.

Add tree-sitter for other languages. The AST scope check covers Python only. For TypeScript, Go, or Rust, a tree-sitter parser can produce the same symbol-to-hash map. Keep the interface identical, a function from source text to a dictionary of names and fingerprints, and the rest of the tool needs no changes.

Feed errors back to the agent. Run the linter with --json and paste the output into the next prompt. A model that sees “comment lines dropped from 6 to 1” at cart.py can restore them in one pass. If you also run Claude Code beside Cursor, the Cursor and Claude Code workflow setup shows how to share one rule set between the two tools.

Pair it with a safety net. The linter flags damage after the fact, while a pre-apply snapshot makes rollback instant. The Fast Apply safety net project covers snapshots and one-command rollback, and the two projects fit together without overlap.

The Complete Project

Everything fits in a short tree. The tool is one file, the tests are another, and the guardrails are three small config files. Here is the final layout, with line counts from the demo repo.

editlint-demo/
  editlint.py              # the linter, about 290 lines
  test_editlint.py         # 10 unittest cases, 58 lines
  pre-commit.sh            # staged-mode hook
  .cursor/rules/
    targeted-edits.mdc     # alwaysApply rule for Cursor
  .github/workflows/
    editlint.yml           # CI job from step 13
  shop/
    cart.py                # fixture with business-rule comments
    legacy_report.py       # fixture with CRLF endings
    deploy.sh              # fixture with POSIX shell comments

To carry this Cursor tutorial into a real project, copy editlint.py to the repo root, commit it alone, install the hook, and add the rule file. Then work the way you normally do. Ask Cursor for a change, review the result, and let the hook speak when you commit. After a week, look at which checks fire most. That tells you which habit of your model deserves a tighter rule.

A fair summary of what this buys you: Cursor’s apply step can produce a patch or a rewrite-equivalent result, and the interface will not tell you which one you got. The linter does not need to know. It reads the files, compares them to the last commit, and reports facts. If you are still choosing an editor, the best code editor comparison covers where Cursor fits beside VS Code and Zed. For more on the topic, browse the software category.

Frequently Asked Questions

Does Cursor rewrite the whole file or apply a diff?

It depends on the edit and on the model, and Cursor’s current docs do not publish a per-edit rule. A May 2024 post from Cursor’s team said the default was to have models generate the fully rewritten file, with search/replace blocks as an alternative. Measuring the result with a linter works regardless of which path ran.

Why does the Cursor diff view not prove a small edit?

The diff is computed between the before and after file states. It cannot show whether the model regenerated everything in between. A secondary source dated October 22, 2026 makes the same warning, so check bytes and symbols rather than the colors.

Will this work with Claude Code, Copilot, or Aider?

Yes. The tool compares files against git and never talks to the editor. Any assistant that writes to disk produces output it can lint. Only the Cursor rule file is specific to Cursor.

How do I stop false positives on legacy files?

Use the before-and-after counting that is already built in, and tune the thresholds with --max-blast and --min-comment-loss. For generated files, exclude them from the tracked paths or lint them with a looser option set.

Is this Cursor tutorial only useful for Python projects?

No. The line-ending, comment, placeholder, churn, and blast-radius checks work on any text file, and comment detection covers a dozen common extensions. Only the symbol scope check is Python-specific, and a tree-sitter version would extend it.

Does it need Python 3.12?

The demo and tests ran on Python 3.12.3. The code uses only standard-library modules, so recent Python 3 releases should work, but confirm with the unit tests on your own version before relying on it.

Can the linter fix the damage automatically?

It reports only. Automatic repair is risky, because a tool that guesses which comments to restore can create new problems. A safer pattern is to feed the JSON report back to the model and review the corrected edit.

What does it cost to run?

Nothing beyond your normal Cursor usage. The linter runs locally in milliseconds on typical files. The savings come from catching wasted regeneration early, which matters more now that third-party models bill at API list prices on the new plans.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles