proofctl 0.1.8__tar.gz → 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {proofctl-0.1.8 → proofctl-0.1.10}/PKG-INFO +1 -1
  2. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/hcl_utils.py +24 -2
  3. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/imports.py +335 -10
  4. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/leakage.py +58 -10
  5. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/llm_integration.py +100 -14
  6. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/placeholders.py +24 -0
  7. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/quality.py +157 -9
  8. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/security.py +624 -177
  9. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/terraform.py +68 -23
  10. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/variants.py +82 -33
  11. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/config.py +4 -0
  12. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/engine.py +3 -1
  13. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/PKG-INFO +1 -1
  14. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/SOURCES.txt +5 -0
  15. {proofctl-0.1.8 → proofctl-0.1.10}/pyproject.toml +1 -1
  16. proofctl-0.1.10/tests/test_phase3_audit_fixes.py +277 -0
  17. proofctl-0.1.10/tests/test_phase4_audit_fixes.py +416 -0
  18. proofctl-0.1.10/tests/test_phase5_llm_imports_fixes.py +306 -0
  19. proofctl-0.1.10/tests/test_phase5_p_l_v_fixes.py +279 -0
  20. proofctl-0.1.10/tests/test_phase5_quality_fixes.py +240 -0
  21. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_security_ext.py +8 -2
  22. {proofctl-0.1.8 → proofctl-0.1.10}/LICENSE +0 -0
  23. {proofctl-0.1.8 → proofctl-0.1.10}/README.md +0 -0
  24. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/__init__.py +0 -0
  25. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/_globmatch.py +0 -0
  26. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/baseline.py +0 -0
  27. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/__init__.py +0 -0
  28. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/base.py +0 -0
  29. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/dockerfile.py +0 -0
  30. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/shell_checker.py +0 -0
  31. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/terragrunt.py +0 -0
  32. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/yaml_checker.py +0 -0
  33. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/cli.py +0 -0
  34. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/fixer.py +0 -0
  35. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/models.py +0 -0
  36. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/registry.py +0 -0
  37. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/__init__.py +0 -0
  38. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/html_reporter.py +0 -0
  39. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/json_reporter.py +0 -0
  40. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/terminal.py +0 -0
  41. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/dependency_links.txt +0 -0
  42. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/entry_points.txt +0 -0
  43. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/requires.txt +0 -0
  44. {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/top_level.txt +0 -0
  45. {proofctl-0.1.8 → proofctl-0.1.10}/setup.cfg +0 -0
  46. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_baseline.py +0 -0
  47. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_config_proofctl.py +0 -0
  48. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_dockerfile.py +0 -0
  49. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_engine.py +0 -0
  50. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_fixer.py +0 -0
  51. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_imports.py +0 -0
  52. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_leakage.py +0 -0
  53. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_llm.py +0 -0
  54. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_local_packages.py +0 -0
  55. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_phase1_audit_fixes.py +0 -0
  56. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_phase2_audit_fixes.py +0 -0
  57. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_placeholders.py +0 -0
  58. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_quality.py +0 -0
  59. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_quality_ext.py +0 -0
  60. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_security.py +0 -0
  61. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_suppression.py +0 -0
  62. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_terraform.py +0 -0
  63. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_terraform_ext.py +0 -0
  64. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_terragrunt.py +0 -0
  65. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_variants.py +0 -0
  66. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_yaml.py +0 -0
  67. {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_yaml_k8s_gha.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofctl
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit
5
5
  Author-email: Kolawolu Odunola <kolawolu.o@gmail.com>
6
6
  License: MIT
@@ -77,12 +77,29 @@ def parse_blocks(source: str) -> list[HclBlock]:
77
77
  return _parse(source.splitlines(), base=1, skip_outer=False)
78
78
 
79
79
 
80
+ def _is_escaped(line: str, idx: int) -> bool:
81
+ """True if `line[idx]` is preceded by an odd run of backslashes.
82
+
83
+ Counting the run is the only correct way to tell `\\"` (escaped backslash
84
+ followed by an unescaped quote) from `\"` (escaped quote). The previous
85
+ one-character lookback `line[i-1] == '\\\\'` got this wrong: in
86
+ `password = "my\\\\\\"secret"`, it treated the closing quote as escaped
87
+ and tokenisation silently broke for every rule that walked block bodies.
88
+ """
89
+ n = 0
90
+ j = idx - 1
91
+ while j >= 0 and line[j] == "\\":
92
+ n += 1
93
+ j -= 1
94
+ return n % 2 == 1
95
+
96
+
80
97
  def _strip_comment(line: str) -> str:
81
98
  in_str = False
82
99
  i = 0
83
100
  while i < len(line):
84
101
  c = line[i]
85
- if c == '"' and (i == 0 or line[i - 1] != '\\'):
102
+ if c == '"' and not _is_escaped(line, i):
86
103
  in_str = not in_str
87
104
  if not in_str:
88
105
  if c == '#':
@@ -137,7 +154,12 @@ def _parse(lines: list[str], base: int, skip_outer: bool) -> list[HclBlock]:
137
154
 
138
155
  # ── heredoc passthrough ──
139
156
  if heredoc_end:
140
- if line.strip() == heredoc_end or line.strip().rstrip("-~") == heredoc_end:
157
+ # The end label must appear on a line of its own (whitespace either
158
+ # side allowed for `<<-` indented heredocs). The previous loose
159
+ # `rstrip("-~")` matched lines like `EOF~~~` as terminators, which
160
+ # could prematurely close a heredoc whose body legitimately ended
161
+ # with `~`. C-11 fix: strict whole-line match after .strip().
162
+ if line.strip() == heredoc_end:
141
163
  heredoc_end = None
142
164
  continue
143
165
  hm = _HEREDOC_START_RE.search(line)
@@ -70,6 +70,230 @@ def _parse_pyproject_deps(text: str) -> list[tuple[str, str | None, int]]:
70
70
  results.append((name, spec, lineno))
71
71
  return results
72
72
 
73
+
74
+ def _parse_pipfile(text: str) -> list[tuple[str, str | None, int]]:
75
+ """Parse a Pipfile (`[packages]` and `[dev-packages]`) for dep names.
76
+
77
+ Falls back to a line-based parser if `tomllib` rejects the file. Best-effort
78
+ — line numbers are accurate for the line-based pass; the tomllib pass uses
79
+ section line numbers as a fallback.
80
+ """
81
+ results: list[tuple[str, str | None, int]] = []
82
+
83
+ # Line-based scan first (gives real line numbers, very forgiving).
84
+ section: str | None = None
85
+ for lineno, line in enumerate(text.splitlines(), start=1):
86
+ stripped = line.strip()
87
+ if not stripped or stripped.startswith("#"):
88
+ continue
89
+ if stripped.startswith("[") and stripped.endswith("]"):
90
+ section = stripped[1:-1].strip().lower()
91
+ continue
92
+ if section in ("packages", "dev-packages"):
93
+ # name = "*" / name = ">=1.0" / name = { version = "*", ... }
94
+ m = re.match(
95
+ r'^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)\s*=\s*(.*)$',
96
+ stripped,
97
+ )
98
+ if not m:
99
+ continue
100
+ name = m.group(1)
101
+ rhs = m.group(3).strip()
102
+ spec: str | None = None
103
+ qm = re.match(r'^["\']([^"\']*)["\']', rhs)
104
+ if qm:
105
+ spec = qm.group(1).strip() or None
106
+ if spec == "*":
107
+ spec = None
108
+ else:
109
+ # Inline table: { version = "1.2.3", ... } — pull version out.
110
+ vm = re.search(r'version\s*=\s*["\']([^"\']*)["\']', rhs)
111
+ if vm:
112
+ spec = vm.group(1).strip() or None
113
+ results.append((name, spec, lineno))
114
+ return results
115
+
116
+
117
+ def _parse_poetry_lock(text: str) -> list[tuple[str, str | None, int]]:
118
+ """Parse poetry.lock — `[[package]]` arrays each with a `name = "..."` field.
119
+
120
+ Line-based to preserve line numbers without a TOML parser dependency.
121
+ """
122
+ results: list[tuple[str, str | None, int]] = []
123
+ in_pkg = False
124
+ pkg_name: str | None = None
125
+ pkg_version: str | None = None
126
+ pkg_lineno = 0
127
+ for lineno, line in enumerate(text.splitlines(), start=1):
128
+ stripped = line.strip()
129
+ if stripped == "[[package]]":
130
+ # Flush previous package (if any) before starting a new one.
131
+ if pkg_name:
132
+ results.append((pkg_name, pkg_version, pkg_lineno))
133
+ in_pkg = True
134
+ pkg_name = None
135
+ pkg_version = None
136
+ pkg_lineno = lineno
137
+ continue
138
+ if stripped.startswith("[") and stripped != "[[package]]":
139
+ # Top-level / metadata sections terminate the current package.
140
+ if in_pkg and pkg_name:
141
+ results.append((pkg_name, pkg_version, pkg_lineno))
142
+ in_pkg = False
143
+ pkg_name = None
144
+ pkg_version = None
145
+ continue
146
+ if not in_pkg:
147
+ continue
148
+ m = re.match(r'^name\s*=\s*["\']([^"\']+)["\']', stripped)
149
+ if m:
150
+ pkg_name = m.group(1)
151
+ pkg_lineno = lineno
152
+ continue
153
+ m = re.match(r'^version\s*=\s*["\']([^"\']+)["\']', stripped)
154
+ if m:
155
+ pkg_version = "==" + m.group(1)
156
+ if in_pkg and pkg_name:
157
+ results.append((pkg_name, pkg_version, pkg_lineno))
158
+ return results
159
+
160
+
161
+ def _parse_uv_lock(text: str) -> list[tuple[str, str | None, int]]:
162
+ """uv.lock uses the same `[[package]] / name = "..."` shape as poetry.lock."""
163
+ return _parse_poetry_lock(text)
164
+
165
+
166
+ def _parse_environment_yml(text: str) -> list[tuple[str, str | None, int]]:
167
+ """Parse a conda environment.yml `dependencies:` list.
168
+
169
+ Recognises entries like `numpy=1.20`, `numpy==1.20`, `numpy>=1.20`, and
170
+ nested pip blocks (`- pip:`). Best-effort, line-based — avoids a hard
171
+ dependency on PyYAML for this code path even though it's already a project
172
+ dep, because environment.yml may include conda-only constructs that PyYAML
173
+ treats as plain strings anyway.
174
+ """
175
+ results: list[tuple[str, str | None, int]] = []
176
+ in_deps = False
177
+ for lineno, raw in enumerate(text.splitlines(), start=1):
178
+ stripped = raw.strip()
179
+ if not stripped or stripped.startswith("#"):
180
+ continue
181
+ if re.match(r"^dependencies\s*:\s*$", stripped):
182
+ in_deps = True
183
+ continue
184
+ if in_deps and re.match(r"^[A-Za-z_][A-Za-z0-9_-]*\s*:\s*", stripped) and not raw.lstrip().startswith("-"):
185
+ # New top-level key — exit dependencies block.
186
+ in_deps = False
187
+ continue
188
+ if not in_deps:
189
+ continue
190
+ if not stripped.startswith("-"):
191
+ continue
192
+ item = stripped[1:].strip()
193
+ if not item or item.endswith(":"):
194
+ # Sub-key like `pip:` — keep walking inside it.
195
+ continue
196
+ # Strip surrounding quotes.
197
+ if (item.startswith('"') and item.endswith('"')) or (
198
+ item.startswith("'") and item.endswith("'")
199
+ ):
200
+ item = item[1:-1]
201
+ m = re.match(
202
+ r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)\s*([=<>!~][^\s]*)?",
203
+ item,
204
+ )
205
+ if m:
206
+ name = m.group(1)
207
+ spec = m.group(3) or None
208
+ results.append((name, spec, lineno))
209
+ return results
210
+
211
+
212
+ def _parse_setup_py(text: str) -> list[tuple[str, str | None, int]]:
213
+ """Best-effort: walk an AST looking for `install_requires=[...]` literal lists.
214
+
215
+ Skips dynamic constructions; never crashes on malformed input.
216
+ """
217
+ results: list[tuple[str, str | None, int]] = []
218
+ try:
219
+ tree = ast.parse(text)
220
+ except SyntaxError:
221
+ return results
222
+ for node in ast.walk(tree):
223
+ if not isinstance(node, ast.keyword):
224
+ continue
225
+ if node.arg not in ("install_requires", "tests_require", "extras_require"):
226
+ continue
227
+ value = node.value
228
+ if isinstance(value, ast.List):
229
+ for elt in value.elts:
230
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
231
+ parsed = _parse_requirements(elt.value)
232
+ for name, spec, _ in parsed:
233
+ results.append((name, spec, getattr(elt, "lineno", 0)))
234
+ elif isinstance(value, ast.Dict):
235
+ # extras_require={"dev": ["pytest", ...]}
236
+ for v in value.values:
237
+ if isinstance(v, ast.List):
238
+ for elt in v.elts:
239
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
240
+ parsed = _parse_requirements(elt.value)
241
+ for name, spec, _ in parsed:
242
+ results.append((name, spec, getattr(elt, "lineno", 0)))
243
+ return results
244
+
245
+
246
+ def _parse_setup_cfg(text: str) -> list[tuple[str, str | None, int]]:
247
+ """Parse `[options] install_requires` block of a setup.cfg.
248
+
249
+ Line-based — preserves line numbers and avoids configparser quirks around
250
+ multi-line indented values.
251
+ """
252
+ results: list[tuple[str, str | None, int]] = []
253
+ section: str | None = None
254
+ in_install = False
255
+ base_indent: int | None = None
256
+ for lineno, raw in enumerate(text.splitlines(), start=1):
257
+ stripped = raw.strip()
258
+ if not stripped or stripped.startswith("#") or stripped.startswith(";"):
259
+ continue
260
+ sec = re.match(r"^\[([^\]]+)\]\s*$", stripped)
261
+ if sec:
262
+ section = sec.group(1).strip().lower()
263
+ in_install = False
264
+ base_indent = None
265
+ continue
266
+ if section == "options" and re.match(r"^install_requires\s*=", stripped):
267
+ in_install = True
268
+ base_indent = None
269
+ # Same-line value: `install_requires = pkg1; pkg2`
270
+ after = stripped.split("=", 1)[1].strip()
271
+ if after:
272
+ m = re.match(
273
+ r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)\s*([~<>=!][^\s;#]*)?",
274
+ after,
275
+ )
276
+ if m:
277
+ results.append((m.group(1), m.group(3) or None, lineno))
278
+ continue
279
+ if in_install:
280
+ indent = len(raw) - len(raw.lstrip())
281
+ if indent == 0:
282
+ in_install = False
283
+ continue
284
+ if base_indent is None:
285
+ base_indent = indent
286
+ if indent < base_indent:
287
+ in_install = False
288
+ continue
289
+ m = re.match(
290
+ r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)\s*([~<>=!][^\s;#]*)?",
291
+ stripped,
292
+ )
293
+ if m:
294
+ results.append((m.group(1), m.group(3) or None, lineno))
295
+ return results
296
+
73
297
  # mypy error patterns indicating phantom method calls
74
298
  _MYPY_ATTR_RE = re.compile(
75
299
  r'^(.+):(\d+):\s+error:\s+(?:Item\s+"[^"]+"\s+of\s+)?"([^"]+)"\s+has\s+no\s+attribute\s+"([^"]+)"'
@@ -412,11 +636,64 @@ class DependencyChecker(DirectoryChecker):
412
636
 
413
637
  def _find_dep_files(self, root: Path) -> list[Path]:
414
638
  files: list[Path] = []
639
+ # Top-level (cheap, always included).
415
640
  files.extend(root.glob("requirements*.txt"))
416
641
  files.extend(root.glob("requirements/*.txt"))
417
- pyproject = root / "pyproject.toml"
418
- if pyproject.exists():
419
- files.append(pyproject)
642
+
643
+ # Walk the subtree for every supported manifest type. Reuses the same
644
+ # skip-set as the local-package walker so we don't descend into venvs,
645
+ # caches, or vendored deps. Cap at the same depth.
646
+ manifest_names: frozenset[str] = frozenset({
647
+ "pyproject.toml", "Pipfile", "poetry.lock", "uv.lock",
648
+ "environment.yml", "environment.yaml",
649
+ "setup.py", "setup.cfg",
650
+ })
651
+ # Names matched as glob patterns within each visited directory.
652
+ manifest_globs: tuple[str, ...] = ("requirements*.txt",)
653
+
654
+ seen: set[Path] = set()
655
+
656
+ def _add(p: Path) -> None:
657
+ try:
658
+ key = p.resolve()
659
+ except OSError:
660
+ return
661
+ if key in seen:
662
+ return
663
+ seen.add(key)
664
+ files.append(p)
665
+
666
+ for f in list(files):
667
+ _add(f)
668
+
669
+ def _walk(path: Path, depth: int) -> None:
670
+ if depth > _PKG_SCAN_MAX_DEPTH:
671
+ return
672
+ try:
673
+ children = list(path.iterdir())
674
+ except (OSError, PermissionError):
675
+ return
676
+ for child in children:
677
+ if child.is_symlink():
678
+ continue
679
+ if child.is_dir():
680
+ if child.name in _PKG_SCAN_SKIP_DIRS:
681
+ continue
682
+ if child.name.endswith(".egg-info"):
683
+ continue
684
+ if child.name.startswith(".") and child.name not in {".github"}:
685
+ continue
686
+ _walk(child, depth + 1)
687
+ else:
688
+ if child.name in manifest_names:
689
+ _add(child)
690
+ else:
691
+ for pat in manifest_globs:
692
+ if child.match(pat):
693
+ _add(child)
694
+ break
695
+
696
+ _walk(root, 0)
420
697
  return files
421
698
 
422
699
  def _check_dep_file(self, path: Path) -> list[Finding]:
@@ -425,10 +702,30 @@ class DependencyChecker(DirectoryChecker):
425
702
  except OSError:
426
703
  return []
427
704
 
428
- if path.suffix == ".toml":
429
- deps = _parse_pyproject_deps(text)
430
- else:
431
- deps = _parse_requirements(text)
705
+ name = path.name
706
+ try:
707
+ if name == "pyproject.toml":
708
+ deps = _parse_pyproject_deps(text)
709
+ elif name == "Pipfile":
710
+ deps = _parse_pipfile(text)
711
+ elif name == "poetry.lock":
712
+ deps = _parse_poetry_lock(text)
713
+ elif name == "uv.lock":
714
+ deps = _parse_uv_lock(text)
715
+ elif name in ("environment.yml", "environment.yaml"):
716
+ deps = _parse_environment_yml(text)
717
+ elif name == "setup.py":
718
+ deps = _parse_setup_py(text)
719
+ elif name == "setup.cfg":
720
+ deps = _parse_setup_cfg(text)
721
+ elif path.suffix == ".toml":
722
+ # Other TOML manifests at non-standard names — try project deps.
723
+ deps = _parse_pyproject_deps(text)
724
+ else:
725
+ deps = _parse_requirements(text)
726
+ except Exception:
727
+ # Best-effort: never crash on a malformed manifest.
728
+ return []
432
729
 
433
730
  findings = []
434
731
  for name, spec, lineno in deps:
@@ -474,9 +771,33 @@ class MethodChecker(DirectoryChecker):
474
771
  """
475
772
 
476
773
  def check(self, root: Path, py_files: list[Path]) -> list[Finding]:
477
- if not shutil.which("mypy") or not py_files:
774
+ if not py_files:
478
775
  return []
479
- return self._run_mypy(py_files)
776
+ if not shutil.which("mypy"):
777
+ # Surface the disabled state as a single INFO finding so CI users
778
+ # don't silently lose phantom-method coverage.
779
+ return [self._mypy_missing_finding(root)]
780
+ try:
781
+ return self._run_mypy(py_files)
782
+ except FileNotFoundError:
783
+ return [self._mypy_missing_finding(root)]
784
+
785
+ def _mypy_missing_finding(self, root: Path) -> Finding:
786
+ return Finding(
787
+ file=str(root),
788
+ line=None,
789
+ col=None,
790
+ rule_id="PROOFCTL-M-001",
791
+ rule_name="Phantom method check disabled",
792
+ severity=Severity.INFO,
793
+ message=(
794
+ "M-001 disabled — mypy not installed; install proofctl[dev] or "
795
+ "pip install mypy to enable phantom-method detection"
796
+ ),
797
+ hint="pip install mypy # or: pip install proofctl[dev]",
798
+ authority="mypy attr-defined — AI calls methods that don't exist",
799
+ docs_url="https://mypy.readthedocs.io/en/stable/error_codes.html",
800
+ )
480
801
 
481
802
  def _run_mypy(self, py_files: list[Path]) -> list[Finding]:
482
803
  try:
@@ -492,7 +813,11 @@ class MethodChecker(DirectoryChecker):
492
813
  text=True,
493
814
  timeout=300,
494
815
  )
495
- except (subprocess.TimeoutExpired, OSError):
816
+ except subprocess.TimeoutExpired:
817
+ return []
818
+ except FileNotFoundError:
819
+ raise
820
+ except OSError:
496
821
  return []
497
822
 
498
823
  # Index scoped files for quick membership check after path normalisation.
@@ -41,11 +41,18 @@ _PRE_PARSE_PATTERNS: list[tuple[str, re.Pattern, str, str, str]] = [
41
41
  ),
42
42
  ]
43
43
 
44
- # AST-based: method/attribute names that indicate wrong-language idiom
44
+ # AST-based: method/attribute names that indicate wrong-language idiom.
45
+ # `.push()` and `.length` are noisy in Python codebases — many real Python
46
+ # objects (queues, ORM cursors, PyMongo, Redis pipelines) expose them
47
+ # legitimately. They are downgraded to INFO by default and can be promoted
48
+ # back to ERROR via `LeakageChecker(l001_strict_idioms=True)`.
45
49
  _JS_ATTR_NAMES = {
46
50
  "push": ("Use list.append(x) instead of .push(x).", "PROOFCTL-L-001"),
47
51
  "length": ("Use len(x) instead of .length.", "PROOFCTL-L-001"),
48
52
  }
53
+ # Sub-set of _JS_ATTR_NAMES whose severity is governed by the
54
+ # `l001_strict_idioms` toggle (see H-20).
55
+ _JS_FUZZY_ATTRS: frozenset[str] = frozenset({"push", "length"})
49
56
  _JS_CALL_NAMES = {
50
57
  "console": {"log", "warn", "error", "info", "debug"},
51
58
  }
@@ -63,18 +70,27 @@ _GO_FMT_CALLS = {"Println", "Printf", "Fprintf", "Sprintf", "Errorf", "Print"}
63
70
 
64
71
 
65
72
  class _LeakageVisitor(ast.NodeVisitor):
66
- def __init__(self, path: Path):
73
+ def __init__(self, path: Path, strict_idioms: bool = False):
67
74
  self.path = path
75
+ self._strict_idioms = strict_idioms
68
76
  self.findings: list[Finding] = []
69
77
 
70
- def _find(self, node: ast.AST, rule_id: str, rule_name: str, msg: str, hint: str) -> None:
78
+ def _find(
79
+ self,
80
+ node: ast.AST,
81
+ rule_id: str,
82
+ rule_name: str,
83
+ msg: str,
84
+ hint: str,
85
+ severity: Severity = Severity.ERROR,
86
+ ) -> None:
71
87
  self.findings.append(Finding(
72
88
  file=str(self.path),
73
89
  line=getattr(node, "lineno", None),
74
90
  col=getattr(node, "col_offset", None),
75
91
  rule_id=rule_id,
76
92
  rule_name=rule_name,
77
- severity=Severity.ERROR,
93
+ severity=severity,
78
94
  message=msg,
79
95
  hint=hint,
80
96
  authority="Cross-language leakage — AI conflated Python with another language",
@@ -101,10 +117,20 @@ class _LeakageVisitor(ast.NodeVisitor):
101
117
  # JS: .push(), .length
102
118
  if attr in _JS_ATTR_NAMES:
103
119
  hint, rule_id = _JS_ATTR_NAMES[attr]
120
+ # H-20: `.push()` / `.length` produced too many false positives
121
+ # against Python objects that legitimately expose those names
122
+ # (queues, ORM cursors, PyMongo). Demote to INFO unless the
123
+ # caller explicitly opts into strict cross-language idiom
124
+ # checking via `l001_strict_idioms`.
125
+ if attr in _JS_FUZZY_ATTRS and not self._strict_idioms:
126
+ sev = Severity.INFO
127
+ else:
128
+ sev = Severity.ERROR
104
129
  self._find(
105
130
  node, rule_id, f"JS attribute '.{attr}' in Python",
106
131
  f"'.{attr}' is a JavaScript idiom.",
107
132
  hint,
133
+ severity=sev,
108
134
  )
109
135
 
110
136
  # JS: console.log / console.warn etc.
@@ -150,16 +176,32 @@ class _LeakageVisitor(ast.NodeVisitor):
150
176
  self.generic_visit(node)
151
177
 
152
178
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
153
- """PROOFCTL-L-002d: Java-style getter/setter pairs."""
179
+ """PROOFCTL-L-002d: Java-style getter/setter pairs.
180
+
181
+ H-21: Only flag when getX AND setX both exist for the same suffix X.
182
+ Two unrelated methods like `getInfo` + `setColor` are not a Java
183
+ pattern; only matched pairs (`getFoo` + `setFoo`) are.
184
+ """
154
185
  method_names = {
155
186
  n.name for n in ast.walk(node)
156
187
  if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
157
188
  }
189
+
190
+ def _suffix(name: str, prefix: str) -> str | None:
191
+ if name.startswith(prefix) and len(name) > 3 and name[3].isupper():
192
+ return name[3:]
193
+ return None
194
+
195
+ get_suffixes = {s for n in method_names if (s := _suffix(n, "get"))}
196
+ set_suffixes = {s for n in method_names if (s := _suffix(n, "set"))}
197
+ matched_suffixes = get_suffixes & set_suffixes
198
+
199
+ # Only flag method names that participate in a matched pair.
158
200
  java_getter_setters = {
159
- name for name in method_names
160
- if (name.startswith("get") or name.startswith("set"))
161
- and len(name) > 3
162
- and name[3].isupper()
201
+ f"{prefix}{suffix}"
202
+ for suffix in matched_suffixes
203
+ for prefix in ("get", "set")
204
+ if f"{prefix}{suffix}" in method_names
163
205
  }
164
206
  # Flag the class once if it has 2+ getter/setter pairs with no @property
165
207
  has_property = any(
@@ -190,6 +232,12 @@ class _LeakageVisitor(ast.NodeVisitor):
190
232
 
191
233
 
192
234
  class LeakageChecker(FileChecker):
235
+ def __init__(self, l001_strict_idioms: bool = False) -> None:
236
+ # H-20: When True, `.push()` / `.length` are reported as ERROR.
237
+ # Default False keeps them at INFO to avoid noise on Python objects
238
+ # that legitimately expose those names (queues, ORMs, etc.).
239
+ self._l001_strict_idioms = l001_strict_idioms
240
+
193
241
  def check(self, path: Path, source: str, tree: ast.Module | None) -> list[Finding]:
194
242
  findings: list[Finding] = []
195
243
  if tree is None:
@@ -203,7 +251,7 @@ class LeakageChecker(FileChecker):
203
251
  # they MUST be inside a string literal, not live code. Skip to avoid
204
252
  # false positives on files that embed other languages as strings
205
253
  # (e.g. HTML reporters, template generators).
206
- v = _LeakageVisitor(path)
254
+ v = _LeakageVisitor(path, strict_idioms=self._l001_strict_idioms)
207
255
  v.visit(tree)
208
256
  findings.extend(v.findings)
209
257
  return findings