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

1"""Static analysis hints pre-pass (issue #523). 

2 

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. 

6 

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""" 

14 

15from __future__ import annotations 

16 

17import shutil 

18import subprocess 

19from collections.abc import Sequence 

20from pathlib import Path 

21 

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") 

26 

27#: How many diagnostics from one linter reach the prompt. 

28_MAX_LINES = 5 

29 

30 

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). 

33 

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) 

48 

49 

50def _inside(root: Path, files: Sequence[str] | None) -> list[str]: 

51 """The subset of *files* that are files inside *root* (pure apart from stat). 

52 

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: 

55 

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. 

63 

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 

86 

87 

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. 

90 

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. 

98 

99 Only paths that are **files inside** ``root`` are linted. Two reasons, both 

100 found in review of this change: 

101 

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. 

113 

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 "" 

121 

122 hints: list[str] = [] 

123 

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) 

134 

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) 

145 

146 if not hints: 

147 return "" 

148 

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)