#!/usr/bin/env python3
"""FWD Algorithmic Code-Style Checker.

Algorithmic code-style checker for FWD code reviews. Parses a bzr unified
diff, reads the new-version sources from the working copy, and emits
Textile-formatted style findings derived from the gcd-code-style skill.
Supplement to the LLM review; all findings reference line numbers in the
new version of each source file.

Usage:
    fwd-style-check.py [-h] [--workdir PATH] [--out PATH]
                       [--list-rules] [--enable PATTERNS]
                       [--disable PATTERNS] [DIFF]

Positional arguments:
    DIFF               Path to a bzr unified diff file. Omit (or pass '-')
                       to read the diff from stdin. Default: stdin.

Options:
    -h, --help         Show this help message and exit.
    --workdir PATH     Working-copy directory containing the NEW version
                       of each changed file. Must match the '+++ new/...'
                       side of the diff. Default: current directory.
    --out PATH         Where to write the Textile report. Default: a
                       timestamped name in the current directory (e.g.
                       style-check_20260420-143022.textile). Parent
                       directories are created on demand.
    --list-rules       Print all registered rule IDs (with languages and
                       AST requirement) and exit.
    --enable PATTERNS  Comma-separated fnmatch patterns; only matching
                       rules run. Applied before --disable.
    --disable PATTERNS Comma-separated fnmatch patterns; matching rules
                       are skipped. Applied after --enable.

Examples:
    bzr diff | fwd-style-check.py
    fwd-style-check.py branch.diff
    fwd-style-check.py --workdir ./src --out review.textile branch.diff
    fwd-style-check.py --list-rules
    fwd-style-check.py --disable style.line-length,style.blank-* branch.diff
    fwd-style-check.py --enable 'style.hard-tab,style.cr-endings' branch.diff

Languages: .java, .js/.mjs, .ts/.tsx, .c, .cpp/.cc/.cxx, .h/.hpp/.hh/.hxx.

AST-driven rules require tree-sitter. Install with:

    pip install tree-sitter tree-sitter-language-pack

Without it, the checker runs in regex-only mode and notes it in the
report header.

Version: 2026-04-21
"""

import argparse
import fnmatch
import re
import sys
from collections import defaultdict, namedtuple
from datetime import date, datetime
from pathlib import Path


# ---- AST backend (optional) ----

_AST_PARSERS = None
_AST_ERROR = None


def _load_parsers():
   """Lazy-load tree-sitter parsers. Returns dict[language_name -> parser]
   or None if unavailable.
   """
   global _AST_PARSERS, _AST_ERROR
   if _AST_PARSERS is not None or _AST_ERROR is not None:
      return _AST_PARSERS

   try:
      from tree_sitter_language_pack import get_parser as _get
   except ImportError:
      try:
         from tree_sitter_languages import get_parser as _get
      except ImportError as e:
         _AST_ERROR = (
            "tree-sitter bundle not installed - AST-driven rules skipped. "
            "Install with: pip install tree-sitter tree-sitter-language-pack"
         )
         return None

   parsers = {}
   for lang_key in ("java", "javascript", "typescript", "c", "cpp"):
      try:
         parsers[lang_key] = _get(lang_key)
      except Exception as e:
         _AST_ERROR = f"tree-sitter parser for {lang_key} failed to load: {e}"
         return None
   _AST_PARSERS = parsers
   return _AST_PARSERS


# ---- Finding model ----

Finding = namedtuple("Finding", "file line severity rule_id message")

MAJOR_RULES = frozenset([
   "style.hard-tab",
   "style.cr-endings",
   "style.copyright-year",
   "style.file-history-sequence",
   "style.block-delimiter-required",
])


def _severity(rule_id):
   return "MAJOR" if rule_id in MAJOR_RULES else "MINOR"


# ---- Language dispatch ----

EXT_TO_LANG = {
   ".java": "java",
   ".js": "javascript",
   ".mjs": "javascript",
   ".ts": "typescript",
   ".tsx": "typescript",
   ".c": "c",
   ".cpp": "cpp",
   ".cc": "cpp",
   ".cxx": "cpp",
   ".h": "cpp",
   ".hpp": "cpp",
   ".hh": "cpp",
   ".hxx": "cpp",
}


def language_of(path):
   return EXT_TO_LANG.get(Path(path).suffix.lower())


# ---- Diff parsing ----

_RE_FILE_MARK = re.compile(
   r"^=== (modified|added|renamed) file '([^']+)'(?: => '([^']+)')?$"
)
_RE_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")


def parse_unified_diff(lines_iter):
   """Parse a bzr unified diff from an iterable of lines.

   Accepts any iterable yielding strings (e.g. an open text-mode file,
   sys.stdin, or a pre-split list). Each yielded line may or may not
   include a trailing newline.

   Returns dict[path -> dict]:
      {
         "added_linenos": set[int],   # 1-based new-file line numbers
         "added_ranges":  list[(start,end)],  # merged contiguous runs
      }
   """
   files = {}
   current_file = None
   current_line = 0
   in_hunk = False

   for raw in lines_iter:
      line = raw.rstrip("\n")

      m = _RE_FILE_MARK.match(line)
      if m:
         kind, first, second = m.group(1), m.group(2), m.group(3)
         path = second if kind == "renamed" and second else first
         current_file = path
         files.setdefault(path, {"added_linenos": set(), "added_ranges": []})
         in_hunk = False
         continue

      if line.startswith("--- ") or line.startswith("+++ "):
         in_hunk = False
         continue

      m = _RE_HUNK.match(line)
      if m and current_file is not None:
         current_line = int(m.group(1))
         in_hunk = True
         continue

      if not in_hunk or current_file is None:
         continue

      if line.startswith("+"):
         files[current_file]["added_linenos"].add(current_line)
         current_line += 1
      elif line.startswith("-"):
         pass  # deleted line; does not advance new-file counter
      elif line.startswith(" "):
         current_line += 1
      elif line.startswith("\\"):
         pass  # "\ No newline at end of file"
      else:
         # Blank line inside a hunk can still be a context line
         current_line += 1

   # Build merged ranges per file.
   for info in files.values():
      if not info["added_linenos"]:
         info["added_ranges"] = []
         continue
      sorted_lns = sorted(info["added_linenos"])
      ranges = []
      start = prev = sorted_lns[0]
      for ln in sorted_lns[1:]:
         if ln == prev + 1:
            prev = ln
         else:
            ranges.append((start, prev))
            start = prev = ln
      ranges.append((start, prev))
      info["added_ranges"] = ranges

   return files


def node_touches_diff(node, added_ranges):
   """True if node's line range overlaps any added range."""
   start = node.start_point[0] + 1
   end = node.end_point[0] + 1
   for s, e in added_ranges:
      if not (end < s or start > e):
         return True
   return False


