|
1
|
|
|
2
|
"""FWD Algorithmic Code-Style Checker.
|
|
3
|
|
|
4
|
Algorithmic code-style checker for FWD code reviews. Parses a bzr unified
|
|
5
|
diff, reads the new-version sources from the working copy, and emits
|
|
6
|
Textile-formatted style findings derived from the gcd-code-style skill.
|
|
7
|
Supplement to the LLM review; all findings reference line numbers in the
|
|
8
|
new version of each source file.
|
|
9
|
|
|
10
|
Usage:
|
|
11
|
fwd-style-check.py [-h] [--workdir PATH] [--out PATH]
|
|
12
|
[--list-rules] [--enable PATTERNS]
|
|
13
|
[--disable PATTERNS] [DIFF]
|
|
14
|
|
|
15
|
Positional arguments:
|
|
16
|
DIFF Path to a bzr unified diff file. Omit (or pass '-')
|
|
17
|
to read the diff from stdin. Default: stdin.
|
|
18
|
|
|
19
|
Options:
|
|
20
|
-h, --help Show this help message and exit.
|
|
21
|
--workdir PATH Working-copy directory containing the NEW version
|
|
22
|
of each changed file. Must match the '+++ new/...'
|
|
23
|
side of the diff. Default: current directory.
|
|
24
|
--out PATH Where to write the Textile report. Default: a
|
|
25
|
timestamped name in the current directory (e.g.
|
|
26
|
style-check_20260420-143022.textile). Parent
|
|
27
|
directories are created on demand.
|
|
28
|
--list-rules Print all registered rule IDs (with languages and
|
|
29
|
AST requirement) and exit.
|
|
30
|
--enable PATTERNS Comma-separated fnmatch patterns; only matching
|
|
31
|
rules run. Applied before --disable.
|
|
32
|
--disable PATTERNS Comma-separated fnmatch patterns; matching rules
|
|
33
|
are skipped. Applied after --enable.
|
|
34
|
|
|
35
|
Examples:
|
|
36
|
bzr diff | fwd-style-check.py
|
|
37
|
fwd-style-check.py branch.diff
|
|
38
|
fwd-style-check.py --workdir ./src --out review.textile branch.diff
|
|
39
|
fwd-style-check.py --list-rules
|
|
40
|
fwd-style-check.py --disable style.line-length,style.blank-* branch.diff
|
|
41
|
fwd-style-check.py --enable 'style.hard-tab,style.cr-endings' branch.diff
|
|
42
|
|
|
43
|
Languages: .java, .js/.mjs, .ts/.tsx, .c, .cpp/.cc/.cxx, .h/.hpp/.hh/.hxx.
|
|
44
|
|
|
45
|
AST-driven rules require tree-sitter. Install with:
|
|
46
|
|
|
47
|
pip install tree-sitter tree-sitter-language-pack
|
|
48
|
|
|
49
|
Without it, the checker runs in regex-only mode and notes it in the
|
|
50
|
report header.
|
|
51
|
|
|
52
|
Version: 2026-04-21
|
|
53
|
"""
|
|
54
|
|
|
55
|
import argparse
|
|
56
|
import fnmatch
|
|
57
|
import re
|
|
58
|
import sys
|
|
59
|
from collections import defaultdict, namedtuple
|
|
60
|
from datetime import date, datetime
|
|
61
|
from pathlib import Path
|
|
62
|
|
|
63
|
|
|
64
|
|
|
65
|
|
|
66
|
_AST_PARSERS = None
|
|
67
|
_AST_ERROR = None
|
|
68
|
|
|
69
|
|
|
70
|
def _load_parsers():
|
|
71
|
"""Lazy-load tree-sitter parsers. Returns dict[language_name -> parser]
|
|
72
|
or None if unavailable.
|
|
73
|
"""
|
|
74
|
global _AST_PARSERS, _AST_ERROR
|
|
75
|
if _AST_PARSERS is not None or _AST_ERROR is not None:
|
|
76
|
return _AST_PARSERS
|
|
77
|
|
|
78
|
try:
|
|
79
|
from tree_sitter_language_pack import get_parser as _get
|
|
80
|
except ImportError:
|
|
81
|
try:
|
|
82
|
from tree_sitter_languages import get_parser as _get
|
|
83
|
except ImportError as e:
|
|
84
|
_AST_ERROR = (
|
|
85
|
"tree-sitter bundle not installed - AST-driven rules skipped. "
|
|
86
|
"Install with: pip install tree-sitter tree-sitter-language-pack"
|
|
87
|
)
|
|
88
|
return None
|
|
89
|
|
|
90
|
parsers = {}
|
|
91
|
for lang_key in ("java", "javascript", "typescript", "c", "cpp"):
|
|
92
|
try:
|
|
93
|
parsers[lang_key] = _get(lang_key)
|
|
94
|
except Exception as e:
|
|
95
|
_AST_ERROR = f"tree-sitter parser for {lang_key} failed to load: {e}"
|
|
96
|
return None
|
|
97
|
_AST_PARSERS = parsers
|
|
98
|
return _AST_PARSERS
|
|
99
|
|
|
100
|
|
|
101
|
|
|
102
|
|
|
103
|
Finding = namedtuple("Finding", "file line severity rule_id message")
|
|
104
|
|
|
105
|
MAJOR_RULES = frozenset([
|
|
106
|
"style.hard-tab",
|
|
107
|
"style.cr-endings",
|
|
108
|
"style.copyright-year",
|
|
109
|
"style.file-history-sequence",
|
|
110
|
"style.block-delimiter-required",
|
|
111
|
])
|
|
112
|
|
|
113
|
|
|
114
|
def _severity(rule_id):
|
|
115
|
return "MAJOR" if rule_id in MAJOR_RULES else "MINOR"
|
|
116
|
|
|
117
|
|
|
118
|
|
|
119
|
|
|
120
|
EXT_TO_LANG = {
|
|
121
|
".java": "java",
|
|
122
|
".js": "javascript",
|
|
123
|
".mjs": "javascript",
|
|
124
|
".ts": "typescript",
|
|
125
|
".tsx": "typescript",
|
|
126
|
".c": "c",
|
|
127
|
".cpp": "cpp",
|
|
128
|
".cc": "cpp",
|
|
129
|
".cxx": "cpp",
|
|
130
|
".h": "cpp",
|
|
131
|
".hpp": "cpp",
|
|
132
|
".hh": "cpp",
|
|
133
|
".hxx": "cpp",
|
|
134
|
}
|
|
135
|
|
|
136
|
|
|
137
|
def language_of(path):
|
|
138
|
return EXT_TO_LANG.get(Path(path).suffix.lower())
|
|
139
|
|
|
140
|
|
|
141
|
|
|
142
|
|
|
143
|
_RE_FILE_MARK = re.compile(
|
|
144
|
r"^=== (modified|added|renamed) file '([^']+)'(?: => '([^']+)')?$"
|
|
145
|
)
|
|
146
|
_RE_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
147
|
|
|
148
|
|
|
149
|
def parse_unified_diff(lines_iter):
|
|
150
|
"""Parse a bzr unified diff from an iterable of lines.
|
|
151
|
|
|
152
|
Accepts any iterable yielding strings (e.g. an open text-mode file,
|
|
153
|
sys.stdin, or a pre-split list). Each yielded line may or may not
|
|
154
|
include a trailing newline.
|
|
155
|
|
|
156
|
Returns dict[path -> dict]:
|
|
157
|
{
|
|
158
|
"added_linenos": set[int], # 1-based new-file line numbers
|
|
159
|
"added_ranges": list[(start,end)], # merged contiguous runs
|
|
160
|
}
|
|
161
|
"""
|
|
162
|
files = {}
|
|
163
|
current_file = None
|
|
164
|
current_line = 0
|
|
165
|
in_hunk = False
|
|
166
|
|
|
167
|
for raw in lines_iter:
|
|
168
|
line = raw.rstrip("\n")
|
|
169
|
|
|
170
|
m = _RE_FILE_MARK.match(line)
|
|
171
|
if m:
|
|
172
|
kind, first, second = m.group(1), m.group(2), m.group(3)
|
|
173
|
path = second if kind == "renamed" and second else first
|
|
174
|
current_file = path
|
|
175
|
files.setdefault(path, {"added_linenos": set(), "added_ranges": []})
|
|
176
|
in_hunk = False
|
|
177
|
continue
|
|
178
|
|
|
179
|
if line.startswith("--- ") or line.startswith("+++ "):
|
|
180
|
in_hunk = False
|
|
181
|
continue
|
|
182
|
|
|
183
|
m = _RE_HUNK.match(line)
|
|
184
|
if m and current_file is not None:
|
|
185
|
current_line = int(m.group(1))
|
|
186
|
in_hunk = True
|
|
187
|
continue
|
|
188
|
|
|
189
|
if not in_hunk or current_file is None:
|
|
190
|
continue
|
|
191
|
|
|
192
|
if line.startswith("+"):
|
|
193
|
files[current_file]["added_linenos"].add(current_line)
|
|
194
|
current_line += 1
|
|
195
|
elif line.startswith("-"):
|
|
196
|
pass
|
|
197
|
elif line.startswith(" "):
|
|
198
|
current_line += 1
|
|
199
|
elif line.startswith("\\"):
|
|
200
|
pass
|
|
201
|
else:
|
|
202
|
|
|
203
|
current_line += 1
|
|
204
|
|
|
205
|
|
|
206
|
for info in files.values():
|
|
207
|
if not info["added_linenos"]:
|
|
208
|
info["added_ranges"] = []
|
|
209
|
continue
|
|
210
|
sorted_lns = sorted(info["added_linenos"])
|
|
211
|
ranges = []
|
|
212
|
start = prev = sorted_lns[0]
|
|
213
|
for ln in sorted_lns[1:]:
|
|
214
|
if ln == prev + 1:
|
|
215
|
prev = ln
|
|
216
|
else:
|
|
217
|
ranges.append((start, prev))
|
|
218
|
start = prev = ln
|
|
219
|
ranges.append((start, prev))
|
|
220
|
info["added_ranges"] = ranges
|
|
221
|
|
|
222
|
return files
|
|
223
|
|
|
224
|
|
|
225
|
def node_touches_diff(node, added_ranges):
|
|
226
|
"""True if node's line range overlaps any added range."""
|
|
227
|
start = node.start_point[0] + 1
|
|
228
|
end = node.end_point[0] + 1
|
|
229
|
for s, e in added_ranges:
|
|
230
|
if not (end < s or start > e):
|
|
231
|
return True
|
|
232
|
return False
|
|
233
|
|
|
234
|
|
|
235
|
|
|
236
|
|
|
237
|
def strip_code(source):
|
|
238
|
"""Return a same-length 'code skeleton' with string literals and
|
|
239
|
comments replaced by spaces. Preserves positions so column numbers
|
|
240
|
remain aligned with the original.
|
|
241
|
|
|
242
|
Handles Java/JS/TS/C/C++ lexical elements: // comments, /* */ comments,
|
|
243
|
" strings, ' chars, JS template literals (`...`). Not perfect (escapes
|
|
244
|
and nested template substitutions), but adequate for line-local regex
|
|
245
|
rules.
|
|
246
|
"""
|
|
247
|
out = []
|
|
248
|
i = 0
|
|
249
|
n = len(source)
|
|
250
|
state = "code"
|
|
251
|
while i < n:
|
|
252
|
c = source[i]
|
|
253
|
nxt = source[i + 1] if i + 1 < n else ""
|
|
254
|
|
|
255
|
if state == "code":
|
|
256
|
if c == "/" and nxt == "/":
|
|
257
|
state = "line_comment"
|
|
258
|
out.append(" ")
|
|
259
|
i += 2
|
|
260
|
continue
|
|
261
|
if c == "/" and nxt == "*":
|
|
262
|
state = "block_comment"
|
|
263
|
out.append(" ")
|
|
264
|
i += 2
|
|
265
|
continue
|
|
266
|
if c == '"':
|
|
267
|
state = "dstr"
|
|
268
|
out.append('"')
|
|
269
|
i += 1
|
|
270
|
continue
|
|
271
|
if c == "'":
|
|
272
|
state = "sstr"
|
|
273
|
out.append("'")
|
|
274
|
i += 1
|
|
275
|
continue
|
|
276
|
if c == "`":
|
|
277
|
state = "tstr"
|
|
278
|
out.append("`")
|
|
279
|
i += 1
|
|
280
|
continue
|
|
281
|
out.append(c)
|
|
282
|
i += 1
|
|
283
|
continue
|
|
284
|
|
|
285
|
if state == "line_comment":
|
|
286
|
if c == "\n":
|
|
287
|
state = "code"
|
|
288
|
out.append("\n")
|
|
289
|
else:
|
|
290
|
out.append(" ")
|
|
291
|
i += 1
|
|
292
|
continue
|
|
293
|
|
|
294
|
if state == "block_comment":
|
|
295
|
if c == "*" and nxt == "/":
|
|
296
|
state = "code"
|
|
297
|
out.append(" ")
|
|
298
|
i += 2
|
|
299
|
continue
|
|
300
|
out.append("\n" if c == "\n" else " ")
|
|
301
|
i += 1
|
|
302
|
continue
|
|
303
|
|
|
304
|
if state in ("dstr", "sstr", "tstr"):
|
|
305
|
quote = {"dstr": '"', "sstr": "'", "tstr": "`"}[state]
|
|
306
|
if c == "\\" and nxt:
|
|
307
|
out.append(" ")
|
|
308
|
i += 2
|
|
309
|
continue
|
|
310
|
if c == quote:
|
|
311
|
state = "code"
|
|
312
|
out.append(quote)
|
|
313
|
i += 1
|
|
314
|
continue
|
|
315
|
|
|
316
|
|
|
317
|
out.append("\n" if c == "\n" else " ")
|
|
318
|
i += 1
|
|
319
|
continue
|
|
320
|
|
|
321
|
return "".join(out)
|
|
322
|
|
|
323
|
|
|
324
|
|
|
325
|
|
|
326
|
class FileContext:
|
|
327
|
def __init__(self, rel_path, full_path, source_bytes, language,
|
|
328
|
added_linenos, added_ranges, tree, parse_errors):
|
|
329
|
self.rel_path = rel_path
|
|
330
|
self.full_path = full_path
|
|
331
|
self.source_bytes = source_bytes
|
|
332
|
self.source_text = source_bytes.decode("utf-8", errors="replace")
|
|
333
|
self.source_lines = self.source_text.splitlines()
|
|
334
|
self.code_text = strip_code(self.source_text)
|
|
335
|
self.code_lines = self.code_text.splitlines()
|
|
336
|
self.language = language
|
|
337
|
self.added_linenos = added_linenos
|
|
338
|
self.added_ranges = added_ranges
|
|
339
|
self.tree = tree
|
|
340
|
self.parse_errors = parse_errors
|
|
341
|
self.today_year = date.today().year
|
|
342
|
|
|
343
|
|
|
344
|
|
|
345
|
|
|
346
|
def code_line(ctx, lineno):
|
|
347
|
"""1-based code-skeleton line access; empty string on out-of-range."""
|
|
348
|
idx = lineno - 1
|
|
349
|
if 0 <= idx < len(ctx.code_lines):
|
|
350
|
return ctx.code_lines[idx]
|
|
351
|
return ""
|
|
352
|
|
|
353
|
|
|
354
|
def source_line(ctx, lineno):
|
|
355
|
idx = lineno - 1
|
|
356
|
if 0 <= idx < len(ctx.source_lines):
|
|
357
|
return ctx.source_lines[idx]
|
|
358
|
return ""
|
|
359
|
|
|
360
|
|
|
361
|
def visual_len(s):
|
|
362
|
"""Expand tabs to 3-space stops (project convention)."""
|
|
363
|
out = 0
|
|
364
|
for ch in s:
|
|
365
|
if ch == "\t":
|
|
366
|
out += 3 - (out % 3)
|
|
367
|
else:
|
|
368
|
out += 1
|
|
369
|
return out
|
|
370
|
|
|
371
|
|
|
372
|
JAVA_KEYWORDS = frozenset([
|
|
373
|
"abstract", "assert", "boolean", "break", "byte", "case", "catch",
|
|
374
|
"char", "class", "const", "continue", "default", "do", "double",
|
|
375
|
"else", "enum", "extends", "final", "finally", "float", "for", "goto",
|
|
376
|
"if", "implements", "import", "instanceof", "int", "interface", "long",
|
|
377
|
"native", "new", "package", "private", "protected", "public", "return",
|
|
378
|
"short", "static", "strictfp", "super", "switch", "synchronized", "this",
|
|
379
|
"throw", "throws", "transient", "try", "void", "volatile", "while",
|
|
380
|
"yield", "var", "record", "sealed", "permits", "non-sealed",
|
|
381
|
])
|
|
382
|
|
|
383
|
JS_KEYWORDS = frozenset([
|
|
384
|
"async", "await", "break", "case", "catch", "class", "const", "continue",
|
|
385
|
"debugger", "default", "delete", "do", "else", "enum", "export", "extends",
|
|
386
|
"false", "finally", "for", "function", "if", "import", "in", "instanceof",
|
|
387
|
"let", "new", "null", "of", "return", "super", "switch", "this", "throw",
|
|
388
|
"true", "try", "typeof", "var", "void", "while", "with", "yield",
|
|
389
|
"interface", "public", "private", "protected", "static", "type",
|
|
390
|
])
|
|
391
|
|
|
392
|
C_KEYWORDS = frozenset([
|
|
393
|
"auto", "break", "case", "char", "const", "continue", "default", "do",
|
|
394
|
"double", "else", "enum", "extern", "float", "for", "goto", "if", "inline",
|
|
395
|
"int", "long", "register", "restrict", "return", "short", "signed",
|
|
396
|
"sizeof", "static", "struct", "switch", "typedef", "union", "unsigned",
|
|
397
|
"void", "volatile", "while", "_Bool", "_Complex", "_Atomic",
|
|
398
|
|
|
399
|
"class", "namespace", "template", "typename", "new", "delete", "this",
|
|
400
|
"throw", "try", "catch", "public", "private", "protected", "virtual",
|
|
401
|
"override", "final", "friend", "using", "operator", "explicit", "mutable",
|
|
402
|
"nullptr", "bool", "true", "false", "constexpr", "decltype",
|
|
403
|
])
|
|
404
|
|
|
405
|
|
|
406
|
def keywords_for(language):
|
|
407
|
if language == "java":
|
|
408
|
return JAVA_KEYWORDS
|
|
409
|
if language in ("javascript", "typescript"):
|
|
410
|
return JS_KEYWORDS
|
|
411
|
return C_KEYWORDS
|
|
412
|
|
|
413
|
|
|
414
|
|
|
415
|
|
|
416
|
|
|
417
|
|
|
418
|
|
|
419
|
|
|
420
|
_RULE_REGISTRY = []
|
|
421
|
|
|
422
|
|
|
423
|
def rule(rule_id, languages, requires_ast=False):
|
|
424
|
"""Register a rule function and attach metadata."""
|
|
425
|
def decorator(fn):
|
|
426
|
fn.rule_id = rule_id
|
|
427
|
fn.languages = frozenset(languages)
|
|
428
|
fn.requires_ast = requires_ast
|
|
429
|
_RULE_REGISTRY.append(fn)
|
|
430
|
return fn
|
|
431
|
return decorator
|
|
432
|
|
|
433
|
|
|
434
|
def _split_patterns(value):
|
|
435
|
"""Split a comma/whitespace separated pattern list; ignore empties."""
|
|
436
|
if not value:
|
|
437
|
return []
|
|
438
|
parts = re.split(r"[,\s]+", value.strip())
|
|
439
|
return [p for p in parts if p]
|
|
440
|
|
|
441
|
|
|
442
|
def _match_any(rule_id, patterns):
|
|
443
|
return any(fnmatch.fnmatchcase(rule_id, p) for p in patterns)
|
|
444
|
|
|
445
|
|
|
446
|
def select_rules(enable_patterns, disable_patterns):
|
|
447
|
"""Return (selected_rules, unknown_patterns).
|
|
448
|
|
|
449
|
`enable_patterns` narrows the registry to matching rules (empty list
|
|
450
|
means keep all). `disable_patterns` removes matches from that set.
|
|
451
|
Patterns that match no known rule are returned as `unknown_patterns`
|
|
452
|
so the caller can warn.
|
|
453
|
"""
|
|
454
|
all_ids = [fn.rule_id for fn in _RULE_REGISTRY]
|
|
455
|
unknown = []
|
|
456
|
for p in enable_patterns:
|
|
457
|
if not any(fnmatch.fnmatchcase(rid, p) for rid in all_ids):
|
|
458
|
unknown.append(p)
|
|
459
|
for p in disable_patterns:
|
|
460
|
if not any(fnmatch.fnmatchcase(rid, p) for rid in all_ids):
|
|
461
|
unknown.append(p)
|
|
462
|
|
|
463
|
selected = []
|
|
464
|
for fn in _RULE_REGISTRY:
|
|
465
|
if enable_patterns and not _match_any(fn.rule_id, enable_patterns):
|
|
466
|
continue
|
|
467
|
if disable_patterns and _match_any(fn.rule_id, disable_patterns):
|
|
468
|
continue
|
|
469
|
selected.append(fn)
|
|
470
|
return selected, unknown
|
|
471
|
|
|
472
|
|
|
473
|
ALL_LANGS = ["java", "javascript", "typescript", "c", "cpp"]
|
|
474
|
|
|
475
|
|
|
476
|
|
|
477
|
|
|
478
|
@rule("style.hard-tab", ALL_LANGS)
|
|
479
|
def check_hard_tab(ctx):
|
|
480
|
out = []
|
|
481
|
for ln in sorted(ctx.added_linenos):
|
|
482
|
if "\t" in source_line(ctx, ln):
|
|
483
|
out.append(Finding(ctx.rel_path, ln, _severity("style.hard-tab"),
|
|
484
|
"style.hard-tab", "hard tab character"))
|
|
485
|
return out
|
|
486
|
|
|
487
|
|
|
488
|
@rule("style.cr-endings", ALL_LANGS)
|
|
489
|
def check_cr_endings(ctx):
|
|
490
|
|
|
491
|
if b"\r" not in ctx.source_bytes:
|
|
492
|
return []
|
|
493
|
|
|
494
|
return [Finding(ctx.rel_path, 1, _severity("style.cr-endings"),
|
|
495
|
"style.cr-endings",
|
|
496
|
"file contains CR (\\r) characters; must be LF only")]
|
|
497
|
|
|
498
|
|
|
499
|
@rule("style.line-length", ALL_LANGS)
|
|
500
|
def check_line_length(ctx):
|
|
501
|
out = []
|
|
502
|
for ln in sorted(ctx.added_linenos):
|
|
503
|
s = source_line(ctx, ln)
|
|
504
|
if visual_len(s) > 110:
|
|
505
|
out.append(Finding(ctx.rel_path, ln, _severity("style.line-length"),
|
|
506
|
"style.line-length",
|
|
507
|
f"line length {visual_len(s)} > 110"))
|
|
508
|
return out
|
|
509
|
|
|
510
|
|
|
511
|
_RE_COMMA_NOSPACE = re.compile(r",([^\s,)\]}])")
|
|
512
|
|
|
513
|
|
|
514
|
@rule("style.comma-space", ALL_LANGS)
|
|
515
|
def check_comma_space(ctx):
|
|
516
|
out = []
|
|
517
|
for ln in sorted(ctx.added_linenos):
|
|
518
|
s = code_line(ctx, ln)
|
|
519
|
if _RE_COMMA_NOSPACE.search(s):
|
|
520
|
out.append(Finding(ctx.rel_path, ln, _severity("style.comma-space"),
|
|
521
|
"style.comma-space", "missing space after comma"))
|
|
522
|
return out
|
|
523
|
|
|
524
|
|
|
525
|
_RE_PAREN_OPEN_SPACE = re.compile(r"\(\s+[^\s)]")
|
|
526
|
_RE_PAREN_CLOSE_SPACE = re.compile(r"[^\s(]\s+\)")
|
|
527
|
|
|
528
|
|
|
529
|
@rule("style.paren-inner-space", ALL_LANGS)
|
|
530
|
def check_paren_inner_space(ctx):
|
|
531
|
out = []
|
|
532
|
for ln in sorted(ctx.added_linenos):
|
|
533
|
s = code_line(ctx, ln)
|
|
534
|
stripped = s.rstrip()
|
|
535
|
fired = False
|
|
536
|
|
|
537
|
|
|
538
|
m = _RE_PAREN_OPEN_SPACE.search(s)
|
|
539
|
if m and s.rstrip().endswith("("):
|
|
540
|
|
|
541
|
m = None
|
|
542
|
if m:
|
|
543
|
out.append(Finding(ctx.rel_path, ln,
|
|
544
|
_severity("style.paren-inner-space"),
|
|
545
|
"style.paren-inner-space",
|
|
546
|
"unexpected whitespace after '('"))
|
|
547
|
fired = True
|
|
548
|
m = _RE_PAREN_CLOSE_SPACE.search(s)
|
|
549
|
if m:
|
|
550
|
|
|
551
|
|
|
552
|
if not fired:
|
|
553
|
out.append(Finding(ctx.rel_path, ln,
|
|
554
|
_severity("style.paren-inner-space"),
|
|
555
|
"style.paren-inner-space",
|
|
556
|
"unexpected whitespace before ')'"))
|
|
557
|
return out
|
|
558
|
|
|
559
|
|
|
560
|
_RE_KEYWORD_PAREN_JAVA = re.compile(
|
|
561
|
r"\b(if|while|for|switch|catch|synchronized|return|throw)\("
|
|
562
|
)
|
|
563
|
_RE_KEYWORD_PAREN_C = re.compile(
|
|
564
|
r"\b(if|while|for|switch|return|sizeof)\("
|
|
565
|
)
|
|
566
|
_RE_KEYWORD_PAREN_JS = re.compile(
|
|
567
|
r"\b(if|while|for|switch|catch|return|throw)\("
|
|
568
|
)
|
|
569
|
|
|
570
|
|
|
571
|
@rule("style.keyword-paren", ALL_LANGS)
|
|
572
|
def check_keyword_paren(ctx):
|
|
573
|
if ctx.language == "java":
|
|
574
|
pat = _RE_KEYWORD_PAREN_JAVA
|
|
575
|
elif ctx.language in ("javascript", "typescript"):
|
|
576
|
pat = _RE_KEYWORD_PAREN_JS
|
|
577
|
else:
|
|
578
|
pat = _RE_KEYWORD_PAREN_C
|
|
579
|
out = []
|
|
580
|
for ln in sorted(ctx.added_linenos):
|
|
581
|
s = code_line(ctx, ln)
|
|
582
|
m = pat.search(s)
|
|
583
|
if m:
|
|
584
|
out.append(Finding(ctx.rel_path, ln, _severity("style.keyword-paren"),
|
|
585
|
"style.keyword-paren",
|
|
586
|
f"missing space between '{m.group(1)}' and '('"))
|
|
587
|
return out
|
|
588
|
|
|
589
|
|
|
590
|
_RE_IDENT_SPACE_PAREN = re.compile(r"\b([A-Za-z_]\w*)\s+\(")
|
|
591
|
|
|
592
|
|
|
593
|
@rule("style.funcname-paren", ALL_LANGS)
|
|
594
|
def check_funcname_paren(ctx):
|
|
595
|
"""Flag 'foo (' where foo is not a keyword.
|
|
596
|
|
|
597
|
Heuristic: skip lines that look like control-flow keywords or
|
|
598
|
declarations (handled by style.keyword-paren), and skip identifiers
|
|
599
|
that are in the language keyword set. Also skip when the identifier
|
|
600
|
directly precedes an operator on the left (e.g. cast: `(int) (x+1)`
|
|
601
|
hits `) (` which is not \\w+\\s+\\().
|
|
602
|
"""
|
|
603
|
kws = keywords_for(ctx.language)
|
|
604
|
out = []
|
|
605
|
for ln in sorted(ctx.added_linenos):
|
|
606
|
s = code_line(ctx, ln)
|
|
607
|
for m in _RE_IDENT_SPACE_PAREN.finditer(s):
|
|
608
|
name = m.group(1)
|
|
609
|
if name in kws:
|
|
610
|
continue
|
|
611
|
|
|
612
|
start = m.start()
|
|
613
|
if start > 0 and s[start - 1] == "@":
|
|
614
|
continue
|
|
615
|
out.append(Finding(ctx.rel_path, ln, _severity("style.funcname-paren"),
|
|
616
|
"style.funcname-paren",
|
|
617
|
f"space between identifier '{name}' and '('"))
|
|
618
|
return out
|
|
619
|
|
|
620
|
|
|
621
|
|
|
622
|
_RE_BIN_OP = re.compile(
|
|
623
|
r"""(?x)
|
|
624
|
(?<![<>!=+\-*/%&|^~]) # not already part of an op
|
|
625
|
(?P<op>==|!=|<=|>=|&&|\|\||\+=|-=|\*=|/=|%=)
|
|
626
|
(?![<>!=+\-*/%&|^~])
|
|
627
|
"""
|
|
628
|
)
|
|
629
|
_RE_BIN_OP_ASSIGN = re.compile(r"(?<![<>!=+\-*/%&|^~])=(?![=])")
|
|
630
|
|
|
631
|
|
|
632
|
@rule("style.binary-op-space", ALL_LANGS)
|
|
633
|
def check_binary_op_space(ctx):
|
|
634
|
out = []
|
|
635
|
for ln in sorted(ctx.added_linenos):
|
|
636
|
s = code_line(ctx, ln)
|
|
637
|
|
|
638
|
if s.lstrip().startswith("@"):
|
|
639
|
continue
|
|
640
|
|
|
641
|
seen_cols = set()
|
|
642
|
for m in _RE_BIN_OP.finditer(s):
|
|
643
|
start = m.start("op")
|
|
644
|
end = m.end("op")
|
|
645
|
before = s[start - 1] if start > 0 else " "
|
|
646
|
after = s[end] if end < len(s) else " "
|
|
647
|
if not before.isspace() or not after.isspace():
|
|
648
|
out.append(Finding(
|
|
649
|
ctx.rel_path, ln, _severity("style.binary-op-space"),
|
|
650
|
"style.binary-op-space",
|
|
651
|
f"binary operator '{m.group('op')}' lacks surrounding spaces"))
|
|
652
|
seen_cols.add(start)
|
|
653
|
|
|
654
|
for m in _RE_BIN_OP_ASSIGN.finditer(s):
|
|
655
|
start = m.start()
|
|
656
|
if start in seen_cols:
|
|
657
|
continue
|
|
658
|
before = s[start - 1] if start > 0 else " "
|
|
659
|
after = s[start + 1] if start + 1 < len(s) else " "
|
|
660
|
if not before.isspace() or not after.isspace():
|
|
661
|
out.append(Finding(
|
|
662
|
ctx.rel_path, ln, _severity("style.binary-op-space"),
|
|
663
|
"style.binary-op-space",
|
|
664
|
"assignment '=' lacks surrounding spaces"))
|
|
665
|
return out
|
|
666
|
|
|
667
|
|
|
668
|
_RE_UNARY_SPACED = re.compile(r"(?:\w\s+(\+\+|--))|(?:(\+\+|--)\s+\w)")
|
|
669
|
|
|
670
|
|
|
671
|
@rule("style.unary-tight", ALL_LANGS)
|
|
672
|
def check_unary_tight(ctx):
|
|
673
|
out = []
|
|
674
|
for ln in sorted(ctx.added_linenos):
|
|
675
|
s = code_line(ctx, ln)
|
|
676
|
m = _RE_UNARY_SPACED.search(s)
|
|
677
|
if m:
|
|
678
|
out.append(Finding(ctx.rel_path, ln, _severity("style.unary-tight"),
|
|
679
|
"style.unary-tight",
|
|
680
|
"unary '++' / '--' must be tight against operand"))
|
|
681
|
return out
|
|
682
|
|
|
683
|
|
|
684
|
_CONTINUATION_ENDERS = (",", "(", "+", "-", "*", "/", "%", "?", ":",
|
|
685
|
"&&", "||", "[", "{", "=", "&", "|", "^", "<", ">")
|
|
686
|
|
|
687
|
|
|
688
|
def _is_continuation(ctx, ln):
|
|
689
|
"""True if line `ln` is a continuation of a previous logical line."""
|
|
690
|
prev = ln - 1
|
|
691
|
while prev >= 1:
|
|
692
|
s = code_line(ctx, prev).rstrip()
|
|
693
|
if s:
|
|
694
|
break
|
|
695
|
prev -= 1
|
|
696
|
if prev < 1:
|
|
697
|
return False
|
|
698
|
s = code_line(ctx, prev).rstrip()
|
|
699
|
if not s:
|
|
700
|
return False
|
|
701
|
for ender in ("&&", "||"):
|
|
702
|
if s.endswith(ender):
|
|
703
|
return True
|
|
704
|
return s[-1] in (",", "(", "+", "-", "*", "/", "%", "?", ":", "[",
|
|
705
|
"=", "&", "|", "^", "<", ">")
|
|
706
|
|
|
707
|
|
|
708
|
_RE_LEADING_WS = re.compile(r"^([ \t]*)")
|
|
709
|
|
|
710
|
|
|
711
|
@rule("style.indent-multiple-of-3", ALL_LANGS)
|
|
712
|
def check_indent_mult_3(ctx):
|
|
713
|
"""Report only the first line of each consecutive run sharing the same
|
|
714
|
bad indent. A run breaks when the previous line is not an added line,
|
|
715
|
or when the indent width changes.
|
|
716
|
"""
|
|
717
|
out = []
|
|
718
|
prev_flagged_indent = None
|
|
719
|
for ln in sorted(ctx.added_linenos):
|
|
720
|
s = source_line(ctx, ln)
|
|
721
|
if not s.strip():
|
|
722
|
prev_flagged_indent = None
|
|
723
|
continue
|
|
724
|
lead = _RE_LEADING_WS.match(s).group(1)
|
|
725
|
if "\t" in lead:
|
|
726
|
prev_flagged_indent = None
|
|
727
|
continue
|
|
728
|
if _is_continuation(ctx, ln):
|
|
729
|
prev_flagged_indent = None
|
|
730
|
continue
|
|
731
|
indent = len(lead)
|
|
732
|
if indent % 3 != 0:
|
|
733
|
prev_in_diff = (ln - 1) in ctx.added_linenos
|
|
734
|
if prev_in_diff and prev_flagged_indent == indent:
|
|
735
|
|
|
736
|
continue
|
|
737
|
out.append(Finding(ctx.rel_path, ln,
|
|
738
|
_severity("style.indent-multiple-of-3"),
|
|
739
|
"style.indent-multiple-of-3",
|
|
740
|
f"indent {indent} spaces is not a multiple of 3"))
|
|
741
|
prev_flagged_indent = indent
|
|
742
|
else:
|
|
743
|
prev_flagged_indent = None
|
|
744
|
return out
|
|
745
|
|
|
746
|
|
|
747
|
|
|
748
|
|
|
749
|
_RE_COPYRIGHT = re.compile(
|
|
750
|
r"Copyright\s*\(c\)\s*(\d{4})(?:\s*-\s*(\d{4}))?,?\s*Golden",
|
|
751
|
re.IGNORECASE,
|
|
752
|
)
|
|
753
|
|
|
754
|
|
|
755
|
@rule("style.copyright-year", ALL_LANGS)
|
|
756
|
def check_copyright_year(ctx):
|
|
757
|
"""Scan first 60 lines for a copyright header; verify latest year."""
|
|
758
|
out = []
|
|
759
|
for ln in range(1, min(60, len(ctx.source_lines)) + 1):
|
|
760
|
s = source_line(ctx, ln)
|
|
761
|
m = _RE_COPYRIGHT.search(s)
|
|
762
|
if not m:
|
|
763
|
continue
|
|
764
|
latest = int(m.group(2)) if m.group(2) else int(m.group(1))
|
|
765
|
if latest < ctx.today_year:
|
|
766
|
out.append(Finding(
|
|
767
|
ctx.rel_path, ln, _severity("style.copyright-year"),
|
|
768
|
"style.copyright-year",
|
|
769
|
f"copyright latest year {latest} "
|
|
770
|
f"does not match current year {ctx.today_year}"))
|
|
771
|
break
|
|
772
|
return out
|
|
773
|
|
|
774
|
|
|
775
|
|
|
776
|
|
|
777
|
_RE_HISTORY = re.compile(
|
|
778
|
r"^\*\* (?P<id>\d{3,4}|\s{3,4}) (?P<init>[A-Z]{2,3} ?) (?P<date>\d{8}) "
|
|
779
|
)
|
|
780
|
_RE_HISTORY_LOOSE = re.compile(r"^\*\*\s+(\d{1,5}|\s+)\s+[A-Z].*\d{8}\b")
|
|
781
|
|
|
782
|
|
|
783
|
@rule("style.file-history-sequence", ALL_LANGS)
|
|
784
|
def check_file_history(ctx):
|
|
785
|
"""Validate newly added file-history entries.
|
|
786
|
|
|
787
|
Pre-existing malformed or out-of-order entries are NOT flagged.
|
|
788
|
"""
|
|
789
|
out = []
|
|
790
|
|
|
791
|
history_lines = []
|
|
792
|
for idx, s in enumerate(ctx.source_lines, start=1):
|
|
793
|
if _RE_HISTORY_LOOSE.match(s):
|
|
794
|
history_lines.append(idx)
|
|
795
|
|
|
796
|
if not history_lines:
|
|
797
|
return out
|
|
798
|
|
|
799
|
|
|
800
|
|
|
801
|
existing_ids = []
|
|
802
|
for ln in history_lines:
|
|
803
|
if ln in ctx.added_linenos:
|
|
804
|
continue
|
|
805
|
m = _RE_HISTORY.match(source_line(ctx, ln))
|
|
806
|
if m:
|
|
807
|
token = m.group("id").strip()
|
|
808
|
if token.isdigit():
|
|
809
|
existing_ids.append(int(token))
|
|
810
|
max_existing = max(existing_ids) if existing_ids else 0
|
|
811
|
|
|
812
|
added_entries = [ln for ln in history_lines if ln in ctx.added_linenos]
|
|
813
|
expected = max_existing + 1
|
|
814
|
for ln in added_entries:
|
|
815
|
s = source_line(ctx, ln)
|
|
816
|
m = _RE_HISTORY.match(s)
|
|
817
|
if not m:
|
|
818
|
|
|
819
|
|
|
820
|
out.append(Finding(
|
|
821
|
ctx.rel_path, ln, _severity("style.file-history-sequence"),
|
|
822
|
"style.file-history-sequence",
|
|
823
|
"history entry does not match '** NNN INIT YYYYMMDD '"))
|
|
824
|
continue
|
|
825
|
token = m.group("id").strip()
|
|
826
|
if not token:
|
|
827
|
|
|
828
|
continue
|
|
829
|
seq = int(token)
|
|
830
|
if seq != expected:
|
|
831
|
out.append(Finding(
|
|
832
|
ctx.rel_path, ln, _severity("style.file-history-sequence"),
|
|
833
|
"style.file-history-sequence",
|
|
834
|
f"history sequence ID {seq} does not follow max existing "
|
|
835
|
f"ID {max_existing} (expected {expected})"))
|
|
836
|
expected = max(expected, seq + 1)
|
|
837
|
return out
|
|
838
|
|
|
839
|
|
|
840
|
|
|
841
|
|
|
842
|
_RE_IMPORT_EXPLICIT = re.compile(
|
|
843
|
r"^\s*import\s+((?:[a-z]\w*\.)+)([A-Z]\w*)\s*;"
|
|
844
|
)
|
|
845
|
_RE_IMPORT_WILDCARD = re.compile(r"^\s*import\s+((?:[a-z]\w*\.)+)\*\s*;")
|
|
846
|
|
|
847
|
|
|
848
|
@rule("style.import-wildcard-suggestion", ["java"])
|
|
849
|
def check_import_wildcard(ctx):
|
|
850
|
out = []
|
|
851
|
per_pkg = defaultdict(list)
|
|
852
|
wildcard_pkgs = set()
|
|
853
|
for ln, s in enumerate(ctx.source_lines, start=1):
|
|
854
|
m = _RE_IMPORT_EXPLICIT.match(s)
|
|
855
|
if m:
|
|
856
|
pkg = m.group(1).rstrip(".")
|
|
857
|
per_pkg[pkg].append((ln, m.group(2)))
|
|
858
|
continue
|
|
859
|
m = _RE_IMPORT_WILDCARD.match(s)
|
|
860
|
if m:
|
|
861
|
wildcard_pkgs.add(m.group(1).rstrip("."))
|
|
862
|
|
|
863
|
for pkg, entries in per_pkg.items():
|
|
864
|
if pkg in wildcard_pkgs:
|
|
865
|
continue
|
|
866
|
if len(entries) < 2:
|
|
867
|
continue
|
|
868
|
|
|
869
|
for ln, cls in entries:
|
|
870
|
if ln in ctx.added_linenos:
|
|
871
|
out.append(Finding(
|
|
872
|
ctx.rel_path, ln,
|
|
873
|
_severity("style.import-wildcard-suggestion"),
|
|
874
|
"style.import-wildcard-suggestion",
|
|
875
|
f"{len(entries)} explicit imports from {pkg} "
|
|
876
|
f"- consider consolidating to 'import {pkg}.*;'"))
|
|
877
|
return out
|
|
878
|
|
|
879
|
|
|
880
|
|
|
881
|
|
|
882
|
_RE_PTR_TYPE_STAR = re.compile(
|
|
883
|
r"\b([A-Za-z_]\w*)(\s*\*\s+|\*\s*)([A-Za-z_]\w*)\b"
|
|
884
|
)
|
|
885
|
|
|
886
|
|
|
887
|
_C_BUILTIN_TYPES = frozenset([
|
|
888
|
"void", "char", "short", "int", "long", "signed", "unsigned", "float",
|
|
889
|
"double", "bool", "size_t", "ssize_t", "uint8_t", "uint16_t", "uint32_t",
|
|
890
|
"uint64_t", "int8_t", "int16_t", "int32_t", "int64_t", "FILE", "wchar_t",
|
|
891
|
"jint", "jlong", "jbyte", "jshort", "jchar", "jboolean", "jfloat",
|
|
892
|
"jdouble", "jobject", "jstring", "jclass", "jthrowable", "jarray",
|
|
893
|
"JNIEnv", "jsize",
|
|
894
|
])
|
|
895
|
|
|
896
|
|
|
897
|
@rule("style.pointer-asterisk-with-name", ["c", "cpp"])
|
|
898
|
def check_pointer_asterisk(ctx):
|
|
899
|
"""Required form: `type *name`. Flag `type* name` and `type * name`."""
|
|
900
|
out = []
|
|
901
|
for ln in sorted(ctx.added_linenos):
|
|
902
|
s = code_line(ctx, ln)
|
|
903
|
src = source_line(ctx, ln)
|
|
904
|
stripped = s.strip()
|
|
905
|
|
|
906
|
if not stripped or stripped.startswith(("#", "//", "*", "/*")):
|
|
907
|
continue
|
|
908
|
|
|
909
|
|
|
910
|
for m in _RE_PTR_TYPE_STAR.finditer(s):
|
|
911
|
type_name = m.group(1)
|
|
912
|
middle = m.group(2)
|
|
913
|
|
|
914
|
|
|
915
|
if (type_name not in _C_BUILTIN_TYPES
|
|
916
|
and not type_name.endswith("_t")
|
|
917
|
and not type_name[0].isupper()):
|
|
918
|
continue
|
|
919
|
|
|
920
|
|
|
921
|
if middle.endswith(" ") and "*" in middle:
|
|
922
|
|
|
923
|
|
|
924
|
|
|
925
|
|
|
926
|
|
|
927
|
|
|
928
|
|
|
929
|
|
|
930
|
pass
|
|
931
|
|
|
932
|
|
|
933
|
raw = m.group(0)
|
|
934
|
|
|
935
|
t_end = m.start(2)
|
|
936
|
n_start = m.end(2)
|
|
937
|
between = s[t_end:n_start]
|
|
938
|
|
|
939
|
|
|
940
|
if re.fullmatch(r" +\*", between):
|
|
941
|
continue
|
|
942
|
out.append(Finding(
|
|
943
|
ctx.rel_path, ln,
|
|
944
|
_severity("style.pointer-asterisk-with-name"),
|
|
945
|
"style.pointer-asterisk-with-name",
|
|
946
|
f"pointer asterisk must stick to variable name "
|
|
947
|
f"(use 'type *name', got '{type_name}{between}{m.group(3)}')"))
|
|
948
|
return out
|
|
949
|
|
|
950
|
|
|
951
|
|
|
952
|
|
|
953
|
_RE_SEPARATE_DECL = re.compile(
|
|
954
|
r"""^\s*
|
|
955
|
(?:final\s+|static\s+|private\s+|public\s+|protected\s+)*
|
|
956
|
([A-Za-z_][\w<>\[\], ?]*?)
|
|
957
|
\s+
|
|
958
|
([A-Za-z_]\w*)
|
|
959
|
(?:\s*=\s*[^,;]+)?
|
|
960
|
\s*,\s*
|
|
961
|
([A-Za-z_]\w*)
|
|
962
|
""",
|
|
963
|
re.VERBOSE,
|
|
964
|
)
|
|
965
|
|
|
966
|
|
|
967
|
@rule("style.separate-var-decl", ["java", "c", "cpp"])
|
|
968
|
def check_separate_var_decl(ctx):
|
|
969
|
out = []
|
|
970
|
kws = keywords_for(ctx.language)
|
|
971
|
for ln in sorted(ctx.added_linenos):
|
|
972
|
s = code_line(ctx, ln)
|
|
973
|
m = _RE_SEPARATE_DECL.match(s)
|
|
974
|
if not m:
|
|
975
|
continue
|
|
976
|
first_tok = s.lstrip().split()[0] if s.lstrip() else ""
|
|
977
|
if first_tok in ("if", "while", "for", "switch", "return", "throw",
|
|
978
|
"catch"):
|
|
979
|
continue
|
|
980
|
|
|
981
|
type_part = m.group(1)
|
|
982
|
if type_part.strip() in kws:
|
|
983
|
|
|
984
|
pass
|
|
985
|
out.append(Finding(
|
|
986
|
ctx.rel_path, ln, _severity("style.separate-var-decl"),
|
|
987
|
"style.separate-var-decl",
|
|
988
|
f"declaration introduces multiple names "
|
|
989
|
f"('{m.group(2)}', '{m.group(3)}') - split into separate declarations"))
|
|
990
|
return out
|
|
991
|
|
|
992
|
|
|
993
|
|
|
994
|
|
|
995
|
def _node_line_range(node):
|
|
996
|
return (node.start_point[0] + 1, node.end_point[0] + 1)
|
|
997
|
|
|
998
|
|
|
999
|
def _text_of(node, source_bytes):
|
|
1000
|
return source_bytes[node.start_byte:node.end_byte].decode(
|
|
1001
|
"utf-8", errors="replace")
|
|
1002
|
|
|
1003
|
|
|
1004
|
def _walk(node):
|
|
1005
|
yield node
|
|
1006
|
for c in node.children:
|
|
1007
|
yield from _walk(c)
|
|
1008
|
|
|
1009
|
|
|
1010
|
@rule("style.brace-own-line", ALL_LANGS, requires_ast=True)
|
|
1011
|
def check_brace_own_line(ctx):
|
|
1012
|
if not ctx.tree:
|
|
1013
|
return []
|
|
1014
|
out = []
|
|
1015
|
seen = set()
|
|
1016
|
block_types = {
|
|
1017
|
"java": {"block", "class_body", "interface_body", "enum_body",
|
|
1018
|
"constructor_body", "switch_block", "array_initializer"},
|
|
1019
|
"javascript": {"statement_block", "class_body", "object"},
|
|
1020
|
"typescript": {"statement_block", "class_body", "object",
|
|
1021
|
"object_type"},
|
|
1022
|
"c": {"compound_statement", "initializer_list", "field_declaration_list",
|
|
1023
|
"enumerator_list"},
|
|
1024
|
"cpp": {"compound_statement", "initializer_list",
|
|
1025
|
"field_declaration_list", "enumerator_list"},
|
|
1026
|
}
|
|
1027
|
wanted = block_types.get(ctx.language, set())
|
|
1028
|
for node in _walk(ctx.tree.root_node):
|
|
1029
|
if node.type not in wanted:
|
|
1030
|
continue
|
|
1031
|
|
|
1032
|
open_brace = None
|
|
1033
|
for c in node.children:
|
|
1034
|
if c.type == "{":
|
|
1035
|
open_brace = c
|
|
1036
|
break
|
|
1037
|
if open_brace is None:
|
|
1038
|
continue
|
|
1039
|
ln = open_brace.start_point[0] + 1
|
|
1040
|
col = open_brace.start_point[1]
|
|
1041
|
if ln not in ctx.added_linenos:
|
|
1042
|
continue
|
|
1043
|
|
|
1044
|
close_brace = None
|
|
1045
|
for c in node.children:
|
|
1046
|
if c.type == "}":
|
|
1047
|
close_brace = c
|
|
1048
|
if close_brace is not None:
|
|
1049
|
if (close_brace.start_point[0] == open_brace.start_point[0]
|
|
1050
|
and close_brace.start_point[1] == col + 1):
|
|
1051
|
continue
|
|
1052
|
src = source_line(ctx, ln)
|
|
1053
|
before = src[:col].rstrip()
|
|
1054
|
after = src[col + 1:].strip()
|
|
1055
|
if before and after:
|
|
1056
|
|
|
1057
|
key = (ln, col)
|
|
1058
|
if key in seen:
|
|
1059
|
continue
|
|
1060
|
seen.add(key)
|
|
1061
|
out.append(Finding(
|
|
1062
|
ctx.rel_path, ln, _severity("style.brace-own-line"),
|
|
1063
|
"style.brace-own-line",
|
|
1064
|
"'{' must be on its own line"))
|
|
1065
|
elif before and not after:
|
|
1066
|
|
|
1067
|
key = (ln, col)
|
|
1068
|
if key in seen:
|
|
1069
|
continue
|
|
1070
|
seen.add(key)
|
|
1071
|
out.append(Finding(
|
|
1072
|
ctx.rel_path, ln, _severity("style.brace-own-line"),
|
|
1073
|
"style.brace-own-line",
|
|
1074
|
"'{' must be on its own line"))
|
|
1075
|
return out
|
|
1076
|
|
|
1077
|
|
|
1078
|
@rule("style.blank-after-open-brace", ALL_LANGS)
|
|
1079
|
def check_blank_after_open_brace(ctx):
|
|
1080
|
out = []
|
|
1081
|
for ln in sorted(ctx.added_linenos):
|
|
1082
|
s = source_line(ctx, ln).strip()
|
|
1083
|
if s != "{":
|
|
1084
|
continue
|
|
1085
|
nxt = source_line(ctx, ln + 1)
|
|
1086
|
if nxt is not None and nxt.strip() == "" and ln + 1 <= len(ctx.source_lines):
|
|
1087
|
out.append(Finding(
|
|
1088
|
ctx.rel_path, ln, _severity("style.blank-after-open-brace"),
|
|
1089
|
"style.blank-after-open-brace",
|
|
1090
|
"no blank line allowed after '{'"))
|
|
1091
|
return out
|
|
1092
|
|
|
1093
|
|
|
1094
|
@rule("style.blank-before-close-brace", ALL_LANGS)
|
|
1095
|
def check_blank_before_close_brace(ctx):
|
|
1096
|
out = []
|
|
1097
|
for ln in sorted(ctx.added_linenos):
|
|
1098
|
s = source_line(ctx, ln).strip()
|
|
1099
|
if s != "}":
|
|
1100
|
continue
|
|
1101
|
prev = source_line(ctx, ln - 1)
|
|
1102
|
if prev is not None and prev.strip() == "" and ln - 1 >= 1:
|
|
1103
|
out.append(Finding(
|
|
1104
|
ctx.rel_path, ln, _severity("style.blank-before-close-brace"),
|
|
1105
|
"style.blank-before-close-brace",
|
|
1106
|
"no blank line allowed before '}'"))
|
|
1107
|
return out
|
|
1108
|
|
|
1109
|
|
|
1110
|
@rule("style.block-delimiter-required", ALL_LANGS, requires_ast=True)
|
|
1111
|
def check_block_delimiter(ctx):
|
|
1112
|
if not ctx.tree:
|
|
1113
|
return []
|
|
1114
|
out = []
|
|
1115
|
stmt_types = {
|
|
1116
|
"if_statement", "while_statement", "for_statement",
|
|
1117
|
"do_statement", "for_in_statement", "for_of_statement",
|
|
1118
|
}
|
|
1119
|
body_types_block = {
|
|
1120
|
"java": {"block"},
|
|
1121
|
"javascript": {"statement_block"},
|
|
1122
|
"typescript": {"statement_block"},
|
|
1123
|
"c": {"compound_statement"},
|
|
1124
|
"cpp": {"compound_statement"},
|
|
1125
|
}
|
|
1126
|
block_names = body_types_block.get(ctx.language, set())
|
|
1127
|
for node in _walk(ctx.tree.root_node):
|
|
1128
|
if node.type not in stmt_types:
|
|
1129
|
continue
|
|
1130
|
start_ln = node.start_point[0] + 1
|
|
1131
|
if start_ln not in ctx.added_linenos:
|
|
1132
|
continue
|
|
1133
|
|
|
1134
|
|
|
1135
|
|
|
1136
|
for fld in ("consequence", "body"):
|
|
1137
|
body = node.child_by_field_name(fld)
|
|
1138
|
if body is not None:
|
|
1139
|
break
|
|
1140
|
else:
|
|
1141
|
body = None
|
|
1142
|
if body is None:
|
|
1143
|
continue
|
|
1144
|
if body.type in block_names:
|
|
1145
|
continue
|
|
1146
|
out.append(Finding(
|
|
1147
|
ctx.rel_path, start_ln, _severity("style.block-delimiter-required"),
|
|
1148
|
"style.block-delimiter-required",
|
|
1149
|
f"'{node.type.replace('_statement','')}' body must use '{{ }}' "
|
|
1150
|
f"even for a single statement"))
|
|
1151
|
|
|
1152
|
alt = node.child_by_field_name("alternative")
|
|
1153
|
if alt is not None and alt.type not in block_names:
|
|
1154
|
if alt.type not in stmt_types:
|
|
1155
|
out.append(Finding(
|
|
1156
|
ctx.rel_path, alt.start_point[0] + 1,
|
|
1157
|
_severity("style.block-delimiter-required"),
|
|
1158
|
"style.block-delimiter-required",
|
|
1159
|
"'else' body must use '{ }' even for a single statement"))
|
|
1160
|
return out
|
|
1161
|
|
|
1162
|
|
|
1163
|
@rule("style.class-declaration-wrap", ["java", "javascript", "typescript",
|
|
1164
|
"cpp"], requires_ast=True)
|
|
1165
|
def check_class_decl_wrap(ctx):
|
|
1166
|
if not ctx.tree:
|
|
1167
|
return []
|
|
1168
|
out = []
|
|
1169
|
decl_types = {
|
|
1170
|
"java": {"class_declaration", "interface_declaration", "enum_declaration",
|
|
1171
|
"record_declaration"},
|
|
1172
|
"javascript": {"class_declaration"},
|
|
1173
|
"typescript": {"class_declaration", "interface_declaration"},
|
|
1174
|
"cpp": {"class_specifier", "struct_specifier"},
|
|
1175
|
}
|
|
1176
|
types = decl_types.get(ctx.language, set())
|
|
1177
|
for node in _walk(ctx.tree.root_node):
|
|
1178
|
if node.type not in types:
|
|
1179
|
continue
|
|
1180
|
if not node_touches_diff(node, ctx.added_ranges):
|
|
1181
|
continue
|
|
1182
|
|
|
1183
|
open_brace_col = None
|
|
1184
|
open_brace_line = None
|
|
1185
|
for c in node.children:
|
|
1186
|
if c.type == "{":
|
|
1187
|
open_brace_line = c.start_point[0] + 1
|
|
1188
|
open_brace_col = c.start_point[1]
|
|
1189
|
break
|
|
1190
|
if open_brace_line is None:
|
|
1191
|
continue
|
|
1192
|
start_line = node.start_point[0] + 1
|
|
1193
|
|
|
1194
|
if start_line == open_brace_line:
|
|
1195
|
header_text = ctx.source_lines[start_line - 1][
|
|
1196
|
node.start_point[1]:open_brace_col]
|
|
1197
|
else:
|
|
1198
|
header_parts = [
|
|
1199
|
ctx.source_lines[start_line - 1][node.start_point[1]:]
|
|
1200
|
]
|
|
1201
|
for ln in range(start_line + 1, open_brace_line):
|
|
1202
|
header_parts.append(ctx.source_lines[ln - 1])
|
|
1203
|
header_parts.append(ctx.source_lines[open_brace_line - 1][
|
|
1204
|
:open_brace_col])
|
|
1205
|
header_text = "\n".join(header_parts)
|
|
1206
|
|
|
1207
|
|
|
1208
|
lines = header_text.split("\n")
|
|
1209
|
for offset, ln_text in enumerate(lines):
|
|
1210
|
has_extends = re.search(r"\bextends\b", ln_text)
|
|
1211
|
has_implements = re.search(r"\bimplements\b", ln_text)
|
|
1212
|
has_name = offset == 0 and re.search(
|
|
1213
|
r"\b(class|interface|enum|struct|record)\s+\w+", ln_text)
|
|
1214
|
violations = [bool(has_extends), bool(has_implements), bool(has_name)]
|
|
1215
|
if sum(violations) >= 2:
|
|
1216
|
out.append(Finding(
|
|
1217
|
ctx.rel_path, start_line + offset,
|
|
1218
|
_severity("style.class-declaration-wrap"),
|
|
1219
|
"style.class-declaration-wrap",
|
|
1220
|
"class/interface name, 'extends', and 'implements' "
|
|
1221
|
"must each be on a separate line"))
|
|
1222
|
break
|
|
1223
|
return out
|
|
1224
|
|
|
1225
|
|
|
1226
|
@rule("style.annotation-own-line", ["java", "typescript"], requires_ast=True)
|
|
1227
|
def check_annotation_own_line(ctx):
|
|
1228
|
if not ctx.tree:
|
|
1229
|
return []
|
|
1230
|
out = []
|
|
1231
|
ann_types = {
|
|
1232
|
"java": {"annotation", "marker_annotation"},
|
|
1233
|
"typescript": {"decorator"},
|
|
1234
|
}
|
|
1235
|
wanted = ann_types.get(ctx.language, set())
|
|
1236
|
for node in _walk(ctx.tree.root_node):
|
|
1237
|
if node.type not in wanted:
|
|
1238
|
continue
|
|
1239
|
if not node_touches_diff(node, ctx.added_ranges):
|
|
1240
|
continue
|
|
1241
|
end_line, end_col = node.end_point
|
|
1242
|
end_line_text = ctx.source_lines[end_line] if end_line < len(
|
|
1243
|
ctx.source_lines) else ""
|
|
1244
|
trailing = end_line_text[end_col:].strip()
|
|
1245
|
if trailing and not trailing.startswith("//"):
|
|
1246
|
out.append(Finding(
|
|
1247
|
ctx.rel_path, end_line + 1,
|
|
1248
|
_severity("style.annotation-own-line"),
|
|
1249
|
"style.annotation-own-line",
|
|
1250
|
"annotation/decorator must be on its own line"))
|
|
1251
|
return out
|
|
1252
|
|
|
1253
|
|
|
1254
|
@rule("style.comment-attached", ALL_LANGS)
|
|
1255
|
def check_comment_attached(ctx):
|
|
1256
|
out = []
|
|
1257
|
for ln in sorted(ctx.added_linenos):
|
|
1258
|
s = ctx.source_lines[ln - 1] if ln - 1 < len(ctx.source_lines) else ""
|
|
1259
|
stripped = s.strip()
|
|
1260
|
is_comment = (stripped.startswith("//")
|
|
1261
|
or stripped.startswith("*")
|
|
1262
|
or stripped.endswith("*/"))
|
|
1263
|
if not is_comment:
|
|
1264
|
continue
|
|
1265
|
nxt = ctx.source_lines[ln] if ln < len(ctx.source_lines) else ""
|
|
1266
|
if nxt.strip() != "":
|
|
1267
|
continue
|
|
1268
|
|
|
1269
|
follow = None
|
|
1270
|
for j in range(ln + 1, min(ln + 5, len(ctx.source_lines)) + 1):
|
|
1271
|
candidate = ctx.source_lines[j - 1] if j - 1 < len(
|
|
1272
|
ctx.source_lines) else ""
|
|
1273
|
if candidate.strip() != "":
|
|
1274
|
follow = candidate.strip()
|
|
1275
|
break
|
|
1276
|
if follow is None:
|
|
1277
|
continue
|
|
1278
|
|
|
1279
|
|
|
1280
|
if (follow.startswith("//") or follow.startswith("*")
|
|
1281
|
or follow.startswith("/*")):
|
|
1282
|
continue
|
|
1283
|
out.append(Finding(
|
|
1284
|
ctx.rel_path, ln, _severity("style.comment-attached"),
|
|
1285
|
"style.comment-attached",
|
|
1286
|
"no blank line allowed between comment and its subject"))
|
|
1287
|
return out
|
|
1288
|
|
|
1289
|
|
|
1290
|
_RE_LABEL = re.compile(r"^\s*([A-Za-z_]\w*)\s*:\s*(\S.*)?$")
|
|
1291
|
|
|
1292
|
|
|
1293
|
@rule("style.label-own-line", ["java", "c", "cpp"])
|
|
1294
|
def check_label_own_line(ctx):
|
|
1295
|
out = []
|
|
1296
|
for ln in sorted(ctx.added_linenos):
|
|
1297
|
s = code_line(ctx, ln)
|
|
1298
|
m = _RE_LABEL.match(s)
|
|
1299
|
if not m:
|
|
1300
|
continue
|
|
1301
|
|
|
1302
|
label = m.group(1)
|
|
1303
|
if label in ("case", "default"):
|
|
1304
|
continue
|
|
1305
|
trailing = m.group(2)
|
|
1306
|
if not trailing:
|
|
1307
|
continue
|
|
1308
|
|
|
1309
|
out.append(Finding(
|
|
1310
|
ctx.rel_path, ln, _severity("style.label-own-line"),
|
|
1311
|
"style.label-own-line",
|
|
1312
|
f"label '{label}' must be on its own line"))
|
|
1313
|
return out
|
|
1314
|
|
|
1315
|
|
|
1316
|
@rule("style.blank-between-methods", ALL_LANGS, requires_ast=True)
|
|
1317
|
def check_blank_between_methods(ctx):
|
|
1318
|
if not ctx.tree:
|
|
1319
|
return []
|
|
1320
|
out = []
|
|
1321
|
method_types = {
|
|
1322
|
"java": {"method_declaration", "constructor_declaration"},
|
|
1323
|
"javascript": {"method_definition", "function_declaration"},
|
|
1324
|
"typescript": {"method_definition", "function_declaration"},
|
|
1325
|
"c": {"function_definition"},
|
|
1326
|
"cpp": {"function_definition"},
|
|
1327
|
}
|
|
1328
|
wanted = method_types.get(ctx.language, set())
|
|
1329
|
for node in _walk(ctx.tree.root_node):
|
|
1330
|
if node.type not in wanted:
|
|
1331
|
continue
|
|
1332
|
start_line = node.start_point[0] + 1
|
|
1333
|
if start_line not in ctx.added_linenos:
|
|
1334
|
continue
|
|
1335
|
|
|
1336
|
parent = node.parent
|
|
1337
|
if parent is None:
|
|
1338
|
continue
|
|
1339
|
prev_sibling = None
|
|
1340
|
for c in parent.children:
|
|
1341
|
if c == node:
|
|
1342
|
break
|
|
1343
|
prev_sibling = c
|
|
1344
|
if prev_sibling is None:
|
|
1345
|
continue
|
|
1346
|
|
|
1347
|
prev_end_line = prev_sibling.end_point[0] + 1
|
|
1348
|
gap_blank = False
|
|
1349
|
for mid in range(prev_end_line + 1, start_line):
|
|
1350
|
if ctx.source_lines[mid - 1].strip() == "":
|
|
1351
|
gap_blank = True
|
|
1352
|
break
|
|
1353
|
if not gap_blank and start_line > prev_end_line + 1:
|
|
1354
|
|
|
1355
|
continue
|
|
1356
|
if not gap_blank:
|
|
1357
|
out.append(Finding(
|
|
1358
|
ctx.rel_path, start_line,
|
|
1359
|
_severity("style.blank-between-methods"),
|
|
1360
|
"style.blank-between-methods",
|
|
1361
|
"method/function declaration must be preceded by a blank line"))
|
|
1362
|
return out
|
|
1363
|
|
|
1364
|
|
|
1365
|
|
|
1366
|
|
|
1367
|
def iter_files(diff_data):
|
|
1368
|
"""Yield (rel_path, info) for each changed file."""
|
|
1369
|
for path, info in sorted(diff_data.items()):
|
|
1370
|
yield path, info
|
|
1371
|
|
|
1372
|
|
|
1373
|
def _parse_tree(parsers, language, source_bytes):
|
|
1374
|
if parsers is None or language not in parsers:
|
|
1375
|
return None, ["no parser"]
|
|
1376
|
try:
|
|
1377
|
tree = parsers[language].parse(source_bytes)
|
|
1378
|
return tree, []
|
|
1379
|
except Exception as e:
|
|
1380
|
return None, [f"parse error: {e}"]
|
|
1381
|
|
|
1382
|
|
|
1383
|
def check_file(parsers, rel_path, full_path, info, rules):
|
|
1384
|
language = language_of(rel_path)
|
|
1385
|
if language is None:
|
|
1386
|
return []
|
|
1387
|
try:
|
|
1388
|
source_bytes = Path(full_path).read_bytes()
|
|
1389
|
except FileNotFoundError:
|
|
1390
|
return []
|
|
1391
|
|
|
1392
|
tree, errors = _parse_tree(parsers, language, source_bytes)
|
|
1393
|
|
|
1394
|
ctx = FileContext(
|
|
1395
|
rel_path=rel_path,
|
|
1396
|
full_path=full_path,
|
|
1397
|
source_bytes=source_bytes,
|
|
1398
|
language=language,
|
|
1399
|
added_linenos=info["added_linenos"],
|
|
1400
|
added_ranges=info["added_ranges"],
|
|
1401
|
tree=tree,
|
|
1402
|
parse_errors=errors,
|
|
1403
|
)
|
|
1404
|
|
|
1405
|
findings = []
|
|
1406
|
for fn in rules:
|
|
1407
|
if ctx.language not in fn.languages:
|
|
1408
|
continue
|
|
1409
|
if fn.requires_ast and tree is None:
|
|
1410
|
continue
|
|
1411
|
try:
|
|
1412
|
findings.extend(fn(ctx) or [])
|
|
1413
|
except Exception as e:
|
|
1414
|
|
|
1415
|
findings.append(Finding(
|
|
1416
|
rel_path, 1, "MINOR", fn.rule_id,
|
|
1417
|
f"[rule error: {e.__class__.__name__}: {e}]"))
|
|
1418
|
return findings
|
|
1419
|
|
|
1420
|
|
|
1421
|
def skipped_rule_list():
|
|
1422
|
"""Rules from the spec that we deliberately do NOT implement."""
|
|
1423
|
return [
|
|
1424
|
"style.align-variable-columns",
|
|
1425
|
"style.align-by-longest-type",
|
|
1426
|
"style.short-definition-one-line",
|
|
1427
|
"style.align-static-initializer",
|
|
1428
|
"style.align-method-call-params",
|
|
1429
|
"style.static-member-via-classname",
|
|
1430
|
]
|
|
1431
|
|
|
1432
|
|
|
1433
|
def _format_findings(findings, ast_available, ast_note,
|
|
1434
|
enable_patterns=None, disable_patterns=None,
|
|
1435
|
active_rule_ids=None):
|
|
1436
|
out = []
|
|
1437
|
out.append("h3. Algorithmic Style Check (experimental)")
|
|
1438
|
out.append("")
|
|
1439
|
out.append("_Generated by fwd-style-check.py. Supplement to the LLM "
|
|
1440
|
"style pass - compare before relying on it._")
|
|
1441
|
out.append("")
|
|
1442
|
if ast_available:
|
|
1443
|
out.append("_Coverage: Java, JavaScript, TypeScript, C, C++ "
|
|
1444
|
"(tree-sitter AST)._")
|
|
1445
|
else:
|
|
1446
|
out.append(f"_Coverage: regex-only mode. {ast_note}_")
|
|
1447
|
out.append("")
|
|
1448
|
out.append("_Skipped rules (require rewriting or type resolution): "
|
|
1449
|
+ ", ".join(skipped_rule_list()) + "._")
|
|
1450
|
out.append("")
|
|
1451
|
if enable_patterns:
|
|
1452
|
out.append("_Enabled patterns: " + ", ".join(enable_patterns) + "._")
|
|
1453
|
out.append("")
|
|
1454
|
if disable_patterns:
|
|
1455
|
out.append("_Disabled patterns: " + ", ".join(disable_patterns) + "._")
|
|
1456
|
out.append("")
|
|
1457
|
if (enable_patterns or disable_patterns) and active_rule_ids is not None:
|
|
1458
|
out.append(f"_Active rules ({len(active_rule_ids)}): "
|
|
1459
|
+ ", ".join(sorted(active_rule_ids)) + "._")
|
|
1460
|
out.append("")
|
|
1461
|
|
|
1462
|
if not findings:
|
|
1463
|
out.append("h4. Findings")
|
|
1464
|
out.append("")
|
|
1465
|
out.append("No findings.")
|
|
1466
|
return "\n".join(out) + "\n"
|
|
1467
|
|
|
1468
|
|
|
1469
|
findings = sorted(findings, key=lambda f: (f.file, f.line, f.rule_id))
|
|
1470
|
|
|
1471
|
out.append("h4. Findings")
|
|
1472
|
out.append("")
|
|
1473
|
for f in findings:
|
|
1474
|
out.append(f"* *[{f.severity}]* _{f.rule_id}_ "
|
|
1475
|
f"@{f.file}@:{f.line} - {f.message}")
|
|
1476
|
out.append("")
|
|
1477
|
|
|
1478
|
|
|
1479
|
out.append("h4. Summary")
|
|
1480
|
out.append("")
|
|
1481
|
files = sorted({f.file for f in findings})
|
|
1482
|
out.append(f"- {len(findings)} findings across {len(files)} files")
|
|
1483
|
by_rule = defaultdict(int)
|
|
1484
|
for f in findings:
|
|
1485
|
by_rule[f.rule_id] += 1
|
|
1486
|
rule_str = ", ".join(f"{k}={v}"
|
|
1487
|
for k, v in sorted(by_rule.items(),
|
|
1488
|
key=lambda kv: -kv[1]))
|
|
1489
|
out.append(f"- By rule: {rule_str}")
|
|
1490
|
out.append("")
|
|
1491
|
return "\n".join(out)
|
|
1492
|
|
|
1493
|
|
|
1494
|
def _default_out_name(diff_arg):
|
|
1495
|
"""Generate a unique default --out filename.
|
|
1496
|
|
|
1497
|
Uses the diff basename as a stem when a file was provided; otherwise
|
|
1498
|
embeds a timestamp. Always placed in the current working directory.
|
|
1499
|
"""
|
|
1500
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
1501
|
if diff_arg and diff_arg != "-":
|
|
1502
|
stem = Path(diff_arg).stem or "diff"
|
|
1503
|
return Path.cwd() / f"{stem}_style-check_{ts}.textile"
|
|
1504
|
return Path.cwd() / f"style-check_{ts}.textile"
|
|
1505
|
|
|
1506
|
|
|
1507
|
def main():
|
|
1508
|
p = argparse.ArgumentParser(
|
|
1509
|
prog="fwd-style-check.py",
|
|
1510
|
description=(
|
|
1511
|
"Algorithmic code-style checker for FWD code reviews. Parses a "
|
|
1512
|
"bzr unified diff, reads the new-version sources from the working "
|
|
1513
|
"copy, and emits Textile-formatted style findings derived from the "
|
|
1514
|
"gcd-code-style skill. Supplement to the LLM review; all findings "
|
|
1515
|
"reference line numbers in the new version of each source file."
|
|
1516
|
),
|
|
1517
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1518
|
epilog=(
|
|
1519
|
"Examples:\n"
|
|
1520
|
" bzr diff | fwd-style-check.py\n"
|
|
1521
|
" fwd-style-check.py branch.diff\n"
|
|
1522
|
" fwd-style-check.py --workdir ./src --out review.textile branch.diff\n"
|
|
1523
|
"\n"
|
|
1524
|
"Languages: .java, .js/.mjs, .ts/.tsx, .c, .cpp/.cc/.cxx, "
|
|
1525
|
".h/.hpp/.hh/.hxx.\n"
|
|
1526
|
"AST-driven rules require tree-sitter. Install with:\n"
|
|
1527
|
" pip install tree-sitter tree-sitter-language-pack\n"
|
|
1528
|
"Without it, the checker runs in regex-only mode and notes it in "
|
|
1529
|
"the report header."
|
|
1530
|
),
|
|
1531
|
)
|
|
1532
|
p.add_argument(
|
|
1533
|
"--workdir", type=Path, default=Path("."), metavar="PATH",
|
|
1534
|
help="Working-copy directory containing the NEW version of each "
|
|
1535
|
"changed file. Must match the '+++ new/...' side of the diff. "
|
|
1536
|
"Default: current directory.")
|
|
1537
|
p.add_argument(
|
|
1538
|
"--out", type=Path, default=None, metavar="PATH",
|
|
1539
|
help="Where to write the Textile report. Default: a timestamped "
|
|
1540
|
"name in the current directory (e.g. style-check_20260420-143022."
|
|
1541
|
"textile). Parent directories are created on demand.")
|
|
1542
|
p.add_argument(
|
|
1543
|
"--list-rules", action="store_true",
|
|
1544
|
help="Print all registered rule IDs (with languages and AST "
|
|
1545
|
"requirement) and exit.")
|
|
1546
|
p.add_argument(
|
|
1547
|
"--enable", type=str, default="", metavar="PATTERNS",
|
|
1548
|
help="Comma/space-separated fnmatch patterns; only matching rules "
|
|
1549
|
"run. Applied before --disable. Example: "
|
|
1550
|
"'style.hard-tab,style.cr-endings' or 'style.blank-*'.")
|
|
1551
|
p.add_argument(
|
|
1552
|
"--disable", type=str, default="", metavar="PATTERNS",
|
|
1553
|
help="Comma/space-separated fnmatch patterns; matching rules are "
|
|
1554
|
"skipped. Applied after --enable. Example: "
|
|
1555
|
"'style.line-length,style.blank-*'.")
|
|
1556
|
p.add_argument(
|
|
1557
|
"diff", type=str, nargs="?", default=None, metavar="DIFF",
|
|
1558
|
help="Path to a bzr unified diff file. Omit (or pass '-') to read "
|
|
1559
|
"the diff from stdin. Default: stdin.")
|
|
1560
|
args = p.parse_args()
|
|
1561
|
|
|
1562
|
if args.list_rules:
|
|
1563
|
rows = sorted(_RULE_REGISTRY, key=lambda fn: fn.rule_id)
|
|
1564
|
id_w = max(len(fn.rule_id) for fn in rows)
|
|
1565
|
print(f"{'RULE ID'.ljust(id_w)} SEV AST LANGUAGES")
|
|
1566
|
for fn in rows:
|
|
1567
|
langs = ",".join(sorted(fn.languages))
|
|
1568
|
sev = _severity(fn.rule_id)
|
|
1569
|
ast = "yes" if fn.requires_ast else "no"
|
|
1570
|
print(f"{fn.rule_id.ljust(id_w)} {sev:<5} {ast:<3} {langs}")
|
|
1571
|
return
|
|
1572
|
|
|
1573
|
enable_patterns = _split_patterns(args.enable)
|
|
1574
|
disable_patterns = _split_patterns(args.disable)
|
|
1575
|
selected_rules, unknown = select_rules(enable_patterns, disable_patterns)
|
|
1576
|
if unknown:
|
|
1577
|
print("WARNING: pattern(s) matched no known rule: "
|
|
1578
|
+ ", ".join(sorted(set(unknown))), file=sys.stderr)
|
|
1579
|
if not selected_rules:
|
|
1580
|
print("ERROR: no rules selected after applying --enable/--disable.",
|
|
1581
|
file=sys.stderr)
|
|
1582
|
sys.exit(2)
|
|
1583
|
|
|
1584
|
if not args.workdir.is_dir():
|
|
1585
|
print(f"ERROR: workdir not found: {args.workdir}", file=sys.stderr)
|
|
1586
|
sys.exit(2)
|
|
1587
|
|
|
1588
|
if args.out is None:
|
|
1589
|
args.out = _default_out_name(args.diff)
|
|
1590
|
|
|
1591
|
parsers = _load_parsers()
|
|
1592
|
ast_available = parsers is not None
|
|
1593
|
ast_note = _AST_ERROR or ""
|
|
1594
|
|
|
1595
|
if args.diff is None or args.diff == "-":
|
|
1596
|
if sys.stdin.isatty():
|
|
1597
|
print("ERROR: no diff argument and stdin is a TTY. "
|
|
1598
|
"Pipe 'bzr diff' in or pass a diff file path.",
|
|
1599
|
file=sys.stderr)
|
|
1600
|
sys.exit(2)
|
|
1601
|
diff_data = parse_unified_diff(sys.stdin)
|
|
1602
|
else:
|
|
1603
|
diff_path = Path(args.diff)
|
|
1604
|
if not diff_path.is_file():
|
|
1605
|
print(f"ERROR: diff file not found: {diff_path}", file=sys.stderr)
|
|
1606
|
sys.exit(2)
|
|
1607
|
with open(diff_path, "r", encoding="utf-8", errors="replace") as f:
|
|
1608
|
diff_data = parse_unified_diff(f)
|
|
1609
|
|
|
1610
|
all_findings = []
|
|
1611
|
for rel_path, info in iter_files(diff_data):
|
|
1612
|
full_path = args.workdir / rel_path
|
|
1613
|
all_findings.extend(
|
|
1614
|
check_file(parsers, rel_path, full_path, info, selected_rules))
|
|
1615
|
|
|
1616
|
active_ids = {fn.rule_id for fn in selected_rules}
|
|
1617
|
out_text = _format_findings(
|
|
1618
|
all_findings, ast_available, ast_note,
|
|
1619
|
enable_patterns=enable_patterns,
|
|
1620
|
disable_patterns=disable_patterns,
|
|
1621
|
active_rule_ids=active_ids)
|
|
1622
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
1623
|
args.out.write_text(out_text, encoding="utf-8")
|
|
1624
|
print(f"Wrote {len(all_findings)} findings to {args.out}")
|
|
1625
|
|
|
1626
|
|
|
1627
|
if __name__ == "__main__":
|
|
1628
|
main()
|