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.
Files changed (52) hide show
  1. code_jig-0.12.0.dist-info/METADATA +154 -0
  2. code_jig-0.12.0.dist-info/RECORD +52 -0
  3. code_jig-0.12.0.dist-info/WHEEL +4 -0
  4. code_jig-0.12.0.dist-info/entry_points.txt +2 -0
  5. code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
  6. codejig/__init__.py +1 -0
  7. codejig/__main__.py +106 -0
  8. codejig/_core/VERSION +2 -0
  9. codejig/_core/bench/requirements.toml +4 -0
  10. codejig/_core/core/AGENTS.core.md +9 -0
  11. codejig/_core/core/agents/explorer.toml +24 -0
  12. codejig/_core/core/agents/judge.toml +19 -0
  13. codejig/_core/core/agents/reviewer.toml +10 -0
  14. codejig/_core/core/tiers.toml +4 -0
  15. codejig/_core/skills/bug-hunt/SKILL.md +131 -0
  16. codejig/_core/skills/code-analysis/SKILL.md +72 -0
  17. codejig/_core/skills/codegen/SKILL.md +46 -0
  18. codejig/_core/skills/cross-review/SKILL.md +32 -0
  19. codejig/_core/skills/grilling/SKILL.md +60 -0
  20. codejig/_core/skills/hld-trace/SKILL.md +69 -0
  21. codejig/_core/skills/improve/SKILL.md +38 -0
  22. codejig/_core/skills/redacao/SKILL.md +114 -0
  23. codejig/_core/skills/redacao/checar.py +75 -0
  24. codejig/_core/skills/redacao/reference.md +118 -0
  25. codejig/_core/skills/regression-tests/SKILL.md +53 -0
  26. codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
  27. codejig/_core/skills/skill-validate/SKILL.md +58 -0
  28. codejig/_core/skills/web-consensus/SKILL.md +55 -0
  29. codejig/analyze.py +499 -0
  30. codejig/atomicwrite.py +49 -0
  31. codejig/collect.py +311 -0
  32. codejig/doctor.py +370 -0
  33. codejig/evidence.py +418 -0
  34. codejig/feedback.py +165 -0
  35. codejig/gitgate.py +278 -0
  36. codejig/hooks.py +64 -0
  37. codejig/humanize.py +206 -0
  38. codejig/install.py +381 -0
  39. codejig/managed.py +64 -0
  40. codejig/migrate.py +515 -0
  41. codejig/overlay.py +43 -0
  42. codejig/paths.py +40 -0
  43. codejig/probe.py +136 -0
  44. codejig/review.py +73 -0
  45. codejig/scrub.py +255 -0
  46. codejig/skillcheck.py +400 -0
  47. codejig/skilllint.py +99 -0
  48. codejig/sources.py +248 -0
  49. codejig/tiers.py +63 -0
  50. codejig/tokens.py +118 -0
  51. codejig/trace.py +137 -0
  52. 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