# ---- Comment / string stripping (regex-based, language-agnostic enough) ----

def strip_code(source):
   """Return a same-length 'code skeleton' with string literals and
   comments replaced by spaces. Preserves positions so column numbers
   remain aligned with the original.

   Handles Java/JS/TS/C/C++ lexical elements: // comments, /* */ comments,
   " strings, ' chars, JS template literals (`...`). Not perfect (escapes
   and nested template substitutions), but adequate for line-local regex
   rules.
   """
   out = []
   i = 0
   n = len(source)
   state = "code"  # code | line_comment | block_comment | dstr | sstr | tstr
   while i < n:
      c = source[i]
      nxt = source[i + 1] if i + 1 < n else ""

      if state == "code":
         if c == "/" and nxt == "/":
            state = "line_comment"
            out.append("  ")
            i += 2
            continue
         if c == "/" and nxt == "*":
            state = "block_comment"
            out.append("  ")
            i += 2
            continue
         if c == '"':
            state = "dstr"
            out.append('"')
            i += 1
            continue
         if c == "'":
            state = "sstr"
            out.append("'")
            i += 1
            continue
         if c == "`":
            state = "tstr"
            out.append("`")
            i += 1
            continue
         out.append(c)
         i += 1
         continue

      if state == "line_comment":
         if c == "\n":
            state = "code"
            out.append("\n")
         else:
            out.append(" ")
         i += 1
         continue

      if state == "block_comment":
         if c == "*" and nxt == "/":
            state = "code"
            out.append("  ")
            i += 2
            continue
         out.append("\n" if c == "\n" else " ")
         i += 1
         continue

      if state in ("dstr", "sstr", "tstr"):
         quote = {"dstr": '"', "sstr": "'", "tstr": "`"}[state]
         if c == "\\" and nxt:
            out.append("  ")
            i += 2
            continue
         if c == quote:
            state = "code"
            out.append(quote)
            i += 1
            continue
         # Preserve newlines inside template literals; strings in Java/C
         # shouldn't span lines but be tolerant.
         out.append("\n" if c == "\n" else " ")
         i += 1
         continue

   return "".join(out)


# ---- Context passed to rules ----

class FileContext:
   def __init__(self, rel_path, full_path, source_bytes, language,
                added_linenos, added_ranges, tree, parse_errors):
      self.rel_path = rel_path
      self.full_path = full_path
      self.source_bytes = source_bytes
      self.source_text = source_bytes.decode("utf-8", errors="replace")
      self.source_lines = self.source_text.splitlines()
      self.code_text = strip_code(self.source_text)
      self.code_lines = self.code_text.splitlines()
      self.language = language
      self.added_linenos = added_linenos
      self.added_ranges = added_ranges
      self.tree = tree
      self.parse_errors = parse_errors
      self.today_year = date.today().year


# ---- Rule utilities ----

def code_line(ctx, lineno):
   """1-based code-skeleton line access; empty string on out-of-range."""
   idx = lineno - 1
   if 0 <= idx < len(ctx.code_lines):
      return ctx.code_lines[idx]
   return ""


def source_line(ctx, lineno):
   idx = lineno - 1
   if 0 <= idx < len(ctx.source_lines):
      return ctx.source_lines[idx]
   return ""


def visual_len(s):
   """Expand tabs to 3-space stops (project convention)."""
   out = 0
   for ch in s:
      if ch == "\t":
         out += 3 - (out % 3)
      else:
         out += 1
   return out


JAVA_KEYWORDS = frozenset([
   "abstract", "assert", "boolean", "break", "byte", "case", "catch",
   "char", "class", "const", "continue", "default", "do", "double",
   "else", "enum", "extends", "final", "finally", "float", "for", "goto",
   "if", "implements", "import", "instanceof", "int", "interface", "long",
   "native", "new", "package", "private", "protected", "public", "return",
   "short", "static", "strictfp", "super", "switch", "synchronized", "this",
   "throw", "throws", "transient", "try", "void", "volatile", "while",
   "yield", "var", "record", "sealed", "permits", "non-sealed",
])

JS_KEYWORDS = frozenset([
   "async", "await", "break", "case", "catch", "class", "const", "continue",
   "debugger", "default", "delete", "do", "else", "enum", "export", "extends",
   "false", "finally", "for", "function", "if", "import", "in", "instanceof",
   "let", "new", "null", "of", "return", "super", "switch", "this", "throw",
   "true", "try", "typeof", "var", "void", "while", "with", "yield",
   "interface", "public", "private", "protected", "static", "type",
])

C_KEYWORDS = frozenset([
   "auto", "break", "case", "char", "const", "continue", "default", "do",
   "double", "else", "enum", "extern", "float", "for", "goto", "if", "inline",
   "int", "long", "register", "restrict", "return", "short", "signed",
   "sizeof", "static", "struct", "switch", "typedef", "union", "unsigned",
   "void", "volatile", "while", "_Bool", "_Complex", "_Atomic",
   # C++ additions
   "class", "namespace", "template", "typename", "new", "delete", "this",
   "throw", "try", "catch", "public", "private", "protected", "virtual",
   "override", "final", "friend", "using", "operator", "explicit", "mutable",
   "nullptr", "bool", "true", "false", "constexpr", "decltype",
])


def keywords_for(language):
   if language == "java":
      return JAVA_KEYWORDS
   if language in ("javascript", "typescript"):
      return JS_KEYWORDS
   return C_KEYWORDS


# ---- RULES ----

# Each rule function signature:
#     def check_xxx(ctx) -> list[Finding]
# Rules decide their own added-line scoping.

_RULE_REGISTRY = []


def rule(rule_id, languages, requires_ast=False):
   """Register a rule function and attach metadata."""
   def decorator(fn):
      fn.rule_id = rule_id
      fn.languages = frozenset(languages)
      fn.requires_ast = requires_ast
      _RULE_REGISTRY.append(fn)
      return fn
   return decorator


def _split_patterns(value):
   """Split a comma/whitespace separated pattern list; ignore empties."""
   if not value:
      return []
   parts = re.split(r"[,\s]+", value.strip())
   return [p for p in parts if p]


def _match_any(rule_id, patterns):
   return any(fnmatch.fnmatchcase(rule_id, p) for p in patterns)


