agent-comment-lint 1.2.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.
- agent_comment_lint/__init__.py +3 -0
- agent_comment_lint/__main__.py +7 -0
- agent_comment_lint/cli.py +384 -0
- agent_comment_lint/config.py +124 -0
- agent_comment_lint/diff.py +98 -0
- agent_comment_lint/engine.py +30 -0
- agent_comment_lint/gitio.py +125 -0
- agent_comment_lint/governance.py +84 -0
- agent_comment_lint/hook.py +94 -0
- agent_comment_lint/lexers/__init__.py +19 -0
- agent_comment_lint/lexers/base.py +114 -0
- agent_comment_lint/lexers/csharp.py +23 -0
- agent_comment_lint/lexers/css.py +37 -0
- agent_comment_lint/lexers/hashlike.py +53 -0
- agent_comment_lint/lexers/javascript.py +176 -0
- agent_comment_lint/lexers/linescan.py +206 -0
- agent_comment_lint/lexers/python.py +116 -0
- agent_comment_lint/report.py +124 -0
- agent_comment_lint/rules.py +174 -0
- agent_comment_lint-1.2.0.dist-info/METADATA +200 -0
- agent_comment_lint-1.2.0.dist-info/RECORD +25 -0
- agent_comment_lint-1.2.0.dist-info/WHEEL +5 -0
- agent_comment_lint-1.2.0.dist-info/entry_points.txt +2 -0
- agent_comment_lint-1.2.0.dist-info/licenses/LICENSE +21 -0
- agent_comment_lint-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""The command line: choose the files, read them, hand each to the engine, print the report."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from . import config as settings
|
|
9
|
+
from . import engine, gitio, hook, report
|
|
10
|
+
from .diff import parse_diff_ranges
|
|
11
|
+
from .lexers import EXTENSIONS, LexError
|
|
12
|
+
|
|
13
|
+
DESCRIPTION = "Check code comments against fixed rules. Exit 0 clean, 1 violations, 2 could not scan."
|
|
14
|
+
SCOPE_NOTE = "Covered file types: %s" % " ".join(EXTENSIONS)
|
|
15
|
+
|
|
16
|
+
# A byte-order mark is not part of the text: left in, it hides the first line's comment marker.
|
|
17
|
+
ENCODING = "utf-8-sig"
|
|
18
|
+
BYTE_ORDER_MARK = "\ufeff"
|
|
19
|
+
|
|
20
|
+
DIFF_MISMATCH = "git counts %d added line(s) but its diff shows %d, so the changed lines cannot be trusted"
|
|
21
|
+
BINARY_NOT_TEXT = "git shows no changed lines for it (a binary file) and it is not UTF-8 text, so it cannot be judged"
|
|
22
|
+
HOOK_INPUT = "standard input is not a Claude Code PreToolUse event: %s"
|
|
23
|
+
HOOK_NOT_UTF8 = "is not UTF-8 text, so the write cannot be judged"
|
|
24
|
+
HOOK_BLOCKED = "Blocked: agent-comment-lint rejects this write.\n"
|
|
25
|
+
HOOK_HEADER = HOOK_BLOCKED + report.HOOK_SCOPE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parser():
|
|
29
|
+
parser = argparse.ArgumentParser(description=DESCRIPTION, epilog=SCOPE_NOTE)
|
|
30
|
+
parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
31
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
32
|
+
mode.add_argument("--diff", metavar="BASE",
|
|
33
|
+
help="judge only the lines changed since the merge base with BASE")
|
|
34
|
+
mode.add_argument("--all", action="store_true", help="judge every covered file in the repository")
|
|
35
|
+
mode.add_argument("--files", nargs="+", metavar="PATH", help="judge the named files")
|
|
36
|
+
mode.add_argument("--stdin-path", metavar="PATH",
|
|
37
|
+
help="judge the text on standard input as if it were the file PATH")
|
|
38
|
+
mode.add_argument("--hook", action="store_true",
|
|
39
|
+
help="judge the write in a Claude Code PreToolUse event on standard input; exit 2 blocks it")
|
|
40
|
+
parser.add_argument("--json", action="store_true", help="print one JSON document instead of text")
|
|
41
|
+
parser.add_argument("--root", metavar="DIR",
|
|
42
|
+
help="repository root (default: the git top level of the working directory)")
|
|
43
|
+
parser.add_argument("--config", metavar="FILE",
|
|
44
|
+
help="settings file (default: %s at the root)" % settings.CONFIG_NAME)
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Refused(Exception):
|
|
49
|
+
"""The run cannot give a verdict; `where` names the argument at fault."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, where, message):
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.where = where
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mode(args):
|
|
57
|
+
"""The chosen mode's name; an empty --diff value is still diff mode, so it is refused as a ref, not skipped."""
|
|
58
|
+
if args.hook:
|
|
59
|
+
return "hook"
|
|
60
|
+
if args.all:
|
|
61
|
+
return "all"
|
|
62
|
+
for name in ("diff", "files"):
|
|
63
|
+
if getattr(args, name) is not None:
|
|
64
|
+
return name
|
|
65
|
+
return "stdin"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _work_tree(directory, mode):
|
|
69
|
+
"""The git top level of `directory`; None where git names none and the mode can give a verdict without it."""
|
|
70
|
+
try:
|
|
71
|
+
return gitio.toplevel(directory)
|
|
72
|
+
except gitio.GitError:
|
|
73
|
+
if mode == "diff" or (mode == "all" and gitio.claims_repository(directory)):
|
|
74
|
+
raise
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _root(args, cwd, mode):
|
|
79
|
+
"""(root, whether git lists its files); only --all asks git about a root the caller named."""
|
|
80
|
+
if not args.root:
|
|
81
|
+
top = _work_tree(cwd, mode)
|
|
82
|
+
return os.path.realpath(top or cwd), top is not None
|
|
83
|
+
root = os.path.realpath(os.path.join(cwd, args.root))
|
|
84
|
+
if not os.path.isdir(root):
|
|
85
|
+
raise _Refused("--root", "%s is not a directory" % args.root)
|
|
86
|
+
return root, mode == "all" and _work_tree(root, mode) is not None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_config(args, root, cwd):
|
|
90
|
+
"""The repository's settings; a file named with --config must exist, the default one may be absent."""
|
|
91
|
+
path = os.path.join(cwd, args.config) if args.config else os.path.join(root, settings.CONFIG_NAME)
|
|
92
|
+
if not args.config and not os.path.exists(path):
|
|
93
|
+
return settings.EMPTY
|
|
94
|
+
try:
|
|
95
|
+
with open(path, encoding=ENCODING) as handle:
|
|
96
|
+
text = handle.read()
|
|
97
|
+
except OSError as error:
|
|
98
|
+
raise settings.ConfigError(error.strerror or str(error))
|
|
99
|
+
except UnicodeDecodeError:
|
|
100
|
+
raise settings.ConfigError("is not UTF-8 text")
|
|
101
|
+
return settings.parse(text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _judged(config, path):
|
|
105
|
+
return (engine.in_scope(path) and not settings.skipped_by_default(path)
|
|
106
|
+
and not settings.excluded(config, path))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _walked_files(root):
|
|
110
|
+
"""Every file under `root`, for a tree that is not a git work tree."""
|
|
111
|
+
found = []
|
|
112
|
+
for directory, names, files in os.walk(root):
|
|
113
|
+
names[:] = sorted(name for name in names if name not in settings.DEFAULT_SKIP_DIRS)
|
|
114
|
+
for name in files:
|
|
115
|
+
found.append(os.path.relpath(os.path.join(directory, name), root).replace(os.sep, "/"))
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolved_inside(root, directory, path):
|
|
120
|
+
"""The real path of `path` taken from `directory`; None when it lands outside the root."""
|
|
121
|
+
resolved = os.path.realpath(os.path.join(directory, path))
|
|
122
|
+
try:
|
|
123
|
+
inside = os.path.normcase(os.path.commonpath([root, resolved])) == os.path.normcase(root)
|
|
124
|
+
except ValueError:
|
|
125
|
+
# Paths on two drives, or under a UNC root and a drive, share no part at all.
|
|
126
|
+
inside = False
|
|
127
|
+
return resolved if inside else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _relative_to_root(root, cwd, path):
|
|
131
|
+
"""The root-relative form of a path the caller named; None when it resolves outside the root."""
|
|
132
|
+
resolved = _resolved_inside(root, cwd, path)
|
|
133
|
+
return None if resolved is None else os.path.relpath(resolved, root).replace(os.sep, "/")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read(root, path, errors, newline=None):
|
|
137
|
+
"""The file's text; raises OSError for anything that is not a readable file inside the root."""
|
|
138
|
+
resolved = _resolved_inside(root, root, path)
|
|
139
|
+
if resolved is None:
|
|
140
|
+
raise OSError(0, "resolves outside the repository root")
|
|
141
|
+
if os.path.isdir(resolved):
|
|
142
|
+
raise OSError(0, "is a directory, not a file")
|
|
143
|
+
with open(resolved, encoding=ENCODING, errors=errors, newline=newline) as handle:
|
|
144
|
+
return handle.read()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _stdin_text(stdin, errors="replace"):
|
|
148
|
+
"""Standard input decoded as a file is, whatever the locale; a stream with no bytes behind it is taken as text."""
|
|
149
|
+
raw = getattr(stdin, "buffer", None)
|
|
150
|
+
if raw is not None:
|
|
151
|
+
return raw.read().decode(ENCODING, errors=errors)
|
|
152
|
+
text = stdin.read()
|
|
153
|
+
return text[1:] if text.startswith(BYTE_ORDER_MARK) else text
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Run:
|
|
157
|
+
"""Accumulates one run's results; the report is built from it once every file is judged."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, mode):
|
|
160
|
+
self.mode = mode
|
|
161
|
+
self.scanned = 0
|
|
162
|
+
self.unresolved = 0
|
|
163
|
+
self.findings = []
|
|
164
|
+
self.errors = []
|
|
165
|
+
self.texts = {}
|
|
166
|
+
self.tool = None
|
|
167
|
+
self.governance = settings.EMPTY.governance
|
|
168
|
+
|
|
169
|
+
def judge(self, path, text, ranges=None):
|
|
170
|
+
try:
|
|
171
|
+
result = engine.check_text(path, text, self.governance)
|
|
172
|
+
except LexError as error:
|
|
173
|
+
self.errors.append((path, error.line, error.message))
|
|
174
|
+
return
|
|
175
|
+
found = result.findings if ranges is None else engine.within(result.findings, ranges)
|
|
176
|
+
if found:
|
|
177
|
+
self.texts[path] = text
|
|
178
|
+
self.scanned += 1
|
|
179
|
+
self.unresolved += result.unresolved_lines
|
|
180
|
+
self.findings.extend((path, finding) for finding in found)
|
|
181
|
+
|
|
182
|
+
def judge_file(self, root, path, ranges=None, errors="replace"):
|
|
183
|
+
try:
|
|
184
|
+
text = _read(root, path, errors)
|
|
185
|
+
except UnicodeDecodeError:
|
|
186
|
+
self.errors.append((path, None, BINARY_NOT_TEXT))
|
|
187
|
+
return
|
|
188
|
+
except OSError as error:
|
|
189
|
+
self.errors.append((path, None, error.strerror or str(error)))
|
|
190
|
+
return
|
|
191
|
+
self.judge(path, text, ranges)
|
|
192
|
+
|
|
193
|
+
def report(self):
|
|
194
|
+
return report.Report(self.mode, self.scanned, self.findings, self.errors, self.unresolved)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _run_all(run, root, config, listed_by_git):
|
|
198
|
+
paths = gitio.listed_files(root) if listed_by_git else _walked_files(root)
|
|
199
|
+
judged = [path for path in dict.fromkeys(paths)
|
|
200
|
+
if _judged(config, path) and os.path.isfile(os.path.join(root, path))]
|
|
201
|
+
if not judged:
|
|
202
|
+
raise _Refused("--all", "no covered file under the root, so there is nothing to give a verdict on")
|
|
203
|
+
for path in judged:
|
|
204
|
+
run.judge_file(root, path)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_diff(run, root, config, base):
|
|
208
|
+
"""Judge the changed lines; a file whose parsed ranges do not add up to git's own count is refused, not passed."""
|
|
209
|
+
span = gitio.diff_span(root, base)
|
|
210
|
+
ranges = parse_diff_ranges(gitio.changed_diff(root, span))
|
|
211
|
+
for path, (added, same_content) in gitio.changed_files(root, span).items():
|
|
212
|
+
if same_content or not _judged(config, path):
|
|
213
|
+
# A mode change or a pure rename changes no line, so the file is not read, whatever it holds.
|
|
214
|
+
continue
|
|
215
|
+
if added is None:
|
|
216
|
+
# Git prints no lines for a file it treats as binary, so every line of it counts as changed.
|
|
217
|
+
run.judge_file(root, path, errors="strict")
|
|
218
|
+
continue
|
|
219
|
+
spans = ranges.get(path, [])
|
|
220
|
+
parsed = sum(end - start + 1 for start, end in spans)
|
|
221
|
+
if parsed != added:
|
|
222
|
+
run.errors.append((path, None, DIFF_MISMATCH % (added, parsed)))
|
|
223
|
+
continue
|
|
224
|
+
run.judge_file(root, path, spans)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _named(run, root, cwd, config, path):
|
|
228
|
+
"""The root-relative path of a named file the policy judges; None when it is skipped or refused."""
|
|
229
|
+
relative = _relative_to_root(root, cwd, path)
|
|
230
|
+
if relative is None:
|
|
231
|
+
run.errors.append((path, None, "resolves outside the repository root"))
|
|
232
|
+
return None
|
|
233
|
+
if os.path.isdir(os.path.join(root, relative)):
|
|
234
|
+
run.errors.append((relative, None, "is a directory, not a file"))
|
|
235
|
+
return None
|
|
236
|
+
return relative if _judged(config, relative) else None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _current_text(root, relative):
|
|
240
|
+
"""The file before the write, line endings kept so an edit's old string still matches; None when absent."""
|
|
241
|
+
try:
|
|
242
|
+
return _read(root, relative, "strict", newline="")
|
|
243
|
+
except FileNotFoundError:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _sibling_worktree(root, path):
|
|
248
|
+
"""The top of another worktree of the root's repository that holds `path`; None when there is none."""
|
|
249
|
+
directory = os.path.dirname(path)
|
|
250
|
+
while not os.path.isdir(directory):
|
|
251
|
+
parent = os.path.dirname(directory)
|
|
252
|
+
if parent == directory:
|
|
253
|
+
return None
|
|
254
|
+
directory = parent
|
|
255
|
+
try:
|
|
256
|
+
if gitio.common_dir(directory) != gitio.common_dir(root):
|
|
257
|
+
return None
|
|
258
|
+
return os.path.realpath(gitio.toplevel(directory))
|
|
259
|
+
except gitio.GitError:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _run_hook(run, root, cwd, load_config, stdin, follow_worktrees):
|
|
264
|
+
"""Judge the whole file a proposed write would leave, on the lines the write adds or changes."""
|
|
265
|
+
try:
|
|
266
|
+
target = hook.target(json.loads(_stdin_text(stdin, errors="strict")))
|
|
267
|
+
except (ValueError, hook.HookInputError) as error:
|
|
268
|
+
raise _Refused("--hook", HOOK_INPUT % error)
|
|
269
|
+
if target is None:
|
|
270
|
+
return
|
|
271
|
+
relative = _relative_to_root(root, cwd, target.path)
|
|
272
|
+
if relative is None and follow_worktrees:
|
|
273
|
+
# A session started in one checkout often writes into another worktree of the same repository.
|
|
274
|
+
sibling = _sibling_worktree(root, os.path.join(cwd, target.path))
|
|
275
|
+
if sibling is not None:
|
|
276
|
+
root = sibling
|
|
277
|
+
relative = _relative_to_root(root, cwd, target.path)
|
|
278
|
+
if relative is None or not engine.in_scope(relative) or settings.skipped_by_default(relative):
|
|
279
|
+
return
|
|
280
|
+
config = load_config(root)
|
|
281
|
+
if settings.excluded(config, relative):
|
|
282
|
+
return
|
|
283
|
+
run.governance = config.governance
|
|
284
|
+
run.tool = target.tool
|
|
285
|
+
try:
|
|
286
|
+
try:
|
|
287
|
+
current = _current_text(root, relative)
|
|
288
|
+
except UnicodeDecodeError:
|
|
289
|
+
if target.tool != "Write":
|
|
290
|
+
raise
|
|
291
|
+
current = None
|
|
292
|
+
after = hook.proposed_text(target, current)
|
|
293
|
+
except hook.HookInputError as error:
|
|
294
|
+
raise _Refused("--hook", HOOK_INPUT % error)
|
|
295
|
+
except UnicodeDecodeError:
|
|
296
|
+
run.errors.append((relative, None, HOOK_NOT_UTF8))
|
|
297
|
+
return
|
|
298
|
+
except OSError as error:
|
|
299
|
+
run.errors.append((relative, None, error.strerror or str(error)))
|
|
300
|
+
return
|
|
301
|
+
if after is not None:
|
|
302
|
+
run.judge(relative, after, hook.changed_ranges(current, after))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _execute(args, cwd, stdin):
|
|
306
|
+
run = _Run(_mode(args))
|
|
307
|
+
try:
|
|
308
|
+
root, listed_by_git = _root(args, cwd, run.mode)
|
|
309
|
+
if run.mode == "hook":
|
|
310
|
+
# A write the hook never judges must not depend on the settings file.
|
|
311
|
+
_run_hook(run, root, cwd, lambda judged_root: _load_config(args, judged_root, cwd), stdin,
|
|
312
|
+
follow_worktrees=not args.root)
|
|
313
|
+
return run
|
|
314
|
+
config = _load_config(args, root, cwd)
|
|
315
|
+
run.governance = config.governance
|
|
316
|
+
if run.mode == "all":
|
|
317
|
+
_run_all(run, root, config, listed_by_git)
|
|
318
|
+
elif run.mode == "diff":
|
|
319
|
+
_run_diff(run, root, config, args.diff)
|
|
320
|
+
elif run.mode == "files":
|
|
321
|
+
named = [_named(run, root, cwd, config, path) for path in args.files]
|
|
322
|
+
for relative in dict.fromkeys(path for path in named if path is not None):
|
|
323
|
+
run.judge_file(root, relative)
|
|
324
|
+
else:
|
|
325
|
+
relative = _named(run, root, cwd, config, args.stdin_path)
|
|
326
|
+
if relative is not None:
|
|
327
|
+
run.judge(relative, _stdin_text(stdin))
|
|
328
|
+
except _Refused as refusal:
|
|
329
|
+
run.errors.append((refusal.where, None, str(refusal)))
|
|
330
|
+
except gitio.GitError as error:
|
|
331
|
+
run.errors.append(("git", None, str(error)))
|
|
332
|
+
except settings.ConfigError as error:
|
|
333
|
+
run.errors.append((args.config or settings.CONFIG_NAME, None, str(error)))
|
|
334
|
+
return run
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _hook_closing(tool):
|
|
338
|
+
"""The lines that end a blocked write's message; a Write also learns to re-send only the comment lines."""
|
|
339
|
+
return report.HOOK_CLOSING + ((report.WRITE_RETRY,) if tool == "Write" else ())
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _hook_verdict(run_result, texts, tool, stderr):
|
|
343
|
+
"""Claude Code blocks a write when its hook exits 2 and shows the agent standard error; a clean write is silent."""
|
|
344
|
+
if not report.exit_code(run_result):
|
|
345
|
+
return 0
|
|
346
|
+
stderr.write(report.hook_text(run_result, texts, HOOK_HEADER, _hook_closing(tool)))
|
|
347
|
+
return 2
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _utf8(stream, errors):
|
|
351
|
+
"""Switch a real console stream to UTF-8, so output does not depend on the Windows ANSI code page."""
|
|
352
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
353
|
+
if reconfigure is not None:
|
|
354
|
+
reconfigure(encoding="utf-8", errors=errors)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main(argv=None, stdin=None, stdout=None, stderr=None, cwd=None):
|
|
358
|
+
"""Run the check and return the exit code: 0 clean, 1 violations, 2 could not scan."""
|
|
359
|
+
if stdout is None:
|
|
360
|
+
_utf8(sys.stdout, "strict")
|
|
361
|
+
if stderr is None:
|
|
362
|
+
_utf8(sys.stderr, "backslashreplace")
|
|
363
|
+
stdout = stdout or sys.stdout
|
|
364
|
+
stderr = stderr or sys.stderr
|
|
365
|
+
parser = _parser()
|
|
366
|
+
args = parser.parse_args(argv)
|
|
367
|
+
if args.hook and args.json:
|
|
368
|
+
parser.error("--json does not apply to --hook, whose verdict is its exit code")
|
|
369
|
+
if args.hook:
|
|
370
|
+
try:
|
|
371
|
+
run = _execute(args, cwd or os.getcwd(), stdin or sys.stdin)
|
|
372
|
+
return _hook_verdict(run.report(), run.texts, run.tool, stderr)
|
|
373
|
+
except Exception as error:
|
|
374
|
+
stderr.write("%s--hook: could not scan: %s: %s\n" % (HOOK_HEADER, type(error).__name__, error))
|
|
375
|
+
return 2
|
|
376
|
+
result = _execute(args, cwd or os.getcwd(), stdin or sys.stdin).report()
|
|
377
|
+
if args.json:
|
|
378
|
+
stdout.write(report.as_json(result))
|
|
379
|
+
stderr.write(report.as_text(result)[1])
|
|
380
|
+
else:
|
|
381
|
+
out, err = report.as_text(result)
|
|
382
|
+
stdout.write(out)
|
|
383
|
+
stderr.write(err)
|
|
384
|
+
return report.exit_code(result)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""A repository's settings: paths the policy does not judge, and governance it adds. Each entry gives its reason."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from collections import namedtuple
|
|
5
|
+
|
|
6
|
+
from . import governance
|
|
7
|
+
|
|
8
|
+
CONFIG_NAME = ".agent-comment-lint.json"
|
|
9
|
+
|
|
10
|
+
# Build output, dependencies and tool state: never authored by hand in any repository.
|
|
11
|
+
DEFAULT_SKIP_DIRS = frozenset((
|
|
12
|
+
"node_modules", "bin", "obj", ".terraform", "dist", "build", "coverage", ".git", ".claude", "generated",
|
|
13
|
+
"Migrations", ".venv", "venv", "__pycache__", ".pytest_cache"))
|
|
14
|
+
DEFAULT_SKIP_FILES = frozenset((".terraform.lock.hcl",))
|
|
15
|
+
|
|
16
|
+
Config = namedtuple("Config", ["exclusions", "governance"])
|
|
17
|
+
Config.__doc__ = "Parsed settings: (pattern, reason, compiled regex) per exclusion, and the governance Matcher."
|
|
18
|
+
|
|
19
|
+
EMPTY = Config((), governance.DEFAULT)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
"""The settings file is malformed, so the run cannot know what to judge."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pattern_regex(pattern):
|
|
27
|
+
"""Compile a path glob: `*` and `?` stay inside one segment, `**` crosses them, a final `/` takes a tree."""
|
|
28
|
+
if pattern.endswith("/"):
|
|
29
|
+
pattern += "**"
|
|
30
|
+
out = []
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(pattern):
|
|
33
|
+
if pattern.startswith("**/", i):
|
|
34
|
+
out.append("(?:.*/)?")
|
|
35
|
+
i += 3
|
|
36
|
+
elif pattern.startswith("**", i):
|
|
37
|
+
out.append(".*")
|
|
38
|
+
i += 2
|
|
39
|
+
elif pattern[i] == "*":
|
|
40
|
+
out.append("[^/]*")
|
|
41
|
+
i += 1
|
|
42
|
+
elif pattern[i] == "?":
|
|
43
|
+
out.append("[^/]")
|
|
44
|
+
i += 1
|
|
45
|
+
else:
|
|
46
|
+
out.append(re.escape(pattern[i]))
|
|
47
|
+
i += 1
|
|
48
|
+
return re.compile("".join(out) + r"\Z")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _exclusion(entry, position):
|
|
52
|
+
if not isinstance(entry, dict) or set(entry) != {"path", "reason"}:
|
|
53
|
+
raise ConfigError("exclude[%d]: expected exactly the keys \"path\" and \"reason\"" % position)
|
|
54
|
+
path, reason = entry["path"], entry["reason"]
|
|
55
|
+
if not isinstance(path, str) or not path or path.startswith(("/", "./", "../")):
|
|
56
|
+
raise ConfigError("exclude[%d]: \"path\" must be a non-empty path relative to the repository root" % position)
|
|
57
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
58
|
+
raise ConfigError("exclude[%d]: \"reason\" must say why %s is not judged" % (position, path))
|
|
59
|
+
return path, reason, _pattern_regex(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extra(entry, position):
|
|
63
|
+
where = "governance.extra[%d]" % position
|
|
64
|
+
if not isinstance(entry, dict) or set(entry) != {"pattern", "reason"}:
|
|
65
|
+
raise ConfigError("%s: expected exactly the keys \"pattern\" and \"reason\"" % where)
|
|
66
|
+
pattern, reason = entry["pattern"], entry["reason"]
|
|
67
|
+
if not isinstance(pattern, str) or not pattern:
|
|
68
|
+
raise ConfigError("%s: \"pattern\" must be a non-empty regular expression" % where)
|
|
69
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
70
|
+
raise ConfigError("%s: \"reason\" must say why %s is a governance reference" % (where, pattern))
|
|
71
|
+
try:
|
|
72
|
+
regex = re.compile(pattern)
|
|
73
|
+
except re.error as error:
|
|
74
|
+
raise ConfigError("%s: %r is not a valid regular expression: %s" % (where, pattern, error))
|
|
75
|
+
if regex.search(""):
|
|
76
|
+
raise ConfigError("%s: %r matches empty text, so it would name no reference" % (where, pattern))
|
|
77
|
+
return governance.Extra(pattern, reason, regex)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _governance(data):
|
|
81
|
+
"""The Matcher the settings ask for; the core is always part of it, so a setting can only add references."""
|
|
82
|
+
if not isinstance(data, dict) or set(data) - {"packs", "extra"}:
|
|
83
|
+
raise ConfigError("\"governance\" must be an object whose only keys are \"packs\" and \"extra\"")
|
|
84
|
+
packs, extras = data.get("packs", []), data.get("extra", [])
|
|
85
|
+
if not isinstance(packs, list) or not all(isinstance(name, str) for name in packs):
|
|
86
|
+
raise ConfigError("\"governance.packs\" must be a list of pack names")
|
|
87
|
+
unknown = [name for name in packs if name not in governance.PACKS]
|
|
88
|
+
if unknown:
|
|
89
|
+
raise ConfigError("unknown governance pack %r; the packs are %s"
|
|
90
|
+
% (unknown[0], ", ".join(sorted(governance.PACKS))))
|
|
91
|
+
if not isinstance(extras, list):
|
|
92
|
+
raise ConfigError("\"governance.extra\" must be a list")
|
|
93
|
+
if not packs and not extras:
|
|
94
|
+
return governance.DEFAULT
|
|
95
|
+
return governance.matcher(tuple(dict.fromkeys(packs)),
|
|
96
|
+
tuple(_extra(entry, position) for position, entry in enumerate(extras)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse(text):
|
|
100
|
+
"""Parse the settings file's text; raises ConfigError on anything but the documented shape."""
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(text)
|
|
103
|
+
except ValueError as error:
|
|
104
|
+
raise ConfigError("not valid JSON: %s" % error)
|
|
105
|
+
if not isinstance(data, dict) or set(data) - {"$schema", "exclude", "governance"}:
|
|
106
|
+
raise ConfigError("expected one object whose only keys are \"$schema\", \"exclude\" and \"governance\"")
|
|
107
|
+
if "$schema" in data and not isinstance(data["$schema"], str):
|
|
108
|
+
raise ConfigError("\"$schema\" must be a string")
|
|
109
|
+
entries = data.get("exclude", [])
|
|
110
|
+
if not isinstance(entries, list):
|
|
111
|
+
raise ConfigError("\"exclude\" must be a list")
|
|
112
|
+
return Config(tuple(_exclusion(entry, position) for position, entry in enumerate(entries)),
|
|
113
|
+
_governance(data.get("governance", {})))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def skipped_by_default(path):
|
|
117
|
+
"""True for a path under a directory, or with a file name, that no repository authors by hand."""
|
|
118
|
+
parts = path.split("/")
|
|
119
|
+
return parts[-1] in DEFAULT_SKIP_FILES or any(part in DEFAULT_SKIP_DIRS for part in parts[:-1])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def excluded(config, path):
|
|
123
|
+
"""True when the repository's settings exclude this root-relative path."""
|
|
124
|
+
return any(regex.match(path) for _pattern, _reason, regex in config.exclusions)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Parsing a unified diff into the new-file line ranges it adds or changes. No git needed."""
|
|
2
|
+
import re
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
|
|
5
|
+
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
6
|
+
|
|
7
|
+
# Git quotes a name that holds a quote, a backslash or a control character, whatever `core.quotePath` says.
|
|
8
|
+
QUOTED_ESCAPE = re.compile(r"\\([0-7]{1,3}|.)")
|
|
9
|
+
ESCAPED_BYTES = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Hunk(namedtuple("Hunk", ["old_count", "start", "new_count"])):
|
|
13
|
+
"""One `@@ -a,b +c,d @@` header: the old line count, the first new line and the new line count."""
|
|
14
|
+
|
|
15
|
+
def new_range(self):
|
|
16
|
+
"""Inclusive (start, end) of the new-file lines the hunk covers; None for a pure deletion."""
|
|
17
|
+
return (self.start, self.start + self.new_count - 1) if self.new_count else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_hunk(header):
|
|
21
|
+
"""The Hunk a `@@ -a,b +c,d @@` header announces; None for any other line."""
|
|
22
|
+
match = HUNK_HEADER.match(header)
|
|
23
|
+
if not match:
|
|
24
|
+
return None
|
|
25
|
+
old_count, start, new_count = match.groups()
|
|
26
|
+
return Hunk(1 if old_count is None else int(old_count), int(start), 1 if new_count is None else int(new_count))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _escaped_byte(match):
|
|
30
|
+
code = match.group(1)
|
|
31
|
+
if code[0] in "01234567":
|
|
32
|
+
return chr(int(code, 8) & 0xFF)
|
|
33
|
+
return chr(ESCAPED_BYTES.get(code, ord(code)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unquoted(name):
|
|
37
|
+
"""A name as git C-quotes it, back to its text: escapes become their bytes, and the bytes are read as UTF-8."""
|
|
38
|
+
if len(name) < 2 or not (name.startswith('"') and name.endswith('"')):
|
|
39
|
+
return name
|
|
40
|
+
escaped = name[1:-1].encode("utf-8").decode("latin-1")
|
|
41
|
+
return QUOTED_ESCAPE.sub(_escaped_byte, escaped).encode("latin-1").decode("utf-8", errors="replace")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _new_path(plus_line):
|
|
45
|
+
"""The path a `+++ ...` line names; None for `/dev/null`, which marks a deleted file."""
|
|
46
|
+
target = _unquoted(plus_line[4:].split("\t", 1)[0])
|
|
47
|
+
if target == "/dev/null":
|
|
48
|
+
return None
|
|
49
|
+
return target[2:] if target[:2] in ("a/", "b/") else target
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _HunkBody:
|
|
53
|
+
"""Counts down the lines a hunk header announced, so a source line shaped like a header is never read as one."""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.old = self.new = 0
|
|
57
|
+
|
|
58
|
+
def open(self, hunk):
|
|
59
|
+
self.old, self.new = hunk.old_count, hunk.new_count
|
|
60
|
+
|
|
61
|
+
def consume(self, line):
|
|
62
|
+
"""Take `line` off the announced counts; False, with the body closed, when it is not one of those lines."""
|
|
63
|
+
marker = line[:1]
|
|
64
|
+
if marker == "\\":
|
|
65
|
+
return True
|
|
66
|
+
if marker == "-" and self.old:
|
|
67
|
+
self.old -= 1
|
|
68
|
+
elif marker == "+" and self.new:
|
|
69
|
+
self.new -= 1
|
|
70
|
+
elif marker == " " and self.old and self.new:
|
|
71
|
+
self.old -= 1
|
|
72
|
+
self.new -= 1
|
|
73
|
+
else:
|
|
74
|
+
self.old = self.new = 0
|
|
75
|
+
return False
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_diff_ranges(diff_text):
|
|
80
|
+
"""Map each file in a `git diff -U0` to its new-file (start, end) ranges; empty when it only loses lines."""
|
|
81
|
+
ranges = {}
|
|
82
|
+
current = None
|
|
83
|
+
body = _HunkBody()
|
|
84
|
+
for line in diff_text.split("\n"):
|
|
85
|
+
if body.consume(line):
|
|
86
|
+
continue
|
|
87
|
+
if line.startswith("+++ "):
|
|
88
|
+
current = _new_path(line)
|
|
89
|
+
if current is not None:
|
|
90
|
+
ranges.setdefault(current, [])
|
|
91
|
+
continue
|
|
92
|
+
hunk = parse_hunk(line) if current is not None else None
|
|
93
|
+
if hunk is not None:
|
|
94
|
+
body.open(hunk)
|
|
95
|
+
added = hunk.new_range()
|
|
96
|
+
if added:
|
|
97
|
+
ranges[current].append(added)
|
|
98
|
+
return ranges
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Judging one file: pick its lexer by extension, lex the text, apply the rules. No I/O."""
|
|
2
|
+
from collections import namedtuple
|
|
3
|
+
|
|
4
|
+
from . import rules
|
|
5
|
+
from .governance import DEFAULT
|
|
6
|
+
from .lexers import family_for
|
|
7
|
+
|
|
8
|
+
FileResult = namedtuple("FileResult", ["findings", "unresolved_lines"])
|
|
9
|
+
FileResult.__doc__ = "The findings for one file, and how many of its lines the line lexers left unjudged."
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def in_scope(path):
|
|
13
|
+
"""True when the path's extension belongs to a covered language."""
|
|
14
|
+
return family_for(path) is not None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_text(path, text, governance=DEFAULT):
|
|
18
|
+
"""Every finding in `text`, read as the language `path` names; raises LexError when it cannot be lexed."""
|
|
19
|
+
family = family_for(path)
|
|
20
|
+
if family is None:
|
|
21
|
+
raise ValueError("%s: not a covered file type" % path)
|
|
22
|
+
raw_lines = text.splitlines()
|
|
23
|
+
lexed = family.lex(text)
|
|
24
|
+
return FileResult(rules.check(raw_lines, lexed, family.markers, governance), rules.unresolved_lines(lexed))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def within(findings, ranges):
|
|
28
|
+
"""The findings whose line span overlaps one of the inclusive (start, end) ranges."""
|
|
29
|
+
return [finding for finding in findings
|
|
30
|
+
if any(finding.line <= end and start <= finding.end_line for start, end in ranges)]
|