litclaude-ai 0.2.2
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.
- package/CHANGELOG.md +155 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/README_ko-KR.md +374 -0
- package/RELEASE_CHECKLIST.md +165 -0
- package/bin/litclaude-ai.js +643 -0
- package/cover.png +0 -0
- package/docs/agents.md +67 -0
- package/docs/hooks.md +134 -0
- package/docs/lsp.md +40 -0
- package/docs/migration.md +209 -0
- package/docs/workflow-compatibility-audit.md +119 -0
- package/generate_cover.py +123 -0
- package/package.json +48 -0
- package/plugins/litclaude/.claude-plugin/plugin.json +25 -0
- package/plugins/litclaude/.lsp.json +13 -0
- package/plugins/litclaude/.mcp.json +9 -0
- package/plugins/litclaude/agents/boulder-executor.md +12 -0
- package/plugins/litclaude/agents/librarian-researcher.md +15 -0
- package/plugins/litclaude/agents/oracle-verifier.md +16 -0
- package/plugins/litclaude/agents/prometheus-planner.md +13 -0
- package/plugins/litclaude/agents/qa-runner.md +16 -0
- package/plugins/litclaude/agents/quality-reviewer.md +17 -0
- package/plugins/litclaude/bin/litclaude-hook.js +110 -0
- package/plugins/litclaude/bin/litclaude-hud.js +271 -0
- package/plugins/litclaude/bin/litclaude-lsp-doctor.js +15 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +70 -0
- package/plugins/litclaude/commands/deep-interview.md +21 -0
- package/plugins/litclaude/commands/dynamic-workflow.md +36 -0
- package/plugins/litclaude/commands/lit-loop.md +40 -0
- package/plugins/litclaude/commands/lit-plan.md +35 -0
- package/plugins/litclaude/commands/litgoal.md +30 -0
- package/plugins/litclaude/commands/review-work.md +35 -0
- package/plugins/litclaude/commands/start-work.md +36 -0
- package/plugins/litclaude/hooks/hooks.json +54 -0
- package/plugins/litclaude/lib/context-pressure.mjs +25 -0
- package/plugins/litclaude/lib/hud-accent-palette.mjs +58 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +266 -0
- package/plugins/litclaude/lib/litgoal/ledger.mjs +16 -0
- package/plugins/litclaude/lib/litgoal/paths.mjs +7 -0
- package/plugins/litclaude/lib/litgoal/state.mjs +67 -0
- package/plugins/litclaude/lib/mutated-file-paths.mjs +63 -0
- package/plugins/litclaude/lib/start-work-continuation.mjs +99 -0
- package/plugins/litclaude/lib/workflow-check.mjs +83 -0
- package/plugins/litclaude/skills/ai-slop-remover/SKILL.md +142 -0
- package/plugins/litclaude/skills/comment-checker/SKILL.md +55 -0
- package/plugins/litclaude/skills/debugging/SKILL.md +70 -0
- package/plugins/litclaude/skills/debugging/references/methodology/00-setup.md +108 -0
- package/plugins/litclaude/skills/debugging/references/methodology/02-investigate.md +126 -0
- package/plugins/litclaude/skills/debugging/references/methodology/04-oracle-triple.md +106 -0
- package/plugins/litclaude/skills/debugging/references/methodology/05-escalate.md +69 -0
- package/plugins/litclaude/skills/debugging/references/methodology/06-fix.md +116 -0
- package/plugins/litclaude/skills/debugging/references/methodology/08-qa.md +94 -0
- package/plugins/litclaude/skills/debugging/references/methodology/09-cleanup.md +164 -0
- package/plugins/litclaude/skills/debugging/references/methodology/partial-runtime-evidence.md +228 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/go.md +252 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/native-binary.md +484 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/node.md +260 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/python.md +248 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/rust.md +234 -0
- package/plugins/litclaude/skills/debugging/references/tools/ghidra.md +212 -0
- package/plugins/litclaude/skills/debugging/references/tools/playwright-cli.md +194 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwndbg.md +263 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwntools.md +265 -0
- package/plugins/litclaude/skills/deep-interview/SKILL.md +323 -0
- package/plugins/litclaude/skills/deep-interview/scripts/render_progress.py +193 -0
- package/plugins/litclaude/skills/frontend-ui-ux/SKILL.md +62 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +144 -0
- package/plugins/litclaude/skills/lit-plan/SKILL.md +125 -0
- package/plugins/litclaude/skills/litgoal/SKILL.md +219 -0
- package/plugins/litclaude/skills/lsp/SKILL.md +63 -0
- package/plugins/litclaude/skills/programming/SKILL.md +106 -0
- package/plugins/litclaude/skills/programming/references/go/README.md +90 -0
- package/plugins/litclaude/skills/programming/references/go/backend-stack.md +641 -0
- package/plugins/litclaude/skills/programming/references/go/bootstrap.md +328 -0
- package/plugins/litclaude/skills/programming/references/go/bubbletea-v2.md +360 -0
- package/plugins/litclaude/skills/programming/references/go/cobra-stack.md +468 -0
- package/plugins/litclaude/skills/programming/references/go/concurrency.md +362 -0
- package/plugins/litclaude/skills/programming/references/go/data-modeling.md +329 -0
- package/plugins/litclaude/skills/programming/references/go/error-handling.md +359 -0
- package/plugins/litclaude/skills/programming/references/go/golangci-strict.md +236 -0
- package/plugins/litclaude/skills/programming/references/go/grpc-connect.md +375 -0
- package/plugins/litclaude/skills/programming/references/go/libraries.md +337 -0
- package/plugins/litclaude/skills/programming/references/go/one-liners.md +202 -0
- package/plugins/litclaude/skills/programming/references/go/sqlc-pgx.md +471 -0
- package/plugins/litclaude/skills/programming/references/go/testing.md +467 -0
- package/plugins/litclaude/skills/programming/references/go/type-patterns.md +298 -0
- package/plugins/litclaude/skills/programming/references/python/README.md +314 -0
- package/plugins/litclaude/skills/programming/references/python/async-anyio.md +442 -0
- package/plugins/litclaude/skills/programming/references/python/data-modeling.md +233 -0
- package/plugins/litclaude/skills/programming/references/python/data-processing.md +133 -0
- package/plugins/litclaude/skills/programming/references/python/error-handling.md +218 -0
- package/plugins/litclaude/skills/programming/references/python/fastapi-stack.md +316 -0
- package/plugins/litclaude/skills/programming/references/python/httpx2-optimization.md +360 -0
- package/plugins/litclaude/skills/programming/references/python/libraries.md +307 -0
- package/plugins/litclaude/skills/programming/references/python/one-liners.md +268 -0
- package/plugins/litclaude/skills/programming/references/python/orjson-stack.md +378 -0
- package/plugins/litclaude/skills/programming/references/python/pydantic-ai.md +285 -0
- package/plugins/litclaude/skills/programming/references/python/pyproject-strict.md +232 -0
- package/plugins/litclaude/skills/programming/references/python/textual-tui.md +201 -0
- package/plugins/litclaude/skills/programming/references/python/type-patterns.md +176 -0
- package/plugins/litclaude/skills/programming/references/rust/README.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/async-tokio.md +299 -0
- package/plugins/litclaude/skills/programming/references/rust/axum-stack.md +467 -0
- package/plugins/litclaude/skills/programming/references/rust/cargo-strict.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/clap-stack.md +409 -0
- package/plugins/litclaude/skills/programming/references/rust/concurrency.md +375 -0
- package/plugins/litclaude/skills/programming/references/rust/libraries.md +439 -0
- package/plugins/litclaude/skills/programming/references/rust/one-liners.md +291 -0
- package/plugins/litclaude/skills/programming/references/rust/proptest-insta.md +429 -0
- package/plugins/litclaude/skills/programming/references/rust/type-state.md +354 -0
- package/plugins/litclaude/skills/programming/references/rust/unsafe-discipline.md +250 -0
- package/plugins/litclaude/skills/programming/references/rust/zero-cost-safety.md +527 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/README.md +289 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
- package/plugins/litclaude/skills/programming/references/typescript/README.md +195 -0
- package/plugins/litclaude/skills/programming/references/typescript/backend-hono.md +672 -0
- package/plugins/litclaude/skills/programming/references/typescript/bootstrap.md +199 -0
- package/plugins/litclaude/skills/programming/references/typescript/data-modeling.md +202 -0
- package/plugins/litclaude/skills/programming/references/typescript/error-handling.md +169 -0
- package/plugins/litclaude/skills/programming/references/typescript/tsconfig-strict.md +152 -0
- package/plugins/litclaude/skills/programming/references/typescript/type-patterns.md +196 -0
- package/plugins/litclaude/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
- package/plugins/litclaude/skills/programming/scripts/go/new-project.py +138 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.editorconfig +13 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.golangci.yml +95 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/ci.yml +37 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/config.go +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/gitignore +15 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/run.go +15 -0
- package/plugins/litclaude/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-project.py +172 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-script.py +116 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
- package/plugins/litclaude/skills/programming/scripts/rust/new-project.py +175 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/new-project.ts +177 -0
- package/plugins/litclaude/skills/refactor/SKILL.md +73 -0
- package/plugins/litclaude/skills/remove-ai-slops/SKILL.md +52 -0
- package/plugins/litclaude/skills/review-work/SKILL.md +331 -0
- package/plugins/litclaude/skills/rules/SKILL.md +66 -0
- package/plugins/litclaude/skills/start-work/SKILL.md +132 -0
- package/scripts/audit-plan-checkboxes.mjs +37 -0
- package/scripts/doctor.mjs +41 -0
- package/scripts/inspect-agent-tools.mjs +27 -0
- package/scripts/postinstall.mjs +50 -0
- package/scripts/qa-claude-plugin-smoke.sh +60 -0
- package/scripts/qa-portable-install.sh +136 -0
- package/scripts/validate-plugin.mjs +72 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
# noqa: SIZE_OK — single self-contained checker, splitting adds import ceremony for no readability gain
|
|
7
|
+
"""Check Python files for no-excuse violations.
|
|
8
|
+
|
|
9
|
+
The python-programmer skill enforces these rules. Run after editing.
|
|
10
|
+
|
|
11
|
+
Rules:
|
|
12
|
+
cast-any - cast(Any, ...) / cast(typing.Any, ...) / typing.cast(Any, ...)
|
|
13
|
+
type-ignore - `# type: ignore` comments (any variant)
|
|
14
|
+
pyright-ignore - `# pyright: ignore` comments (any variant)
|
|
15
|
+
bare-except - `except:` with no class
|
|
16
|
+
silent-except - `except X: pass` or `except X: ...` (single statement)
|
|
17
|
+
no-asyncio - `import asyncio` / `from asyncio import ...`
|
|
18
|
+
Opt out per import line: trailing `# noqa: ANYIO_OK`
|
|
19
|
+
no-pandas - `import pandas` / `from pandas import ...`
|
|
20
|
+
Opt out per import line: trailing `# noqa: PANDAS_OK`
|
|
21
|
+
mutable-dataclass - @dataclass without frozen=True
|
|
22
|
+
Opt out: trailing `# noqa: MUTABLE_OK`
|
|
23
|
+
missing-slots - @dataclass without slots=True
|
|
24
|
+
Opt out: trailing `# noqa: SLOTS_OK`
|
|
25
|
+
raw-dict-return - function returns bare `dict` type
|
|
26
|
+
Opt out: trailing `# noqa: DICT_OK`
|
|
27
|
+
missing-assert-never - match statement without assert_never in default case
|
|
28
|
+
Opt out: `# noqa: MATCH_OK` on the match line
|
|
29
|
+
generic-exception - raise ValueError/TypeError/RuntimeError with bare string
|
|
30
|
+
Opt out: trailing `# noqa: GENERIC_ERR_OK`
|
|
31
|
+
no-object - `object` used as type annotation (param, return, variable)
|
|
32
|
+
Opt out: trailing `# noqa: OBJECT_OK`
|
|
33
|
+
if-elif-on-variant - isinstance/enum-comparison if/elif chain (should be match/case)
|
|
34
|
+
Opt out: trailing `# noqa: IF_VARIANT_OK`
|
|
35
|
+
oversized-module - file exceeds 250 pure LOC (non-blank, non-comment)
|
|
36
|
+
Opt out: `# noqa: SIZE_OK` in first 10 lines
|
|
37
|
+
broad-except - `except Exception` / `except BaseException` (too broad)
|
|
38
|
+
Opt out: trailing `# noqa: BROAD_EXCEPT_OK`
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
check-no-excuse-rules.py <file-or-dir>...
|
|
42
|
+
|
|
43
|
+
Exit codes:
|
|
44
|
+
0 - no violations
|
|
45
|
+
1 - one or more violations
|
|
46
|
+
2 - input error (path missing, etc.)
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import ast
|
|
51
|
+
import io
|
|
52
|
+
import re
|
|
53
|
+
import sys
|
|
54
|
+
import tokenize
|
|
55
|
+
from collections.abc import Iterable
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
EXCLUDED_DIRS = frozenset({
|
|
60
|
+
".git", ".hg", ".svn", ".venv", "venv", "env", ".env",
|
|
61
|
+
"__pycache__", ".tox", ".nox", "dist", "build", ".eggs",
|
|
62
|
+
".ruff_cache", ".mypy_cache", ".pytest_cache", ".basedpyright",
|
|
63
|
+
"node_modules",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
SUPPRESSION_RE = re.compile(r"#\s*(type|pyright)\s*:\s*ignore\b")
|
|
67
|
+
ANYIO_OK_RE = re.compile(r"#\s*noqa:\s*ANYIO_OK\b")
|
|
68
|
+
PANDAS_OK_RE = re.compile(r"#\s*noqa:\s*PANDAS_OK\b")
|
|
69
|
+
|
|
70
|
+
BANNED_IMPORTS: dict[str, tuple[str, re.Pattern[str], str]] = {
|
|
71
|
+
"asyncio": (
|
|
72
|
+
"no-asyncio",
|
|
73
|
+
ANYIO_OK_RE,
|
|
74
|
+
"import asyncio - use anyio (opt out: trailing `# noqa: ANYIO_OK`)",
|
|
75
|
+
),
|
|
76
|
+
"pandas": (
|
|
77
|
+
"no-pandas",
|
|
78
|
+
PANDAS_OK_RE,
|
|
79
|
+
"import pandas - use polars (opt out: trailing `# noqa: PANDAS_OK`)",
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Opt-out patterns for new Rust-like rules
|
|
84
|
+
MUTABLE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MUTABLE_OK")
|
|
85
|
+
SLOTS_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SLOTS_OK")
|
|
86
|
+
DICT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*DICT_OK")
|
|
87
|
+
MATCH_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MATCH_OK")
|
|
88
|
+
GENERIC_ERR_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*GENERIC_ERR_OK")
|
|
89
|
+
OBJECT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*OBJECT_OK")
|
|
90
|
+
IF_VARIANT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*IF_VARIANT_OK")
|
|
91
|
+
SIZE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SIZE_OK")
|
|
92
|
+
BROAD_EXCEPT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*BROAD_EXCEPT_OK")
|
|
93
|
+
|
|
94
|
+
PURE_LOC_LIMIT: int = 250
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Violation:
|
|
99
|
+
rule: str
|
|
100
|
+
file: Path
|
|
101
|
+
line: int
|
|
102
|
+
col: int
|
|
103
|
+
message: str
|
|
104
|
+
|
|
105
|
+
def render(self) -> str:
|
|
106
|
+
return f"{self.file}:{self.line}:{self.col}: [{self.rule}] {self.message}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def discover_files(inputs: Iterable[Path]) -> list[Path]:
|
|
110
|
+
seen: set[Path] = set()
|
|
111
|
+
for raw in inputs:
|
|
112
|
+
path = raw.resolve()
|
|
113
|
+
if not path.exists():
|
|
114
|
+
print(f"check-no-excuse-rules: input does not exist: {path}", file=sys.stderr)
|
|
115
|
+
sys.exit(2)
|
|
116
|
+
if path.is_file():
|
|
117
|
+
if path.suffix == ".py":
|
|
118
|
+
seen.add(path)
|
|
119
|
+
continue
|
|
120
|
+
for child in path.rglob("*.py"):
|
|
121
|
+
if any(part in EXCLUDED_DIRS for part in child.parts):
|
|
122
|
+
continue
|
|
123
|
+
seen.add(child)
|
|
124
|
+
return sorted(seen)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_any_node(node: ast.AST) -> bool:
|
|
128
|
+
if isinstance(node, ast.Name):
|
|
129
|
+
return node.id == "Any"
|
|
130
|
+
if isinstance(node, ast.Attribute):
|
|
131
|
+
return node.attr == "Any"
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_cast_callable(node: ast.AST) -> bool:
|
|
136
|
+
if isinstance(node, ast.Name):
|
|
137
|
+
return node.id == "cast"
|
|
138
|
+
if isinstance(node, ast.Attribute):
|
|
139
|
+
return node.attr == "cast"
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def find_node_violations(tree: ast.AST, file: Path) -> list[Violation]:
|
|
144
|
+
violations: list[Violation] = []
|
|
145
|
+
|
|
146
|
+
for node in ast.walk(tree):
|
|
147
|
+
if (
|
|
148
|
+
isinstance(node, ast.Call)
|
|
149
|
+
and is_cast_callable(node.func)
|
|
150
|
+
and node.args
|
|
151
|
+
and is_any_node(node.args[0])
|
|
152
|
+
):
|
|
153
|
+
violations.append(Violation(
|
|
154
|
+
rule="cast-any",
|
|
155
|
+
file=file,
|
|
156
|
+
line=node.lineno,
|
|
157
|
+
col=node.col_offset + 1,
|
|
158
|
+
message="cast(Any, ...) - narrow with isinstance/TypeGuard or use a Protocol/TypedDict",
|
|
159
|
+
))
|
|
160
|
+
|
|
161
|
+
if isinstance(node, ast.ExceptHandler):
|
|
162
|
+
if node.type is None:
|
|
163
|
+
violations.append(Violation(
|
|
164
|
+
rule="bare-except",
|
|
165
|
+
file=file,
|
|
166
|
+
line=node.lineno,
|
|
167
|
+
col=node.col_offset + 1,
|
|
168
|
+
message="bare `except:` - catch the narrowest exception you mean",
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
if len(node.body) != 1:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
body = node.body[0]
|
|
175
|
+
if isinstance(body, ast.Pass):
|
|
176
|
+
violations.append(Violation(
|
|
177
|
+
rule="silent-except",
|
|
178
|
+
file=file,
|
|
179
|
+
line=body.lineno,
|
|
180
|
+
col=body.col_offset + 1,
|
|
181
|
+
message="silent `except: pass` - log, re-raise, or actually handle the error",
|
|
182
|
+
))
|
|
183
|
+
elif (
|
|
184
|
+
isinstance(body, ast.Expr)
|
|
185
|
+
and isinstance(body.value, ast.Constant)
|
|
186
|
+
and body.value.value is Ellipsis
|
|
187
|
+
):
|
|
188
|
+
violations.append(Violation(
|
|
189
|
+
rule="silent-except",
|
|
190
|
+
file=file,
|
|
191
|
+
line=body.lineno,
|
|
192
|
+
col=body.col_offset + 1,
|
|
193
|
+
message="silent `except: ...` - log, re-raise, or actually handle the error",
|
|
194
|
+
))
|
|
195
|
+
|
|
196
|
+
return violations
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def find_import_violations(tree: ast.AST, source_lines: list[str], file: Path) -> list[Violation]:
|
|
200
|
+
violations: list[Violation] = []
|
|
201
|
+
|
|
202
|
+
def line_text(lineno: int) -> str:
|
|
203
|
+
index = lineno - 1
|
|
204
|
+
return source_lines[index] if 0 <= index < len(source_lines) else ""
|
|
205
|
+
|
|
206
|
+
for node in ast.walk(tree):
|
|
207
|
+
if isinstance(node, ast.Import): # noqa: IF_VARIANT_OK — filtering walk, not closed union
|
|
208
|
+
for alias in node.names:
|
|
209
|
+
top = alias.name.split(".")[0]
|
|
210
|
+
if top not in BANNED_IMPORTS:
|
|
211
|
+
continue
|
|
212
|
+
rule, opt_re, message = BANNED_IMPORTS[top]
|
|
213
|
+
if opt_re.search(line_text(node.lineno)):
|
|
214
|
+
continue
|
|
215
|
+
violations.append(Violation(
|
|
216
|
+
rule=rule,
|
|
217
|
+
file=file,
|
|
218
|
+
line=node.lineno,
|
|
219
|
+
col=node.col_offset + 1,
|
|
220
|
+
message=message,
|
|
221
|
+
))
|
|
222
|
+
elif isinstance(node, ast.ImportFrom):
|
|
223
|
+
top = (node.module or "").split(".")[0]
|
|
224
|
+
if top not in BANNED_IMPORTS:
|
|
225
|
+
continue
|
|
226
|
+
rule, opt_re, message = BANNED_IMPORTS[top]
|
|
227
|
+
if opt_re.search(line_text(node.lineno)):
|
|
228
|
+
continue
|
|
229
|
+
violations.append(Violation(
|
|
230
|
+
rule=rule,
|
|
231
|
+
file=file,
|
|
232
|
+
line=node.lineno,
|
|
233
|
+
col=node.col_offset + 1,
|
|
234
|
+
message=message,
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
return violations
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def find_comment_violations(source: str, file: Path) -> list[Violation]:
|
|
241
|
+
"""Use tokenize so we don't false-match `# type: ignore` inside string literals."""
|
|
242
|
+
violations: list[Violation] = []
|
|
243
|
+
try:
|
|
244
|
+
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
|
245
|
+
except tokenize.TokenError as exc:
|
|
246
|
+
print(f"check-no-excuse-rules: tokenize failed for {file}: {exc}", file=sys.stderr)
|
|
247
|
+
return violations
|
|
248
|
+
|
|
249
|
+
for tok in tokens:
|
|
250
|
+
if tok.type != tokenize.COMMENT:
|
|
251
|
+
continue
|
|
252
|
+
match = SUPPRESSION_RE.search(tok.string)
|
|
253
|
+
if not match:
|
|
254
|
+
continue
|
|
255
|
+
kind = match.group(1)
|
|
256
|
+
rule = "type-ignore" if kind == "type" else "pyright-ignore"
|
|
257
|
+
violations.append(Violation(
|
|
258
|
+
rule=rule,
|
|
259
|
+
file=file,
|
|
260
|
+
line=tok.start[0],
|
|
261
|
+
col=tok.start[1] + match.start() + 1,
|
|
262
|
+
message=f"`# {kind}: ignore` - fix the underlying type instead",
|
|
263
|
+
))
|
|
264
|
+
return violations
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ─────────────────────────────────────────────────────────────────
|
|
268
|
+
# Rust-like pattern checks
|
|
269
|
+
# ─────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _has_keyword(decorator_node: ast.Call, keyword: str) -> bool | None:
|
|
273
|
+
"""Check if a decorator call has a specific keyword argument.
|
|
274
|
+
|
|
275
|
+
Returns True if keyword is True, False if keyword is False or absent, None if not a Call.
|
|
276
|
+
"""
|
|
277
|
+
for kw in decorator_node.keywords:
|
|
278
|
+
if kw.arg == keyword and isinstance(kw.value, ast.Constant):
|
|
279
|
+
return bool(kw.value.value)
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _is_dataclass_decorator(node: ast.expr) -> tuple[bool, ast.Call | None]:
|
|
284
|
+
"""Return (is_dataclass, call_node_or_None)."""
|
|
285
|
+
if isinstance(node, ast.Name) and node.id == "dataclass":
|
|
286
|
+
return True, None
|
|
287
|
+
if isinstance(node, ast.Attribute) and node.attr == "dataclass":
|
|
288
|
+
return True, None
|
|
289
|
+
if isinstance(node, ast.Call):
|
|
290
|
+
inner, _ = _is_dataclass_decorator(node.func)
|
|
291
|
+
if inner:
|
|
292
|
+
return True, node
|
|
293
|
+
return False, None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def find_dataclass_violations(
|
|
297
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
298
|
+
) -> list[Violation]:
|
|
299
|
+
"""Check @dataclass decorators for frozen=True and slots=True."""
|
|
300
|
+
violations: list[Violation] = []
|
|
301
|
+
for node in ast.walk(tree):
|
|
302
|
+
if not isinstance(node, ast.ClassDef):
|
|
303
|
+
continue
|
|
304
|
+
for dec in node.decorator_list:
|
|
305
|
+
is_dc, call_node = _is_dataclass_decorator(dec)
|
|
306
|
+
if not is_dc:
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# Get the line of the decorator for opt-out check
|
|
310
|
+
dec_line = source_lines[dec.lineno - 1] if dec.lineno <= len(source_lines) else ""
|
|
311
|
+
|
|
312
|
+
if call_node is not None:
|
|
313
|
+
has_frozen = _has_keyword(call_node, "frozen")
|
|
314
|
+
has_slots = _has_keyword(call_node, "slots")
|
|
315
|
+
else:
|
|
316
|
+
# bare @dataclass with no arguments
|
|
317
|
+
has_frozen = False
|
|
318
|
+
has_slots = False
|
|
319
|
+
|
|
320
|
+
if not has_frozen and not MUTABLE_OK_RE.search(dec_line):
|
|
321
|
+
violations.append(Violation(
|
|
322
|
+
rule="mutable-dataclass",
|
|
323
|
+
file=file,
|
|
324
|
+
line=dec.lineno,
|
|
325
|
+
col=dec.col_offset + 1,
|
|
326
|
+
message=f"class {node.name}: @dataclass without frozen=True",
|
|
327
|
+
))
|
|
328
|
+
|
|
329
|
+
if not has_slots and not SLOTS_OK_RE.search(dec_line):
|
|
330
|
+
violations.append(Violation(
|
|
331
|
+
rule="missing-slots",
|
|
332
|
+
file=file,
|
|
333
|
+
line=dec.lineno,
|
|
334
|
+
col=dec.col_offset + 1,
|
|
335
|
+
message=f"class {node.name}: @dataclass without slots=True",
|
|
336
|
+
))
|
|
337
|
+
return violations
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def find_dict_return_violations(
|
|
341
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
342
|
+
) -> list[Violation]:
|
|
343
|
+
"""Check for functions returning bare `dict` type."""
|
|
344
|
+
violations: list[Violation] = []
|
|
345
|
+
for node in ast.walk(tree):
|
|
346
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
347
|
+
continue
|
|
348
|
+
ret = node.returns
|
|
349
|
+
if ret is None:
|
|
350
|
+
continue
|
|
351
|
+
# Check for bare `dict` return annotation
|
|
352
|
+
is_bare_dict = (
|
|
353
|
+
(isinstance(ret, ast.Name) and ret.id == "dict")
|
|
354
|
+
or (isinstance(ret, ast.Attribute) and ret.attr == "dict")
|
|
355
|
+
)
|
|
356
|
+
if not is_bare_dict:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
func_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
|
360
|
+
if DICT_OK_RE.search(func_line):
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
violations.append(Violation(
|
|
364
|
+
rule="raw-dict-return",
|
|
365
|
+
file=file,
|
|
366
|
+
line=node.lineno,
|
|
367
|
+
col=node.col_offset + 1,
|
|
368
|
+
message=f"`{node.name}` returns bare dict - use TypedDict/dataclass/Pydantic model",
|
|
369
|
+
))
|
|
370
|
+
return violations
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def find_match_violations(
|
|
374
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
375
|
+
) -> list[Violation]:
|
|
376
|
+
"""Check match statements for assert_never in default case."""
|
|
377
|
+
violations: list[Violation] = []
|
|
378
|
+
for node in ast.walk(tree):
|
|
379
|
+
if not isinstance(node, ast.Match):
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
match_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
|
383
|
+
if MATCH_OK_RE.search(match_line):
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
has_assert_never = False
|
|
387
|
+
for case in node.cases:
|
|
388
|
+
# Wildcard: `case _:` -> MatchAs(pattern=None, name=None)
|
|
389
|
+
# `case _ as x:` -> MatchAs(pattern=MatchAs(pattern=None, name=None), name="x")
|
|
390
|
+
pattern = case.pattern
|
|
391
|
+
is_wildcard = (
|
|
392
|
+
isinstance(pattern, ast.MatchAs)
|
|
393
|
+
and (
|
|
394
|
+
pattern.pattern is None
|
|
395
|
+
or (
|
|
396
|
+
isinstance(pattern.pattern, ast.MatchAs)
|
|
397
|
+
and pattern.pattern.pattern is None
|
|
398
|
+
and pattern.pattern.name is None
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
if not is_wildcard:
|
|
403
|
+
continue
|
|
404
|
+
# Check if body contains assert_never call
|
|
405
|
+
for stmt in case.body:
|
|
406
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
407
|
+
func = stmt.value.func
|
|
408
|
+
if (
|
|
409
|
+
(isinstance(func, ast.Name) and func.id == "assert_never")
|
|
410
|
+
or (isinstance(func, ast.Attribute) and func.attr == "assert_never")
|
|
411
|
+
):
|
|
412
|
+
has_assert_never = True
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
if not has_assert_never:
|
|
416
|
+
violations.append(Violation(
|
|
417
|
+
rule="missing-assert-never",
|
|
418
|
+
file=file,
|
|
419
|
+
line=node.lineno,
|
|
420
|
+
col=node.col_offset + 1,
|
|
421
|
+
message="match without `case _: assert_never(x)` default",
|
|
422
|
+
))
|
|
423
|
+
return violations
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def find_generic_exception_violations(
|
|
427
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
428
|
+
) -> list[Violation]:
|
|
429
|
+
"""Check for raise ValueError/TypeError/RuntimeError with bare string or f-string."""
|
|
430
|
+
GENERIC_EXCEPTIONS = {"ValueError", "TypeError", "RuntimeError", "KeyError"}
|
|
431
|
+
violations: list[Violation] = []
|
|
432
|
+
for node in ast.walk(tree):
|
|
433
|
+
if not isinstance(node, ast.Raise) or node.exc is None:
|
|
434
|
+
continue
|
|
435
|
+
exc = node.exc
|
|
436
|
+
# Match: raise SomeError("string literal")
|
|
437
|
+
if not isinstance(exc, ast.Call):
|
|
438
|
+
continue
|
|
439
|
+
func = exc.func
|
|
440
|
+
exc_name: str | None = None
|
|
441
|
+
if isinstance(func, ast.Name) and func.id in GENERIC_EXCEPTIONS:
|
|
442
|
+
exc_name = func.id
|
|
443
|
+
elif isinstance(func, ast.Attribute) and func.attr in GENERIC_EXCEPTIONS:
|
|
444
|
+
exc_name = func.attr
|
|
445
|
+
if exc_name is None:
|
|
446
|
+
continue
|
|
447
|
+
# Check if all arguments are string literals or f-strings
|
|
448
|
+
if not exc.args:
|
|
449
|
+
continue
|
|
450
|
+
all_str = all(
|
|
451
|
+
(isinstance(arg, ast.Constant) and isinstance(arg.value, str))
|
|
452
|
+
or isinstance(arg, ast.JoinedStr)
|
|
453
|
+
for arg in exc.args
|
|
454
|
+
)
|
|
455
|
+
if not all_str:
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
raise_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
|
459
|
+
if GENERIC_ERR_OK_RE.search(raise_line):
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
violations.append(Violation(
|
|
463
|
+
rule="generic-exception",
|
|
464
|
+
file=file,
|
|
465
|
+
line=node.lineno,
|
|
466
|
+
col=node.col_offset + 1,
|
|
467
|
+
message=f"`raise {exc_name}(\"...\")` - define a typed error class instead",
|
|
468
|
+
))
|
|
469
|
+
return violations
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _is_isinstance_test(node: ast.expr) -> bool:
|
|
473
|
+
"""Check if node is an isinstance() call."""
|
|
474
|
+
return (
|
|
475
|
+
isinstance(node, ast.Call)
|
|
476
|
+
and isinstance(node.func, ast.Name)
|
|
477
|
+
and node.func.id == "isinstance"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _is_enum_comparison(node: ast.expr) -> bool:
|
|
482
|
+
"""Check if node is `x == Enum.VALUE` or `x is Enum.VALUE`."""
|
|
483
|
+
if isinstance(node, ast.Compare) and len(node.ops) == 1:
|
|
484
|
+
op = node.ops[0]
|
|
485
|
+
if isinstance(op, (ast.Eq, ast.Is)):
|
|
486
|
+
comparator = node.comparators[0]
|
|
487
|
+
# x == Enum.VALUE (attribute access on the right)
|
|
488
|
+
if isinstance(comparator, ast.Attribute):
|
|
489
|
+
return True
|
|
490
|
+
# Enum.VALUE == x (attribute access on the left)
|
|
491
|
+
if isinstance(node.left, ast.Attribute):
|
|
492
|
+
return True
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def find_object_annotation_violations(
|
|
497
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
498
|
+
) -> list[Violation]:
|
|
499
|
+
"""Check for `object` used as a type annotation."""
|
|
500
|
+
violations: list[Violation] = []
|
|
501
|
+
|
|
502
|
+
def _check_annotation(ann: ast.expr | None) -> None:
|
|
503
|
+
if ann is None:
|
|
504
|
+
return
|
|
505
|
+
for child in ast.walk(ann):
|
|
506
|
+
if isinstance(child, ast.Name) and child.id == "object":
|
|
507
|
+
line = source_lines[child.lineno - 1] if child.lineno <= len(source_lines) else ""
|
|
508
|
+
if OBJECT_OK_RE.search(line):
|
|
509
|
+
return
|
|
510
|
+
violations.append(Violation(
|
|
511
|
+
rule="no-object",
|
|
512
|
+
file=file,
|
|
513
|
+
line=child.lineno,
|
|
514
|
+
col=child.col_offset + 1,
|
|
515
|
+
message="`object` as type annotation \u2014 use Protocol, TypeVar, or union",
|
|
516
|
+
))
|
|
517
|
+
|
|
518
|
+
for node in ast.walk(tree):
|
|
519
|
+
match node: # noqa: MATCH_OK — filtering walk, not discriminating a closed union
|
|
520
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
|
521
|
+
all_args = (
|
|
522
|
+
node.args.args
|
|
523
|
+
+ node.args.posonlyargs
|
|
524
|
+
+ node.args.kwonlyargs
|
|
525
|
+
)
|
|
526
|
+
for arg in all_args:
|
|
527
|
+
_check_annotation(arg.annotation)
|
|
528
|
+
if node.args.vararg:
|
|
529
|
+
_check_annotation(node.args.vararg.annotation)
|
|
530
|
+
if node.args.kwarg:
|
|
531
|
+
_check_annotation(node.args.kwarg.annotation)
|
|
532
|
+
_check_annotation(node.returns)
|
|
533
|
+
case ast.AnnAssign():
|
|
534
|
+
_check_annotation(node.annotation)
|
|
535
|
+
return violations
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def find_if_elif_variant_violations(
|
|
539
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
540
|
+
) -> list[Violation]:
|
|
541
|
+
"""Check for if/elif chains on isinstance or enum comparison."""
|
|
542
|
+
violations: list[Violation] = []
|
|
543
|
+
for node in ast.walk(tree):
|
|
544
|
+
if not isinstance(node, ast.If):
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
|
548
|
+
if IF_VARIANT_OK_RE.search(line):
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
is_variant_test = _is_isinstance_test(node.test) or _is_enum_comparison(node.test)
|
|
552
|
+
if not is_variant_test:
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
# Must have at least one elif that is also a variant test
|
|
556
|
+
orelse = node.orelse
|
|
557
|
+
while orelse and len(orelse) == 1 and isinstance(orelse[0], ast.If):
|
|
558
|
+
elif_node = orelse[0]
|
|
559
|
+
if _is_isinstance_test(elif_node.test) or _is_enum_comparison(elif_node.test):
|
|
560
|
+
violations.append(Violation(
|
|
561
|
+
rule="if-elif-on-variant",
|
|
562
|
+
file=file,
|
|
563
|
+
line=node.lineno,
|
|
564
|
+
col=node.col_offset + 1,
|
|
565
|
+
message="isinstance/enum if/elif chain \u2014 use match/case + assert_never",
|
|
566
|
+
))
|
|
567
|
+
break
|
|
568
|
+
orelse = elif_node.orelse
|
|
569
|
+
return violations
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def find_broad_except_violations(
|
|
573
|
+
tree: ast.Module, source_lines: list[str], file: Path,
|
|
574
|
+
) -> list[Violation]:
|
|
575
|
+
"""Check for except Exception / except BaseException (too broad)."""
|
|
576
|
+
BROAD_EXCEPTIONS = {"Exception", "BaseException"}
|
|
577
|
+
violations: list[Violation] = []
|
|
578
|
+
for node in ast.walk(tree):
|
|
579
|
+
if not isinstance(node, ast.ExceptHandler):
|
|
580
|
+
continue
|
|
581
|
+
if node.type is None:
|
|
582
|
+
continue # already caught by bare-except
|
|
583
|
+
|
|
584
|
+
exc_name: str | None = None
|
|
585
|
+
if isinstance(node.type, ast.Name) and node.type.id in BROAD_EXCEPTIONS:
|
|
586
|
+
exc_name = node.type.id
|
|
587
|
+
elif isinstance(node.type, ast.Attribute) and node.type.attr in BROAD_EXCEPTIONS:
|
|
588
|
+
exc_name = node.type.attr
|
|
589
|
+
if exc_name is None:
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
|
593
|
+
if BROAD_EXCEPT_OK_RE.search(line):
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
violations.append(Violation(
|
|
597
|
+
rule="broad-except",
|
|
598
|
+
file=file,
|
|
599
|
+
line=node.lineno,
|
|
600
|
+
col=node.col_offset + 1,
|
|
601
|
+
message=f"`except {exc_name}` is too broad \u2014 catch the specific exception you expect",
|
|
602
|
+
))
|
|
603
|
+
return violations
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def find_oversized_module_violations(
|
|
607
|
+
source_lines: list[str], file: Path,
|
|
608
|
+
) -> list[Violation]:
|
|
609
|
+
"""Check if file exceeds 250 pure LOC (non-blank, non-comment)."""
|
|
610
|
+
# File-level opt-out in first 10 lines (shebang + script metadata can push it down)
|
|
611
|
+
for line in source_lines[:10]:
|
|
612
|
+
if SIZE_OK_RE.search(line):
|
|
613
|
+
return []
|
|
614
|
+
|
|
615
|
+
pure_loc = sum(
|
|
616
|
+
1 for line in source_lines
|
|
617
|
+
if line.strip() and not line.strip().startswith("#")
|
|
618
|
+
)
|
|
619
|
+
if pure_loc > PURE_LOC_LIMIT:
|
|
620
|
+
return [Violation(
|
|
621
|
+
rule="oversized-module",
|
|
622
|
+
file=file,
|
|
623
|
+
line=1,
|
|
624
|
+
col=1,
|
|
625
|
+
message=f"{pure_loc} pure LOC (limit: {PURE_LOC_LIMIT}) \u2014 split by responsibility",
|
|
626
|
+
)]
|
|
627
|
+
return []
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def check_file(file: Path) -> list[Violation]:
|
|
631
|
+
source = file.read_text(encoding="utf-8")
|
|
632
|
+
try:
|
|
633
|
+
tree = ast.parse(source, filename=str(file))
|
|
634
|
+
except SyntaxError as exc:
|
|
635
|
+
return [Violation(
|
|
636
|
+
rule="syntax-error",
|
|
637
|
+
file=file,
|
|
638
|
+
line=exc.lineno or 1,
|
|
639
|
+
col=exc.offset or 1,
|
|
640
|
+
message=f"SyntaxError: {exc.msg}",
|
|
641
|
+
)]
|
|
642
|
+
|
|
643
|
+
source_lines = source.splitlines()
|
|
644
|
+
return [
|
|
645
|
+
*find_node_violations(tree, file),
|
|
646
|
+
*find_import_violations(tree, source_lines, file),
|
|
647
|
+
*find_comment_violations(source, file),
|
|
648
|
+
*find_dataclass_violations(tree, source_lines, file),
|
|
649
|
+
*find_dict_return_violations(tree, source_lines, file),
|
|
650
|
+
*find_match_violations(tree, source_lines, file),
|
|
651
|
+
*find_generic_exception_violations(tree, source_lines, file),
|
|
652
|
+
*find_object_annotation_violations(tree, source_lines, file),
|
|
653
|
+
*find_if_elif_variant_violations(tree, source_lines, file),
|
|
654
|
+
*find_oversized_module_violations(source_lines, file),
|
|
655
|
+
*find_broad_except_violations(tree, source_lines, file),
|
|
656
|
+
]
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def main() -> int:
|
|
660
|
+
if len(sys.argv) < 2:
|
|
661
|
+
print("usage: check-no-excuse-rules.py <file-or-dir>...", file=sys.stderr)
|
|
662
|
+
return 2
|
|
663
|
+
|
|
664
|
+
files = discover_files(Path(arg) for arg in sys.argv[1:])
|
|
665
|
+
if not files:
|
|
666
|
+
print("check-no-excuse-rules: no .py files found", file=sys.stderr)
|
|
667
|
+
return 0
|
|
668
|
+
|
|
669
|
+
violations: list[Violation] = []
|
|
670
|
+
for file in files:
|
|
671
|
+
violations.extend(check_file(file))
|
|
672
|
+
|
|
673
|
+
if not violations:
|
|
674
|
+
print(f"no violations in {len(files)} file(s)")
|
|
675
|
+
return 0
|
|
676
|
+
|
|
677
|
+
for violation in violations:
|
|
678
|
+
print(violation.render(), file=sys.stderr)
|
|
679
|
+
print(
|
|
680
|
+
f"\n{len(violations)} violation(s) in {len(files)} file(s)",
|
|
681
|
+
file=sys.stderr,
|
|
682
|
+
)
|
|
683
|
+
return 1
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
if __name__ == "__main__":
|
|
687
|
+
sys.exit(main())
|