code-jig 0.12.0__py3-none-any.whl
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.
- code_jig-0.12.0.dist-info/METADATA +154 -0
- code_jig-0.12.0.dist-info/RECORD +52 -0
- code_jig-0.12.0.dist-info/WHEEL +4 -0
- code_jig-0.12.0.dist-info/entry_points.txt +2 -0
- code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
- codejig/__init__.py +1 -0
- codejig/__main__.py +106 -0
- codejig/_core/VERSION +2 -0
- codejig/_core/bench/requirements.toml +4 -0
- codejig/_core/core/AGENTS.core.md +9 -0
- codejig/_core/core/agents/explorer.toml +24 -0
- codejig/_core/core/agents/judge.toml +19 -0
- codejig/_core/core/agents/reviewer.toml +10 -0
- codejig/_core/core/tiers.toml +4 -0
- codejig/_core/skills/bug-hunt/SKILL.md +131 -0
- codejig/_core/skills/code-analysis/SKILL.md +72 -0
- codejig/_core/skills/codegen/SKILL.md +46 -0
- codejig/_core/skills/cross-review/SKILL.md +32 -0
- codejig/_core/skills/grilling/SKILL.md +60 -0
- codejig/_core/skills/hld-trace/SKILL.md +69 -0
- codejig/_core/skills/improve/SKILL.md +38 -0
- codejig/_core/skills/redacao/SKILL.md +114 -0
- codejig/_core/skills/redacao/checar.py +75 -0
- codejig/_core/skills/redacao/reference.md +118 -0
- codejig/_core/skills/regression-tests/SKILL.md +53 -0
- codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
- codejig/_core/skills/skill-validate/SKILL.md +58 -0
- codejig/_core/skills/web-consensus/SKILL.md +55 -0
- codejig/analyze.py +499 -0
- codejig/atomicwrite.py +49 -0
- codejig/collect.py +311 -0
- codejig/doctor.py +370 -0
- codejig/evidence.py +418 -0
- codejig/feedback.py +165 -0
- codejig/gitgate.py +278 -0
- codejig/hooks.py +64 -0
- codejig/humanize.py +206 -0
- codejig/install.py +381 -0
- codejig/managed.py +64 -0
- codejig/migrate.py +515 -0
- codejig/overlay.py +43 -0
- codejig/paths.py +40 -0
- codejig/probe.py +136 -0
- codejig/review.py +73 -0
- codejig/scrub.py +255 -0
- codejig/skillcheck.py +400 -0
- codejig/skilllint.py +99 -0
- codejig/sources.py +248 -0
- codejig/tiers.py +63 -0
- codejig/tokens.py +118 -0
- codejig/trace.py +137 -0
- codejig/watermark.py +147 -0
codejig/analyze.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CATEGORIES = ("security", "secret", "dependency", "bug", "type", "unused", "complexity", "duplication")
|
|
18
|
+
TOOL_ENV = {"SEMGREP_SEND_METRICS": "off", "SEMGREP_ENABLE_VERSION_CHECK": "0", "DOTNET_CLI_TELEMETRY_OPTOUT": "1",
|
|
19
|
+
"DOTNET_NOLOGO": "1", "KIOTA_CLI_TELEMETRY_OPTOUT": "1", "PYTHONUTF8": "1"}
|
|
20
|
+
COGNITIVE, CYCLOMATIC, NLOC, PARAMS = 15, 15, 100, 7
|
|
21
|
+
CODE_SUFFIXES = {".py", ".cs", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".java", ".kt", ".go", ".rs", ".swift", ".php", ".rb", ".scala", ".lua", ".c", ".cc", ".cpp", ".h", ".hpp", ".m"}
|
|
22
|
+
SKIP_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", "dist", "build", "__pycache__"}
|
|
23
|
+
ESLINT_COMPLEXITY = {"complexity", "max-depth", "max-lines-per-function", "max-params", "max-nested-callbacks",
|
|
24
|
+
"sonarjs/cognitive-complexity", "sonarjs/cyclomatic-complexity", "sonarjs/nested-control-flow"}
|
|
25
|
+
ESLINT_SECURITY = {f"sonarjs/{r}" for r in (
|
|
26
|
+
"code-eval", "cookie-no-httponly", "cors", "csrf", "encryption-secure-mode", "file-permissions", "hardcoded-secret-signatures",
|
|
27
|
+
"hashing", "insecure-cookie", "no-clear-text-protocols", "no-hardcoded-ip", "no-hardcoded-passwords", "no-hardcoded-secrets",
|
|
28
|
+
"no-ip-forward", "no-os-command-from-path", "no-unsafe-unzip")}
|
|
29
|
+
DOTNET_COMPLEXITY = {"S3776", "S1541", "S138", "S107", "CA1502", "CA1505", "CA1506"}
|
|
30
|
+
DOTNET_SECURITY = {"CA2100", "S2068", "S2077", "S2245", "S3649", "S4423", "S4426", "S4433", "S4507", "S4790",
|
|
31
|
+
"S5332", "S5344", "S5443", "S5542", "S5547", "S5659", "S5773", "S6418"}
|
|
32
|
+
MSBUILD = re.compile(r"^(.+?)\((\d+),\d+\): (warning|error) ([A-Za-z]+\d+): (.*?)(?: \[[^\]]*\])?$")
|
|
33
|
+
MSBUILD_PROJECT = re.compile(r"^(.+?) : (warning|error) ([A-Za-z]+\d+): (.*?)(?: \[[^\]]*\])?$")
|
|
34
|
+
VULTURE = re.compile(r"^(.+?):(\d+): (.+?) \((\d+)% confidence[^)]*\)$")
|
|
35
|
+
KNIP_UNUSED = ("files", "dependencies", "devDependencies", "optionalPeerDependencies", "exports", "types", "nsExports", "nsTypes",
|
|
36
|
+
"enumMembers", "namespaceMembers", "duplicates", "catalog", "catalogReferences")
|
|
37
|
+
KNIP_BROKEN = ("unlisted", "unresolved", "binaries", "cycles")
|
|
38
|
+
MYPY = re.compile(r"^(.+?):(\d+)(?::\d+)*: error: (.*?)(?: \[([\w-]+)\])?$")
|
|
39
|
+
DUP_LOC = re.compile(r"^(.+):(\d+) ~ (\d+)$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Finding:
|
|
44
|
+
category: str
|
|
45
|
+
tool: str
|
|
46
|
+
rule: str
|
|
47
|
+
path: str
|
|
48
|
+
line: int
|
|
49
|
+
message: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Tool:
|
|
54
|
+
name: str
|
|
55
|
+
stack: str
|
|
56
|
+
argv: Callable[[str, Path, Path], list[str]]
|
|
57
|
+
parse: Callable[[str, Path, Path], list[Finding]]
|
|
58
|
+
applies: Callable[[Path], bool] = lambda root: True
|
|
59
|
+
hint: str = ""
|
|
60
|
+
ok: tuple[int, ...] = (0,)
|
|
61
|
+
reason: str = ""
|
|
62
|
+
binary: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Report:
|
|
67
|
+
findings: list[Finding]
|
|
68
|
+
skipped: list[tuple[str, str]]
|
|
69
|
+
errors: list[tuple[str, str]]
|
|
70
|
+
ran: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect(root: Path) -> set[str]:
|
|
74
|
+
stacks = set()
|
|
75
|
+
if any(root.glob("*.sln")) or any(root.glob("*.slnx")) or any(root.glob("*.csproj")) or any(root.glob("*/*.csproj")):
|
|
76
|
+
stacks.add("dotnet")
|
|
77
|
+
if (root / "package.json").is_file():
|
|
78
|
+
stacks.add("node")
|
|
79
|
+
markers = ("pyproject.toml", "setup.py", "setup.cfg")
|
|
80
|
+
if any((root / m).is_file() for m in markers) or any(root.glob("requirements*.txt")) or any(root.glob("*.py")) or any(root.glob("*/*.py")):
|
|
81
|
+
stacks.add("python")
|
|
82
|
+
return stacks
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve(name: str, root: Path, stack: str, path: str | None = None) -> str | None:
|
|
86
|
+
if stack == "node":
|
|
87
|
+
local = shutil.which(name, path=str(root / "node_modules" / ".bin"))
|
|
88
|
+
if local:
|
|
89
|
+
return local
|
|
90
|
+
return shutil.which(name, path=path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rel(path: str, root: Path) -> str:
|
|
94
|
+
p = Path(path)
|
|
95
|
+
if not p.is_absolute():
|
|
96
|
+
p = root / p
|
|
97
|
+
try:
|
|
98
|
+
return p.resolve().relative_to(root.resolve()).as_posix()
|
|
99
|
+
except (ValueError, OSError):
|
|
100
|
+
return Path(path).as_posix()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess:
|
|
104
|
+
return subprocess.run([shutil.which("git") or "git", *args], cwd=root, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def source_files(root: Path) -> list[str]:
|
|
108
|
+
try:
|
|
109
|
+
r = _git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
|
|
110
|
+
except OSError:
|
|
111
|
+
r = None
|
|
112
|
+
if r is not None and r.returncode == 0:
|
|
113
|
+
names = [n for n in r.stdout.split("\0") if n]
|
|
114
|
+
else:
|
|
115
|
+
names = []
|
|
116
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
117
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
118
|
+
names += [Path(dirpath, f).relative_to(root).as_posix() for f in filenames]
|
|
119
|
+
return sorted(n for n in names if (root / n).is_file())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def changed(root: Path, base: str) -> set[str]:
|
|
123
|
+
mb = _git(root, "merge-base", base, "HEAD")
|
|
124
|
+
if mb.returncode != 0:
|
|
125
|
+
raise ValueError(f"{base}: {(mb.stderr.strip().splitlines() or ['not a commit'])[0]}")
|
|
126
|
+
diff = _git(root, "diff", "--name-only", "--no-renames", "--relative", "-z", mb.stdout.strip())
|
|
127
|
+
extra = _git(root, "ls-files", "--others", "--exclude-standard", "-z")
|
|
128
|
+
return {n for n in (diff.stdout + "\0" + extra.stdout).split("\0") if n}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _json(out: str, kind: type):
|
|
132
|
+
data = json.loads(out)
|
|
133
|
+
if not isinstance(data, kind):
|
|
134
|
+
raise ValueError(f"expected a JSON {kind.__name__}")
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _lizard_args(exe: str, root: Path, tmp: Path) -> list[str]:
|
|
139
|
+
listing = tmp / "files.txt"
|
|
140
|
+
code = [n for n in source_files(root) if Path(n).suffix.lower() in CODE_SUFFIXES]
|
|
141
|
+
listing.write_text("\n".join(code) + "\n", encoding="utf-8")
|
|
142
|
+
return [exe, "-Ecognitive", "-Eduplicate", "--csv", "-f", str(listing)]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _lizard_metrics(rows: list[str], root: Path) -> list[Finding]:
|
|
146
|
+
found = []
|
|
147
|
+
for row in csv.reader(rows):
|
|
148
|
+
if len(row) < 11 or not row[0].isdigit():
|
|
149
|
+
continue
|
|
150
|
+
cog = int(row[11]) if len(row) > 11 and row[11].isdigit() else 0
|
|
151
|
+
path, name, start = rel(row[6], root), row[7], int(row[9])
|
|
152
|
+
for rule, value, limit, label in (("cognitive-complexity", cog, COGNITIVE, "cognitive complexity"),
|
|
153
|
+
("cyclomatic-complexity", int(row[1]), CYCLOMATIC, "cyclomatic complexity"),
|
|
154
|
+
("function-length", int(row[0]), NLOC, "lines of code"),
|
|
155
|
+
("parameters", int(row[3]), PARAMS, "parameters")):
|
|
156
|
+
if value > limit:
|
|
157
|
+
found.append(Finding("complexity", "lizard", rule, path, start, f"{name} has {value} {label} (limit {limit})"))
|
|
158
|
+
return found
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _lizard_duplicates(lines: list[str], root: Path) -> list[Finding]:
|
|
162
|
+
found = []
|
|
163
|
+
block: list[tuple[str, int, int]] = []
|
|
164
|
+
for line in lines:
|
|
165
|
+
m = DUP_LOC.match(line.strip())
|
|
166
|
+
if m:
|
|
167
|
+
block.append((rel(m.group(1), root), int(m.group(2)), int(m.group(3))))
|
|
168
|
+
elif line.startswith("^^^") and block:
|
|
169
|
+
(path, start, _), others = block[0], block[1:]
|
|
170
|
+
where = ", ".join(f"{p}:{s}-{e}" for p, s, e in others)
|
|
171
|
+
found.append(Finding("duplication", "lizard", "duplicate-block", path, start, f"same code as {where}"))
|
|
172
|
+
block = []
|
|
173
|
+
return found
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_lizard(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
177
|
+
lines = out.splitlines()
|
|
178
|
+
split = lines.index("Duplicates") if "Duplicates" in lines else len(lines)
|
|
179
|
+
return _lizard_metrics(lines[:split], root) + _lizard_duplicates(lines[split:], root)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_ruff(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
183
|
+
found = []
|
|
184
|
+
for item in _json(out, list):
|
|
185
|
+
code = item.get("code")
|
|
186
|
+
if not code:
|
|
187
|
+
category, rule = "bug", "syntax"
|
|
188
|
+
elif re.fullmatch(r"S\d+", code):
|
|
189
|
+
category, rule = "security", code
|
|
190
|
+
elif code.startswith(("C90", "PLR09")):
|
|
191
|
+
category, rule = "complexity", code
|
|
192
|
+
else:
|
|
193
|
+
category, rule = "bug", code
|
|
194
|
+
found.append(Finding(category, "ruff", rule, rel(item["filename"], root), int(item["location"]["row"]), item["message"]))
|
|
195
|
+
return found
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_mypy(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
199
|
+
return [Finding("type", "mypy", m.group(4) or "error", rel(m.group(1), root), int(m.group(2)), m.group(3))
|
|
200
|
+
for m in map(MYPY.match, out.splitlines()) if m]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_eslint(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
204
|
+
found = []
|
|
205
|
+
for result in _json(out, list):
|
|
206
|
+
path = rel(result["filePath"], root)
|
|
207
|
+
for msg in result.get("messages") or []:
|
|
208
|
+
rule = msg.get("ruleId") or "parse"
|
|
209
|
+
if rule in ESLINT_COMPLEXITY:
|
|
210
|
+
category = "complexity"
|
|
211
|
+
elif rule.startswith("security/") or rule in ESLINT_SECURITY:
|
|
212
|
+
category = "security"
|
|
213
|
+
else:
|
|
214
|
+
category = "bug"
|
|
215
|
+
found.append(Finding(category, "eslint", rule, path, int(msg.get("line") or 0), msg.get("message", "")))
|
|
216
|
+
return found
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_tsc(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
220
|
+
return [Finding("type", "tsc", m.group(4), rel(m.group(1), root), int(m.group(2)), m.group(5))
|
|
221
|
+
for m in map(MSBUILD.match, out.splitlines()) if m]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _dotnet_category(rule: str) -> str:
|
|
225
|
+
if re.fullmatch(r"NU190[1-4]", rule):
|
|
226
|
+
return "dependency"
|
|
227
|
+
if rule in DOTNET_COMPLEXITY:
|
|
228
|
+
return "complexity"
|
|
229
|
+
if rule in DOTNET_SECURITY or rule.startswith(("CA3", "CA5", "CA23")):
|
|
230
|
+
return "security"
|
|
231
|
+
if rule.startswith("CS"):
|
|
232
|
+
return "type"
|
|
233
|
+
return "bug"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _dotnet_lines(out: str):
|
|
237
|
+
for line in (ln.strip() for ln in out.splitlines()):
|
|
238
|
+
m = MSBUILD.match(line)
|
|
239
|
+
if m:
|
|
240
|
+
yield m.group(4), m.group(1), int(m.group(2)), m.group(5)
|
|
241
|
+
continue
|
|
242
|
+
m = MSBUILD_PROJECT.match(line)
|
|
243
|
+
if m:
|
|
244
|
+
yield m.group(3), m.group(1), 0, m.group(4)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_dotnet(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
248
|
+
seen: dict[tuple, Finding] = {}
|
|
249
|
+
for rule, path, line, message in _dotnet_lines(out):
|
|
250
|
+
f = Finding(_dotnet_category(rule), "dotnet", rule, rel(path, root), line, message)
|
|
251
|
+
seen.setdefault((f.path, f.line, f.rule, f.message), f)
|
|
252
|
+
return list(seen.values())
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def parse_vulture(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
256
|
+
found = []
|
|
257
|
+
for m in map(VULTURE.match, out.splitlines()):
|
|
258
|
+
if m:
|
|
259
|
+
rule = re.sub(r"\W+", "-", re.sub(r"'[^']*'", " ", m.group(3)).strip().lower()).strip("-")
|
|
260
|
+
found.append(Finding("unused", "vulture", rule, rel(m.group(1), root), int(m.group(2)), f"{m.group(3)} ({m.group(4)}% confidence)"))
|
|
261
|
+
return found
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _knip_item(entry: dict, key: str, item, root: Path) -> Finding:
|
|
265
|
+
category = "bug" if key in KNIP_BROKEN else "unused"
|
|
266
|
+
if isinstance(item, list):
|
|
267
|
+
names = " -> ".join(str(i.get("name", "")) for i in item)
|
|
268
|
+
return Finding(category, "knip", f"knip/{key}", rel(entry["file"], root), 0, f"{key}: {names}")
|
|
269
|
+
name = ".".join(str(part) for part in (item.get("namespace"), item.get("name", "")) if part)
|
|
270
|
+
return Finding(category, "knip", f"knip/{key}", rel(entry["file"], root), int(item.get("line") or 0), f"{key}: {name}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_knip(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
274
|
+
issues = _json(out, dict).get("issues")
|
|
275
|
+
if not isinstance(issues, list):
|
|
276
|
+
raise ValueError("expected an issues list")
|
|
277
|
+
return [_knip_item(entry, key, item, root)
|
|
278
|
+
for entry in issues for key in KNIP_UNUSED + KNIP_BROKEN for item in entry.get(key) or []]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_semgrep(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
282
|
+
return [Finding("security", "semgrep", r["check_id"], rel(r["path"], root), int(r["start"]["line"]), r["extra"].get("message", ""))
|
|
283
|
+
for r in _json(out, dict).get("results") or []]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_gitleaks(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
287
|
+
report = tmp / "gitleaks.json"
|
|
288
|
+
if not report.is_file() or not report.read_text(encoding="utf-8").strip():
|
|
289
|
+
return []
|
|
290
|
+
return [Finding("secret", "gitleaks", r.get("RuleID", "secret"), rel(r["File"], root), int(r.get("StartLine") or 0),
|
|
291
|
+
r.get("Description", "") + (f" (fingerprint {r['Fingerprint']})" if r.get("Fingerprint") else ""))
|
|
292
|
+
for r in _json(report.read_text(encoding="utf-8"), list)]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_osv(out: str, root: Path, tmp: Path) -> list[Finding]:
|
|
296
|
+
if not out.strip():
|
|
297
|
+
return []
|
|
298
|
+
found = []
|
|
299
|
+
for result in _json(out, dict).get("results") or []:
|
|
300
|
+
path = rel(result["source"]["path"], root)
|
|
301
|
+
for pkg in result.get("packages") or []:
|
|
302
|
+
name = f"{pkg['package']['name']}@{pkg['package'].get('version', '?')}"
|
|
303
|
+
for vuln in pkg.get("vulnerabilities") or []:
|
|
304
|
+
found.append(Finding("dependency", "osv-scanner", vuln["id"], path, 0, f"{name}: {vuln.get('summary', '')}"))
|
|
305
|
+
return found
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _has_mypy_config(root: Path) -> bool:
|
|
309
|
+
def contains(name: str, text: str) -> bool:
|
|
310
|
+
f = root / name
|
|
311
|
+
return f.is_file() and text in f.read_text(encoding="utf-8", errors="replace")
|
|
312
|
+
return (root / "mypy.ini").is_file() or (root / ".mypy.ini").is_file() or contains("setup.cfg", "[mypy") or contains("pyproject.toml", "[tool.mypy")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _vulture_args(exe: str, root: Path, tmp: Path) -> list[str]:
|
|
316
|
+
pyproject = root / "pyproject.toml"
|
|
317
|
+
if pyproject.is_file() and "[tool.vulture" in pyproject.read_text(encoding="utf-8", errors="replace"):
|
|
318
|
+
return [exe]
|
|
319
|
+
excludes = ",".join(f"{d}/*,*/{d}/*" for d in sorted(SKIP_DIRS))
|
|
320
|
+
return [exe, ".", "--min-confidence", "80", "--exclude", excludes]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _has_eslint_config(root: Path) -> bool:
|
|
324
|
+
return any(root.glob("eslint.config.*")) or any(root.glob(".eslintrc*"))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _semgrep_rules(root: Path) -> Path | None:
|
|
328
|
+
for name in (".semgrep.yml", ".semgrep.yaml", ".semgrep"):
|
|
329
|
+
if (root / name).exists():
|
|
330
|
+
return root / name
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _dotnet_target(root: Path) -> list[str]:
|
|
335
|
+
for pattern in ("*.sln", "*.slnx", "*.csproj"):
|
|
336
|
+
found = sorted(root.glob(pattern))
|
|
337
|
+
if found:
|
|
338
|
+
return [found[0].name]
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
_GITLEAKS = ("--no-banner", "--redact", "--exit-code", "0", "--report-format", "json", "--report-path")
|
|
343
|
+
|
|
344
|
+
TOOLS = [
|
|
345
|
+
Tool("lizard", "any", _lizard_args, parse_lizard, hint="pip install lizard"),
|
|
346
|
+
Tool("ruff", "python", lambda exe, root, tmp: [exe, "check", "--output-format", "json", "--no-cache", "--exit-zero", "--extend-select", "S,B", "."],
|
|
347
|
+
parse_ruff, hint="pip install ruff"),
|
|
348
|
+
Tool("vulture", "python", _vulture_args,
|
|
349
|
+
parse_vulture, hint="pip install vulture", ok=(0, 3)),
|
|
350
|
+
Tool("mypy", "python", lambda exe, root, tmp: [exe, "--no-error-summary", "--no-pretty", "--show-error-codes", "."],
|
|
351
|
+
parse_mypy, _has_mypy_config, "pip install mypy", reason="no mypy config"),
|
|
352
|
+
Tool("eslint", "node", lambda exe, root, tmp: [exe, ".", "-f", "json"], parse_eslint, _has_eslint_config,
|
|
353
|
+
"npm install -D eslint eslint-plugin-sonarjs", reason="no eslint config"),
|
|
354
|
+
Tool("knip", "node", lambda exe, root, tmp: [exe, "--reporter", "json", "--no-progress"], parse_knip,
|
|
355
|
+
hint="npm install -D knip"),
|
|
356
|
+
Tool("tsc", "node", lambda exe, root, tmp: [exe, "--noEmit", "--pretty", "false", "-p", "."], parse_tsc,
|
|
357
|
+
lambda root: (root / "tsconfig.json").is_file(), "npm install -D typescript", reason="no tsconfig.json"),
|
|
358
|
+
Tool("dotnet", "dotnet", lambda exe, root, tmp: [exe, "build", "--nologo", "--no-incremental", "-clp:NoSummary", *_dotnet_target(root)],
|
|
359
|
+
parse_dotnet, lambda root: bool(_dotnet_target(root)), "install the .NET SDK", reason="no solution or project at the root"),
|
|
360
|
+
Tool("semgrep", "any", lambda exe, root, tmp: [exe, "scan", "--metrics=off", "--disable-version-check", "--config", str(_semgrep_rules(root)), "--json", "--quiet", "."],
|
|
361
|
+
parse_semgrep, lambda root: _semgrep_rules(root) is not None, "pip install semgrep", reason="no local rules (.semgrep.yml or .semgrep/)"),
|
|
362
|
+
Tool("gitleaks", "any", lambda exe, root, tmp: [exe, "dir", *_GITLEAKS, str(tmp / "gitleaks.json"), "."],
|
|
363
|
+
parse_gitleaks, hint="install gitleaks from its GitHub releases", binary="gitleaks"),
|
|
364
|
+
Tool("gitleaks-history", "any", lambda exe, root, tmp: [exe, "git", *_GITLEAKS, str(tmp / "gitleaks.json"), "."],
|
|
365
|
+
parse_gitleaks, lambda root: (root / ".git").exists(), "install gitleaks from its GitHub releases", reason="not a git repository", binary="gitleaks"),
|
|
366
|
+
Tool("osv-scanner", "any", lambda exe, root, tmp: [exe, "scan", "source", "-r", "--offline", "--download-offline-databases", "--format", "json", "."],
|
|
367
|
+
parse_osv, hint="install osv-scanner from its GitHub releases", ok=(0, 1, 128)),
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _first(text: str) -> str:
|
|
372
|
+
return (text.strip().splitlines() or [""])[0][:300]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
376
|
+
if os.name == "nt":
|
|
377
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True, check=False)
|
|
378
|
+
return
|
|
379
|
+
try:
|
|
380
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
381
|
+
except OSError:
|
|
382
|
+
proc.kill()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _execute(argv: list[str], root: Path, env: dict, timeout: int) -> tuple[int, str, str]:
|
|
386
|
+
group = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True}
|
|
387
|
+
with subprocess.Popen(argv, cwd=root, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
388
|
+
text=True, encoding="utf-8", errors="replace", **group) as proc:
|
|
389
|
+
try:
|
|
390
|
+
out, err = proc.communicate(timeout=timeout)
|
|
391
|
+
except BaseException:
|
|
392
|
+
_kill_tree(proc)
|
|
393
|
+
proc.communicate()
|
|
394
|
+
raise
|
|
395
|
+
return proc.returncode, out, err
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _run_one(t: Tool, exe: str, root: Path, env: dict, timeout: int) -> tuple[list[Finding], str | None]:
|
|
399
|
+
with tempfile.TemporaryDirectory() as d:
|
|
400
|
+
tmp = Path(d)
|
|
401
|
+
try:
|
|
402
|
+
code, out, err = _execute(t.argv(exe, root, tmp), root, env, timeout)
|
|
403
|
+
except subprocess.TimeoutExpired:
|
|
404
|
+
return [], f"timed out after {timeout}s"
|
|
405
|
+
except OSError as e:
|
|
406
|
+
return [], f"cannot run: {e}"
|
|
407
|
+
try:
|
|
408
|
+
found = t.parse(out, root, tmp)
|
|
409
|
+
except (ValueError, KeyError, TypeError, IndexError, AttributeError) as e:
|
|
410
|
+
return [], f"unreadable output: {e}; {_first(err)}"
|
|
411
|
+
if code not in t.ok and not found:
|
|
412
|
+
return [], f"exit {code}: {_first(err) or _first(out)}"
|
|
413
|
+
return found, None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _add_new(findings: list[Finding], seen: set, found: list[Finding]) -> None:
|
|
417
|
+
for f in found:
|
|
418
|
+
key = (f.tool, f.rule, f.path, f.line, "" if f.line else f.message)
|
|
419
|
+
if key not in seen:
|
|
420
|
+
seen.add(key)
|
|
421
|
+
findings.append(f)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def run_tools(tools: list[Tool], root: Path, stacks: set[str], path: str | None = None, timeout: int = 600) -> Report:
|
|
425
|
+
report = Report([], [], [])
|
|
426
|
+
env = {**os.environ, **TOOL_ENV}
|
|
427
|
+
if path is not None:
|
|
428
|
+
env["PATH"] = path
|
|
429
|
+
seen = set()
|
|
430
|
+
for t in tools:
|
|
431
|
+
if not stacks or (t.stack != "any" and t.stack not in stacks):
|
|
432
|
+
continue
|
|
433
|
+
if not t.applies(root):
|
|
434
|
+
report.skipped.append((t.name, t.reason))
|
|
435
|
+
continue
|
|
436
|
+
exe = resolve(t.binary or t.name, root, t.stack, path)
|
|
437
|
+
if exe is None:
|
|
438
|
+
report.skipped.append((t.name, f"not installed; {t.hint}"))
|
|
439
|
+
continue
|
|
440
|
+
found, error = _run_one(t, exe, root, env, timeout)
|
|
441
|
+
report.ran.append(t.name)
|
|
442
|
+
_add_new(report.findings, seen, found)
|
|
443
|
+
if error:
|
|
444
|
+
report.errors.append((t.name, error))
|
|
445
|
+
return report
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def printable(text: str) -> str:
|
|
449
|
+
return "".join(ch if ch.isprintable() else f"\\x{ord(ch):02x}" for ch in text)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def render(report: Report, stacks: set[str], top: int = 10) -> list[str]:
|
|
453
|
+
counts = Counter(f.category for f in report.findings)
|
|
454
|
+
out = [f"analyze: {', '.join(sorted(stacks))}", " ".join(f"{c} {counts.get(c, 0)}" for c in CATEGORIES)]
|
|
455
|
+
for category in CATEGORIES:
|
|
456
|
+
items = sorted((f for f in report.findings if f.category == category), key=lambda f: (f.path, f.line, f.rule))
|
|
457
|
+
if not items:
|
|
458
|
+
continue
|
|
459
|
+
out.append(f"== {category} ({len(items)})")
|
|
460
|
+
for f in items[:top]:
|
|
461
|
+
where = f"{f.path}:{f.line}" if f.line else f.path
|
|
462
|
+
out.append(f" {printable(where)} {printable(f.rule)} {printable(f.message)[:200]}")
|
|
463
|
+
if len(items) > top:
|
|
464
|
+
out.append(f" and {len(items) - top} more")
|
|
465
|
+
out += [f"skipped: {name} ({why})" for name, why in report.skipped]
|
|
466
|
+
out += [f"error: {name}: {printable(why)}" for name, why in report.errors]
|
|
467
|
+
return out
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def utf8_stdout() -> None:
|
|
471
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
472
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def cli(args) -> int:
|
|
476
|
+
utf8_stdout()
|
|
477
|
+
root = Path(args.path).resolve()
|
|
478
|
+
stacks = detect(root)
|
|
479
|
+
if not stacks:
|
|
480
|
+
print(f"code-jig: no .NET, Node or Python project found in {root}", file=sys.stderr)
|
|
481
|
+
return 2
|
|
482
|
+
try:
|
|
483
|
+
keep = changed(root, args.base) if args.base else None
|
|
484
|
+
except (ValueError, OSError) as e:
|
|
485
|
+
print(f"code-jig: {e}", file=sys.stderr)
|
|
486
|
+
return 2
|
|
487
|
+
report = run_tools(TOOLS, root, stacks, timeout=args.timeout)
|
|
488
|
+
if keep is not None:
|
|
489
|
+
report = Report([f for f in report.findings if f.path in keep], report.skipped, report.errors, report.ran)
|
|
490
|
+
if args.json:
|
|
491
|
+
print(json.dumps({"stacks": sorted(stacks), "findings": [asdict(f) for f in report.findings],
|
|
492
|
+
"skipped": report.skipped, "errors": report.errors}, ensure_ascii=False, indent=1))
|
|
493
|
+
else:
|
|
494
|
+
for line in render(report, stacks, args.top):
|
|
495
|
+
print(line)
|
|
496
|
+
if not report.ran:
|
|
497
|
+
print("code-jig: no analyzer ran; install at least one of the skipped tools", file=sys.stderr)
|
|
498
|
+
return 2
|
|
499
|
+
return 1 if report.findings or report.errors else 0
|
codejig/atomicwrite.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
import stat
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_inside(path: str, root: str | os.PathLike[str] | None = None) -> Path | None:
|
|
9
|
+
"""The real path of `path` when it is a regular file, not a symlink, inside `root`.
|
|
10
|
+
|
|
11
|
+
`root` defaults to the working directory; a relative `path` is taken from it.
|
|
12
|
+
Anything else (symlink, directory, device, missing file, a path that escapes
|
|
13
|
+
the root) gives None, so callers never touch files they were not handed.
|
|
14
|
+
"""
|
|
15
|
+
base = Path(os.path.realpath(root if root is not None else os.getcwd()))
|
|
16
|
+
p = Path(path)
|
|
17
|
+
if not p.is_absolute():
|
|
18
|
+
p = base / p
|
|
19
|
+
try:
|
|
20
|
+
st = os.lstat(p)
|
|
21
|
+
except OSError:
|
|
22
|
+
return None
|
|
23
|
+
if not stat.S_ISREG(st.st_mode):
|
|
24
|
+
return None
|
|
25
|
+
real = Path(os.path.realpath(p))
|
|
26
|
+
return real if real.is_relative_to(base) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def atomic_write(target: Path, data: bytes, mode: int | None = None) -> None:
|
|
30
|
+
"""Replace `target` with `data` without a window where it is partial or wider-permissioned.
|
|
31
|
+
|
|
32
|
+
The temp file gets an unpredictable name (mkstemp, O_EXCL) in the target's own
|
|
33
|
+
directory, so os.replace stays on one filesystem and nobody can pre-plant it.
|
|
34
|
+
It keeps the target's permission bits, or `mode` for a new file.
|
|
35
|
+
"""
|
|
36
|
+
if mode is None:
|
|
37
|
+
mode = stat.S_IMODE(os.stat(target).st_mode) if target.exists() else 0o600
|
|
38
|
+
fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
|
|
39
|
+
try:
|
|
40
|
+
with os.fdopen(fd, "wb") as f:
|
|
41
|
+
f.write(data)
|
|
42
|
+
f.flush()
|
|
43
|
+
os.fsync(f.fileno())
|
|
44
|
+
os.chmod(tmp, mode)
|
|
45
|
+
os.replace(tmp, target)
|
|
46
|
+
except BaseException:
|
|
47
|
+
with contextlib.suppress(OSError):
|
|
48
|
+
os.unlink(tmp)
|
|
49
|
+
raise
|