LaNorme rule reference¶
This reference describes every rule code LaNorme can emit, one section per code, covering what each rule catches, what it deliberately ignores, where to configure it, and its measured precision, recall, and F1 where a labelled corpus exists.
Corpora live under evals/corpora/ and scorers under evals/.
Live rule list: lanorme rules.
Default policy and per-check configuration: see the README.
The rules are grouped by category, roughly in the order lanorme rules uses;
CMT-005..007 and SECRETPY-001 live in their own checks (restating,
docstrings, secrets). A -000 code (TYPE-000, DRY-000, ...) is not a
rule but a notice that a check skipped a file it could not parse, and
RUN-000 reports a check that raised; both stay warnings whatever promote
says.
Attribute access: ATTR-001 / ATTR-002¶
Opt-in (default-off); both are advisory warnings. Enable with
[tool.lanorme.attribute_access] enabled = true. The premise: when an
attribute name is a constant at the call site, the type is known too, so the
dynamic form only hides the attribute from the type checker.
ATTR-001:hasattr(x, "name")with a literal identifier name. Branching on structure is duck typing; prefer aruntime_checkableProtocolwithisinstance, or EAFP (try: ... except AttributeError).ATTR-002:getattr(x, "name")(no default),setattr(x, "name", v), ordelattr(x, "name")with a literal identifier name. Use direct attribute access (x.name).
High-confidence cases only. Exempt: three-argument getattr(x, "name",
default) (the safe-access idiom); dunder names (__class__, __name__, ...);
names that are not valid identifiers (cannot be written as x.name); and files
under tests/ or test/. Dynamic names (getattr(x, name)) are reflection and exempt
unless flag_dynamic is set.
Config:
[tool.lanorme.attribute_access]
enabled = true
flag_dynamic = false # also flag non-literal (reflective) attribute names
Comments: CMT-* and PROSE-* on .py¶
CMT-001: No commented-out code¶
Default-on. Walks every # comment and parses its text as Python; if the
result is one of _CODE_NODES (imports, assigns, defs, control flow,
returns / raises / asserts, ...), the comment is treated as disabled code.
Guards: comments ending in . / ? / ! are prose; foo(...) (literal
...) is illustrative; label: type with no value is documentation; tooling
pragmas (# noqa, # type:, ...) are skipped; and the lines of a
PEP 723 # /// script ... # /// inline
metadata block are tooling, not code.
To recover the shapes ast.parse rejects standalone, the comment text is
tried in several wrapping strategies before being declared prose:
- Block headers ending in
:are tried with apassbody. try:is tried with apassbody plus a syntheticexcept Exception.elif/elseare tried inside anif True: passprefix.except/finallyare tried inside atry: passprefix.- Bare
return/yield/raiseare tried insidedef _(): .... - Decorator lines (
@foo) are tried followed bydef _(): pass.
Measured against the 165-comment corpus under
evals/corpora/comments_commented_code/ with evals/score_cmt001.py:
P = 0.985 / R = 1.000 / F1 = 0.992 (TP = 66, FP = 1, FN = 0). The
single FP is an illustrative call signature following a Typical usage:
header.
Config:
[tool.lanorme.comments]
commented_code = true # default-on; set false to disable CMT-001
CMT-002: No verbose comments¶
Default-on. Flags any single comment longer than max_comment_chars
(default 120), and any block of consecutive standalone comments longer
than its allowance.
The block allowance is not a constant. A flat cap makes this rule fight
COMPLEXITY-001: that rule warns at complexity 10 precisely because such
code is hard to follow, and a six-line cap then forbids explaining why.
So the allowance grows with the complexity of the code the block
introduces:
allowance = max_block_lines + (complexity - 1) * block_lines_per_branch
At the defaults a trivial helper allows 6 lines and a function at the
COMPLEXITY-001 warning threshold allows 24. The complexity used is that
of the function the block sits inside, or the one it sits directly above
(within two lines, so a preamble counts). A module-level banner far from
any definition gets the base allowance, and the message names the
complexity it scored so the number is never a mystery.
Set block_lines_per_branch = 0 for a flat cap.
Config:
[tool.lanorme.comments]
verbose = true # default-on; set false to disable CMT-002
max_comment_chars = 120
max_block_lines = 6 # the base, for straight-line code
block_lines_per_branch = 2 # extra lines earned per decision point
CMT-005: No comments that restate the next line of code¶
Default-off. Experimental. Lives in its own restating check.
Precision-first by design: it only flags a comment when every content word
and verb maps onto the adjacent statement, and an allowlist exempts comments
that carry a why, a caveat, a unit, or a reference. It will miss synonym
paraphrases. Full design: docs/cmt005-design.md.
Measured against the 167-comment corpus under
evals/corpora/comments_restating/ with evals/score_cmt005.py:
P = 1.000 / R = 0.418 / F1 = 0.589 (TP = 33, FP = 0, FN = 46, TN = 88).
The 0.418 recall is bounded by the design's refusal to chase synonym
paraphrases without losing precision.
Config:
[tool.lanorme.restating]
enabled = true
CMT-006 / CMT-007: docstrings that exist, and that say something¶
Default-off. Opinionated. Lives in its own docstrings check.
Every other rule in the comment family subtracts: CMT-001 deletes
commented-out code, CMT-002 caps comment length, CMT-005 deletes
comments that restate the next line, PROSE-001 / PROSE-003 strip em
dashes and emoji. The cheapest way to satisfy all of them is to write
nothing. These two point the other way.
CMT-006: a public function or class whose span reachesmin_lines(default 5) carries a docstring. Dunders, private names,test_*files,__init__.py,conftest.py,setup.py,alembic/andmigrations/are out of scope.CMT-007: that docstring says more than the signature. A docstring is vacuous when every content word in it is already carried by the definition's name, its parameters, or its enclosing class. Padding does not help, because padding is restatement.
CMT-007 reuses the vocabulary machinery behind CMT-005: identifier
splitting, stemming, and the 11-category allowlist that exempts a comment
carrying a why, a caveat, a unit or a reference. Two additions on top:
- Abbreviation coverage. A stemmer links
processtoprocessingbut not toproc, so a docstring word also counts as restatement when it extends a signature stem of at least 3 characters, or is extended by one. The floor stops a two-letter name such asgoswallowinggovern. - Emptiness before allowlist. A docstring with no content word left
after filler removal is vacuous whatever the allowlist says, so
This is a helper function.is not rescued by it.
Measured against the 23-definition corpus under
evals/corpora/docstrings_vacuous/ with evals/score_cmt007.py:
P = 1.000 / R = 1.000 / F1 = 1.000 (TP = 9, FP = 0, FN = 0, TN = 14).
That corpus was written alongside the rule and tuned against, so treat the
figure as a regression guard rather than an unbiased estimate. The
independent evidence is held-out: CMT-007 returns zero findings over
the 18 generated modules (about 10,000 lines) in evals/readability/runs/
and evals/articulacy/runs*/, and zero over LaNorme's own src/ with
require_private on, while CMT-006 finds 114 missing docstrings in the
same generated corpora.
CMT-007 is a guard, not a finder. Its job is to stop CMT-006 being
satisfied by """Go."""; on code written in good faith it should stay
silent, and on this evidence it does.
Config:
[tool.lanorme.docstrings]
enabled = true
min_lines = 5 # definitions shorter than this need no docstring
require_private = false # also require them on _private definitions
PROSE-001 / PROSE-003 on comments and docstrings¶
Off until enabled. The same rule codes that the prose check emits on
Markdown also fire here, on # comments and """...""" docstrings,
when configured.
Config:
[tool.lanorme.comments]
em_dash = true # emit PROSE-001 on comments/docstrings
emoji = true # emit PROSE-003 on comments/docstrings
Docs: DOCS-001..008¶
Opt-in (default-off), tree-scoped. Enforces a familiar Diataxis-style structure
and accessibility on a Markdown documentation tree. It inspects only Markdown
under docs_root (default docs); files anywhere else are ignored, so it never
imposes a docs structure on source trees or stray Markdown. Headings and images
inside fenced code blocks are never matched, so a sample showing a hash heading
or an image link is left alone. DOCS-001..004 are build-failing errors;
DOCS-005..008 are advisory warnings.
DOCS-001: a content page has exactly one level-1 heading.DOCS-002: heading levels descend one step at a time (no skipped levels).DOCS-003: a content page opens with a canonical skimmer line. The first prose line after the H1 must begin with one ofThis page,This tutorial,This guide,This reference,This how-to, orThis explanation.DOCS-004: every image carries non-empty alternative text, for both Markdown image links and HTMLimgtags.DOCS-005: prefer SVG or Mermaid over a local raster image (warning).DOCS-006: a known section directory that has pages carries anindex.mdlanding page (warning).DOCS-007: every page lives in a known section or is a known top-level page (warning).DOCS-008: headings are not numbered by hand; renderers number sections for you (warning).
Vendored or generated directories (.git, .venv, node_modules,
__pycache__, dist, build, and so on) are never treated as part of a docs
tree.
Config (all keys optional; the defaults are shown):
[tool.lanorme.docs]
enabled = true # default false (the whole check is opt-in)
docs_root = "docs" # tree to inspect, relative to the scan target
# Diataxis section directories expected under docs_root.
sections = ["tutorials", "how-to", "reference", "explanation"]
# Pages allowed at the top level without a section home (DOCS-007).
known_top_level = [
"index.md",
"RULES.md",
"reference/configuration.md",
"reference/rules-index.md",
"reference/cli.md",
]
# Image extensions treated as raster formats for DOCS-005.
raster_extensions = ["png", "jpg", "jpeg", "gif", "webp", "bmp"]
allow = ["**/logo.png"] # globs exempt from the prefer-SVG warning (default empty)
The check is tree-scoped (scope = "tree"): it reads the directory layout, not
only individual files, so the section-index and quadrant rules can reason about
where each page sits.
Domain terminology: TERM-NNN¶
Configurable ubiquitous-language enforcement. Inert by default. Each
rule the user configures gets a code from the TERM- family.
Config:
[[tool.lanorme.domain_terms.rules]]
id = "TERM-001"
canonical = "Account"
forbidden = ["Acct", "Acnt"]
[[tool.lanorme.domain_terms.rules]]
id = "TERM-002"
canonical = "Customer"
forbidden = ["Cust", "Client"]
Duplication: DRY-001¶
Default-on. Detects exact structural clones: functions with an identical
normalised AST body and at least five statements. Normalisation strips variable
names and string literals, so two functions differing only in identifier
spelling or string-constant content still match. It is precise but strict: a
single added statement, a reordering, a changed number, or a renamed attribute
defeats the match. For the fuzzier "these should share a helper" cases, see
SIMILAR-001 below.
Config: none. False positives on intentionally parallel
adapters across bounded contexts are a known limit; suppress them with
[tool.lanorme.per-file-ignores], # noqa: DRY-001, or
# lanorme: ignore[DRY-001].
Near-duplicate: SIMILAR-001¶
Opt-in (default-off), advisory warning (never fails the build). The fuzzy
companion to DRY-001: it catches near-duplicates that the exact check misses
(one or two added statements, reordering, a changed number, a renamed
attribute, a renamed call) so a reviewer can decide whether to extract a shared
helper.
Two functions in a file are compared on two signals. Structure: a token
sequence over the body that abstracts away variable names, attribute names and
numbers, scored with difflib (so a one-statement or reorder drift still
aligns). Anchors: the meaning-bearing tokens DRY-001 discards: string
literals, called names, operator kinds, and accessed attribute names, each
scored by weighted Jaccard. A pair flags only when the structure is similar
and every anchor agrees, which keeps precision high: parallel boilerplate
that shares a shape but differs in its string keys or source attributes (config
builders, dispatch tables, field mappers, framework handlers) is rejected.
Equality/dunder/@property boilerplate and drifted logging-message strings are
handled specially. Measured on the bundled corpus
(evals/corpora/duplication_similar/, scorer evals/score_similar.py):
precision 1.000 / recall 0.850 / F1 0.919. Known recall gaps: fully renamed
attribute sets and error-message-only drift.
[tool.lanorme.similarity]
enabled = true
# threshold overrides (defaults shown):
min_statements = 5
struct_ratio = 0.55
str_jaccard = 0.60
op_jaccard = 0.60
call_jaccard = 0.35
attr_jaccard = 0.10
File limits: SIZE-* / COMPLEXITY-001 / PARAM-001¶
All default-on.
SIZE-001: Python files. Warn at 300 effective (non-blank, non-comment) lines; error at 500.SIZE-002: functions and methods. Warn at 50 lines; error at 80.SIZE-003: classes with more than 10 methods (warning only). Useful as a smell on services and views; on rich aggregate roots in a DDD codebase, expect to silence it viaper-file-ignores.COMPLEXITY-001: cyclomatic complexity. Warn at 10; error at 15 (the ruffC901/mccabedefault thresholds). Complexity is 1 plus one for each decision point: anif/elif/for/while/except/with/assert/ ternary, each extraand/oroperand, each refutablematchcase (an irrefutable catch-all such ascase _:or a barecase x:does not count, like anelse), and, inside a comprehension, each filterifand each nestedforclause. One deliberate divergence from a textbook count: a comprehension's primaryforis treated as a single expression and does not count, so a plain[f(x) for x in xs]costs nothing. That keeps the 10 / 15 thresholds calibrated for code that leans on comprehensions; the conditional and nested-loop branching they can hide still counts.PARAM-001: function/method parameter count, excludingself/cls. Warn at 5; error at 8.
Skips __init__.py, conftest.py, alembic/, migrations/, and
test_* files.
Every threshold above is a default, not a fixed number. A project sets its own without giving up the rule:
[tool.lanorme.file_limits]
file_warn_lines = 400
file_error_lines = 600
func_warn_lines = 60
func_error_lines = 100
class_method_warn = 15
complexity_warn = 12
complexity_error = 20
param_warn = 6
param_error = 10
Each key is optional and an unset key keeps its default, so a project
declares only what differs. There is no enabled key: these retune the
limits rather than switch the rules off. A warn threshold set above its
error threshold describes no reachable band, so the error value wins for
both and everything at the limit reports an error rather than a warning.
Under cascading config a nested lanorme.toml sets its own limits for the
files below it, which is how a legacy subtree keeps a looser ceiling while
the rest of the repository holds the strict one.
The rule strings carry no number (SIZE-001: File exceeds the effective
line limit, not ... exceeds 500 effective lines), so retuning a
threshold does not move a finding's baseline anchor. The number a finding
was measured against appears in its message.
Forbidden paths: PATH-001¶
Inert until configured.
Config:
[tool.lanorme.forbidden_paths]
dirs = ["legacy_src", "build_artifacts"]
Layer dependencies: LAYER-001..006¶
For hexagonal / layered codebases with a domain/, application/,
infrastructure/, api/ layout. Inert in their absence.
If the layers live under a nested package directory, set the top-level
[tool.lanorme] source_root (e.g. "src/myproject") so they are classified
relative to it. Files outside source_root are layer-exempt;
composition_root is then read relative to source_root too. Reported paths
stay relative to the scan target.
LAYER-001:domain/must not import any other layer.LAYER-002:application/may only import fromdomain/.LAYER-003:infrastructure/may only import fromdomain/andapplication/.LAYER-004:api/may only import fromdomain/andapplication/.LAYER-005: only the composition root may import frominfrastructure/.LAYER-006: atransport_layersentry is not among the configuredlayers, so it has no effect (advisory warning, exit 0).
These rules track Cockburn's hexagonal architecture and Seemann's composition-root pattern.
The composition-root exception applies to any layer listed in
transport_layers (default ["api"]). Apps with several peer transport
adapters (a REST api/, an mcp_server/, a grpc_server/) can list them all
so each keeps its own composition root. A transport peer must also appear in
layers and be given an allowed entry.
Config (all keys optional; the defaults are shown):
[tool.lanorme.layer_deps]
# Files allowed to import infrastructure (the composition root).
# fnmatch globs against the source-relative path, so a module FILE
# (api/dependencies.py) is recognised, not only a directory.
composition_root = ["api/dependencies/**", "api/v1/dependencies/**", "api/v1/main.py"]
# For layouts whose layers differ. Defaults shown.
layers = ["domain", "application", "infrastructure", "api"]
# Transport (inbound adapter) layers eligible for the composition-root
# exception. A peer must also appear in layers and get an allowed entry.
transport_layers = ["api"]
[tool.lanorme.layer_deps.allowed]
application = ["domain"]
infrastructure = ["domain", "application"]
api = ["domain", "application"]
Meta: META-001..005¶
Self-validation that every registered check produces well-formed output.
META-001: non-emptyname.META-002: non-emptydescription.META-003: non-emptyruleslist.META-004:CheckResult.checkmatches the check'sname.META-005: violations carry a non-emptyfile,rule,message, andfix.
If you ship a plugin, run lanorme check . --check=meta once to confirm
it conforms.
Keyword arguments: KWARG-001¶
Opt-in. With enabled = true, every multi-argument function definition
must contain a bare * separator to force keyword-only call sites.
Config:
[tool.lanorme.named_args]
enabled = true
Naming canon: NAMING-006..008¶
Default-on warnings. Lives in the naming_canon check.
Three rules, each stating a point the naming canon agrees on across languages and schools. The sources are the ones the rules were read from:
- Robert C. Martin, Clean Code, chapter 2, "Meaningful Names": classes and objects take noun or noun-phrase names and a class name should not be a verb; methods take verb or verb-phrase names.
- Steve McConnell, Code Complete, 2nd edition, section 7.3, "Good Routine
Names": name a procedure with a strong verb followed by an object, name a
function for the value it returns, and avoid vague verbs such as
HandleCalculation,PerformServices,ProcessInputandDealWithOutput. - Brian Kernighan and Rob Pike, The Practice of Programming, section 1.1: use active names for functions.
- The Java Code Conventions, the .NET names of classes and names of members guidelines, and the Kotlin coding conventions: classes are nouns or noun phrases, methods are verbs or verb phrases.
- The Swift API Design Guidelines: name functions by their side effects, an imperative verb phrase when they have them and a noun phrase when they do not.
- Effective Go and the
Rust API Guidelines:
a getter carries no
Getprefix. Bertrand Meyer, Object-Oriented Software Construction, and the Ada 95 Quality and Style Guide, section 3.2.3, state the same split as command-query separation: verbs for procedures, nouns for value-returning functions, predicates for boolean ones. - PEP 8 is silent on
parts of speech, and Python's own library follows the second school:
len,basename,Path.cwd()and every property are queries named for their value.
Where the schools split, on whether a pure query must also lead with a verb,
these rules stay out; that stricter reading is the opt-in
naming_clean_code check below. Every finding here is a warning. A
project that wants the canon as a hard standard promotes the codes, as
LaNorme does on itself.
NAMING-006: A class is named as a thing, not as an action¶
A class whose first word is unambiguously a verb and whose last word is the
verb's object reads as an imperative sentence: FetchUsers, SendEmail,
ValidateOrder, CalculateTax. A class is a thing, so the fix is to name
the thing, usually the agent (OrderValidator, EmailSender) or what it
holds.
Precision comes from two guards. The first word must be on a short
verb-only list: Build, Check, Run, Update, Process, Load,
Test, Compute and Render are nouns as often as verbs (BuildResult,
ProcessPool, TestUser, ComputeNode), so they never open a finding, and
ProcessPayment or UpdateUser is a known miss. And the last word must not
be an action's own attribute or artefact: FetchOptions holds options,
ConnectTimeout is a timeout, CompileError is an error, DeleteView is a
view, LoadBalancer is an agent noun. Words a verb acts on (User,
Order, Connection, Session, Token) are deliberately not on that
list, or CreateUser would pass.
A message object is a noun by convention and is exempt through its suffix:
Command, Query, Event, Handler, UseCase, Request, Response,
Job, Task, extensible through command_suffixes. A verb-first class
without one, such as SQLAlchemy's DDL objects CreateTable and
AddConstraint, is reported; a project that models statements that way
adds them to exempt. Names that are not PascalCase (a ctypes structure
such as CONSOLE_SCREEN_BUFFER_INFO) and classes defined inside a function
are not judged.
NAMING-007: A function that does something is named verb-first¶
A function whose body returns no value (no return x, no yield) exists
for its effect, so its name says what it does, verb first. The finding is a
function like layout(root) that writes files, cert_verify(conn) that
sets connection options, or versioned_session(session) that attaches a
listener. When a verb sits later in the name the fix says where to move it
(verify_cert); otherwise it asks for one.
What is left alone, because the name was not the author's to choose or the function is not a command:
- A function that returns a value or yields, including an explicit
return None: a query may be named for its value. - A body that is only a docstring,
passor...(a stub or a protocol member), or that ends inraise(a raiser such askey_not_found). - A function defined inside another function: closures such as
wrapperand the hooks a test registers inline are local. - A function under any decorator other than
staticmethod,classmethod,abstractmethod,overrideorfinal: a route, a fixture, a property setter, a signal receiver or a CLI command is named by the framework's contract. - Dunders, keyword-clash names ending in
_, hook names (on_,pre_,post_,before_,after_,pytest_, and_hook,_handler,_callback,_listener,_receiver), conversions and constructors (from_,to_,as_,into_,with_, andx_to_y), entry points (main,async_main,cli), standard-library protocol methods on a class (keys,fetchone,rollback,flush,info), and the Django and Scrapy hook names on plain classes (process_request,process_item).setUpandtearDownpass becausesetandtearare verbs. - Files under
migrations/oralembic/inside the scanned tree, whose names a tool generated. A name with non-ASCII letters is not judged.
The verb test is recall-first: a word counts as a verb if it is listed, is
a third-person form (matches), carries a verb suffix (simplify,
normalise), sits behind a fused prefix (reload, unquote,
deregister, aclose) or opens with one of the verbs Python fuses onto
the next word (getheaders, setdefault, isdigit, iteritems; not
password, endpoint or checksum), and leading modifiers are skipped
(bulk_insert, re_apply, atomic_write, safe_delete). A word wrongly
counted as a verb hides a finding and never creates one. The verbs
setting extends the vocabulary, and a configured weak verb counts as a
verb here too.
NAMING-008: A function does not open with a weak verb¶
handle_, process_, perform_, do_, manage_ and deal_with_ say
that something happens to the object without saying what. Code Complete
lists them as the verbs to avoid, and do_ is the Python spelling of the
same evasion. The finding is a function such as handle_data or
process_order whose body parses, stores, validates or prices; the fix is
to say which.
A bare handle or process with no object is a dispatcher's slot and is
not reported. A method on a class with bases may be overriding an inherited
name (do_GET on a request handler, process on a SQLAlchemy type), so
it is not reported either; nor is any name NAMING-007 leaves alone as
not the author's to choose. The rule applies to queries as well as
commands. The list is replaced, not extended, through weak_verbs.
Config:
[tool.lanorme.naming_canon]
verbs = ["frob"] # words that read as verbs in this codebase
command_suffixes = ["Interactor"] # extends the message-object suffixes (NAMING-006)
weak_verbs = ["handle", "process"] # replaces the default list (NAMING-008)
exempt = ["CreateTable"] # names no rule here judges, with or without leading underscores
Measured on the third-party code under benchmarks/.corpora/ (Flask,
requests, rich and SQLAlchemy: 879 files, about 620k lines): NAMING-006
reports 15 classes, of which SQLAlchemy's DeleteAll, SaveUpdateAll,
RemoveORMEventsGlobally and six DDL statement objects are the bulk;
NAMING-007 reports 220 functions, 101 of them with the verb elsewhere in
the name; NAMING-008 reports 24. LaNorme's own source and tests are
clean under all three, with the codes promoted to errors.
Measured against the labelled corpora with evals/score_naming006.py,
evals/score_naming007.py and evals/score_naming008.py:
| rule | corpus | P | R | F1 | TP | FP | FN | TN |
|---|---|---|---|---|---|---|---|---|
NAMING-006 |
naming_verb_class |
1.000 | 1.000 | 1.000 | 7 | 0 | 0 | 15 |
NAMING-007 |
naming_command |
1.000 | 1.000 | 1.000 | 9 | 0 | 0 | 31 |
NAMING-008 |
naming_weak_verb |
1.000 | 1.000 | 1.000 | 7 | 0 | 0 | 12 |
Those corpora are regression guards for the exemptions listed above, not an unbiased estimate; the calibration evidence is the third-party measurement.
Naming, Clean Code: NAMING-009..011¶
Default-off. Opinionated. Lives in its own naming_clean_code check.
Chapter 2 of Clean Code goes past the canon above in two places, and this check enforces both, plus the module-level version of one of them.
NAMING-009: A class name carries no noise word¶
Clean Code names the words to keep off a class: Manager, Processor,
Data and Info name a job title or a shrug where a thing should be.
Helper, Util and Utils join them. The finding is ClassManager,
DependencyProcessor, ConfigData or ScriptInfo; the fix is to say what
the thing is (a registry, a pool, a scheduler) or what it holds. A single
word (Manager as a domain class), MetaData and its compounds, a
ContextManager and names that are not PascalCase are not reported.
NAMING-010: A module is not a junk drawer¶
utils, util, utilities, helpers, helper, common, misc and
stuff promise nothing about what is inside; the
Go package-naming advice says so in as
many words. A module or a package (an __init__.py in a directory of that
name) is reported on line 0; the fix is to split it by responsibility and
name each part for what it holds. Flask, requests and SQLAlchemy each carry
at least one, which is why the rule is opt-in.
NAMING-011: Every function starts with a verb¶
The Java-school reading: a query leads with a verb too, so
_shell_violations() becomes find_shell_violations() and url_for()
becomes build_url(). Commands are NAMING-007's and a raiser exists to
raise, so this rule takes the rest: functions that return a value, and
stubs. Predicates that carry an auxiliary anywhere (is_valid,
line_has_noqa), constructors under @classmethod, properties,
conversions and every reserved-name shape NAMING-007 lists are exempt,
and the same verb vocabulary applies.
This is a house choice, not a correction. Naming a pure function for its
value is the other canonical school (Code Complete, Ada, Swift, Kotlin, Go,
Rust and Python's own library), and it is the one LaNorme's source follows:
on LaNorme's src/ it reports every helper named for the value it
returns, well over a hundred of them. A project that opts in is choosing
the Java-school reading for itself.
Config:
[tool.lanorme.naming_clean_code]
enabled = true
verbs = ["frob"] # words that read as verbs in this codebase
exempt = ["url_for"] # names no rule here judges
Measured on the third-party code under benchmarks/.corpora/:
NAMING-009 reports 10 classes, NAMING-010 9 modules, NAMING-011
1473 functions. Measured against evals/corpora/naming_every_verb/ with
evals/score_naming011.py: P = 1.000 / R = 1.000 / F1 = 1.000
(TP = 8, FP = 0, FN = 0, TN = 18), a regression guard for the exemptions
rather than an unbiased estimate. NAMING-009 and NAMING-010 match exact
words and have unit tests instead of a corpus.
Naming conventions: NAMING-001..004¶
NAMING-001: opt-in. Repository methods (files underinfrastructure/repositories/orinfrastructure/persistence/) that use a non-canonical synonym prefix (fetch_/retrieve_/find_/remove_/add_) are flagged and steered to the CRUD equivalent (get_/create_/update_/delete_/list_). Conflicts with the DDD ubiquitous-language convention; off by default.NAMING-002: opt-in. Service methods (files underapplication/services/) that use the same synonym prefixes are flagged and steered to the CRUD equivalent. Conflicts with domain-named operations (approve_loan,transfer_funds); off by default.NAMING-003: default-on warning. Endpoint handler names (in files underapi/v1/endpoints/) should match their HTTP verb (get_useron@router.get,delete_useron@router.delete). Health probes and auth-issuance handlers are exempt.NAMING-004: default-on warning. Functions whose return annotation isboolshould use a boolean prefix (is_/has_/can_/should_).
Config:
[tool.lanorme.naming_consistency]
repo_crud = true # enable NAMING-001
service_crud = true # enable NAMING-002
Naming scope: NAMING-005¶
Default-off. Opinionated. Lives in its own naming_scope check.
NAMING-005 does not ban short names. i in a three-line loop is
perfectly readable; the same i bound at the top of a sixty-line function
and used at the bottom is not, because the binding has scrolled out of
sight and the name itself has to carry the meaning. The defect is
shortness held over distance, so the requirement scales with the span.
Span is measured from where a name is first bound to where it is last
referenced, within one function. Only names the function itself binds are
considered (parameters and assignment targets): a referenced-but-unbound
short name such as np or re is an imported module, where the name is
the library's choice and not the function's.
Choosing the default (max_span = 20), measured over LaNorme's own
src/ and the 18 generated modules (about 10,000 lines) under evals/:
| corpus | p90 span | p95 | max | findings at 20 |
|---|---|---|---|---|
src/ |
7 | 10 | 18 | 0 |
| generated code | 21 | 24 | 53 | 9 |
The default sits in that gap, and has a reason beyond the gap: twenty
lines is roughly a screenful, the point at which a binding and its use
stop being visible together. At that default LaNorme's own source and test
suite are clean, and the generated corpus yields rc held over 53 lines,
s over 52 and p over 50.
Short names that stay readable at any distance are exempt by default:
_, i, j, k, n, x, y, z, db, id, fd, fh, ok,
lo, hi, lr, ax, df, ts. Extend the list rather than raising the
span, so the exemption stays visible in config. Numeric and ML code is the
usual reason to: fitted parameters such as a / b / c read fine to
their audience and are the rule's most likely false positive.
Config:
[tool.lanorme.naming_scope]
enabled = true
max_span = 20 # lines between binding and last use
max_short_length = 2 # names this long or shorter are "short"
allow = ["mu"] # extends the default allowlist
Measured against evals/corpora/naming_scope/ with
evals/score_naming005.py: P = 1.000 / R = 1.000 / F1 = 1.000
(TP = 2, FP = 0, FN = 0, TN = 4). That corpus is a regression guard, not
an unbiased estimate; the calibration evidence is the table above.
Pattern divergence: IMPORT-001 / ENDPOINT-001¶
IMPORT-001: default-on. Imports must live at the top of the module (import/from x import ystatements must not be nested inside a function or method body). Equivalent to ruffPLC0415with a different default. Imports inside anif TYPE_CHECKING:guard are exempt, as are files underinfrastructure/observability/andapi/v1/main.py(conditional startup wiring);test_*files are skipped.ENDPOINT-001: default-on warning. Functions defined in files underapi/v1/endpoints/must not exceed nesting depth 4. Deep endpoints correlate with missed branches in auth and validation paths.
Port coverage: PORT-001..003¶
For hexagonal codebases with application/ports/. As with layer_deps, the
top-level [tool.lanorme] source_root anchors ports_dir, adapter_roots,
and composition_root under a nested package directory when set.
PORT-001: every adapter file (under the adapter roots) must import from the ports directory.PORT-002: everyProtocoldeclared in the ports directory must have at least one implementation. Build-failing likePORT-001andPORT-003. Ports realised only by test doubles or sibling plugins are legitimate; list them inports_without_implor ignore the code.PORT-003: no direct import or instantiation of an infrastructure adapter from theapi/layer outside the composition root.
Config (all keys optional; the defaults are shown):
[tool.lanorme.port_coverage]
ports_dir = "application/ports" # where port Protocols live
adapter_roots = ["infrastructure/services"] # dirs scanned for adapters (recursive)
composition_root = ["*dependencies/*", "*v1/main.py"] # PORT-003 exemption (globs)
skip_files = ["__init__.py"]
ports_without_impl = ["repositories.py", "unit_of_work.py", "otel.py", "metrics.py"]
Adapter roots are scanned recursively, so widening adapter_roots to
["infrastructure"] picks up adapters in per-integration subdirectories.
Prose: PROSE-001..004 on Markdown¶
Off until enabled.
PROSE-001: em dashes (U+2014) in prose.PROSE-002: American spellings; suggests the British form.PROSE-003: emoji in prose.
Skips fenced code blocks (```, ~~~) and inline `code` spans.
PROSE-004: Em-dash density above natural English¶
Advisory warning, opt-in (default-off). Where PROSE-001 bans every em dash
outright, PROSE-004 instead measures how often em dashes appear and warns once
per file when the density runs above what natural English sustains. It reuses
the same code-span and fenced-block stripping as the other prose rules, so it
measures the remaining prose alone.
The heuristic has an eligibility floor and two fire thresholds. A file is only
measured when it clears all three floor values (min_em em dashes, min_words
words, min_sentences sentences), which keeps the rule silent on anything too
short to judge. It then fires only when both density thresholds are
exceeded: em dashes per 1000 words above em_per_1000 and the fraction of
sentences carrying an em dash above em_sentence_fraction. ANDing the two axes
stops a single high reading from tripping the warning. Setting em_dash = false
while em_dash_density = true switches a region from the PROSE-001 ban to
this density advisory.
Measured on the labelled corpus evals/corpora/prose_em_dash (run
evals/score_prose004.py): P = 1.000 / R = 1.000 / F1 = 1.000 (TP = 3,
FP = 0, FN = 0, TN = 11). The corpus exists to prove the calibrated thresholds
do not false-positive on natural prose.
Config:
[tool.lanorme.prose]
enabled = true
extensions = [".md", ".markdown"] # default
em_dash = true # default; PROSE-001 ban
emoji = true # default
em_dash_density = true # enable PROSE-004 (default false)
[tool.lanorme.prose.spellings]
customize = "customise" # extend or override the built-in US->UK map
[tool.lanorme.prose.density]
min_em = 4 # eligibility floor: minimum em dashes
min_words = 400 # eligibility floor: minimum words
min_sentences = 30 # eligibility floor: minimum sentences
em_per_1000 = 30.0 # fire threshold: em dashes per 1000 words
em_sentence_fraction = 0.50 # fire threshold: fraction of sentences with an em dash
Security calls: SHELL-001 / DESERIAL-001 / EVAL-001 / CRYPTO-001 / TLS-001 / DEBUG-001¶
All default-on. Single AST walk. Precision-first: when the AST shape is
ambiguous, the rule prefers a false negative over a false positive (no
false sense of security). Use # noqa: <CODE> or # lanorme: ignore[<CODE>]
for legitimate uses (e.g. a pickle load on a trusted local cache) or
[tool.lanorme.per-file-ignores] for broader patches.
SHELL-001:subprocess.run/call/check_call/check_output/Popenwithshell=True;os.system;os.popen.DESERIAL-001:pickle.load(s),marshal.load(s),dill.load(s),cPickle.load(s),yaml.loadwithoutLoader=SafeLoader/CSafeLoader/BaseLoader,yaml.unsafe_load.EVAL-001:eval/exec/compilewhere the first argument is not a string literal. (Literal-argcompile(...)flows are accepted.)CRYPTO-001:hashlib.md5/hashlib.sha1used for security (usedforsecurity=Falseis honoured),hashlib.new("md5"/"sha1", ...),ssl.PROTOCOL_SSLv2/SSLv3/TLSv1/TLSv1_1.TLS-001:requests/httpx/aiohttpcall withverify=False,ssl._create_unverified_context,ssl.CERT_NONEattribute reference.DEBUG-001:Flask(...)/FastAPI(...)constructor withdebug=True,*.run(debug=True)/*.run_server(debug=True), module-levelDEBUG = Truein*settings.py/*config.py.
Each rule has a positive + negative unit test under
tests/unit/test_security_calls.py locking the AST shape.
Security patterns: AUTHN-001 / SQL-001 / SECRETPY-001¶
AUTHN-001: default-on.@router.post/put/patch/deletehandlers must have an auth dependency (a parameter annotated withDepends(get_current_user)orDepends(require_*)). FastAPI-shaped; the rule checks for authentication presence only, not authorisation. Only endpoint files underapi/are scanned; when the package sits under a nested directory (a src layout), set the top-level[tool.lanorme] source_root(e.g."src/myapp") so the layer is found relative to it, or no endpoint is inspected at all. Exempt endpoints:login,logout,refresh,token.SQL-001: default-on. AST-based: only flags SQL string literals that reach a database execution sink (.execute/.executemany/.executescripton a DB-shaped receiver, orread_sql/read_sql_query). Unwrapstext(...)constructors, resolves module- level and function-local string constants, and treats+/%-formatted /.format-built SQL as interpolated (always flagged). Static SQL passed alongside aparams=/parameters=kwarg (or a second positional on.execute) with placeholder marks (:name,%s,?) is treated as safely parameterised and not flagged. Excludesalembic/andtest_*files. Measured againstevals/corpora/security_raw_sql/(120 labels): P = 1.000 / R = 1.000 / F1 = 1.000. Known limitations not in the corpus: SQL built across multiple statements with helper functions; lazy-loaded query templates; non-Python query files.SECRETPY-001: default-on. Lives in thesecretscheck. AST-based: flags credential-named assignments (variable, dict key, or call kwarg) whose value looks like a real secret, plus shape-only matches (PEM private-key blocks, JWT-shaped tokens, Bearer headers, DB / cache URLs with embeddeduser:pass@hostcredentials, and vendor-prefixed credentials: AWSAKIA/ASIA, GitHubghp_/gho_/github_pat_, Slackxox*, Stripesk_live_/sk_test_). Names whose first segment ishelp_/hint_/msg_/ etc. are documentation; names whose last segment is structural (pattern,endpoint,header,name,len, ...) are not credentials. Placeholder markers (<your-...>,REPLACE_ME,example, ...) skip a value unless it is high-entropy enough (32+ chars, mixed case, digits) to defeat the marker (AWS docs-style example secret keys). Excludesconftest.py,seed_dev.py, and files starting withtest_. Measured againstevals/corpora/security_hardcoded_secrets/(155 labels): P = 1.000 / R = 1.000 / F1 = 1.000. Scope warning: Python-source only;.env,*.yaml,*.ipynb,*.tf,Dockerfile, GitHub Actions workflows are out of scope until a separate non-Python rule lands.
Skills: SKILL-001..006¶
On by default. Validates files named SKILL.md against the Agent Skills
specification. It only fires where a
SKILL.md exists, so it is silent on projects without skills. The frontmatter
parser is stdlib only and never turns its own uncertainty into a failure: if a
required value cannot be read cleanly it warns SKILL-006 rather than reporting
the value as missing.
Build failing:
SKILL-001:nameis required; 1 to 64 characters; lowercasea-z, digits and hyphens only; no leading, trailing or consecutive hyphen; and it must match the parent directory name.SKILL-002:descriptionis required, non-empty, and at most 1024 characters.SKILL-003: optional fields are well formed:compatibilityat most 500 characters,metadataa map of string keys to string values, andallowed-toolsa single string.
Advisory warnings:
SKILL-004: theSKILL.mdbody stays under 500 lines (progressive disclosure).SKILL-005: relative Markdown links resolve to a file that exists. Links in fenced code blocks, external URLs, and#anchorsare ignored.SKILL-006: the frontmatter is present but could not be parsed with confidence.
Config:
[tool.lanorme.skills]
enabled = true # default
check_links = true # default; set false to skip SKILL-005
Stale paths: STALE-001¶
Inert until configured. Flags references to old path tokens in docstrings and comments after a refactor.
Config:
[tool.lanorme.stale_paths]
tokens = ["src/", "old_pkg/"]
Stray artifacts: JUNK-001/002¶
Default-on. Surface tree clutter, including the privacy-relevant cases of screenshots and editor backups that frequently contain secrets or PII.
JUNK-001: files matching scratch / temp / OS / build name globs such asscreenshot*,scratch*,untitled*,*~,*.bak,*.orig,*.rej,*.swp,*.swo,*.tmp,tmp.*,temp.*,.DS_Store,Thumbs.db,desktop.ini,nohup.out,core.*,*.pyc,*.pyo,.coverage,coverage.xml.JUNK-002: image / binary extensions outside an asset directory. Default extensions:.png,.jpg,.jpeg,.gif,.bmp,.webp. Default asset directories:assets/,static/,images/,img/,media/,public/,docs/,.github/.
Config:
[tool.lanorme.stray_artifacts]
patterns = ["*.heic"] # extra name globs flagged as JUNK-001
extensions = [".zip", ".pdf"] # extra extensions flagged as JUNK-002
assets = ["screenshots"] # extra dirs where binaries are allowed
allow = ["docs/diagram.png"] # never flag these (globs)
exclude = ["sandbox"] # extra directories to skip entirely
Strong types: TYPE-001..004¶
Default-on. Skips files under tests/ and migrations/. TYPE-001..003 are
build-failing; TYPE-004 is an advisory warning.
TYPE-001:dict[str, Any](and other weakly-typed dict containers) in function signatures or return annotations. Pushes toward DTOs, TypedDicts, and value objects.TYPE-002: baredict/list/tuple/setwithout type parameters.TYPE-003:**kwargsmust be annotated with a concrete type orUnpack[TypedDict]; bare**kwargs: Anyis rejected.TYPE-004(advisory warning): a function with at least one annotated parameter that returns a real value in its own scope should also declare a return type. This is the high-signal completeness subset of ruff'sANN, not blanket presence enforcement: it fires only when the parameters are already typed and a value escapes, so a fully untyped function or a procedure that returns nothing is left alone. Generators (own-scopeyield) are exempt; returns inside a nesteddeforlambdado not count.
Suppressions: SUPPRESS-001 / SUPPRESS-002¶
Default-off. Opinionated. Lives in its own suppressions check.
Every other rule can be switched off on a line with # noqa or
# lanorme: ignore[...]. That is deliberate, and it is also why adding
rules raises a project's ceiling without moving its floor: a rule one
comment away from off is a suggestion, not a standard. These two rules do
not close the hatches, they price them.
SUPPRESS-001: the project's total inline suppressions againstmax_total(default 0). One finding for the project, reporting the count and the most-suppressed files.SUPPRESS-002: a directive that names no rule. A bare# noqaor anALLcode list silences every current rule on the line and every future one, so a line suppressed once quietly opts out of everything added since. Flagged regardless of budget.
Neither code can be silenced inline. lanorme.filtering refuses
# noqa and # lanorme: ignore for the SUPPRESS category, because a
budget an offender can waive on the offending line is not a budget. They
remain switchable in config, and that is the point: an escape belongs in a
reviewed file, not scattered invisibly across source lines. This is the
only asymmetry of its kind in LaNorme.
Use it as a ratchet. Set max_total to today's count, then lower it as
debt is paid; CI fails on the next suppression added rather than on the
backlog:
lanorme check . --check=suppressions
The check must be enabled first. With enabled = true and the default
max_total = 0, that first run fails, and its SUPPRESS-001 message carries
today's count; set max_total to that number.
Comments are read through tokenize and matched from the start of the
comment, so a directive named in prose (`# lines up with --exclude and
noqa handling`) or quoted in a string is documentation, not an escape,¶
and does not count against the budget.
Config:
[tool.lanorme.suppressions]
enabled = true
max_total = 0 # the ratchet: set to today's count, then lower it
allow_blanket = false # set true to permit bare '# noqa'
This rule is a count, not a heuristic, so it carries no scored corpus.
Its correctness is pinned by tests/unit/test_suppressions.py, including
a regression that the SUPPRESS codes survive a # noqa naming them.
Test coverage: TESTFILE-001¶
Default-on warning. For each Python file under one of the hardwired
production directories, verify that a matching test_*.py partner (by name
or by import reference) exists under one of the configured test roots
(tests/integration/ by default). Note this is file presence, not
coverage; it cannot tell you whether the test actually exercises the module.
Findings are reported on the same path base as every other rule (relative to
src_root), so a [per-file-ignores] glob written against the path another
rule reports for a file suppresses this one too, and a baseline records it
the same way.
Config:
[tool.lanorme.test_coverage]
test_roots = ["tests/integration", "tests/unit"]
test_roots lists the directories (relative to the backend root, the parent
of src_root) scanned for partner test files; it defaults to
["tests/integration"]. The scanned production directories
(api/v1/endpoints, application/services, application/commands,
application/queries, infrastructure/repositories,
infrastructure/signing, infrastructure/secrets) and the exempt modules
(dependencies, main, logging, session) are hardwired.
Test style: AAA-001 / AAA-002¶
Off until enabled.
AAA-001: test functions with more thanmin_statements(default 3) body statements must carry at leastrequired_markers(default 2) of the AAA section comment markers (# Arrange,# Act,# Assert) or their BDD synonyms (# Given,# When,# Then). Setup, exercise, call, expect, verify are recognised as additional aliases.AAA-002: two or more test functions in the same file may not share the samedry_prefix_statements(default 3) opening statements (the arrange block). Extract the shared setup into a pytest fixture or a helper.
Config:
[tool.lanorme.test_style]
enabled = true
min_statements = 3
required_markers = 2 # 1..3
dry_prefix_statements = 3
synonyms = ["setup", "given", "when", "then"]