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.
Files changed (156) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/LICENSE +21 -0
  3. package/README.md +369 -0
  4. package/README_ko-KR.md +374 -0
  5. package/RELEASE_CHECKLIST.md +165 -0
  6. package/bin/litclaude-ai.js +643 -0
  7. package/cover.png +0 -0
  8. package/docs/agents.md +67 -0
  9. package/docs/hooks.md +134 -0
  10. package/docs/lsp.md +40 -0
  11. package/docs/migration.md +209 -0
  12. package/docs/workflow-compatibility-audit.md +119 -0
  13. package/generate_cover.py +123 -0
  14. package/package.json +48 -0
  15. package/plugins/litclaude/.claude-plugin/plugin.json +25 -0
  16. package/plugins/litclaude/.lsp.json +13 -0
  17. package/plugins/litclaude/.mcp.json +9 -0
  18. package/plugins/litclaude/agents/boulder-executor.md +12 -0
  19. package/plugins/litclaude/agents/librarian-researcher.md +15 -0
  20. package/plugins/litclaude/agents/oracle-verifier.md +16 -0
  21. package/plugins/litclaude/agents/prometheus-planner.md +13 -0
  22. package/plugins/litclaude/agents/qa-runner.md +16 -0
  23. package/plugins/litclaude/agents/quality-reviewer.md +17 -0
  24. package/plugins/litclaude/bin/litclaude-hook.js +110 -0
  25. package/plugins/litclaude/bin/litclaude-hud.js +271 -0
  26. package/plugins/litclaude/bin/litclaude-lsp-doctor.js +15 -0
  27. package/plugins/litclaude/bin/litclaude-mcp.js +70 -0
  28. package/plugins/litclaude/commands/deep-interview.md +21 -0
  29. package/plugins/litclaude/commands/dynamic-workflow.md +36 -0
  30. package/plugins/litclaude/commands/lit-loop.md +40 -0
  31. package/plugins/litclaude/commands/lit-plan.md +35 -0
  32. package/plugins/litclaude/commands/litgoal.md +30 -0
  33. package/plugins/litclaude/commands/review-work.md +35 -0
  34. package/plugins/litclaude/commands/start-work.md +36 -0
  35. package/plugins/litclaude/hooks/hooks.json +54 -0
  36. package/plugins/litclaude/lib/context-pressure.mjs +25 -0
  37. package/plugins/litclaude/lib/hud-accent-palette.mjs +58 -0
  38. package/plugins/litclaude/lib/litgoal/cli.mjs +266 -0
  39. package/plugins/litclaude/lib/litgoal/ledger.mjs +16 -0
  40. package/plugins/litclaude/lib/litgoal/paths.mjs +7 -0
  41. package/plugins/litclaude/lib/litgoal/state.mjs +67 -0
  42. package/plugins/litclaude/lib/mutated-file-paths.mjs +63 -0
  43. package/plugins/litclaude/lib/start-work-continuation.mjs +99 -0
  44. package/plugins/litclaude/lib/workflow-check.mjs +83 -0
  45. package/plugins/litclaude/skills/ai-slop-remover/SKILL.md +142 -0
  46. package/plugins/litclaude/skills/comment-checker/SKILL.md +55 -0
  47. package/plugins/litclaude/skills/debugging/SKILL.md +70 -0
  48. package/plugins/litclaude/skills/debugging/references/methodology/00-setup.md +108 -0
  49. package/plugins/litclaude/skills/debugging/references/methodology/02-investigate.md +126 -0
  50. package/plugins/litclaude/skills/debugging/references/methodology/04-oracle-triple.md +106 -0
  51. package/plugins/litclaude/skills/debugging/references/methodology/05-escalate.md +69 -0
  52. package/plugins/litclaude/skills/debugging/references/methodology/06-fix.md +116 -0
  53. package/plugins/litclaude/skills/debugging/references/methodology/08-qa.md +94 -0
  54. package/plugins/litclaude/skills/debugging/references/methodology/09-cleanup.md +164 -0
  55. package/plugins/litclaude/skills/debugging/references/methodology/partial-runtime-evidence.md +228 -0
  56. package/plugins/litclaude/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
  57. package/plugins/litclaude/skills/debugging/references/runtimes/go.md +252 -0
  58. package/plugins/litclaude/skills/debugging/references/runtimes/native-binary.md +484 -0
  59. package/plugins/litclaude/skills/debugging/references/runtimes/node.md +260 -0
  60. package/plugins/litclaude/skills/debugging/references/runtimes/python.md +248 -0
  61. package/plugins/litclaude/skills/debugging/references/runtimes/rust.md +234 -0
  62. package/plugins/litclaude/skills/debugging/references/tools/ghidra.md +212 -0
  63. package/plugins/litclaude/skills/debugging/references/tools/playwright-cli.md +194 -0
  64. package/plugins/litclaude/skills/debugging/references/tools/pwndbg.md +263 -0
  65. package/plugins/litclaude/skills/debugging/references/tools/pwntools.md +265 -0
  66. package/plugins/litclaude/skills/deep-interview/SKILL.md +323 -0
  67. package/plugins/litclaude/skills/deep-interview/scripts/render_progress.py +193 -0
  68. package/plugins/litclaude/skills/frontend-ui-ux/SKILL.md +62 -0
  69. package/plugins/litclaude/skills/lit-loop/SKILL.md +144 -0
  70. package/plugins/litclaude/skills/lit-plan/SKILL.md +125 -0
  71. package/plugins/litclaude/skills/litgoal/SKILL.md +219 -0
  72. package/plugins/litclaude/skills/lsp/SKILL.md +63 -0
  73. package/plugins/litclaude/skills/programming/SKILL.md +106 -0
  74. package/plugins/litclaude/skills/programming/references/go/README.md +90 -0
  75. package/plugins/litclaude/skills/programming/references/go/backend-stack.md +641 -0
  76. package/plugins/litclaude/skills/programming/references/go/bootstrap.md +328 -0
  77. package/plugins/litclaude/skills/programming/references/go/bubbletea-v2.md +360 -0
  78. package/plugins/litclaude/skills/programming/references/go/cobra-stack.md +468 -0
  79. package/plugins/litclaude/skills/programming/references/go/concurrency.md +362 -0
  80. package/plugins/litclaude/skills/programming/references/go/data-modeling.md +329 -0
  81. package/plugins/litclaude/skills/programming/references/go/error-handling.md +359 -0
  82. package/plugins/litclaude/skills/programming/references/go/golangci-strict.md +236 -0
  83. package/plugins/litclaude/skills/programming/references/go/grpc-connect.md +375 -0
  84. package/plugins/litclaude/skills/programming/references/go/libraries.md +337 -0
  85. package/plugins/litclaude/skills/programming/references/go/one-liners.md +202 -0
  86. package/plugins/litclaude/skills/programming/references/go/sqlc-pgx.md +471 -0
  87. package/plugins/litclaude/skills/programming/references/go/testing.md +467 -0
  88. package/plugins/litclaude/skills/programming/references/go/type-patterns.md +298 -0
  89. package/plugins/litclaude/skills/programming/references/python/README.md +314 -0
  90. package/plugins/litclaude/skills/programming/references/python/async-anyio.md +442 -0
  91. package/plugins/litclaude/skills/programming/references/python/data-modeling.md +233 -0
  92. package/plugins/litclaude/skills/programming/references/python/data-processing.md +133 -0
  93. package/plugins/litclaude/skills/programming/references/python/error-handling.md +218 -0
  94. package/plugins/litclaude/skills/programming/references/python/fastapi-stack.md +316 -0
  95. package/plugins/litclaude/skills/programming/references/python/httpx2-optimization.md +360 -0
  96. package/plugins/litclaude/skills/programming/references/python/libraries.md +307 -0
  97. package/plugins/litclaude/skills/programming/references/python/one-liners.md +268 -0
  98. package/plugins/litclaude/skills/programming/references/python/orjson-stack.md +378 -0
  99. package/plugins/litclaude/skills/programming/references/python/pydantic-ai.md +285 -0
  100. package/plugins/litclaude/skills/programming/references/python/pyproject-strict.md +232 -0
  101. package/plugins/litclaude/skills/programming/references/python/textual-tui.md +201 -0
  102. package/plugins/litclaude/skills/programming/references/python/type-patterns.md +176 -0
  103. package/plugins/litclaude/skills/programming/references/rust/README.md +317 -0
  104. package/plugins/litclaude/skills/programming/references/rust/async-tokio.md +299 -0
  105. package/plugins/litclaude/skills/programming/references/rust/axum-stack.md +467 -0
  106. package/plugins/litclaude/skills/programming/references/rust/cargo-strict.md +317 -0
  107. package/plugins/litclaude/skills/programming/references/rust/clap-stack.md +409 -0
  108. package/plugins/litclaude/skills/programming/references/rust/concurrency.md +375 -0
  109. package/plugins/litclaude/skills/programming/references/rust/libraries.md +439 -0
  110. package/plugins/litclaude/skills/programming/references/rust/one-liners.md +291 -0
  111. package/plugins/litclaude/skills/programming/references/rust/proptest-insta.md +429 -0
  112. package/plugins/litclaude/skills/programming/references/rust/type-state.md +354 -0
  113. package/plugins/litclaude/skills/programming/references/rust/unsafe-discipline.md +250 -0
  114. package/plugins/litclaude/skills/programming/references/rust/zero-cost-safety.md +527 -0
  115. package/plugins/litclaude/skills/programming/references/rust-ub/README.md +289 -0
  116. package/plugins/litclaude/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
  117. package/plugins/litclaude/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
  118. package/plugins/litclaude/skills/programming/references/typescript/README.md +195 -0
  119. package/plugins/litclaude/skills/programming/references/typescript/backend-hono.md +672 -0
  120. package/plugins/litclaude/skills/programming/references/typescript/bootstrap.md +199 -0
  121. package/plugins/litclaude/skills/programming/references/typescript/data-modeling.md +202 -0
  122. package/plugins/litclaude/skills/programming/references/typescript/error-handling.md +169 -0
  123. package/plugins/litclaude/skills/programming/references/typescript/tsconfig-strict.md +152 -0
  124. package/plugins/litclaude/skills/programming/references/typescript/type-patterns.md +196 -0
  125. package/plugins/litclaude/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
  126. package/plugins/litclaude/skills/programming/scripts/go/new-project.py +138 -0
  127. package/plugins/litclaude/skills/programming/scripts/go/templates/.editorconfig +13 -0
  128. package/plugins/litclaude/skills/programming/scripts/go/templates/.golangci.yml +95 -0
  129. package/plugins/litclaude/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
  130. package/plugins/litclaude/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
  131. package/plugins/litclaude/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
  132. package/plugins/litclaude/skills/programming/scripts/go/templates/ci.yml +37 -0
  133. package/plugins/litclaude/skills/programming/scripts/go/templates/config.go +24 -0
  134. package/plugins/litclaude/skills/programming/scripts/go/templates/gitignore +15 -0
  135. package/plugins/litclaude/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
  136. package/plugins/litclaude/skills/programming/scripts/go/templates/run.go +15 -0
  137. package/plugins/litclaude/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
  138. package/plugins/litclaude/skills/programming/scripts/python/new-project.py +172 -0
  139. package/plugins/litclaude/skills/programming/scripts/python/new-script.py +116 -0
  140. package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
  141. package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
  142. package/plugins/litclaude/skills/programming/scripts/rust/new-project.py +175 -0
  143. package/plugins/litclaude/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
  144. package/plugins/litclaude/skills/programming/scripts/typescript/new-project.ts +177 -0
  145. package/plugins/litclaude/skills/refactor/SKILL.md +73 -0
  146. package/plugins/litclaude/skills/remove-ai-slops/SKILL.md +52 -0
  147. package/plugins/litclaude/skills/review-work/SKILL.md +331 -0
  148. package/plugins/litclaude/skills/rules/SKILL.md +66 -0
  149. package/plugins/litclaude/skills/start-work/SKILL.md +132 -0
  150. package/scripts/audit-plan-checkboxes.mjs +37 -0
  151. package/scripts/doctor.mjs +41 -0
  152. package/scripts/inspect-agent-tools.mjs +27 -0
  153. package/scripts/postinstall.mjs +50 -0
  154. package/scripts/qa-claude-plugin-smoke.sh +60 -0
  155. package/scripts/qa-portable-install.sh +136 -0
  156. 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())