Imagine opening an old billing file of yours to check a late-fee rule, because a customer thinks they were charged too early. The method is short. You find the line:
return unless overdue && days > 10
Ten what? Days since the invoice was sent, or since it was due? Who sets overdue, and can it be true at day three? You wrote this. You can't remember, so you read three more files to find out.
Now picture your coding agent opening the same file to fix the same complaint. It has the same question and less to go on. Does a clearer name change what the agent actually builds, or does it just give it longer words to read?
The file, as most of us write it
Here is the whole class, an example written for this post in the short style most of us use under a deadline:
module Billing
class LateFee
property inv : Invoice
property overdue : Bool
property fees : Array(Fee) = [] of Fee
def initialize(@inv, @overdue)
end
def perform(days)
return unless overdue && days > 10
f = Fee.new(invoice_id: inv.id, amount_in_cents: inv.balance_in_cents * 15 // 1000)
fees << f
end
end
end
We built a naming check, the AED plugin, that reads each file right after the agent edits it. This is what it reports for that file (real output, plugin v0.2.1):
billing/late_fee.cr:4: [AED-N2 warn] boolean attribute `overdue` does not read as a yes/no question — is_this_overdue / has_been_overdue
billing/late_fee.cr:5: [AED-N3 warn] enumerable attribute `fees` does not say it holds a collection — list_of_fees / collection_of_fees / array_of_fees
billing/late_fee.cr:10: [AED-N6 warn] `perform` in `LateFee` takes arguments; a process manager receives everything it needs in `initialize` — move these arguments into `initialize` and leave `def perform` with no parameters
billing/late_fee.cr:12: [AED-N1 warn] local variable `f` does not say what it holds — name it as a short statement of its purpose, e.g. `customer_record_to_update`
The same rule, said out loud
Here is the class rewritten with the conventions, in billing/apply_late_fee_to_overdue_invoice.cr:
module Billing
# When an invoice is still unpaid after its grace period, then add one late fee
# of 1.5% of its balance.
class ApplyLateFeeToOverdueInvoice
GRACE_PERIOD_IN_DAYS = 10
property invoice_that_is_past_due : Invoice
property is_this_invoice_overdue : Bool
property number_of_days_past_due : Int32
property list_of_fees_added : Array(Fee) = [] of Fee
def initialize(@invoice_that_is_past_due, @is_this_invoice_overdue, @number_of_days_past_due)
end
def perform : Nil
return unless is_this_invoice_overdue && has_passed_its_grace_period?
add_one_late_fee_to_the_invoice
end
private def has_passed_its_grace_period? : Bool
number_of_days_past_due > GRACE_PERIOD_IN_DAYS
end
private def add_one_late_fee_to_the_invoice : Nil
late_fee_in_cents = invoice_that_is_past_due.balance_in_cents * 15 // 1000
list_of_fees_added << Fee.new(invoice_id: invoice_that_is_past_due.id, amount_in_cents: late_fee_in_cents)
end
end
end
The 10 now has a name, GRACE_PERIOD_IN_DAYS. The check reads as a sentence: return unless is_this_invoice_overdue && has_passed_its_grace_period?. The comment on top is the rule in plain words, so the reason for the code sits right above it. The overdue flag is still there, now named as the yes-or-no question it answers, is_this_invoice_overdue. The naming check reports nothing on this version. It never flagged inv, which we renamed anyway; it catches the common cases, not all of them.
What a small model did with each version
We wanted to see this at the size of a real app, so we ran a small experiment on a different job. We gave Claude Haiku, a small and fast model, the same task in two versions of our open-source app template: build a pet tracker that lists, shows, adds, edits and deletes pets, following the patterns already in the code.
A larger model then read every line each try wrote and judged which of those five things would work. In the version with short names, it built the tracker twice, and both times the reviewer found none of the five would work: in one, the pet list would come up silently empty; in the other, the page would crash as soon as a pet existed. In the version with the renamed code, plus the naming section in CLAUDE.md and the naming check, the reviewer judged two of the five workable in one try and four in the other. The short-name tries were cheaper: 37 cents less across both, at API prices.
That is two tries a side on one task, judged from the code rather than by clicking through, and the renamed version also had extra instructions and the check, so the names alone don't get all the credit. Even the best try had a serious bug, described below. A smaller snippet test, below, also favored the descriptive versions. It is a demonstration, not proof.
Try this today
Open a file you or your agent changed this week and find the one name you'd have to ask about, like days, data or f. Ask Claude Code: "Rename days to say what it counts, and update every place that uses it." Read the diff; if the new name makes a line read like a sentence, you have felt the whole idea.
Step 2 of the lesson installs the naming check, which reports names like these after every edit and never blocks one, and chapter 3 of the AED book has the rules in full.
Code gets written once and read every time someone, or something, has to change it.
How we tested it
- The naming check output:
aed_lint.rbfrom the AED plugin atplugin-v0.2.1, run on November 1, 2026 on the two example files above. The examples were written for this post. - The pet tracker: August 11, 2026. Claude Haiku in headless Claude Code, fresh each run, two runs per version, one machine. Version one was our template before the renames, with no naming section and the plugin off. Version two was the same code after a renames-only pass (243 findings fixed, every spec still passing), plus the naming section in
CLAUDE.mdand the advisory check. The task told the agents not to run migrations, tests or the server, so nothing was clicked through: a larger model reviewed every try's code against the same rubric and judged the five user actions 0 and 0 workable before, against 2 and 4 after. In the 2-of-5 try, missing form security tokens blocked adding a pet, and edit and delete relied on a form trick the framework doesn't support. In the 4-of-5 try, clicking Delete would silently update the pet and report success. Cost per try: $0.50 and $0.38 against $0.61 and $0.64; most of the extra cost was the model rereading context over more turns. All four tries passed the type check. The data is public. - The snippet test: Claude Haiku answered questions about 10 pairs of Crystal snippets that behave the same, one with descriptive names and one compact. The descriptive versions also had fuller control flow and comments about intent, so this test can't isolate naming either. A second model graded the answers. Descriptive versions scored 60 of 60, compact versions 54 of 60. Seven of the ten pairs tied; the gap came from three, mostly in finding bugs and explaining intent. We wrote the snippets and the questions ourselves. One run, one model family (report).
- Limits: two runs a side, one task, one codebase, one model. The two versions differ by the names, the instructions and the check together, so we can't say how much each part did.
Next in the series: We wrote the lesson down twice, and the mistake kept happening