def select_rules(enable_patterns, disable_patterns):
   """Return (selected_rules, unknown_patterns).

   `enable_patterns` narrows the registry to matching rules (empty list
   means keep all). `disable_patterns` removes matches from that set.
   Patterns that match no known rule are returned as `unknown_patterns`
   so the caller can warn.
   """
   all_ids = [fn.rule_id for fn in _RULE_REGISTRY]
   unknown = []
   for p in enable_patterns:
      if not any(fnmatch.fnmatchcase(rid, p) for rid in all_ids):
         unknown.append(p)
   for p in disable_patterns:
      if not any(fnmatch.fnmatchcase(rid, p) for rid in all_ids):
         unknown.append(p)

   selected = []
   for fn in _RULE_REGISTRY:
      if enable_patterns and not _match_any(fn.rule_id, enable_patterns):
         continue
      if disable_patterns and _match_any(fn.rule_id, disable_patterns):
         continue
      selected.append(fn)
   return selected, unknown


ALL_LANGS = ["java", "javascript", "typescript", "c", "cpp"]


# ---------- Line-local rules ----------

@rule("style.hard-tab", ALL_LANGS)
def check_hard_tab(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      if "\t" in source_line(ctx, ln):
         out.append(Finding(ctx.rel_path, ln, _severity("style.hard-tab"),
                            "style.hard-tab", "hard tab character"))
   return out


@rule("style.cr-endings", ALL_LANGS)
def check_cr_endings(ctx):
   # File-level: any CR anywhere means wrong line endings.
   if b"\r" not in ctx.source_bytes:
      return []
   # Report at line 1 to keep the finding actionable.
   return [Finding(ctx.rel_path, 1, _severity("style.cr-endings"),
                   "style.cr-endings",
                   "file contains CR (\\r) characters; must be LF only")]


@rule("style.line-length", ALL_LANGS)
def check_line_length(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = source_line(ctx, ln)
      if visual_len(s) > 110:
         out.append(Finding(ctx.rel_path, ln, _severity("style.line-length"),
                            "style.line-length",
                            f"line length {visual_len(s)} > 110"))
   return out


_RE_COMMA_NOSPACE = re.compile(r",([^\s,)\]}])")


@rule("style.comma-space", ALL_LANGS)
def check_comma_space(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      if _RE_COMMA_NOSPACE.search(s):
         out.append(Finding(ctx.rel_path, ln, _severity("style.comma-space"),
                            "style.comma-space", "missing space after comma"))
   return out


_RE_PAREN_OPEN_SPACE = re.compile(r"\(\s+[^\s)]")
_RE_PAREN_CLOSE_SPACE = re.compile(r"[^\s(]\s+\)")


@rule("style.paren-inner-space", ALL_LANGS)
def check_paren_inner_space(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      stripped = s.rstrip()
      fired = False
      # Opening: exclude the case where `(` is the last non-whitespace on
      # the line (line-wrap: `method(\n   arg,`).
      m = _RE_PAREN_OPEN_SPACE.search(s)
      if m and s.rstrip().endswith("("):
         # `(` is at the end; skip - this is a line wrap.
         m = None
      if m:
         out.append(Finding(ctx.rel_path, ln,
                            _severity("style.paren-inner-space"),
                            "style.paren-inner-space",
                            "unexpected whitespace after '('"))
         fired = True
      m = _RE_PAREN_CLOSE_SPACE.search(s)
      if m:
         # Allow `\n   )` where the ) is the first non-whitespace on line.
         # (That's the closing of a wrapped call, not this rule's concern.)
         if not fired:
            out.append(Finding(ctx.rel_path, ln,
                               _severity("style.paren-inner-space"),
                               "style.paren-inner-space",
                               "unexpected whitespace before ')'"))
   return out


_RE_KEYWORD_PAREN_JAVA = re.compile(
   r"\b(if|while|for|switch|catch|synchronized|return|throw)\("
)
_RE_KEYWORD_PAREN_C = re.compile(
   r"\b(if|while|for|switch|return|sizeof)\("
)
_RE_KEYWORD_PAREN_JS = re.compile(
   r"\b(if|while|for|switch|catch|return|throw)\("
)


@rule("style.keyword-paren", ALL_LANGS)
def check_keyword_paren(ctx):
   if ctx.language == "java":
      pat = _RE_KEYWORD_PAREN_JAVA
   elif ctx.language in ("javascript", "typescript"):
      pat = _RE_KEYWORD_PAREN_JS
   else:
      pat = _RE_KEYWORD_PAREN_C
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      m = pat.search(s)
      if m:
         out.append(Finding(ctx.rel_path, ln, _severity("style.keyword-paren"),
                            "style.keyword-paren",
                            f"missing space between '{m.group(1)}' and '('"))
   return out


_RE_IDENT_SPACE_PAREN = re.compile(r"\b([A-Za-z_]\w*)\s+\(")


@rule("style.funcname-paren", ALL_LANGS)
def check_funcname_paren(ctx):
   """Flag 'foo (' where foo is not a keyword.

   Heuristic: skip lines that look like control-flow keywords or
   declarations (handled by style.keyword-paren), and skip identifiers
   that are in the language keyword set. Also skip when the identifier
   directly precedes an operator on the left (e.g. cast: `(int) (x+1)`
   hits `) (` which is not \\w+\\s+\\().
   """
   kws = keywords_for(ctx.language)
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      for m in _RE_IDENT_SPACE_PAREN.finditer(s):
         name = m.group(1)
         if name in kws:
            continue
         # Skip annotations / decorators: `@SomeAnnotation (...)`.
         start = m.start()
         if start > 0 and s[start - 1] == "@":
            continue
         out.append(Finding(ctx.rel_path, ln, _severity("style.funcname-paren"),
                            "style.funcname-paren",
                            f"space between identifier '{name}' and '('"))
   return out


# Unspaced binary operators. Conservative set.
_RE_BIN_OP = re.compile(
   r"""(?x)
   (?<![<>!=+\-*/%&|^~])    # not already part of an op
   (?P<op>==|!=|<=|>=|&&|\|\||\+=|-=|\*=|/=|%=)
   (?![<>!=+\-*/%&|^~])
   """
)
_RE_BIN_OP_ASSIGN = re.compile(r"(?<![<>!=+\-*/%&|^~])=(?![=])")


@rule("style.binary-op-space", ALL_LANGS)
def check_binary_op_space(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      # Skip annotation lines (`@Foo(bar=baz)` uses `=` as assign-arg).
      if s.lstrip().startswith("@"):
         continue
      # Run compound ops first.
      seen_cols = set()
      for m in _RE_BIN_OP.finditer(s):
         start = m.start("op")
         end = m.end("op")
         before = s[start - 1] if start > 0 else " "
         after = s[end] if end < len(s) else " "
         if not before.isspace() or not after.isspace():
            out.append(Finding(
               ctx.rel_path, ln, _severity("style.binary-op-space"),
               "style.binary-op-space",
               f"binary operator '{m.group('op')}' lacks surrounding spaces"))
            seen_cols.add(start)
      # Plain '=' assignment.
      for m in _RE_BIN_OP_ASSIGN.finditer(s):
         start = m.start()
         if start in seen_cols:
            continue
         before = s[start - 1] if start > 0 else " "
         after = s[start + 1] if start + 1 < len(s) else " "
         if not before.isspace() or not after.isspace():
            out.append(Finding(
               ctx.rel_path, ln, _severity("style.binary-op-space"),
               "style.binary-op-space",
               "assignment '=' lacks surrounding spaces"))
   return out


_RE_UNARY_SPACED = re.compile(r"(?:\w\s+(\+\+|--))|(?:(\+\+|--)\s+\w)")


@rule("style.unary-tight", ALL_LANGS)
def check_unary_tight(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      m = _RE_UNARY_SPACED.search(s)
      if m:
         out.append(Finding(ctx.rel_path, ln, _severity("style.unary-tight"),
                            "style.unary-tight",
                            "unary '++' / '--' must be tight against operand"))
   return out


_CONTINUATION_ENDERS = (",", "(", "+", "-", "*", "/", "%", "?", ":",
                        "&&", "||", "[", "{", "=", "&", "|", "^", "<", ">")


def _is_continuation(ctx, ln):
   """True if line `ln` is a continuation of a previous logical line."""
   prev = ln - 1
   while prev >= 1:
      s = code_line(ctx, prev).rstrip()
      if s:
         break
      prev -= 1
   if prev < 1:
      return False
   s = code_line(ctx, prev).rstrip()
   if not s:
      return False
   for ender in ("&&", "||"):
      if s.endswith(ender):
         return True
   return s[-1] in (",", "(", "+", "-", "*", "/", "%", "?", ":", "[",
                    "=", "&", "|", "^", "<", ">")


_RE_LEADING_WS = re.compile(r"^([ \t]*)")


@rule("style.indent-multiple-of-3", ALL_LANGS)
def check_indent_mult_3(ctx):
   """Report only the first line of each consecutive run sharing the same
   bad indent. A run breaks when the previous line is not an added line,
   or when the indent width changes.
   """
   out = []
   prev_flagged_indent = None
   for ln in sorted(ctx.added_linenos):
      s = source_line(ctx, ln)
      if not s.strip():
         prev_flagged_indent = None
         continue
      lead = _RE_LEADING_WS.match(s).group(1)
      if "\t" in lead:
         prev_flagged_indent = None
         continue  # hard-tab rule already covered it
      if _is_continuation(ctx, ln):
         prev_flagged_indent = None
         continue
      indent = len(lead)
      if indent % 3 != 0:
         prev_in_diff = (ln - 1) in ctx.added_linenos
         if prev_in_diff and prev_flagged_indent == indent:
            # Continuation of the same bad-indent run; don't re-report.
            continue
         out.append(Finding(ctx.rel_path, ln,
                            _severity("style.indent-multiple-of-3"),
                            "style.indent-multiple-of-3",
                            f"indent {indent} spaces is not a multiple of 3"))
         prev_flagged_indent = indent
      else:
         prev_flagged_indent = None
   return out


# ---------- File-level rules ----------

_RE_COPYRIGHT = re.compile(
   r"Copyright\s*\(c\)\s*(\d{4})(?:\s*-\s*(\d{4}))?,?\s*Golden",
   re.IGNORECASE,
)


@rule("style.copyright-year", ALL_LANGS)
def check_copyright_year(ctx):
   """Scan first 60 lines for a copyright header; verify latest year."""
   out = []
   for ln in range(1, min(60, len(ctx.source_lines)) + 1):
      s = source_line(ctx, ln)
      m = _RE_COPYRIGHT.search(s)
      if not m:
         continue
      latest = int(m.group(2)) if m.group(2) else int(m.group(1))
      if latest < ctx.today_year:
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.copyright-year"),
            "style.copyright-year",
            f"copyright latest year {latest} "
            f"does not match current year {ctx.today_year}"))
      break  # Only the first copyright line is reported.
   return out


# ---------- Regex-based file-history rule ----------

_RE_HISTORY = re.compile(
   r"^\*\* (?P<id>\d{3,4}|\s{3,4}) (?P<init>[A-Z]{2,3} ?) (?P<date>\d{8}) "
)
_RE_HISTORY_LOOSE = re.compile(r"^\*\*\s+(\d{1,5}|\s+)\s+[A-Z].*\d{8}\b")


@rule("style.file-history-sequence", ALL_LANGS)
def check_file_history(ctx):
   """Validate newly added file-history entries.

   Pre-existing malformed or out-of-order entries are NOT flagged.
   """
   out = []
   # Collect all history-ish lines with their sequence IDs (if numeric).
   history_lines = []
   for idx, s in enumerate(ctx.source_lines, start=1):
      if _RE_HISTORY_LOOSE.match(s):
         history_lines.append(idx)

   if not history_lines:
      return out

   # Existing strict (pre-edit) max sequence: max of numeric IDs that are
   # NOT in added_linenos (those are unchanged pre-existing entries).
   existing_ids = []
   for ln in history_lines:
      if ln in ctx.added_linenos:
         continue
      m = _RE_HISTORY.match(source_line(ctx, ln))
      if m:
         token = m.group("id").strip()
         if token.isdigit():
            existing_ids.append(int(token))
   max_existing = max(existing_ids) if existing_ids else 0

   added_entries = [ln for ln in history_lines if ln in ctx.added_linenos]
   expected = max_existing + 1
   for ln in added_entries:
      s = source_line(ctx, ln)
      m = _RE_HISTORY.match(s)
      if not m:
         # The line looks like a history entry but does not match the
         # strict format - flag format.
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.file-history-sequence"),
            "style.file-history-sequence",
            "history entry does not match '** NNN INIT YYYYMMDD '"))
         continue
      token = m.group("id").strip()
      if not token:
         # Continuation line (same author, different action): allowed.
         continue
      seq = int(token)
      if seq != expected:
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.file-history-sequence"),
            "style.file-history-sequence",
            f"history sequence ID {seq} does not follow max existing "
            f"ID {max_existing} (expected {expected})"))
      expected = max(expected, seq + 1)
   return out


