lanorme 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lanorme/RULES.md ADDED
@@ -0,0 +1,521 @@
1
+ # LaNorme rule reference
2
+
3
+ One section per rule code emitted by LaNorme. Each section says what the
4
+ rule catches, what it does not, where to configure it, and (where the
5
+ rule has a labelled corpus under `tests/fixtures/` and a scorer under
6
+ `benchmarks/`) the measured precision / recall / F1 on that corpus.
7
+
8
+ Live rule list: `lanorme check . --check=rules` (or `lanorme rules`).
9
+ Default policy and per-check configuration: see the README.
10
+
11
+ The rules are grouped by category in the same order as `lanorme rules`.
12
+
13
+ ---
14
+
15
+ ## Attribute access: `ATTR-001` / `ATTR-002`
16
+
17
+ Opt-in (default-off); both are advisory warnings. Enable with
18
+ `[tool.lanorme.attribute_access] enabled = true`. The premise: when an
19
+ attribute name is a constant at the call site, the type is known too, so the
20
+ dynamic form only hides the attribute from the type checker.
21
+
22
+ - `ATTR-001`: `hasattr(x, "name")` with a literal identifier name. Branching
23
+ on structure is duck typing; prefer a `runtime_checkable` `Protocol` with
24
+ `isinstance`, or EAFP (`try: ... except AttributeError`).
25
+ - `ATTR-002`: `getattr(x, "name")` (no default), `setattr(x, "name", v)`, or
26
+ `delattr(x, "name")` with a literal identifier name. Use direct attribute
27
+ access (`x.name`).
28
+
29
+ High-confidence cases only. Exempt: three-argument `getattr(x, "name",
30
+ default)` (the safe-access idiom); dunder names (`__class__`, `__name__`, ...);
31
+ names that are not valid identifiers (cannot be written as `x.name`); and files
32
+ under `tests/`. Dynamic names (`getattr(x, name)`) are reflection and exempt
33
+ unless `flag_dynamic` is set.
34
+
35
+ Config:
36
+ ```toml
37
+ [tool.lanorme.attribute_access]
38
+ enabled = true
39
+ flag_dynamic = false # also flag non-literal (reflective) attribute names
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Comments: `CMT-*` and `PROSE-*` on .py
45
+
46
+ ### `CMT-001`: No commented-out code
47
+
48
+ Default-on. Walks every `#` comment and parses its text as Python; if the
49
+ result is one of `_CODE_NODES` (imports, assigns, defs, control flow,
50
+ returns / raises / asserts, ...), the comment is treated as disabled code.
51
+ Guards: comments ending in `.` / `?` / `!` are prose; `foo(...)` (literal
52
+ `...`) is illustrative; `label: type` with no value is documentation.
53
+
54
+ To recover the shapes `ast.parse` rejects standalone, the comment text is
55
+ tried in several wrapping strategies before being declared prose:
56
+
57
+ - Block headers ending in `:` are tried with a `pass` body.
58
+ - `try:` is tried with a `pass` body plus a synthetic `except Exception`.
59
+ - `elif` / `else` are tried inside an `if True: pass` prefix.
60
+ - `except` / `finally` are tried inside a `try: pass` prefix.
61
+ - Bare `return` / `yield` / `raise` are tried inside `def _(): ...`.
62
+ - Decorator lines (`@foo`) are tried followed by `def _(): pass`.
63
+
64
+ Measured against the 165-comment corpus under
65
+ `tests/fixtures/comments_commented_code/` with `benchmarks/score_cmt001.py`:
66
+ **P = 0.985 / R = 1.000 / F1 = 0.992** (TP = 66, FP = 1, FN = 0). The
67
+ single FP is an illustrative call signature following a `Typical usage:`
68
+ header.
69
+
70
+ Config: none.
71
+
72
+ ### `CMT-002`: No verbose comments
73
+
74
+ Default-on. Flags any single comment longer than `max_comment_chars`
75
+ (default 120) and any block of consecutive standalone comments longer
76
+ than `max_block_lines` (default 6).
77
+
78
+ Config:
79
+ ```toml
80
+ [tool.lanorme.comments]
81
+ max_comment_chars = 120
82
+ max_block_lines = 6
83
+ ```
84
+
85
+ ### `CMT-005`: No comments that restate the next line of code
86
+
87
+ Default-off. **Experimental.** Lives in its own `restating` check.
88
+ Detector: AST adjacency + 11-category allowlist + stem-equality + 12-entry
89
+ verb-to-AST-node table + asymmetric coverage floor of 1.0 + 4-content-word
90
+ cap. Designed precision-first; expects to miss synonym paraphrases.
91
+
92
+ Measured against the 167-comment corpus under
93
+ `tests/fixtures/comments_restating/` with `benchmarks/score_cmt005.py`:
94
+ **P = 1.000 / R = 0.418 / F1 = 0.589** (TP = 33,
95
+ FP = 0, FN = 46, TN = 88). The 0.418 recall is bounded by the design's
96
+ refusal to chase synonym paraphrases without losing precision; the metric
97
+ is the headline.
98
+
99
+ Config:
100
+ ```toml
101
+ [tool.lanorme.restating]
102
+ enabled = true
103
+ ```
104
+
105
+ ### `PROSE-001` / `PROSE-003` on comments and docstrings
106
+
107
+ Off until enabled. The same rule codes that the `prose` check emits on
108
+ Markdown also fire here, on `#` comments and `"""..."""` docstrings,
109
+ when configured.
110
+
111
+ Config:
112
+ ```toml
113
+ [tool.lanorme.comments]
114
+ em_dash = true # emit PROSE-001 on comments/docstrings
115
+ emoji = true # emit PROSE-003 on comments/docstrings
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Domain terminology: `TERM-NNN`
121
+
122
+ Configurable ubiquitous-language enforcement. Inert by default. Each
123
+ rule the user configures gets a code from the `TERM-` family.
124
+
125
+ Config:
126
+ ```toml
127
+ [[tool.lanorme.domain_terms.rules]]
128
+ id = "TERM-001"
129
+ canonical = "Account"
130
+ forbidden = ["Acct", "Acnt"]
131
+
132
+ [[tool.lanorme.domain_terms.rules]]
133
+ id = "TERM-002"
134
+ canonical = "Customer"
135
+ forbidden = ["Cust", "Client"]
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Duplication: `DRY-001`
141
+
142
+ Default-on. Functions with identical normalised AST bodies and at least
143
+ five statements are flagged as duplicates. AST normalisation strips
144
+ variable names and string literals so that two functions differing only in
145
+ identifier spelling or string-constant content are still detected.
146
+
147
+ Config: none currently. False positives on intentionally parallel
148
+ adapters across bounded contexts are a known limit; suppress them with
149
+ `[tool.lanorme.per-file-ignores]` or `# noqa: DRY-001`.
150
+
151
+ ---
152
+
153
+ ## File limits: `SIZE-*` / `COMPLEXITY-001` / `PARAM-001`
154
+
155
+ All default-on.
156
+
157
+ - `SIZE-001`: Python files. Warn at 300 effective (non-blank,
158
+ non-comment) lines; error at 500.
159
+ - `SIZE-002`: functions and methods. Warn at 50 lines; error at 80.
160
+ - `SIZE-003`: classes with more than 10 methods (warning only). Useful
161
+ as a smell on services and views; on rich aggregate roots in a DDD
162
+ codebase, expect to silence it via `per-file-ignores`.
163
+ - `COMPLEXITY-001`: cyclomatic complexity. Warn at 10; error at 15.
164
+ Mirrors the ruff `C901` / `mccabe` defaults.
165
+ - `PARAM-001`: function/method parameter count, excluding `self` /
166
+ `cls`. Warn at 5; error at 8.
167
+
168
+ Skips `__init__.py`, `conftest.py`, `alembic/`, `migrations/`, and
169
+ `test_*` files.
170
+
171
+ ---
172
+
173
+ ## Forbidden paths: `PATH-001`
174
+
175
+ Inert until configured.
176
+
177
+ Config:
178
+ ```toml
179
+ [tool.lanorme.forbidden_paths]
180
+ dirs = ["legacy_src", "build_artifacts"]
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Layer dependencies: `LAYER-001..005`
186
+
187
+ For hexagonal / layered codebases with a `domain/`, `application/`,
188
+ `infrastructure/`, `api/` layout. Inert in their absence.
189
+
190
+ If the layers live under a nested package directory, set the top-level
191
+ `[tool.lanorme] source_root` (e.g. `"src/myproject"`) so they are classified
192
+ relative to it. Files outside `source_root` are layer-exempt;
193
+ `composition_root` is then read relative to `source_root` too. Reported paths
194
+ stay relative to the scan target.
195
+
196
+ - `LAYER-001`: `domain/` must not import any other layer.
197
+ - `LAYER-002`: `application/` may only import from `domain/`.
198
+ - `LAYER-003`: `infrastructure/` may only import from `domain/` and
199
+ `application/`.
200
+ - `LAYER-004`: `api/` may only import from `domain/` and
201
+ `application/`.
202
+ - `LAYER-005`: only the composition root may import from
203
+ `infrastructure/`.
204
+
205
+ These rules track Cockburn's hexagonal architecture and Seemann's
206
+ composition-root pattern.
207
+
208
+ Config (all keys optional; the defaults are shown):
209
+
210
+ ```toml
211
+ [tool.lanorme.layer_deps]
212
+ # Files allowed to import infrastructure (the composition root).
213
+ # fnmatch globs against the source-relative path, so a module FILE
214
+ # (api/dependencies.py) is recognised, not only a directory.
215
+ composition_root = ["api/dependencies.py", "api/app.py"]
216
+
217
+ # For layouts whose layers differ. Defaults shown.
218
+ layers = ["domain", "application", "infrastructure", "api"]
219
+ [tool.lanorme.layer_deps.allowed]
220
+ application = ["domain"]
221
+ infrastructure = ["domain", "application"]
222
+ api = ["domain", "application"]
223
+ ```
224
+
225
+ The default `composition_root` is
226
+ `["api/dependencies/**", "api/v1/dependencies/**", "api/v1/main.py"]`.
227
+
228
+ ---
229
+
230
+ ## Meta: `META-001..005`
231
+
232
+ Self-validation that every registered check produces well-formed output.
233
+
234
+ - `META-001`: non-empty `name`.
235
+ - `META-002`: non-empty `description`.
236
+ - `META-003`: non-empty `rules` list.
237
+ - `META-004`: `CheckResult.check` matches the check's `name`.
238
+ - `META-005`: violations carry a non-empty `file`, `rule`, `message`,
239
+ and `fix`.
240
+
241
+ If you ship a plugin, run `lanorme check . --check=meta` once to confirm
242
+ it conforms.
243
+
244
+ ---
245
+
246
+ ## Keyword arguments: `KWARG-001`
247
+
248
+ Opt-in. With `enabled = true`, every multi-argument function definition
249
+ must contain a bare `*` separator to force keyword-only call sites.
250
+
251
+ Config:
252
+ ```toml
253
+ [tool.lanorme.named_args]
254
+ enabled = true
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Naming conventions: `NAMING-001..004`
260
+
261
+ - `NAMING-001`: opt-in. Repository methods (files under
262
+ `infrastructure/repositories/` or `infrastructure/persistence/`) that use
263
+ a non-canonical synonym prefix (`fetch_` / `retrieve_` / `find_` /
264
+ `remove_` / `add_`) are flagged and steered to the CRUD equivalent
265
+ (`get_` / `create_` / `update_` / `delete_` / `list_`). Conflicts with the
266
+ DDD ubiquitous-language convention; off by default.
267
+ - `NAMING-002`: opt-in. Service methods (files under `application/services/`)
268
+ that use the same synonym prefixes are flagged and steered to the CRUD
269
+ equivalent. Conflicts with domain-named operations (`approve_loan`,
270
+ `transfer_funds`); off by default.
271
+ - `NAMING-003`: default-on warning. Endpoint handler names should
272
+ match their HTTP verb (`get_user` on `@router.get`, `delete_user` on
273
+ `@router.delete`).
274
+ - `NAMING-004`: default-on warning. Functions whose return annotation
275
+ is `bool` should use a boolean prefix (`is_` / `has_` / `can_` /
276
+ `should_`).
277
+
278
+ Config:
279
+ ```toml
280
+ [tool.lanorme.naming_consistency]
281
+ repo_crud = true # enable NAMING-001
282
+ service_crud = true # enable NAMING-002
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Pattern divergence: `IMPORT-001` / `ENDPOINT-001`
288
+
289
+ - `IMPORT-001`: default-on. Imports must live at the top of the module
290
+ (`import` / `from x import y` statements must not be nested inside a
291
+ function or method body). Equivalent to ruff `PLC0415` with a different
292
+ default. Imports inside an `if TYPE_CHECKING:` guard are exempt, as are
293
+ files under `infrastructure/observability/` and `api/v1/main.py`
294
+ (conditional startup wiring); `test_*` files are skipped.
295
+ - `ENDPOINT-001`: default-on warning. Functions defined in files under
296
+ `api/v1/endpoints/` must not exceed nesting depth 4. Deep endpoints
297
+ correlate with missed branches in auth and validation paths.
298
+
299
+ ---
300
+
301
+ ## Port coverage: `PORT-001..003`
302
+
303
+ For hexagonal codebases with `application/ports/`. As with `layer_deps`, the
304
+ top-level `[tool.lanorme] source_root` anchors `ports_dir`, `adapter_roots`,
305
+ and `composition_root` under a nested package directory when set.
306
+
307
+ - `PORT-001`: every adapter file (under the adapter roots) must import
308
+ from the ports directory.
309
+ - `PORT-002`: every `Protocol` declared in the ports directory must
310
+ have at least one implementation. Treat as advisory: ports realised
311
+ only by test doubles or sibling plugins are legitimate.
312
+ - `PORT-003`: no direct import or instantiation of an infrastructure
313
+ adapter from the `api/` layer outside the composition root.
314
+
315
+ Config (all keys optional; the defaults are shown):
316
+
317
+ ```toml
318
+ [tool.lanorme.port_coverage]
319
+ ports_dir = "application/ports" # where port Protocols live
320
+ adapter_roots = ["infrastructure/services"] # dirs scanned for adapters (recursive)
321
+ composition_root = ["*dependencies/*", "*v1/main.py"] # PORT-003 exemption (globs)
322
+ skip_files = ["__init__.py"]
323
+ ports_without_impl = ["repositories.py", "unit_of_work.py", "otel.py", "metrics.py"]
324
+ ```
325
+
326
+ Adapter roots are scanned recursively, so widening `adapter_roots` to
327
+ `["infrastructure"]` picks up adapters in per-integration subdirectories.
328
+
329
+ ---
330
+
331
+ ## Prose: `PROSE-001..003` on Markdown
332
+
333
+ Off until enabled.
334
+
335
+ - `PROSE-001`: em dashes (`-`) in prose.
336
+ - `PROSE-002`: American spellings; suggests the British form.
337
+ - `PROSE-003`: emoji in prose.
338
+
339
+ Skips fenced code blocks (` ``` `, `~~~`) and inline `` `code` `` spans.
340
+
341
+ Config:
342
+ ```toml
343
+ [tool.lanorme.prose]
344
+ enabled = true
345
+ extensions = [".md", ".markdown"] # default
346
+ em_dash = true # default
347
+ emoji = true # default
348
+
349
+ [tool.lanorme.prose.spellings]
350
+ customize = "customise" # extend or override the built-in US->UK map
351
+ ```
352
+
353
+ ---
354
+
355
+ ## Security calls: `SHELL-001` / `DESERIAL-001` / `EVAL-001` / `CRYPTO-001` / `TLS-001` / `DEBUG-001`
356
+
357
+ All default-on. Single AST walk. Precision-first: when the AST shape is
358
+ ambiguous, the rule prefers a false negative over a false positive (no
359
+ false sense of security). Use `# noqa: <CODE>` for legitimate uses (e.g.
360
+ a pickle load on a trusted local cache) or `[tool.lanorme.per-file-ignores]`
361
+ for broader patches.
362
+
363
+ - `SHELL-001`: `subprocess.run` / `call` / `check_call` /
364
+ `check_output` / `Popen` with `shell=True`; `os.system`; `os.popen`.
365
+ - `DESERIAL-001`: `pickle.load(s)`, `marshal.load(s)`, `dill.load(s)`,
366
+ `cPickle.load(s)`, `yaml.load` without `Loader=SafeLoader` /
367
+ `CSafeLoader` / `BaseLoader`, `yaml.unsafe_load`.
368
+ - `EVAL-001`: `eval` / `exec` / `compile` where the first argument is
369
+ not a string literal. (Literal-arg `compile(...)` flows are accepted.)
370
+ - `CRYPTO-001`: `hashlib.md5` / `hashlib.sha1` used for security
371
+ (`usedforsecurity=False` is honoured), `hashlib.new("md5"/"sha1", ...)`,
372
+ `ssl.PROTOCOL_SSLv2` / `SSLv3` / `TLSv1` / `TLSv1_1`.
373
+ - `TLS-001`: `requests` / `httpx` / `aiohttp` call with `verify=False`,
374
+ `ssl._create_unverified_context`, `ssl.CERT_NONE` attribute reference.
375
+ - `DEBUG-001`: `Flask(...)` / `FastAPI(...)` constructor with
376
+ `debug=True`, `*.run(debug=True)` / `*.run_server(debug=True)`,
377
+ module-level `DEBUG = True` in `*settings.py` / `*config.py`.
378
+
379
+ Each rule has a positive + negative unit test under
380
+ `tests/unit/test_security_calls.py` locking the AST shape from day one.
381
+
382
+ ---
383
+
384
+ ## Security patterns: `AUTHN-001` / `SQL-001` / `SECRETPY-001`
385
+
386
+ - `AUTHN-001`: default-on. `@router.post` / `put` / `patch` / `delete`
387
+ handlers must have an auth dependency (a parameter annotated with
388
+ `Depends(get_current_user)` or `Depends(require_*)`). FastAPI-shaped;
389
+ the rule checks for **authentication presence only**, not
390
+ authorisation. Exempt endpoints: `login`, `logout`, `refresh`, `token`.
391
+ - `SQL-001`: default-on. AST-based: only flags SQL string literals that
392
+ reach a database execution sink (`.execute` / `.executemany` /
393
+ `.executescript` on a DB-shaped receiver, or `read_sql` /
394
+ `read_sql_query`). Unwraps `text(...)` constructors, resolves module-
395
+ level and function-local string constants, and treats `+` /
396
+ `%`-formatted / `.format`-built SQL as interpolated (always flagged).
397
+ Static SQL passed alongside a `params=` / `parameters=` kwarg (or a
398
+ second positional on `.execute`) with placeholder marks (`:name`,
399
+ `%s`, `?`) is treated as safely parameterised and not flagged.
400
+ Excludes `alembic/` and `test_*` files. Measured against
401
+ `tests/fixtures/security_raw_sql/` (120 labels): **P = 1.000 /
402
+ R = 1.000 / F1 = 1.000**. Known limitations not in the corpus: SQL
403
+ built across multiple statements with helper functions; lazy-loaded
404
+ query templates; non-Python query files.
405
+ - `SECRETPY-001`: default-on. Lives in the `secrets` check. AST-based:
406
+ flags credential-named assignments
407
+ (variable, dict key, or call kwarg) whose value looks like a real
408
+ secret, plus shape-only matches (PEM private-key blocks, JWT-shaped
409
+ tokens, Bearer headers, DB / cache URLs with embedded `user:pass@host`
410
+ credentials, and vendor-prefixed credentials: AWS `AKIA` / `ASIA`,
411
+ GitHub `ghp_` / `gho_` / `github_pat_`, Slack `xox*`, Stripe
412
+ `sk_live_` / `sk_test_`). Names whose first segment is `help_` /
413
+ `hint_` / `msg_` / etc. are documentation; names whose last segment
414
+ is structural (`pattern`, `endpoint`, `header`, `name`, `len`, ...)
415
+ are not credentials. Placeholder markers (`<your-...>`, `REPLACE_ME`,
416
+ `example`, ...) skip a value unless it is high-entropy enough (32+
417
+ chars, mixed case, digits) to defeat the marker (AWS docs-style
418
+ example secret keys). Excludes `conftest.py`, `seed_dev.py`, and
419
+ files starting with `test_`. Measured against
420
+ `tests/fixtures/security_hardcoded_secrets/` (155 labels):
421
+ **P = 1.000 / R = 1.000 / F1 = 1.000**. **Scope warning**:
422
+ Python-source only; `.env`, `*.yaml`, `*.ipynb`, `*.tf`, `Dockerfile`,
423
+ GitHub Actions workflows are out of scope until a separate
424
+ non-Python rule lands.
425
+
426
+ ---
427
+
428
+ ## Stale paths: `STALE-001`
429
+
430
+ Inert until configured. Flags references to old path tokens in
431
+ docstrings and comments after a refactor.
432
+
433
+ Config:
434
+ ```toml
435
+ [tool.lanorme.stale_paths]
436
+ tokens = ["src/", "old_pkg/"]
437
+ ```
438
+
439
+ ---
440
+
441
+ ## Stray artifacts: `JUNK-001/002`
442
+
443
+ Default-on. Surface tree clutter, including the privacy-relevant cases
444
+ of screenshots and editor backups that frequently contain secrets or
445
+ PII.
446
+
447
+ - `JUNK-001`: files matching scratch / temp / OS / build name globs such
448
+ as `screenshot*`, `scratch*`, `untitled*`, `*~`, `*.bak`, `*.orig`,
449
+ `*.rej`, `*.swp`, `*.swo`, `*.tmp`, `tmp.*`, `temp.*`, `.DS_Store`,
450
+ `Thumbs.db`, `desktop.ini`, `nohup.out`, `core.*`, `*.pyc`, `*.pyo`,
451
+ `.coverage`, `coverage.xml`.
452
+ - `JUNK-002`: image / binary extensions outside an asset directory.
453
+ Default extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp`.
454
+ Default asset directories: `assets/`, `static/`, `images/`, `img/`,
455
+ `media/`, `public/`, `docs/`, `.github/`.
456
+
457
+ Config:
458
+ ```toml
459
+ [tool.lanorme.stray_artifacts]
460
+ patterns = ["*.heic"] # extra name globs flagged as JUNK-001
461
+ extensions = [".zip", ".pdf"] # extra extensions flagged as JUNK-002
462
+ assets = ["screenshots"] # extra dirs where binaries are allowed
463
+ allow = ["docs/diagram.png"] # never flag these (globs)
464
+ exclude = ["sandbox"] # extra directories to skip entirely
465
+ ```
466
+
467
+ ---
468
+
469
+ ## Strong types: `TYPE-001..003`
470
+
471
+ All default-on. Skips files under `tests/` and `migrations/`.
472
+
473
+ - `TYPE-001`: `dict[str, Any]` (and other weakly-typed dict containers)
474
+ in function signatures or return annotations. Pushes toward DTOs,
475
+ TypedDicts, and value objects.
476
+ - `TYPE-002`: bare `dict` / `list` / `tuple` / `set` without type
477
+ parameters. Equivalent in spirit to ruff `UP006`.
478
+ - `TYPE-003`: `**kwargs` must be annotated with a concrete type or
479
+ `Unpack[TypedDict]`; bare `**kwargs: Any` is rejected.
480
+
481
+ ---
482
+
483
+ ## Test coverage: `TESTFILE-001`
484
+
485
+ Default-on warning. For each Python file under one of the hardwired
486
+ production directories, verify that a matching `test_*.py` partner (by name
487
+ or by import reference) exists under `tests/integration/`. Note this is
488
+ **file presence**, not coverage; it cannot tell you whether the test
489
+ actually exercises the module.
490
+
491
+ Config: none. The scanned production directories (`api/v1/endpoints`,
492
+ `application/services`, `application/commands`, `application/queries`,
493
+ `infrastructure/repositories`, `infrastructure/signing`,
494
+ `infrastructure/secrets`) and the exempt modules (`dependencies`, `main`,
495
+ `logging`, `session`) are hardwired.
496
+
497
+ ---
498
+
499
+ ## Test style: `AAA-001` / `AAA-002`
500
+
501
+ Off until enabled.
502
+
503
+ - `AAA-001`: test functions with more than `min_statements` (default 3)
504
+ body statements must carry at least `required_markers` (default 2) of
505
+ the AAA section comment markers (`# Arrange`, `# Act`, `# Assert`) or
506
+ their BDD synonyms (`# Given`, `# When`, `# Then`). Setup, exercise,
507
+ call, expect, verify are recognised as additional aliases.
508
+ - `AAA-002`: two or more test functions in the same file may not share
509
+ the same `dry_prefix_statements` (default 3) opening statements (the
510
+ arrange block). Extract the shared setup into a pytest fixture or a
511
+ helper.
512
+
513
+ Config:
514
+ ```toml
515
+ [tool.lanorme.test_style]
516
+ enabled = true
517
+ min_statements = 3
518
+ required_markers = 2 # 1..3
519
+ dry_prefix_statements = 3
520
+ synonyms = ["setup", "given", "when", "then"]
521
+ ```
lanorme/__init__.py ADDED
@@ -0,0 +1,139 @@
1
+ """LaNorme, a configurable, pluggable architecture & code-quality linter.
2
+
3
+ This module is the stable public API for writing checks. A check is any object
4
+ implementing the ``Check`` protocol below; register it with ``register()`` and
5
+ LaNorme will discover and run it.
6
+
7
+ Run all checks against a path:
8
+ lanorme check .
9
+
10
+ Run a single check:
11
+ lanorme check . --check=layer_deps
12
+
13
+ JSON output for tooling/agents:
14
+ lanorme check . --output-format=json
15
+
16
+ List every registered rule:
17
+ lanorme rules
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import enum
23
+ from dataclasses import dataclass, field
24
+ from typing import Protocol, runtime_checkable
25
+
26
+ __version__ = "0.5.1"
27
+
28
+
29
+ class Status(enum.Enum):
30
+ """Result status for a check run."""
31
+
32
+ PASS = "PASS"
33
+ WARN = "WARN"
34
+ FAIL = "FAIL"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Violation:
39
+ """A single rule violation found by a check."""
40
+
41
+ file: str
42
+ line: int
43
+ rule: str
44
+ message: str
45
+ fix: str
46
+
47
+ def to_dict(self) -> dict[str, str | int]:
48
+ return {
49
+ "file": self.file,
50
+ "line": self.line,
51
+ "rule": self.rule,
52
+ "message": self.message,
53
+ "fix": self.fix,
54
+ }
55
+
56
+ def format_human(self) -> str:
57
+ return (
58
+ f" VIOLATION: {self.file}:{self.line} — {self.message}\n"
59
+ f" Rule: {self.rule}\n"
60
+ f" Fix: {self.fix}"
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class CheckResult:
66
+ """Result of running a single check."""
67
+
68
+ check: str
69
+ status: Status
70
+ violations: list[Violation] = field(default_factory=list)
71
+ warnings: list[Violation] = field(default_factory=list)
72
+
73
+ def to_dict(self) -> dict[str, str | list[dict[str, str | int]]]:
74
+ return {
75
+ "check": self.check,
76
+ "status": self.status.value,
77
+ "violations": [v.to_dict() for v in self.violations],
78
+ "warnings": [v.to_dict() for v in self.warnings],
79
+ }
80
+
81
+ def format_human(self) -> str:
82
+ lines = [f"[{self.status.value}] {self.check}"]
83
+ for v in self.violations:
84
+ lines.append(v.format_human())
85
+ for w in self.warnings:
86
+ lines.append(w.format_human())
87
+ lines.append(
88
+ f"--- {self.check}: {len(self.violations)} violations, {len(self.warnings)} warnings ---"
89
+ )
90
+ return "\n".join(lines)
91
+
92
+
93
+ class Check(Protocol):
94
+ """Protocol that all checks must implement."""
95
+
96
+ name: str
97
+ description: str
98
+ rules: list[str]
99
+
100
+ def run(self, *, src_root: str) -> CheckResult:
101
+ """Run the check against the given source root and return results."""
102
+ ...
103
+
104
+
105
+ @runtime_checkable
106
+ class Configurable(Protocol):
107
+ """A check that accepts a ``[tool.lanorme.<name>]`` settings table."""
108
+
109
+ def configure(self, *, settings: dict[str, object]) -> None:
110
+ """Apply configuration to the check before it runs."""
111
+ ...
112
+
113
+
114
+ # --- Check registry ---
115
+
116
+ _registry: dict[str, Check] = {}
117
+
118
+
119
+ def register(check: Check) -> None:
120
+ """Register a check so the unified runner can discover it."""
121
+ _registry[check.name] = check
122
+
123
+
124
+ def get_check(name: str) -> Check | None:
125
+ """Get a registered check by name."""
126
+ return _registry.get(name)
127
+
128
+
129
+ def get_all_checks() -> dict[str, Check]:
130
+ """Return all registered checks."""
131
+ return dict(_registry)
132
+
133
+
134
+ def run_all(*, src_root: str) -> list[CheckResult]:
135
+ """Run all registered checks and return their results."""
136
+ results = []
137
+ for check in _registry.values():
138
+ results.append(check.run(src_root=src_root))
139
+ return results
lanorme/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m lanorme`` to invoke the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lanorme.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,5 @@
1
+ """Built-in checks. Every module here self-registers on import via ``register()``.
2
+
3
+ Drop a new ``*.py`` module in this package (or ship one as a plugin under the
4
+ ``lanorme.checks`` entry-point group) and LaNorme will discover it automatically.
5
+ """