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.
- {proofctl-0.1.8 → proofctl-0.1.10}/PKG-INFO +1 -1
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/hcl_utils.py +24 -2
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/imports.py +335 -10
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/leakage.py +58 -10
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/llm_integration.py +100 -14
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/placeholders.py +24 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/quality.py +157 -9
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/security.py +624 -177
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/terraform.py +68 -23
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/variants.py +82 -33
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/config.py +4 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/engine.py +3 -1
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/SOURCES.txt +5 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/pyproject.toml +1 -1
- proofctl-0.1.10/tests/test_phase3_audit_fixes.py +277 -0
- proofctl-0.1.10/tests/test_phase4_audit_fixes.py +416 -0
- proofctl-0.1.10/tests/test_phase5_llm_imports_fixes.py +306 -0
- proofctl-0.1.10/tests/test_phase5_p_l_v_fixes.py +279 -0
- proofctl-0.1.10/tests/test_phase5_quality_fixes.py +240 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_security_ext.py +8 -2
- {proofctl-0.1.8 → proofctl-0.1.10}/LICENSE +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/README.md +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/__init__.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/_globmatch.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/baseline.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/base.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/dockerfile.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/shell_checker.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/checkers/yaml_checker.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/cli.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/fixer.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/models.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/registry.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/html_reporter.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/json_reporter.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl/reporters/terminal.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/setup.cfg +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_baseline.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_config_proofctl.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_dockerfile.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_engine.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_fixer.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_imports.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_leakage.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_llm.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_local_packages.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_phase1_audit_fixes.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_phase2_audit_fixes.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_placeholders.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_quality.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_quality_ext.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_security.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_suppression.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_terraform.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_terraform_ext.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_terragrunt.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_variants.py +0 -0
- {proofctl-0.1.8 → proofctl-0.1.10}/tests/test_yaml.py +0 -0
- {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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
774
|
+
if not py_files:
|
|
478
775
|
return []
|
|
479
|
-
|
|
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
|
|
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(
|
|
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=
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|