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.
@@ -0,0 +1,3 @@
1
+ """agent-comment-lint: one engine that judges code comments against fixed rules, for any repository."""
2
+
3
+ __version__ = "1.2.0"
@@ -0,0 +1,7 @@
1
+ """Lets `python3 -m agent_comment_lint` run the check."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -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)]