# ---------- Import wildcard suggestion (Java regex) ----------

_RE_IMPORT_EXPLICIT = re.compile(
   r"^\s*import\s+((?:[a-z]\w*\.)+)([A-Z]\w*)\s*;"
)
_RE_IMPORT_WILDCARD = re.compile(r"^\s*import\s+((?:[a-z]\w*\.)+)\*\s*;")


@rule("style.import-wildcard-suggestion", ["java"])
def check_import_wildcard(ctx):
   out = []
   per_pkg = defaultdict(list)  # pkg -> [(lineno, classname)]
   wildcard_pkgs = set()
   for ln, s in enumerate(ctx.source_lines, start=1):
      m = _RE_IMPORT_EXPLICIT.match(s)
      if m:
         pkg = m.group(1).rstrip(".")
         per_pkg[pkg].append((ln, m.group(2)))
         continue
      m = _RE_IMPORT_WILDCARD.match(s)
      if m:
         wildcard_pkgs.add(m.group(1).rstrip("."))

   for pkg, entries in per_pkg.items():
      if pkg in wildcard_pkgs:
         continue
      if len(entries) < 2:
         continue
      # Flag the entries whose line is in added_linenos.
      for ln, cls in entries:
         if ln in ctx.added_linenos:
            out.append(Finding(
               ctx.rel_path, ln,
               _severity("style.import-wildcard-suggestion"),
               "style.import-wildcard-suggestion",
               f"{len(entries)} explicit imports from {pkg} "
               f"- consider consolidating to 'import {pkg}.*;'"))
   return out


