Coverage for src/ai_jury/hints.py: 100%
58 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06:29 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06:29 +0000
1"""Static analysis hints pre-pass (issue #523).
3Collects fast deterministic static linter findings (Ruff, ESLint, Flake8, Gitleaks)
4and injects compact hints into Round 1 prompt context so LLM reviewers focus their
5attention on deep logic bugs and security flaws rather than trivial formatting.
7The pre-pass is scoped to the change under review and to nothing else (#737):
8:func:`collect_static_hints` takes the changed paths as a **required** argument
9and has no whole-tree form. There is no argument that lints the working
10directory, so the "first five diagnostics from anywhere in the repository"
11failure cannot be reached by a caller that simply forgets to pass the paths —
12that call is now a ``TypeError``, not a silently wrong prompt.
13"""
15from __future__ import annotations
17import shutil
18import subprocess
19from collections.abc import Sequence
20from pathlib import Path
22#: Extensions each linter is asked about. A change that touches none of them
23#: produces no block at all.
24_PY_SUFFIXES = (".py",)
25_JS_SUFFIXES = (".js", ".ts", ".jsx", ".tsx")
27#: How many diagnostics from one linter reach the prompt.
28_MAX_LINES = 5
31def _run_linter(cmd: list[str], root: Path, title: str) -> str | None:
32 """Run one linter and format up to :data:`_MAX_LINES` of its output (thin I/O).
34 Returns ``None`` when the linter is clean, unavailable, or fails in any way:
35 the pre-pass is best-effort and never breaks a review.
36 """
37 try:
38 res = subprocess.run(cmd, cwd=str(root), capture_output=True, text=True, timeout=5)
39 except (subprocess.SubprocessError, OSError, Exception):
40 # Best-effort local linter invocation; gracefully ignore errors.
41 return None
42 if res.returncode == 0:
43 return None
44 lines = [line.strip() for line in res.stdout.splitlines()[:_MAX_LINES] if line.strip()]
45 if not lines:
46 return None
47 return f"{title}\n" + "\n".join(f"- {item}" for item in lines)
50def _inside(root: Path, files: Sequence[str] | None) -> list[str]:
51 """The subset of *files* that are files inside *root* (pure apart from stat).
53 Two kinds of name never reach a linter whatever they resolve to, because the
54 linters read them as something other than a path:
56 * a leading ``-`` is a flag;
57 * a leading ``@`` is a **response file**. Ruff's argument parser expands
58 ``@name`` into the paths that file lists, and it does so after ``--`` as
59 well — so a checkout containing a file literally called ``@pwn.py``, whose
60 body is the absolute path of something outside the tree, would have every
61 containment check below pass and the linter read the outside file anyway
62 (review round 2). The names it lists never pass through here at all.
64 The rest are resolved against ``root`` and kept only if they stay under it
65 and are files. ``resolve()`` follows symlinks, so a link inside the
66 repository that points outside it is dropped too, which is the point: what
67 reaches the linter has to be a file this checkout actually contains.
68 """
69 kept: list[str] = []
70 try:
71 base = root.resolve()
72 except OSError: # pragma: no cover - an unresolvable cwd is not a review
73 return kept
74 for name in files or ():
75 if not name or name[0] in "-@":
76 continue
77 try:
78 target = (base / name).resolve()
79 if not target.is_file():
80 continue
81 target.relative_to(base)
82 except (OSError, ValueError):
83 continue
84 kept.append(name)
85 return kept
88def collect_static_hints(files: Sequence[str] | None, root_dir: Path | None = None) -> str:
89 """Run fast local linters on the **changed files** and return a hints string.
91 ``files`` names the paths in the change under review and is required: this
92 function lints those paths and nothing else. There is no whole-tree form, so
93 ``files=[]`` (or ``None``) means "no files to lint" and the answer is the
94 empty string — never the first diagnostics found elsewhere in the repository
95 (#737). A change that touches no file a linter handles — no ``.py`` for
96 Ruff, no ``.js``/``.ts``/``.jsx``/``.tsx`` for ESLint — therefore produces no
97 block at all.
99 Only paths that are **files inside** ``root`` are linted. Two reasons, both
100 found in review of this change:
102 * A diff names paths, and a diff is attacker-controlled. Forwarding them
103 unchecked pointed the linter at anything the diff cared to name —
104 ``../../etc/x.py``, ``/tmp/abs.py`` — and the linter's reading of that
105 file went into the reviewer prompt. The old whole-tree form could not do
106 that, because it was scoped to the working directory; scoping by path
107 has to re-establish what it gave away.
108 * A path in the diff need not exist here: every deletion names one, and a
109 ``--pr`` review of a branch nobody checked out names only such paths.
110 Ruff answers a missing file with ``E902 No such file or directory`` on
111 stdout and a nonzero exit, which this module would have read as a
112 diagnostic and put in front of the panel.
114 Never fails or throws: returns an empty string when the linters are
115 unavailable, clean, or error.
116 """
117 root = root_dir or Path.cwd()
118 paths = _inside(root, files)
119 if not paths:
120 return ""
122 hints: list[str] = []
124 # 1. Ruff, on the changed Python files only.
125 py_files = [f for f in paths if f.endswith(_PY_SUFFIXES)]
126 if py_files and shutil.which("ruff"):
127 block = _run_linter(
128 ["ruff", "check", "--select", "E,F", "--output-format", "concise", "--", *py_files],
129 root,
130 "Python linter (Ruff) warnings:",
131 )
132 if block:
133 hints.append(block)
135 # 2. ESLint, on the changed JS/TS files only.
136 js_files = [f for f in paths if f.endswith(_JS_SUFFIXES)]
137 if js_files and shutil.which("npx") and (root / "package.json").exists():
138 block = _run_linter(
139 ["npx", "eslint", "--format", "compact", "--", *js_files],
140 root,
141 "JS/TS linter (ESLint) warnings:",
142 )
143 if block:
144 hints.append(block)
146 if not hints:
147 return ""
149 out = ["## Static Analysis Hints (Pre-pass)\n"]
150 out.append("Static linters flagged basic syntax/formatting in files changed by this diff:")
151 out.extend(hints)
152 out.append(
153 "\n👉 Note to reviewers: Focus your review on deep logic bugs, security vulnerabilities, "
154 "race conditions, edge cases, and architectural design."
155 )
156 return "\n\n".join(out)