lintex 0.1.0__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.
- lintex/__init__.py +22 -0
- lintex/__main__.py +3 -0
- lintex/cli.py +475 -0
- lintex/config.py +291 -0
- lintex/engine.py +304 -0
- lintex/hooks/__init__.py +13 -0
- lintex/hooks/claude_code.py +119 -0
- lintex/hooks/git.py +124 -0
- lintex/hooks/statusline.py +119 -0
- lintex/knowledge/__init__.py +164 -0
- lintex/knowledge/__main__.py +3 -0
- lintex/knowledge/curated.toml +742 -0
- lintex/knowledge/sync.py +251 -0
- lintex/knowledge/typeshed.json +1248 -0
- lintex/ledger.py +292 -0
- lintex/models.py +66 -0
- lintex/py.typed +0 -0
- lintex/reporters.py +315 -0
- lintex/rules/__init__.py +116 -0
- lintex/rules/agent_artifacts.py +180 -0
- lintex/rules/banned_patterns.py +529 -0
- lintex/rules/base.py +226 -0
- lintex/rules/bloat.py +275 -0
- lintex/rules/hallucinations.py +410 -0
- lintex-0.1.0.dist-info/METADATA +420 -0
- lintex-0.1.0.dist-info/RECORD +29 -0
- lintex-0.1.0.dist-info/WHEEL +4 -0
- lintex-0.1.0.dist-info/entry_points.txt +2 -0
- lintex-0.1.0.dist-info/licenses/LICENSE +21 -0
lintex/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""lintex: a fast linter for code written by LLM coding agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from lintex.config import Config, ConfigError, load_config
|
|
8
|
+
from lintex.engine import Linter, LintResult, lint_paths, lint_source
|
|
9
|
+
from lintex.models import Severity, Violation
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Config",
|
|
13
|
+
"ConfigError",
|
|
14
|
+
"LintResult",
|
|
15
|
+
"Linter",
|
|
16
|
+
"Severity",
|
|
17
|
+
"Violation",
|
|
18
|
+
"__version__",
|
|
19
|
+
"lint_paths",
|
|
20
|
+
"lint_source",
|
|
21
|
+
"load_config",
|
|
22
|
+
]
|
lintex/__main__.py
ADDED
lintex/cli.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
lintex [check] [PATHS...] [--json] [--staged] lint files (default: .)
|
|
4
|
+
lintex rules [--json] list rules
|
|
5
|
+
lintex explain CODE document a rule
|
|
6
|
+
lintex report [--since 24h] [--session ID] what the hook caught and what got fixed
|
|
7
|
+
lintex statusline [--segment] Claude Code status line segment
|
|
8
|
+
lintex install-hook git|claude-code|statusline set up automatic checks
|
|
9
|
+
lintex hook claude-code Claude Code PostToolUse entry point
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import dataclasses
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import textwrap
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from lintex import __version__, ledger
|
|
25
|
+
from lintex.config import Config, ConfigError, load_config
|
|
26
|
+
from lintex.engine import Linter, LintResult
|
|
27
|
+
from lintex.hooks.claude_code import install_claude_code_hook, run_claude_code_hook
|
|
28
|
+
from lintex.hooks.git import (
|
|
29
|
+
GitError,
|
|
30
|
+
changed_python_files,
|
|
31
|
+
default_base,
|
|
32
|
+
install_git_hook,
|
|
33
|
+
repo_root,
|
|
34
|
+
staged_content,
|
|
35
|
+
staged_python_files,
|
|
36
|
+
)
|
|
37
|
+
from lintex.hooks.statusline import (
|
|
38
|
+
install_statusline,
|
|
39
|
+
read_payload,
|
|
40
|
+
render_segment,
|
|
41
|
+
render_statusline,
|
|
42
|
+
)
|
|
43
|
+
from lintex.models import Severity, Violation
|
|
44
|
+
from lintex.reporters import FORMATS, Style, dim_note, render
|
|
45
|
+
from lintex.rules import ALL_RULES, get_rule, is_rule_active
|
|
46
|
+
|
|
47
|
+
COMMANDS = ("check", "rules", "explain", "report", "statusline", "install-hook", "hook")
|
|
48
|
+
EXIT_OK, EXIT_VIOLATIONS, EXIT_USAGE = 0, 1, 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _csv(value: str) -> list[str]:
|
|
52
|
+
return [v.strip().upper() for v in value.split(",") if v.strip()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="lintex",
|
|
58
|
+
description="Fast linter for Python code written by LLM coding agents.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--version", action="version", version=f"lintex {__version__}")
|
|
61
|
+
sub = parser.add_subparsers(dest="command")
|
|
62
|
+
|
|
63
|
+
_add_check_parser(sub)
|
|
64
|
+
|
|
65
|
+
rules = sub.add_parser("rules", help="list available rules")
|
|
66
|
+
rules.add_argument("--json", action="store_true")
|
|
67
|
+
rules.add_argument("--config", metavar="PATH")
|
|
68
|
+
|
|
69
|
+
explain = sub.add_parser("explain", help="show documentation for a rule")
|
|
70
|
+
explain.add_argument("rule", help="rule code or name, e.g. LX101 or hallucinated-api")
|
|
71
|
+
|
|
72
|
+
_add_ledger_parsers(sub)
|
|
73
|
+
|
|
74
|
+
install = sub.add_parser(
|
|
75
|
+
"install-hook", help="install lintex as a git hook, Claude Code hook or status line"
|
|
76
|
+
)
|
|
77
|
+
install.add_argument("target", choices=["git", "claude-code", "statusline"])
|
|
78
|
+
install.add_argument("--force", action="store_true", help="overwrite an existing git hook")
|
|
79
|
+
install.add_argument(
|
|
80
|
+
"--scope",
|
|
81
|
+
choices=["project", "local", "user"],
|
|
82
|
+
default="project",
|
|
83
|
+
help="Claude Code settings file to modify (default: project)",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
hook = sub.add_parser("hook", help="entry points invoked by hooks (reads stdin)")
|
|
87
|
+
hook.add_argument("target", choices=["claude-code"])
|
|
88
|
+
hook.add_argument(
|
|
89
|
+
"--fail-on",
|
|
90
|
+
choices=["error", "warning"],
|
|
91
|
+
default="warning",
|
|
92
|
+
help="lowest severity reported back to the agent (default: warning)",
|
|
93
|
+
)
|
|
94
|
+
return parser
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _add_check_parser(sub: argparse._SubParsersAction) -> None:
|
|
98
|
+
"""`check`, the default command."""
|
|
99
|
+
check = sub.add_parser("check", help="lint files or directories (the default command)")
|
|
100
|
+
check.add_argument("paths", nargs="*", help="files or directories (default: current directory)")
|
|
101
|
+
check.add_argument(
|
|
102
|
+
"--format",
|
|
103
|
+
choices=FORMATS,
|
|
104
|
+
default=None,
|
|
105
|
+
help="output style: pretty (default, grouped by file), full (every finding with why and "
|
|
106
|
+
"fix, for agents), concise (one line per finding), github (Actions annotations), json",
|
|
107
|
+
)
|
|
108
|
+
check.add_argument("--json", action="store_true", help="shorthand for --format json")
|
|
109
|
+
check.add_argument(
|
|
110
|
+
"-v",
|
|
111
|
+
"--verbose",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="show the `why` for every finding, not just the first of each rule",
|
|
114
|
+
)
|
|
115
|
+
check.add_argument(
|
|
116
|
+
"--staged",
|
|
117
|
+
action="store_true",
|
|
118
|
+
help="lint the staged version of staged .py files (for pre-commit)",
|
|
119
|
+
)
|
|
120
|
+
check.add_argument(
|
|
121
|
+
"--changed",
|
|
122
|
+
nargs="?",
|
|
123
|
+
const="auto",
|
|
124
|
+
metavar="REF",
|
|
125
|
+
help="only files changed since REF (default: the base branch), and only findings "
|
|
126
|
+
"on changed lines; new files are checked in full",
|
|
127
|
+
)
|
|
128
|
+
check.add_argument("--config", metavar="PATH", help="explicit config file")
|
|
129
|
+
check.add_argument("--select", type=_csv, metavar="CODES", help="only run these codes/prefixes")
|
|
130
|
+
check.add_argument("--ignore", type=_csv, metavar="CODES", help="skip these codes/prefixes")
|
|
131
|
+
check.add_argument("--enable", type=_csv, metavar="CODES", help="enable opt-in rules")
|
|
132
|
+
check.add_argument(
|
|
133
|
+
"--fail-on",
|
|
134
|
+
choices=["error", "warning"],
|
|
135
|
+
help="lowest severity that makes the exit status non-zero (default: error)",
|
|
136
|
+
)
|
|
137
|
+
check.add_argument("--exit-zero", action="store_true", help="always exit 0")
|
|
138
|
+
color = check.add_mutually_exclusive_group()
|
|
139
|
+
color.add_argument("--color", dest="color", action="store_true", default=None)
|
|
140
|
+
color.add_argument("--no-color", dest="color", action="store_false")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _add_ledger_parsers(sub: argparse._SubParsersAction) -> None:
|
|
144
|
+
"""`report` and `statusline`: the commands that read the session ledger."""
|
|
145
|
+
report = sub.add_parser("report", help="what the Claude Code hook caught, and what got fixed")
|
|
146
|
+
report.add_argument("paths", nargs="?", default=".", help="a path inside the project")
|
|
147
|
+
report.add_argument("--since", default="24h", help="look-back window, e.g. 90m, 24h, 7d")
|
|
148
|
+
report.add_argument("--all", action="store_true", help="the whole ledger, ignoring --since")
|
|
149
|
+
report.add_argument("--session", metavar="ID", help="only this Claude Code session")
|
|
150
|
+
report.add_argument("--json", action="store_true")
|
|
151
|
+
report.add_argument("--clear", action="store_true", help="delete this project's ledger")
|
|
152
|
+
rcolor = report.add_mutually_exclusive_group()
|
|
153
|
+
rcolor.add_argument("--color", dest="color", action="store_true", default=None)
|
|
154
|
+
rcolor.add_argument("--no-color", dest="color", action="store_false")
|
|
155
|
+
|
|
156
|
+
status = sub.add_parser("statusline", help="Claude Code status line (reads payload on stdin)")
|
|
157
|
+
status.add_argument(
|
|
158
|
+
"--segment", action="store_true", help="print only the lintex segment, for embedding"
|
|
159
|
+
)
|
|
160
|
+
scolor = status.add_mutually_exclusive_group()
|
|
161
|
+
scolor.add_argument("--color", dest="color", action="store_true", default=None)
|
|
162
|
+
scolor.add_argument("--no-color", dest="color", action="store_false")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
166
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
167
|
+
if not args_list or (
|
|
168
|
+
args_list[0] not in COMMANDS and args_list[0] not in ("-h", "--help", "--version")
|
|
169
|
+
):
|
|
170
|
+
args_list.insert(0, "check")
|
|
171
|
+
args = build_parser().parse_args(args_list)
|
|
172
|
+
try:
|
|
173
|
+
if args.command == "check":
|
|
174
|
+
return _cmd_check(args)
|
|
175
|
+
if args.command == "rules":
|
|
176
|
+
return _cmd_rules(args)
|
|
177
|
+
if args.command == "explain":
|
|
178
|
+
return _cmd_explain(args)
|
|
179
|
+
if args.command == "report":
|
|
180
|
+
return _cmd_report(args)
|
|
181
|
+
if args.command == "statusline":
|
|
182
|
+
# Claude Code renders ANSI in the status line even though stdout is a
|
|
183
|
+
# pipe, so colour defaults to on here (NO_COLOR and --no-color still win).
|
|
184
|
+
color = args.color if args.color is not None else "NO_COLOR" not in os.environ
|
|
185
|
+
style = Style.detect(sys.stdout, color=color)
|
|
186
|
+
print(render_statusline(read_payload(sys.stdin), style, segment_only=args.segment))
|
|
187
|
+
return EXIT_OK
|
|
188
|
+
if args.command == "install-hook":
|
|
189
|
+
return _cmd_install(args)
|
|
190
|
+
if args.command == "hook":
|
|
191
|
+
return run_claude_code_hook(sys.stdin, sys.stderr, Severity.parse(args.fail_on))
|
|
192
|
+
except (ConfigError, GitError) as exc:
|
|
193
|
+
print(f"lintex: error: {exc}", file=sys.stderr)
|
|
194
|
+
return EXIT_USAGE
|
|
195
|
+
return EXIT_USAGE # pragma: no cover
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_overrides(config: Config, args: argparse.Namespace) -> Config:
|
|
199
|
+
changes = {}
|
|
200
|
+
if args.select:
|
|
201
|
+
changes["select"] = args.select
|
|
202
|
+
if args.ignore:
|
|
203
|
+
changes["ignore"] = config.ignore + args.ignore
|
|
204
|
+
if args.enable:
|
|
205
|
+
changes["enable"] = config.enable + args.enable
|
|
206
|
+
if args.fail_on:
|
|
207
|
+
changes["fail_on"] = Severity.parse(args.fail_on)
|
|
208
|
+
return dataclasses.replace(config, **changes) if changes else config
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
212
|
+
scope_note = ""
|
|
213
|
+
if args.staged or args.changed is not None:
|
|
214
|
+
root = repo_root()
|
|
215
|
+
config = _apply_overrides(load_config(root, args.config), args)
|
|
216
|
+
linter = Linter(config)
|
|
217
|
+
if args.staged:
|
|
218
|
+
result = _lint_staged(linter, root)
|
|
219
|
+
else:
|
|
220
|
+
base = default_base(root) if args.changed == "auto" else args.changed
|
|
221
|
+
result, resolved = _lint_changed(linter, root, base)
|
|
222
|
+
scope_note = (
|
|
223
|
+
f"Showing only findings on lines changed since {base}"
|
|
224
|
+
+ (f" (merge base {resolved})" if resolved != base else "")
|
|
225
|
+
+ ". New files are checked in full."
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
paths = args.paths or ["."]
|
|
229
|
+
config = _apply_overrides(load_config(paths[0], args.config), args)
|
|
230
|
+
linter = Linter(config)
|
|
231
|
+
result = linter.lint_paths(paths)
|
|
232
|
+
|
|
233
|
+
fmt = "json" if args.json else (args.format or "pretty")
|
|
234
|
+
style = Style.detect(sys.stdout, color=args.color)
|
|
235
|
+
print(render(result, fmt, style, verbose=args.verbose))
|
|
236
|
+
if scope_note and fmt in ("pretty", "full", "concise"):
|
|
237
|
+
print(dim_note(scope_note, style))
|
|
238
|
+
if fmt == "pretty" and result.violations and config.source is None:
|
|
239
|
+
print(
|
|
240
|
+
dim_note(
|
|
241
|
+
"No lintex config found. Tune rules or exclude paths with a [tool.lintex] "
|
|
242
|
+
"table in pyproject.toml (see `lintex rules`).",
|
|
243
|
+
style,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
if args.exit_zero:
|
|
247
|
+
return EXIT_OK
|
|
248
|
+
return EXIT_VIOLATIONS if result.failed(config.fail_on) else EXIT_OK
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _lint_staged(linter: Linter, root: Path) -> LintResult:
|
|
252
|
+
import time
|
|
253
|
+
|
|
254
|
+
start = time.perf_counter()
|
|
255
|
+
result = LintResult()
|
|
256
|
+
for rel in staged_python_files(root):
|
|
257
|
+
if linter.config.is_excluded(root / rel):
|
|
258
|
+
continue
|
|
259
|
+
result.files_checked += 1
|
|
260
|
+
result.violations.extend(linter.lint_source(staged_content(root, rel), rel))
|
|
261
|
+
result.duration_ms = (time.perf_counter() - start) * 1000
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _lint_changed(linter: Linter, root: Path, base: str) -> tuple[LintResult, str]:
|
|
266
|
+
import time
|
|
267
|
+
|
|
268
|
+
start = time.perf_counter()
|
|
269
|
+
resolved, files = changed_python_files(root, base)
|
|
270
|
+
result = LintResult()
|
|
271
|
+
for rel, lines in files.items():
|
|
272
|
+
path = root / rel
|
|
273
|
+
if linter.config.is_excluded(path) or not path.is_file():
|
|
274
|
+
continue
|
|
275
|
+
result.files_checked += 1
|
|
276
|
+
violations = linter.lint_file(path, rel)
|
|
277
|
+
if lines is not None:
|
|
278
|
+
violations = [v for v in violations if _touches(v, lines)]
|
|
279
|
+
result.violations.extend(violations)
|
|
280
|
+
result.duration_ms = (time.perf_counter() - start) * 1000
|
|
281
|
+
return result, resolved
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _touches(v: Violation, lines: set[int]) -> bool:
|
|
285
|
+
end = v.end_line or v.line
|
|
286
|
+
if end - v.line > 200: # a whole-module finding: only report it if the module is new
|
|
287
|
+
return False
|
|
288
|
+
return any(line in lines for line in range(v.line, end + 1))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _cmd_rules(args: argparse.Namespace) -> int:
|
|
292
|
+
config = load_config(None, args.config)
|
|
293
|
+
rows = []
|
|
294
|
+
for rule in ALL_RULES:
|
|
295
|
+
rows.append(
|
|
296
|
+
{
|
|
297
|
+
"code": rule.code,
|
|
298
|
+
"name": rule.name,
|
|
299
|
+
"severity": rule.default_severity.value,
|
|
300
|
+
"enabled": is_rule_active(rule, config),
|
|
301
|
+
"default_enabled": rule.default_enabled,
|
|
302
|
+
"summary": rule.summary,
|
|
303
|
+
"options": rule.options,
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
if args.json:
|
|
307
|
+
print(json.dumps(rows, indent=2))
|
|
308
|
+
return EXIT_OK
|
|
309
|
+
style = Style.detect(sys.stdout)
|
|
310
|
+
width = max(len(r["name"]) for r in rows)
|
|
311
|
+
group = ""
|
|
312
|
+
for r in rows:
|
|
313
|
+
if r["code"][:3] != group:
|
|
314
|
+
group = r["code"][:3]
|
|
315
|
+
print(style.paint(_GROUP_TITLES.get(group, ""), "bold"))
|
|
316
|
+
if r["enabled"]:
|
|
317
|
+
state = style.paint(" on ", "green")
|
|
318
|
+
else:
|
|
319
|
+
state = style.paint("off ", "dim")
|
|
320
|
+
sev = style.paint(r["severity"].ljust(7), "red" if r["severity"] == "error" else "yellow")
|
|
321
|
+
name = r["name"].ljust(width)
|
|
322
|
+
note = "" if r["default_enabled"] else style.paint(" (opt-in: add to `enable`)", "dim")
|
|
323
|
+
print(
|
|
324
|
+
f" {style.paint(r['code'], 'cyan', 'bold')} {name} {state} {sev} {r['summary']}{note}"
|
|
325
|
+
)
|
|
326
|
+
print(style.paint("\nRun `lintex explain CODE` for a rule's rationale and options.", "dim"))
|
|
327
|
+
return EXIT_OK
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
_GROUP_TITLES = {
|
|
331
|
+
"LX0": "Parsing",
|
|
332
|
+
"LX1": "Hallucinated imports and APIs",
|
|
333
|
+
"LX2": "Banned and insecure patterns",
|
|
334
|
+
"LX3": "Agent bloat",
|
|
335
|
+
"LX4": "Agent artifacts",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _cmd_explain(args: argparse.Namespace) -> int:
|
|
340
|
+
rule = get_rule(args.rule)
|
|
341
|
+
if rule is None:
|
|
342
|
+
print(
|
|
343
|
+
f"lintex: unknown rule {args.rule!r}; run `lintex rules` to list them", file=sys.stderr
|
|
344
|
+
)
|
|
345
|
+
return EXIT_USAGE
|
|
346
|
+
style = Style.detect(sys.stdout)
|
|
347
|
+
sev = rule.default_severity.value
|
|
348
|
+
badge = style.paint(f" {sev} ", "bold", "red" if sev == "error" else "yellow")
|
|
349
|
+
optin = "" if rule.default_enabled else style.paint(" opt-in", "dim")
|
|
350
|
+
print(
|
|
351
|
+
f"{style.paint(rule.code, 'cyan', 'bold')} {style.paint(rule.name, 'bold')} {badge}{optin}\n"
|
|
352
|
+
)
|
|
353
|
+
print(textwrap.fill(rule.summary, style.width) + "\n")
|
|
354
|
+
if rule.explanation:
|
|
355
|
+
for para in rule.explanation.split("\n\n"):
|
|
356
|
+
if para.startswith(" "):
|
|
357
|
+
print(para + "\n") # preformatted example
|
|
358
|
+
else:
|
|
359
|
+
print(textwrap.fill(para, style.width) + "\n")
|
|
360
|
+
if rule.options:
|
|
361
|
+
print(style.paint(f"Options [tool.lintex.rules.{rule.name}]", "bold"))
|
|
362
|
+
for key, value in rule.options.items():
|
|
363
|
+
print(f" {style.paint(key, 'cyan')} = {json.dumps(value)}")
|
|
364
|
+
print()
|
|
365
|
+
print(style.paint(f"Silence one line with # lintex: ignore[{rule.code}]", "dim"))
|
|
366
|
+
return EXIT_OK
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _cmd_report(args: argparse.Namespace) -> int:
|
|
370
|
+
root = load_config(args.paths).root
|
|
371
|
+
style = Style.detect(sys.stdout, color=args.color)
|
|
372
|
+
if args.clear:
|
|
373
|
+
removed = ledger.clear(root)
|
|
374
|
+
print("Ledger cleared." if removed else "No ledger for this project.")
|
|
375
|
+
return EXIT_OK
|
|
376
|
+
since = None
|
|
377
|
+
if not args.all:
|
|
378
|
+
try:
|
|
379
|
+
since = datetime.now(timezone.utc) - ledger.parse_since(args.since)
|
|
380
|
+
except ValueError as exc:
|
|
381
|
+
raise ConfigError(str(exc)) from None
|
|
382
|
+
summary = ledger.summarize(ledger.load(root, since=since, session=args.session))
|
|
383
|
+
if args.json:
|
|
384
|
+
print(json.dumps(summary.to_dict(), indent=2))
|
|
385
|
+
return EXIT_OK
|
|
386
|
+
scope = "all time" if args.all else f"last {args.since}"
|
|
387
|
+
if args.session:
|
|
388
|
+
scope += f", session {args.session[:8]}"
|
|
389
|
+
print(
|
|
390
|
+
style.paint(
|
|
391
|
+
f"lintex ledger {style.sym['dot']} {root.name} {style.sym['dot']} {scope}", "bold"
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
if summary.runs == 0:
|
|
395
|
+
print(
|
|
396
|
+
dim_note(
|
|
397
|
+
"Nothing recorded yet. The ledger fills as the Claude Code hook checks edits "
|
|
398
|
+
"(`lintex install-hook claude-code`).",
|
|
399
|
+
style,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
return EXIT_OK
|
|
403
|
+
print(render_segment(summary, style))
|
|
404
|
+
print(_report_headline(summary, style))
|
|
405
|
+
if summary.by_code:
|
|
406
|
+
print()
|
|
407
|
+
width = max(len(s.rule) for s in summary.by_code.values())
|
|
408
|
+
ranked = sorted(summary.by_code.items(), key=lambda kv: -(kv[1].caught + kv[1].open))
|
|
409
|
+
for code, stats in ranked:
|
|
410
|
+
state = (
|
|
411
|
+
style.paint("all fixed", "green")
|
|
412
|
+
if stats.open == 0
|
|
413
|
+
else style.paint(f"{stats.open} open", "red" if stats.open else "dim")
|
|
414
|
+
)
|
|
415
|
+
print(
|
|
416
|
+
f" {style.paint(code, 'cyan')} {stats.rule.ljust(width)} "
|
|
417
|
+
f"caught {stats.caught:>3} fixed {stats.fixed:>3} {state}"
|
|
418
|
+
)
|
|
419
|
+
if summary.open_findings:
|
|
420
|
+
print()
|
|
421
|
+
print(style.paint("Still open", "bold"))
|
|
422
|
+
for f in summary.open_findings[:20]:
|
|
423
|
+
sev = style.paint(
|
|
424
|
+
style.sym[f.get("severity", "error")],
|
|
425
|
+
"red" if f.get("severity") == "error" else "yellow",
|
|
426
|
+
)
|
|
427
|
+
print(
|
|
428
|
+
f" {sev} {f.get('path')}:{f.get('line')} {style.paint(f['code'], 'cyan')} {f.get('message')}"
|
|
429
|
+
)
|
|
430
|
+
if len(summary.open_findings) > 20:
|
|
431
|
+
print(style.paint(f" … and {len(summary.open_findings) - 20} more", "dim"))
|
|
432
|
+
return EXIT_OK
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _report_headline(summary: ledger.Summary, style: Style) -> str:
|
|
436
|
+
runs = f"{summary.runs} edit{'s' if summary.runs != 1 else ''}"
|
|
437
|
+
files = f"{summary.files} file{'s' if summary.files != 1 else ''}"
|
|
438
|
+
caught = style.paint(f"{summary.caught} caught", "bold")
|
|
439
|
+
fixed = style.paint(f"{summary.fixed} fixed by the agent", "green", "bold")
|
|
440
|
+
if summary.open:
|
|
441
|
+
open_ = style.paint(f"{summary.open} still open", "red", "bold")
|
|
442
|
+
else:
|
|
443
|
+
open_ = style.paint("nothing open", "green")
|
|
444
|
+
return (
|
|
445
|
+
f"{caught} across {runs} of {files} {style.sym['dot']} {fixed} {style.sym['dot']} {open_}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _cmd_install(args: argparse.Namespace) -> int:
|
|
450
|
+
if args.target == "git":
|
|
451
|
+
hook = install_git_hook(force=args.force)
|
|
452
|
+
print(f"Installed git pre-commit hook at {hook}")
|
|
453
|
+
return EXIT_OK
|
|
454
|
+
project = Path.cwd()
|
|
455
|
+
try:
|
|
456
|
+
project = repo_root()
|
|
457
|
+
except GitError:
|
|
458
|
+
pass
|
|
459
|
+
if args.target == "statusline":
|
|
460
|
+
path, changed = install_statusline(project, scope=args.scope)
|
|
461
|
+
if changed:
|
|
462
|
+
print(f"Set Claude Code statusLine to `lintex statusline` in {path}")
|
|
463
|
+
else:
|
|
464
|
+
print(f"lintex status line already configured in {path}")
|
|
465
|
+
return EXIT_OK
|
|
466
|
+
path, changed = install_claude_code_hook(project, scope=args.scope)
|
|
467
|
+
if changed:
|
|
468
|
+
print(f"Added lintex PostToolUse hook to {path}")
|
|
469
|
+
else:
|
|
470
|
+
print(f"lintex hook already present in {path}")
|
|
471
|
+
return EXIT_OK
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__": # pragma: no cover
|
|
475
|
+
raise SystemExit(main())
|