# ---------- Regex-based pointer asterisk (C/C++) ----------

_RE_PTR_TYPE_STAR = re.compile(
   r"\b([A-Za-z_]\w*)(\s*\*\s+|\*\s*)([A-Za-z_]\w*)\b"
)


_C_BUILTIN_TYPES = frozenset([
   "void", "char", "short", "int", "long", "signed", "unsigned", "float",
   "double", "bool", "size_t", "ssize_t", "uint8_t", "uint16_t", "uint32_t",
   "uint64_t", "int8_t", "int16_t", "int32_t", "int64_t", "FILE", "wchar_t",
   "jint", "jlong", "jbyte", "jshort", "jchar", "jboolean", "jfloat",
   "jdouble", "jobject", "jstring", "jclass", "jthrowable", "jarray",
   "JNIEnv", "jsize",
])


@rule("style.pointer-asterisk-with-name", ["c", "cpp"])
def check_pointer_asterisk(ctx):
   """Required form: `type *name`. Flag `type* name` and `type * name`."""
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      src = source_line(ctx, ln)
      stripped = s.strip()
      # Ignore obvious non-declaration lines: control flow, comments, etc.
      if not stripped or stripped.startswith(("#", "//", "*", "/*")):
         continue
      # Skip lines that are clearly expressions: `x = *p;`, `*p = ...`
      # Heuristic: require declaration-looking pattern anchored at start.
      for m in _RE_PTR_TYPE_STAR.finditer(s):
         type_name = m.group(1)
         middle = m.group(2)
         # Skip: not a plausible type. Must be a known type or end with
         # _t or have uppercase (type-like identifier).
         if (type_name not in _C_BUILTIN_TYPES
               and not type_name.endswith("_t")
               and not type_name[0].isupper()):
            continue
         # Correct form is `type *name`: middle is exactly ' *' (one or
         # more spaces, then *, with no trailing space).
         if middle.endswith(" ") and "*" in middle:
            # `type * name` or `type *name` depending. Correct form has
            # space before '*' and no space after. Actually required
            # form per skill is `char *str` which is space-before,
            # no-space-after. Let me re-check:
            # `char *str` means middle is ` *`. That matches
            # middle.startswith(" ") and middle.endswith("*") not "* ".
            # Actually middle here captures either `\s*\*\s+` or `\*\s*`.
            # We need to detect: is the `*` adjacent to the name?
            pass
         # Simpler test: does the substring between type and name contain
         # '*', and what is the whitespace pattern?
         raw = m.group(0)
         # Extract the slice between type_name end and name start.
         t_end = m.start(2)
         n_start = m.end(2)
         between = s[t_end:n_start]
         # Required form `type *name`: exactly one or more spaces,
         # then '*', then no space before the name.
         if re.fullmatch(r" +\*", between):
            continue  # correct
         out.append(Finding(
            ctx.rel_path, ln,
            _severity("style.pointer-asterisk-with-name"),
            "style.pointer-asterisk-with-name",
            f"pointer asterisk must stick to variable name "
            f"(use 'type *name', got '{type_name}{between}{m.group(3)}')"))
   return out


# ---------- Separate variable declarations ----------

_RE_SEPARATE_DECL = re.compile(
   r"""^\s*
       (?:final\s+|static\s+|private\s+|public\s+|protected\s+)*
       ([A-Za-z_][\w<>\[\], ?]*?)
       \s+
       ([A-Za-z_]\w*)
       (?:\s*=\s*[^,;]+)?
       \s*,\s*
       ([A-Za-z_]\w*)
   """,
   re.VERBOSE,
)


@rule("style.separate-var-decl", ["java", "c", "cpp"])
def check_separate_var_decl(ctx):
   out = []
   kws = keywords_for(ctx.language)
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      m = _RE_SEPARATE_DECL.match(s)
      if not m:
         continue
      first_tok = s.lstrip().split()[0] if s.lstrip() else ""
      if first_tok in ("if", "while", "for", "switch", "return", "throw",
                       "catch"):
         continue
      # Exclude generic angle-bracket constructs that might false-match.
      type_part = m.group(1)
      if type_part.strip() in kws:
         # plain type like `int`, OK
         pass
      out.append(Finding(
         ctx.rel_path, ln, _severity("style.separate-var-decl"),
         "style.separate-var-decl",
         f"declaration introduces multiple names "
         f"('{m.group(2)}', '{m.group(3)}') - split into separate declarations"))
   return out


# ---------- AST-driven rules ----------

def _node_line_range(node):
   return (node.start_point[0] + 1, node.end_point[0] + 1)


def _text_of(node, source_bytes):
   return source_bytes[node.start_byte:node.end_byte].decode(
      "utf-8", errors="replace")


def _walk(node):
   yield node
   for c in node.children:
      yield from _walk(c)


@rule("style.brace-own-line", ALL_LANGS, requires_ast=True)
def check_brace_own_line(ctx):
   if not ctx.tree:
      return []
   out = []
   seen = set()
   block_types = {
      "java": {"block", "class_body", "interface_body", "enum_body",
               "constructor_body", "switch_block", "array_initializer"},
      "javascript": {"statement_block", "class_body", "object"},
      "typescript": {"statement_block", "class_body", "object",
                     "object_type"},
      "c": {"compound_statement", "initializer_list", "field_declaration_list",
            "enumerator_list"},
      "cpp": {"compound_statement", "initializer_list",
              "field_declaration_list", "enumerator_list"},
   }
   wanted = block_types.get(ctx.language, set())
   for node in _walk(ctx.tree.root_node):
      if node.type not in wanted:
         continue
      # Find the opening brace child (token `{`).
      open_brace = None
      for c in node.children:
         if c.type == "{":
            open_brace = c
            break
      if open_brace is None:
         continue
      ln = open_brace.start_point[0] + 1
      col = open_brace.start_point[1]
      if ln not in ctx.added_linenos:
         continue
      # Skip empty literals `{}` on one line.
      close_brace = None
      for c in node.children:
         if c.type == "}":
            close_brace = c
      if close_brace is not None:
         if (close_brace.start_point[0] == open_brace.start_point[0]
               and close_brace.start_point[1] == col + 1):
            continue
      src = source_line(ctx, ln)
      before = src[:col].rstrip()
      after = src[col + 1:].strip()
      if before and after:
         # `} else {` - both before and after content.
         key = (ln, col)
         if key in seen:
            continue
         seen.add(key)
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.brace-own-line"),
            "style.brace-own-line",
            "'{' must be on its own line"))
      elif before and not after:
         # `foo() {` - trailing open brace.
         key = (ln, col)
         if key in seen:
            continue
         seen.add(key)
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.brace-own-line"),
            "style.brace-own-line",
            "'{' must be on its own line"))
   return out


