Project

General

Profile

fwd-style-check.py

Alexandru Lungu, 04/22/2026 06:46 AM

Download (53.9 KB)

 
1
#!/usr/bin/env python3
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
# ---- AST backend (optional) ----
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
# ---- Finding model ----
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
# ---- Language dispatch ----
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
# ---- Diff parsing ----
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  # deleted line; does not advance new-file counter
197
      elif line.startswith(" "):
198
         current_line += 1
199
      elif line.startswith("\\"):
200
         pass  # "\ No newline at end of file"
201
      else:
202
         # Blank line inside a hunk can still be a context line
203
         current_line += 1
204

    
205
   # Build merged ranges per file.
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
# ---- Comment / string stripping (regex-based, language-agnostic enough) ----
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"  # code | line_comment | block_comment | dstr | sstr | tstr
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
         # Preserve newlines inside template literals; strings in Java/C
316
         # shouldn't span lines but be tolerant.
317
         out.append("\n" if c == "\n" else " ")
318
         i += 1
319
         continue
320

    
321
   return "".join(out)
322

    
323

    
324
# ---- Context passed to rules ----
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
# ---- Rule utilities ----
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
   # C++ additions
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
# ---- RULES ----
415

    
416
# Each rule function signature:
417
#     def check_xxx(ctx) -> list[Finding]
418
# Rules decide their own added-line scoping.
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
# ---------- Line-local rules ----------
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
   # File-level: any CR anywhere means wrong line endings.
491
   if b"\r" not in ctx.source_bytes:
492
      return []
493
   # Report at line 1 to keep the finding actionable.
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
      # Opening: exclude the case where `(` is the last non-whitespace on
537
      # the line (line-wrap: `method(\n   arg,`).
538
      m = _RE_PAREN_OPEN_SPACE.search(s)
539
      if m and s.rstrip().endswith("("):
540
         # `(` is at the end; skip - this is a line wrap.
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
         # Allow `\n   )` where the ) is the first non-whitespace on line.
551
         # (That's the closing of a wrapped call, not this rule's concern.)
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
         # Skip annotations / decorators: `@SomeAnnotation (...)`.
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
# Unspaced binary operators. Conservative set.
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
      # Skip annotation lines (`@Foo(bar=baz)` uses `=` as assign-arg).
638
      if s.lstrip().startswith("@"):
639
         continue
640
      # Run compound ops first.
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
      # Plain '=' assignment.
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  # hard-tab rule already covered it
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
            # Continuation of the same bad-indent run; don't re-report.
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
# ---------- File-level rules ----------
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  # Only the first copyright line is reported.
772
   return out
773

    
774

    
775
# ---------- Regex-based file-history rule ----------
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
   # Collect all history-ish lines with their sequence IDs (if numeric).
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
   # Existing strict (pre-edit) max sequence: max of numeric IDs that are
800
   # NOT in added_linenos (those are unchanged pre-existing entries).
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
         # The line looks like a history entry but does not match the
819
         # strict format - flag format.
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
         # Continuation line (same author, different action): allowed.
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
# ---------- Import wildcard suggestion (Java regex) ----------
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)  # pkg -> [(lineno, classname)]
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
      # Flag the entries whose line is in added_linenos.
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
# ---------- Regex-based pointer asterisk (C/C++) ----------
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
      # Ignore obvious non-declaration lines: control flow, comments, etc.
906
      if not stripped or stripped.startswith(("#", "//", "*", "/*")):
907
         continue
908
      # Skip lines that are clearly expressions: `x = *p;`, `*p = ...`
909
      # Heuristic: require declaration-looking pattern anchored at start.
910
      for m in _RE_PTR_TYPE_STAR.finditer(s):
911
         type_name = m.group(1)
912
         middle = m.group(2)
913
         # Skip: not a plausible type. Must be a known type or end with
914
         # _t or have uppercase (type-like identifier).
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
         # Correct form is `type *name`: middle is exactly ' *' (one or
920
         # more spaces, then *, with no trailing space).
921
         if middle.endswith(" ") and "*" in middle:
922
            # `type * name` or `type *name` depending. Correct form has
923
            # space before '*' and no space after. Actually required
924
            # form per skill is `char *str` which is space-before,
925
            # no-space-after. Let me re-check:
926
            # `char *str` means middle is ` *`. That matches
927
            # middle.startswith(" ") and middle.endswith("*") not "* ".
928
            # Actually middle here captures either `\s*\*\s+` or `\*\s*`.
929
            # We need to detect: is the `*` adjacent to the name?
930
            pass
931
         # Simpler test: does the substring between type and name contain
932
         # '*', and what is the whitespace pattern?
933
         raw = m.group(0)
934
         # Extract the slice between type_name end and name start.
935
         t_end = m.start(2)
936
         n_start = m.end(2)
937
         between = s[t_end:n_start]
938
         # Required form `type *name`: exactly one or more spaces,
939
         # then '*', then no space before the name.
940
         if re.fullmatch(r" +\*", between):
941
            continue  # correct
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
# ---------- Separate variable declarations ----------
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
      # Exclude generic angle-bracket constructs that might false-match.
981
      type_part = m.group(1)
982
      if type_part.strip() in kws:
983
         # plain type like `int`, OK
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
# ---------- AST-driven rules ----------
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
      # Find the opening brace child (token `{`).
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
      # Skip empty literals `{}` on one line.
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
         # `} else {` - both before and after content.
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
         # `foo() {` - trailing open brace.
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
      # Find the body child (not the condition, not the 'else').
1134
      # Different grammars use different field names; use "consequence",
1135
      # "body", or look for a direct child that is a statement.
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
      # Also check else-branch if present.
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:  # else-if is itself an if_statement
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
      # Find header range: up to the opening '{'.
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
      # Collect text of header.
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
      # Look for extends/implements keywords on the same line.
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
      # Find the next non-blank line.
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
      # Don't flag if the next non-blank line is another comment (two
1279
      # separate comment blocks are allowed to have a blank between them).
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
      # Exclude `case VAL:` and `default:`.
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
      # Exclude ternary `? :` - tricky but unlikely to match this form.
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
      # Skip if this is the first child of its parent (no preceding sibling).
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
      # Find the line of the previous non-whitespace content.
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
         # No blank but some gap - probably fine.
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
# ---- Main driver ----
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 []  # deleted file
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
         # A rule failure must not crash the whole check.
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
   # Sort: file, line, rule.
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
   # Summary.
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()