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 +521 -0
- lanorme/__init__.py +139 -0
- lanorme/__main__.py +8 -0
- lanorme/checks/__init__.py +5 -0
- lanorme/checks/attribute_access.py +188 -0
- lanorme/checks/comments.py +368 -0
- lanorme/checks/domain_terms.py +231 -0
- lanorme/checks/duplication.py +237 -0
- lanorme/checks/file_limits.py +400 -0
- lanorme/checks/forbidden_paths.py +87 -0
- lanorme/checks/layer_deps.py +280 -0
- lanorme/checks/meta.py +191 -0
- lanorme/checks/named_args.py +240 -0
- lanorme/checks/naming_consistency.py +345 -0
- lanorme/checks/pattern_divergence.py +299 -0
- lanorme/checks/port_coverage.py +462 -0
- lanorme/checks/prose.py +256 -0
- lanorme/checks/restating.py +336 -0
- lanorme/checks/secrets.py +280 -0
- lanorme/checks/security_calls.py +397 -0
- lanorme/checks/security_patterns.py +406 -0
- lanorme/checks/stale_paths.py +165 -0
- lanorme/checks/stray_artifacts.py +180 -0
- lanorme/checks/strong_types.py +278 -0
- lanorme/checks/test_coverage.py +176 -0
- lanorme/checks/test_style.py +287 -0
- lanorme/cli.py +518 -0
- lanorme/discovery.py +101 -0
- lanorme-0.5.1.dist-info/METADATA +220 -0
- lanorme-0.5.1.dist-info/RECORD +33 -0
- lanorme-0.5.1.dist-info/WHEEL +4 -0
- lanorme-0.5.1.dist-info/entry_points.txt +2 -0
- lanorme-0.5.1.dist-info/licenses/LICENSE +21 -0
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