@rule("style.blank-after-open-brace", ALL_LANGS)
def check_blank_after_open_brace(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = source_line(ctx, ln).strip()
      if s != "{":
         continue
      nxt = source_line(ctx, ln + 1)
      if nxt is not None and nxt.strip() == "" and ln + 1 <= len(ctx.source_lines):
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.blank-after-open-brace"),
            "style.blank-after-open-brace",
            "no blank line allowed after '{'"))
   return out


@rule("style.blank-before-close-brace", ALL_LANGS)
def check_blank_before_close_brace(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = source_line(ctx, ln).strip()
      if s != "}":
         continue
      prev = source_line(ctx, ln - 1)
      if prev is not None and prev.strip() == "" and ln - 1 >= 1:
         out.append(Finding(
            ctx.rel_path, ln, _severity("style.blank-before-close-brace"),
            "style.blank-before-close-brace",
            "no blank line allowed before '}'"))
   return out


@rule("style.block-delimiter-required", ALL_LANGS, requires_ast=True)
def check_block_delimiter(ctx):
   if not ctx.tree:
      return []
   out = []
   stmt_types = {
      "if_statement", "while_statement", "for_statement",
      "do_statement", "for_in_statement", "for_of_statement",
   }
   body_types_block = {
      "java": {"block"},
      "javascript": {"statement_block"},
      "typescript": {"statement_block"},
      "c": {"compound_statement"},
      "cpp": {"compound_statement"},
   }
   block_names = body_types_block.get(ctx.language, set())
   for node in _walk(ctx.tree.root_node):
      if node.type not in stmt_types:
         continue
      start_ln = node.start_point[0] + 1
      if start_ln not in ctx.added_linenos:
         continue
      # Find the body child (not the condition, not the 'else').
      # Different grammars use different field names; use "consequence",
      # "body", or look for a direct child that is a statement.
      for fld in ("consequence", "body"):
         body = node.child_by_field_name(fld)
         if body is not None:
            break
      else:
         body = None
      if body is None:
         continue
      if body.type in block_names:
         continue
      out.append(Finding(
         ctx.rel_path, start_ln, _severity("style.block-delimiter-required"),
         "style.block-delimiter-required",
         f"'{node.type.replace('_statement','')}' body must use '{{ }}' "
         f"even for a single statement"))
      # Also check else-branch if present.
      alt = node.child_by_field_name("alternative")
      if alt is not None and alt.type not in block_names:
         if alt.type not in stmt_types:  # else-if is itself an if_statement
            out.append(Finding(
               ctx.rel_path, alt.start_point[0] + 1,
               _severity("style.block-delimiter-required"),
               "style.block-delimiter-required",
               "'else' body must use '{ }' even for a single statement"))
   return out


@rule("style.class-declaration-wrap", ["java", "javascript", "typescript",
                                        "cpp"], requires_ast=True)
def check_class_decl_wrap(ctx):
   if not ctx.tree:
      return []
   out = []
   decl_types = {
      "java": {"class_declaration", "interface_declaration", "enum_declaration",
               "record_declaration"},
      "javascript": {"class_declaration"},
      "typescript": {"class_declaration", "interface_declaration"},
      "cpp": {"class_specifier", "struct_specifier"},
   }
   types = decl_types.get(ctx.language, set())
   for node in _walk(ctx.tree.root_node):
      if node.type not in types:
         continue
      if not node_touches_diff(node, ctx.added_ranges):
         continue
      # Find header range: up to the opening '{'.
      open_brace_col = None
      open_brace_line = None
      for c in node.children:
         if c.type == "{":
            open_brace_line = c.start_point[0] + 1
            open_brace_col = c.start_point[1]
            break
      if open_brace_line is None:
         continue
      start_line = node.start_point[0] + 1
      # Collect text of header.
      if start_line == open_brace_line:
         header_text = ctx.source_lines[start_line - 1][
            node.start_point[1]:open_brace_col]
      else:
         header_parts = [
            ctx.source_lines[start_line - 1][node.start_point[1]:]
         ]
         for ln in range(start_line + 1, open_brace_line):
            header_parts.append(ctx.source_lines[ln - 1])
         header_parts.append(ctx.source_lines[open_brace_line - 1][
            :open_brace_col])
         header_text = "\n".join(header_parts)

      # Look for extends/implements keywords on the same line.
      lines = header_text.split("\n")
      for offset, ln_text in enumerate(lines):
         has_extends = re.search(r"\bextends\b", ln_text)
         has_implements = re.search(r"\bimplements\b", ln_text)
         has_name = offset == 0 and re.search(
            r"\b(class|interface|enum|struct|record)\s+\w+", ln_text)
         violations = [bool(has_extends), bool(has_implements), bool(has_name)]
         if sum(violations) >= 2:
            out.append(Finding(
               ctx.rel_path, start_line + offset,
               _severity("style.class-declaration-wrap"),
               "style.class-declaration-wrap",
               "class/interface name, 'extends', and 'implements' "
               "must each be on a separate line"))
            break
   return out


@rule("style.annotation-own-line", ["java", "typescript"], requires_ast=True)
def check_annotation_own_line(ctx):
   if not ctx.tree:
      return []
   out = []
   ann_types = {
      "java": {"annotation", "marker_annotation"},
      "typescript": {"decorator"},
   }
   wanted = ann_types.get(ctx.language, set())
   for node in _walk(ctx.tree.root_node):
      if node.type not in wanted:
         continue
      if not node_touches_diff(node, ctx.added_ranges):
         continue
      end_line, end_col = node.end_point
      end_line_text = ctx.source_lines[end_line] if end_line < len(
         ctx.source_lines) else ""
      trailing = end_line_text[end_col:].strip()
      if trailing and not trailing.startswith("//"):
         out.append(Finding(
            ctx.rel_path, end_line + 1,
            _severity("style.annotation-own-line"),
            "style.annotation-own-line",
            "annotation/decorator must be on its own line"))
   return out


@rule("style.comment-attached", ALL_LANGS)
def check_comment_attached(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = ctx.source_lines[ln - 1] if ln - 1 < len(ctx.source_lines) else ""
      stripped = s.strip()
      is_comment = (stripped.startswith("//")
                    or stripped.startswith("*")
                    or stripped.endswith("*/"))
      if not is_comment:
         continue
      nxt = ctx.source_lines[ln] if ln < len(ctx.source_lines) else ""
      if nxt.strip() != "":
         continue
      # Find the next non-blank line.
      follow = None
      for j in range(ln + 1, min(ln + 5, len(ctx.source_lines)) + 1):
         candidate = ctx.source_lines[j - 1] if j - 1 < len(
            ctx.source_lines) else ""
         if candidate.strip() != "":
            follow = candidate.strip()
            break
      if follow is None:
         continue
      # Don't flag if the next non-blank line is another comment (two
      # separate comment blocks are allowed to have a blank between them).
      if (follow.startswith("//") or follow.startswith("*")
            or follow.startswith("/*")):
         continue
      out.append(Finding(
         ctx.rel_path, ln, _severity("style.comment-attached"),
         "style.comment-attached",
         "no blank line allowed between comment and its subject"))
   return out


_RE_LABEL = re.compile(r"^\s*([A-Za-z_]\w*)\s*:\s*(\S.*)?$")


@rule("style.label-own-line", ["java", "c", "cpp"])
def check_label_own_line(ctx):
   out = []
   for ln in sorted(ctx.added_linenos):
      s = code_line(ctx, ln)
      m = _RE_LABEL.match(s)
      if not m:
         continue
      # Exclude `case VAL:` and `default:`.
      label = m.group(1)
      if label in ("case", "default"):
         continue
      trailing = m.group(2)
      if not trailing:
         continue
      # Exclude ternary `? :` - tricky but unlikely to match this form.
      out.append(Finding(
         ctx.rel_path, ln, _severity("style.label-own-line"),
         "style.label-own-line",
         f"label '{label}' must be on its own line"))
   return out


@rule("style.blank-between-methods", ALL_LANGS, requires_ast=True)
def check_blank_between_methods(ctx):
   if not ctx.tree:
      return []
   out = []
   method_types = {
      "java": {"method_declaration", "constructor_declaration"},
      "javascript": {"method_definition", "function_declaration"},
      "typescript": {"method_definition", "function_declaration"},
      "c": {"function_definition"},
      "cpp": {"function_definition"},
   }
   wanted = method_types.get(ctx.language, set())
   for node in _walk(ctx.tree.root_node):
      if node.type not in wanted:
         continue
      start_line = node.start_point[0] + 1
      if start_line not in ctx.added_linenos:
         continue
      # Skip if this is the first child of its parent (no preceding sibling).
      parent = node.parent
      if parent is None:
         continue
      prev_sibling = None
      for c in parent.children:
         if c == node:
            break
         prev_sibling = c
      if prev_sibling is None:
         continue
      # Find the line of the previous non-whitespace content.
      prev_end_line = prev_sibling.end_point[0] + 1
      gap_blank = False
      for mid in range(prev_end_line + 1, start_line):
         if ctx.source_lines[mid - 1].strip() == "":
            gap_blank = True
            break
      if not gap_blank and start_line > prev_end_line + 1:
         # No blank but some gap - probably fine.
         continue
      if not gap_blank:
         out.append(Finding(
            ctx.rel_path, start_line,
            _severity("style.blank-between-methods"),
            "style.blank-between-methods",
            "method/function declaration must be preceded by a blank line"))
   return out


# ---- Main driver ----

def iter_files(diff_data):
   """Yield (rel_path, info) for each changed file."""
   for path, info in sorted(diff_data.items()):
      yield path, info


def _parse_tree(parsers, language, source_bytes):
   if parsers is None or language not in parsers:
      return None, ["no parser"]
   try:
      tree = parsers[language].parse(source_bytes)
      return tree, []
   except Exception as e:
      return None, [f"parse error: {e}"]


def check_file(parsers, rel_path, full_path, info, rules):
   language = language_of(rel_path)
   if language is None:
      return []
   try:
      source_bytes = Path(full_path).read_bytes()
   except FileNotFoundError:
      return []  # deleted file

   tree, errors = _parse_tree(parsers, language, source_bytes)

   ctx = FileContext(
      rel_path=rel_path,
      full_path=full_path,
      source_bytes=source_bytes,
      language=language,
      added_linenos=info["added_linenos"],
      added_ranges=info["added_ranges"],
      tree=tree,
      parse_errors=errors,
   )

   findings = []
   for fn in rules:
      if ctx.language not in fn.languages:
         continue
      if fn.requires_ast and tree is None:
         continue
      try:
         findings.extend(fn(ctx) or [])
      except Exception as e:
         # A rule failure must not crash the whole check.
         findings.append(Finding(
            rel_path, 1, "MINOR", fn.rule_id,
            f"[rule error: {e.__class__.__name__}: {e}]"))
   return findings


def skipped_rule_list():
   """Rules from the spec that we deliberately do NOT implement."""
   return [
      "style.align-variable-columns",
      "style.align-by-longest-type",
      "style.short-definition-one-line",
      "style.align-static-initializer",
      "style.align-method-call-params",
      "style.static-member-via-classname",
   ]


def _format_findings(findings, ast_available, ast_note,
                     enable_patterns=None, disable_patterns=None,
                     active_rule_ids=None):
   out = []
   out.append("h3. Algorithmic Style Check (experimental)")
   out.append("")
   out.append("_Generated by fwd-style-check.py. Supplement to the LLM "
              "style pass - compare before relying on it._")
   out.append("")
   if ast_available:
      out.append("_Coverage: Java, JavaScript, TypeScript, C, C++ "
                 "(tree-sitter AST)._")
   else:
      out.append(f"_Coverage: regex-only mode. {ast_note}_")
   out.append("")
   out.append("_Skipped rules (require rewriting or type resolution): "
              + ", ".join(skipped_rule_list()) + "._")
   out.append("")
   if enable_patterns:
      out.append("_Enabled patterns: " + ", ".join(enable_patterns) + "._")
      out.append("")
   if disable_patterns:
      out.append("_Disabled patterns: " + ", ".join(disable_patterns) + "._")
      out.append("")
   if (enable_patterns or disable_patterns) and active_rule_ids is not None:
      out.append(f"_Active rules ({len(active_rule_ids)}): "
                 + ", ".join(sorted(active_rule_ids)) + "._")
      out.append("")

   if not findings:
      out.append("h4. Findings")
      out.append("")
      out.append("No findings.")
      return "\n".join(out) + "\n"

   # Sort: file, line, rule.
   findings = sorted(findings, key=lambda f: (f.file, f.line, f.rule_id))

   out.append("h4. Findings")
   out.append("")
   for f in findings:
      out.append(f"* *[{f.severity}]* _{f.rule_id}_ "
                 f"@{f.file}@:{f.line} - {f.message}")
   out.append("")

   # Summary.
   out.append("h4. Summary")
   out.append("")
   files = sorted({f.file for f in findings})
   out.append(f"- {len(findings)} findings across {len(files)} files")
   by_rule = defaultdict(int)
   for f in findings:
      by_rule[f.rule_id] += 1
   rule_str = ", ".join(f"{k}={v}"
                        for k, v in sorted(by_rule.items(),
                                           key=lambda kv: -kv[1]))
   out.append(f"- By rule: {rule_str}")
   out.append("")
   return "\n".join(out)


def _default_out_name(diff_arg):
   """Generate a unique default --out filename.

   Uses the diff basename as a stem when a file was provided; otherwise
   embeds a timestamp. Always placed in the current working directory.
   """
   ts = datetime.now().strftime("%Y%m%d-%H%M%S")
   if diff_arg and diff_arg != "-":
      stem = Path(diff_arg).stem or "diff"
      return Path.cwd() / f"{stem}_style-check_{ts}.textile"
   return Path.cwd() / f"style-check_{ts}.textile"


def main():
   p = argparse.ArgumentParser(
      prog="fwd-style-check.py",
      description=(
         "Algorithmic code-style checker for FWD code reviews. Parses a "
         "bzr unified diff, reads the new-version sources from the working "
         "copy, and emits Textile-formatted style findings derived from the "
         "gcd-code-style skill. Supplement to the LLM review; all findings "
         "reference line numbers in the new version of each source file."
      ),
      formatter_class=argparse.RawDescriptionHelpFormatter,
      epilog=(
         "Examples:\n"
         "  bzr diff | fwd-style-check.py\n"
         "  fwd-style-check.py branch.diff\n"
         "  fwd-style-check.py --workdir ./src --out review.textile branch.diff\n"
         "\n"
         "Languages: .java, .js/.mjs, .ts/.tsx, .c, .cpp/.cc/.cxx, "
         ".h/.hpp/.hh/.hxx.\n"
         "AST-driven rules require tree-sitter. Install with:\n"
         "  pip install tree-sitter tree-sitter-language-pack\n"
         "Without it, the checker runs in regex-only mode and notes it in "
         "the report header."
      ),
   )
   p.add_argument(
      "--workdir", type=Path, default=Path("."), metavar="PATH",
      help="Working-copy directory containing the NEW version of each "
           "changed file. Must match the '+++ new/...' side of the diff. "
           "Default: current directory.")
   p.add_argument(
      "--out", type=Path, default=None, metavar="PATH",
      help="Where to write the Textile report. Default: a timestamped "
           "name in the current directory (e.g. style-check_20260420-143022."
           "textile). Parent directories are created on demand.")
   p.add_argument(
      "--list-rules", action="store_true",
      help="Print all registered rule IDs (with languages and AST "
           "requirement) and exit.")
   p.add_argument(
      "--enable", type=str, default="", metavar="PATTERNS",
      help="Comma/space-separated fnmatch patterns; only matching rules "
           "run. Applied before --disable. Example: "
           "'style.hard-tab,style.cr-endings' or 'style.blank-*'.")
   p.add_argument(
      "--disable", type=str, default="", metavar="PATTERNS",
      help="Comma/space-separated fnmatch patterns; matching rules are "
           "skipped. Applied after --enable. Example: "
           "'style.line-length,style.blank-*'.")
   p.add_argument(
      "diff", type=str, nargs="?", default=None, metavar="DIFF",
      help="Path to a bzr unified diff file. Omit (or pass '-') to read "
           "the diff from stdin. Default: stdin.")
   args = p.parse_args()

   if args.list_rules:
      rows = sorted(_RULE_REGISTRY, key=lambda fn: fn.rule_id)
      id_w = max(len(fn.rule_id) for fn in rows)
      print(f"{'RULE ID'.ljust(id_w)}  SEV    AST  LANGUAGES")
      for fn in rows:
         langs = ",".join(sorted(fn.languages))
         sev = _severity(fn.rule_id)
         ast = "yes" if fn.requires_ast else "no"
         print(f"{fn.rule_id.ljust(id_w)}  {sev:<5}  {ast:<3}  {langs}")
      return

   enable_patterns = _split_patterns(args.enable)
   disable_patterns = _split_patterns(args.disable)
   selected_rules, unknown = select_rules(enable_patterns, disable_patterns)
   if unknown:
      print("WARNING: pattern(s) matched no known rule: "
            + ", ".join(sorted(set(unknown))), file=sys.stderr)
   if not selected_rules:
      print("ERROR: no rules selected after applying --enable/--disable.",
            file=sys.stderr)
      sys.exit(2)

   if not args.workdir.is_dir():
      print(f"ERROR: workdir not found: {args.workdir}", file=sys.stderr)
      sys.exit(2)

   if args.out is None:
      args.out = _default_out_name(args.diff)

   parsers = _load_parsers()
   ast_available = parsers is not None
   ast_note = _AST_ERROR or ""

   if args.diff is None or args.diff == "-":
      if sys.stdin.isatty():
         print("ERROR: no diff argument and stdin is a TTY. "
               "Pipe 'bzr diff' in or pass a diff file path.",
               file=sys.stderr)
         sys.exit(2)
      diff_data = parse_unified_diff(sys.stdin)
   else:
      diff_path = Path(args.diff)
      if not diff_path.is_file():
         print(f"ERROR: diff file not found: {diff_path}", file=sys.stderr)
         sys.exit(2)
      with open(diff_path, "r", encoding="utf-8", errors="replace") as f:
         diff_data = parse_unified_diff(f)

   all_findings = []
   for rel_path, info in iter_files(diff_data):
      full_path = args.workdir / rel_path
      all_findings.extend(
         check_file(parsers, rel_path, full_path, info, selected_rules))

   active_ids = {fn.rule_id for fn in selected_rules}
   out_text = _format_findings(
      all_findings, ast_available, ast_note,
      enable_patterns=enable_patterns,
      disable_patterns=disable_patterns,
      active_rule_ids=active_ids)
   args.out.parent.mkdir(parents=True, exist_ok=True)
   args.out.write_text(out_text, encoding="utf-8")
   print(f"Wrote {len(all_findings)} findings to {args.out}")


if __name__ == "__main__":
   main()
