@sentry/warden 0.35.1 → 0.36.0

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 (112) hide show
  1. package/dist/cli/args.d.ts +12 -0
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +14 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/commands/init.js +2 -2
  6. package/dist/cli/commands/init.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +16 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.d.ts +4 -2
  11. package/dist/cli/main.d.ts.map +1 -1
  12. package/dist/cli/main.js +34 -13
  13. package/dist/cli/main.js.map +1 -1
  14. package/dist/cli/output/jsonl-schema-gen.d.ts.map +1 -1
  15. package/dist/cli/output/jsonl-schema-gen.js +3 -1
  16. package/dist/cli/output/jsonl-schema-gen.js.map +1 -1
  17. package/dist/cli/output/jsonl.d.ts +54 -0
  18. package/dist/cli/output/jsonl.d.ts.map +1 -1
  19. package/dist/cli/output/jsonl.js +6 -1
  20. package/dist/cli/output/jsonl.js.map +1 -1
  21. package/dist/cli/output/tasks.d.ts.map +1 -1
  22. package/dist/cli/output/tasks.js +7 -0
  23. package/dist/cli/output/tasks.js.map +1 -1
  24. package/dist/config/loader.d.ts +2 -2
  25. package/dist/config/loader.d.ts.map +1 -1
  26. package/dist/config/loader.js +3 -3
  27. package/dist/config/loader.js.map +1 -1
  28. package/dist/config/schema.d.ts +5 -5
  29. package/dist/config/schema.d.ts.map +1 -1
  30. package/dist/config/schema.js +3 -3
  31. package/dist/config/schema.js.map +1 -1
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/sdk/analyze.d.ts.map +1 -1
  37. package/dist/sdk/analyze.js +139 -10
  38. package/dist/sdk/analyze.js.map +1 -1
  39. package/dist/sdk/auth.d.ts +5 -4
  40. package/dist/sdk/auth.d.ts.map +1 -1
  41. package/dist/sdk/auth.js +9 -7
  42. package/dist/sdk/auth.js.map +1 -1
  43. package/dist/sdk/circuit-breaker.js +3 -3
  44. package/dist/sdk/circuit-breaker.js.map +1 -1
  45. package/dist/sdk/errors.d.ts +8 -0
  46. package/dist/sdk/errors.d.ts.map +1 -1
  47. package/dist/sdk/errors.js +44 -1
  48. package/dist/sdk/errors.js.map +1 -1
  49. package/dist/sdk/haiku.d.ts.map +1 -1
  50. package/dist/sdk/haiku.js +21 -12
  51. package/dist/sdk/haiku.js.map +1 -1
  52. package/dist/sdk/otel.d.ts +15 -0
  53. package/dist/sdk/otel.d.ts.map +1 -1
  54. package/dist/sdk/otel.js +50 -6
  55. package/dist/sdk/otel.js.map +1 -1
  56. package/dist/sdk/post-process.d.ts +2 -2
  57. package/dist/sdk/post-process.d.ts.map +1 -1
  58. package/dist/sdk/post-process.js +1 -1
  59. package/dist/sdk/post-process.js.map +1 -1
  60. package/dist/sdk/runtimes/claude.d.ts.map +1 -1
  61. package/dist/sdk/runtimes/claude.js +45 -35
  62. package/dist/sdk/runtimes/claude.js.map +1 -1
  63. package/dist/sdk/runtimes/pi.d.ts.map +1 -1
  64. package/dist/sdk/runtimes/pi.js +90 -6
  65. package/dist/sdk/runtimes/pi.js.map +1 -1
  66. package/dist/sdk/runtimes/types.d.ts +2 -2
  67. package/dist/sdk/runtimes/types.d.ts.map +1 -1
  68. package/dist/sdk/types.d.ts +12 -4
  69. package/dist/sdk/types.d.ts.map +1 -1
  70. package/dist/sdk/verify.d.ts +2 -2
  71. package/dist/sdk/verify.d.ts.map +1 -1
  72. package/dist/sdk/verify.js +1 -1
  73. package/dist/sdk/verify.js.map +1 -1
  74. package/dist/sentry-trace.d.ts +29 -0
  75. package/dist/sentry-trace.d.ts.map +1 -0
  76. package/dist/sentry-trace.js +154 -0
  77. package/dist/sentry-trace.js.map +1 -0
  78. package/dist/sentry.d.ts.map +1 -1
  79. package/dist/sentry.js +56 -0
  80. package/dist/sentry.js.map +1 -1
  81. package/dist/types/index.d.ts +72 -0
  82. package/dist/types/index.d.ts.map +1 -1
  83. package/dist/types/index.js +37 -0
  84. package/dist/types/index.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skills/warden/SKILL.md +0 -76
  87. package/skills/warden/SOURCES.md +0 -39
  88. package/skills/warden/SPEC.md +0 -112
  89. package/skills/warden/references/cli-reference.md +0 -149
  90. package/skills/warden/references/config-schema.md +0 -141
  91. package/skills/warden/references/configuration.md +0 -143
  92. package/skills/warden/references/creating-skills.md +0 -87
  93. package/skills/warden-sweep/SKILL.md +0 -78
  94. package/skills/warden-sweep/SOURCES.md +0 -43
  95. package/skills/warden-sweep/SPEC.md +0 -126
  96. package/skills/warden-sweep/references/issue-phase.md +0 -24
  97. package/skills/warden-sweep/references/organize-phase.md +0 -31
  98. package/skills/warden-sweep/references/patch-phase.md +0 -142
  99. package/skills/warden-sweep/references/patch-prompt.md +0 -72
  100. package/skills/warden-sweep/references/resume-and-artifacts.md +0 -51
  101. package/skills/warden-sweep/references/scan-phase.md +0 -46
  102. package/skills/warden-sweep/references/script-interfaces.md +0 -127
  103. package/skills/warden-sweep/references/verify-phase.md +0 -69
  104. package/skills/warden-sweep/references/verify-prompt.md +0 -25
  105. package/skills/warden-sweep/scripts/_utils.py +0 -99
  106. package/skills/warden-sweep/scripts/create_issue.py +0 -189
  107. package/skills/warden-sweep/scripts/extract_findings.py +0 -219
  108. package/skills/warden-sweep/scripts/find_reviewers.py +0 -114
  109. package/skills/warden-sweep/scripts/generate_report.py +0 -266
  110. package/skills/warden-sweep/scripts/index_prs.py +0 -187
  111. package/skills/warden-sweep/scripts/organize.py +0 -422
  112. package/skills/warden-sweep/scripts/scan.py +0 -584
@@ -1,584 +0,0 @@
1
- #!/usr/bin/env python3
2
- # /// script
3
- # requires-python = ">=3.9"
4
- # dependencies = ["tomli; python_version < '3.11'"]
5
- # ///
6
- """
7
- Warden Sweep: Scan phase.
8
-
9
- Replaces Phase 0 (setup) and Phase 1 (scan) with a single script.
10
- Generates a run ID, creates the sweep directory, checks dependencies,
11
- creates the warden label, enumerates files, runs warden on each file,
12
- writes scan-index.jsonl, and calls extract_findings.py.
13
-
14
- Usage:
15
- uv run scan.py [file ...]
16
- uv run scan.py --sweep-dir .warden/sweeps/abc123
17
- uv run scan.py src/foo.ts src/bar.ts
18
-
19
- Stdout: JSON summary (for LLM consumption)
20
- Stderr: Progress lines as files complete
21
- Exit codes: 0 = success, 1 = fatal, 2 = partial (some files errored)
22
- """
23
- from __future__ import annotations
24
-
25
- import argparse
26
- import hashlib
27
- import json
28
- import os
29
- import secrets
30
- import subprocess
31
- import sys
32
- import threading
33
- from concurrent.futures import ThreadPoolExecutor, as_completed
34
- from datetime import datetime, timezone
35
- from pathlib import Path
36
- from typing import Any
37
-
38
- try:
39
- import tomllib
40
- except ModuleNotFoundError:
41
- import tomli as tomllib # type: ignore[no-redefine]
42
-
43
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
- from _utils import ensure_github_label, run_cmd # noqa: E402
45
-
46
-
47
- SUPPORTED_EXTENSIONS = {
48
- ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java",
49
- ".rb", ".php", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift",
50
- ".kt", ".scala", ".sh", ".bash", ".zsh",
51
- }
52
-
53
-
54
- def generate_run_id() -> str:
55
- """Generate a short random run ID."""
56
- return secrets.token_hex(4)
57
-
58
-
59
- def check_dependencies() -> list[str]:
60
- """Check that required commands are available. Return list of missing."""
61
- import shutil
62
- return [cmd for cmd in ["warden", "gh", "git"] if shutil.which(cmd) is None]
63
-
64
-
65
- def create_sweep_dir(sweep_dir: str) -> None:
66
- """Create the sweep directory structure."""
67
- for subdir in [
68
- "findings",
69
- "security",
70
- "data/verify",
71
- "data/logs",
72
- "data/pr-diffs",
73
- ]:
74
- os.makedirs(os.path.join(sweep_dir, subdir), exist_ok=True)
75
-
76
-
77
- def write_manifest(sweep_dir: str, run_id: str) -> None:
78
- """Write the initial manifest.json."""
79
- repo = "unknown"
80
- try:
81
- result = run_cmd(["git", "remote", "get-url", "origin"])
82
- if result.returncode == 0 and result.stdout.strip():
83
- repo = result.stdout.strip()
84
- else:
85
- repo = os.path.basename(os.getcwd())
86
- except Exception:
87
- repo = os.path.basename(os.getcwd())
88
-
89
- manifest = {
90
- "runId": run_id,
91
- "startedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
92
- "repo": repo,
93
- "phases": {
94
- "scan": "pending",
95
- "verify": "pending",
96
- "issue": "pending",
97
- "patch": "pending",
98
- "organize": "pending",
99
- },
100
- }
101
-
102
- manifest_path = os.path.join(sweep_dir, "data", "manifest.json")
103
- with open(manifest_path, "w") as f:
104
- json.dump(manifest, f, indent=2)
105
- f.write("\n")
106
-
107
-
108
- def load_ignore_paths() -> list[str]:
109
- """Load ignorePaths from warden.toml defaults if present."""
110
- toml_path = "warden.toml"
111
- if not os.path.exists(toml_path):
112
- return []
113
- try:
114
- with open(toml_path, "rb") as f:
115
- config = tomllib.load(f)
116
- paths = config.get("defaults", {}).get("ignorePaths", [])
117
- return paths if isinstance(paths, list) else []
118
- except Exception:
119
- return []
120
-
121
-
122
- def should_ignore(path: str, ignore_patterns: list[str]) -> bool:
123
- """Check if a path matches any ignore pattern (simple glob matching)."""
124
- if not ignore_patterns:
125
- return False
126
-
127
- from fnmatch import fnmatch
128
-
129
- for pattern in ignore_patterns:
130
- if fnmatch(path, pattern):
131
- return True
132
- # Handle ** patterns
133
- if "**" in pattern:
134
- # Convert ** glob to work with fnmatch
135
- simple = pattern.replace("**/", "*/")
136
- if fnmatch(path, simple):
137
- return True
138
- # Also try zero-directory match (** matches zero directories)
139
- collapsed = pattern.replace("**/", "")
140
- if fnmatch(path, collapsed):
141
- return True
142
- # Also try matching any subdirectory
143
- parts = path.split("/")
144
- glob_parts = pattern.split("/")
145
- if glob_parts[0] == "**":
146
- # Match from any point
147
- rest = "/".join(glob_parts[1:])
148
- for i in range(len(parts)):
149
- if fnmatch("/".join(parts[i:]), rest):
150
- return True
151
- elif glob_parts[-1].startswith("*"):
152
- # e.g., dist/** matches dist/anything, src/**/*.py matches src/x/y.py
153
- prefix = pattern.split("**")[0].rstrip("/")
154
- if path.startswith(prefix + "/") or path == prefix:
155
- suffix = pattern.split("**")[-1]
156
- if not suffix or suffix == "/":
157
- # Pure prefix pattern like dist/** - any subpath matches
158
- return True
159
- # Has suffix like **/*.py - check with fnmatch on the remaining path
160
- remaining = path[len(prefix) :].lstrip("/")
161
- suffix_pattern = suffix.lstrip("/")
162
- if fnmatch(remaining, suffix_pattern) or fnmatch(
163
- remaining.split("/")[-1], suffix_pattern
164
- ):
165
- return True
166
- return False
167
-
168
-
169
- def enumerate_files(
170
- specific_files: list[str] | None, ignore_patterns: list[str]
171
- ) -> list[str]:
172
- """Enumerate files to scan using git ls-files, filtered by extension."""
173
- if specific_files:
174
- return [f for f in specific_files if not should_ignore(f, ignore_patterns)]
175
-
176
- result = run_cmd(["git", "ls-files"])
177
- if result.returncode != 0:
178
- print(f"git ls-files failed: {result.stderr}", file=sys.stderr)
179
- return []
180
-
181
- files = []
182
- for line in result.stdout.splitlines():
183
- path = line.strip()
184
- if not path:
185
- continue
186
-
187
- # Filter by extension
188
- ext = os.path.splitext(path)[1].lower()
189
- if ext not in SUPPORTED_EXTENSIONS:
190
- continue
191
-
192
- # Filter by ignore patterns
193
- if should_ignore(path, ignore_patterns):
194
- continue
195
-
196
- files.append(path)
197
-
198
- return files
199
-
200
-
201
- def load_completed_files(sweep_dir: str) -> set[str]:
202
- """Load already-completed files from scan-index.jsonl for incrementality."""
203
- index_path = os.path.join(sweep_dir, "data", "scan-index.jsonl")
204
- completed: set[str] = set()
205
- if not os.path.exists(index_path):
206
- return completed
207
-
208
- with open(index_path) as f:
209
- for line in f:
210
- line = line.strip()
211
- if not line:
212
- continue
213
- try:
214
- entry = json.loads(line)
215
- if entry.get("status") == "complete":
216
- completed.add(entry.get("file", ""))
217
- except json.JSONDecodeError:
218
- continue
219
- return completed
220
-
221
-
222
- def log_path_for_file(sweep_dir: str, file_path: str) -> str:
223
- """Generate a deterministic log path for a file."""
224
- digest = hashlib.sha256(file_path.encode()).hexdigest()[:16]
225
- return os.path.join(sweep_dir, "data", "logs", f"{digest}.jsonl")
226
-
227
-
228
- def scan_file(
229
- file_path: str, log_file: str, timeout: int = 600, skill: str | None = None
230
- ) -> dict[str, Any]:
231
- """Run warden on a single file. Returns scan-index entry."""
232
- try:
233
- cmd = [
234
- "warden", file_path,
235
- "--json", "--log",
236
- "--min-confidence", "off",
237
- "--fail-on", "off",
238
- "--quiet",
239
- "--output", log_file,
240
- ]
241
- if skill:
242
- cmd.extend(["--skill", skill])
243
- result = subprocess.run(
244
- cmd,
245
- capture_output=True,
246
- text=True,
247
- timeout=timeout,
248
- )
249
-
250
- # Check for warden failure
251
- if result.returncode != 0:
252
- error_msg = result.stderr.strip() if result.stderr else "non-zero exit"
253
- return {
254
- "file": file_path,
255
- "status": "error",
256
- "error": f"warden failed: {error_msg}",
257
- "exitCode": result.returncode,
258
- }
259
-
260
- # Check that log file was created
261
- if not os.path.exists(log_file):
262
- return {
263
- "file": file_path,
264
- "status": "error",
265
- "error": "log file not created",
266
- "exitCode": result.returncode,
267
- }
268
-
269
- # Count findings from the log file
270
- finding_count = 0
271
- skills: set[str] = set()
272
- with open(log_file) as f:
273
- for line in f:
274
- line = line.strip()
275
- if not line:
276
- continue
277
- try:
278
- record = json.loads(line)
279
- if record.get("type") == "summary":
280
- continue
281
- record_skill = record.get("skill", "")
282
- if record_skill:
283
- skills.add(record_skill)
284
- findings = record.get("findings", [])
285
- finding_count += len(findings)
286
- except json.JSONDecodeError:
287
- continue
288
-
289
- return {
290
- "file": file_path,
291
- "logPath": log_file,
292
- "skills": sorted(skills),
293
- "findingCount": finding_count,
294
- "status": "complete",
295
- "exitCode": result.returncode,
296
- }
297
-
298
- except subprocess.TimeoutExpired:
299
- return {
300
- "file": file_path,
301
- "status": "error",
302
- "error": "timeout",
303
- "exitCode": -1,
304
- }
305
- except FileNotFoundError:
306
- return {
307
- "file": file_path,
308
- "status": "error",
309
- "error": "warden not found",
310
- "exitCode": -1,
311
- }
312
- except Exception as e:
313
- return {
314
- "file": file_path,
315
- "status": "error",
316
- "error": str(e),
317
- "exitCode": -1,
318
- }
319
-
320
-
321
- def run_extract_findings(sweep_dir: str, script_dir: str) -> None:
322
- """Run extract_findings.py as a subprocess."""
323
- extract_script = os.path.join(script_dir, "extract_findings.py")
324
- logs_dir = os.path.join(sweep_dir, "data", "logs")
325
- scan_index = os.path.join(sweep_dir, "data", "scan-index.jsonl")
326
- output = os.path.join(sweep_dir, "data", "all-findings.jsonl")
327
-
328
- try:
329
- result = subprocess.run(
330
- [
331
- sys.executable, extract_script,
332
- logs_dir,
333
- "--scan-index", scan_index,
334
- "-o", output,
335
- ],
336
- capture_output=True,
337
- text=True,
338
- timeout=120,
339
- )
340
- if result.returncode != 0:
341
- print(
342
- f"Warning: extract_findings.py failed: {result.stderr}",
343
- file=sys.stderr,
344
- )
345
- except Exception as e:
346
- print(f"Warning: extract_findings.py failed: {e}", file=sys.stderr)
347
-
348
-
349
- def load_findings_compact(sweep_dir: str) -> tuple[list[dict[str, Any]], dict[str, int]]:
350
- """Load findings from all-findings.jsonl and return compact list + severity counts."""
351
- findings_path = os.path.join(sweep_dir, "data", "all-findings.jsonl")
352
- findings: list[dict[str, Any]] = []
353
- by_severity: dict[str, int] = {}
354
-
355
- if not os.path.exists(findings_path):
356
- return findings, by_severity
357
-
358
- with open(findings_path) as f:
359
- for line in f:
360
- line = line.strip()
361
- if not line:
362
- continue
363
- try:
364
- record = json.loads(line)
365
- severity = record.get("severity", "info")
366
- by_severity[severity] = by_severity.get(severity, 0) + 1
367
-
368
- location = record.get("location", {})
369
- findings.append({
370
- "findingId": record.get("findingId", ""),
371
- "title": record.get("title", ""),
372
- "file": record.get("file", ""),
373
- "startLine": location.get("startLine"),
374
- "severity": severity,
375
- "confidence": record.get("confidence"),
376
- "skill": record.get("skill", ""),
377
- })
378
- except json.JSONDecodeError:
379
- continue
380
-
381
- return findings, by_severity
382
-
383
-
384
- def update_manifest_phase(sweep_dir: str, phase: str, status: str) -> None:
385
- """Update a phase status in manifest.json."""
386
- manifest_path = os.path.join(sweep_dir, "data", "manifest.json")
387
- if not os.path.exists(manifest_path):
388
- return
389
-
390
- with open(manifest_path) as f:
391
- manifest = json.load(f)
392
-
393
- manifest.setdefault("phases", {})[phase] = status
394
-
395
- with open(manifest_path, "w") as f:
396
- json.dump(manifest, f, indent=2)
397
- f.write("\n")
398
-
399
-
400
- def main() -> None:
401
- parser = argparse.ArgumentParser(
402
- description="Warden Sweep: Scan phase (setup + scan)"
403
- )
404
- parser.add_argument(
405
- "files",
406
- nargs="*",
407
- help="Specific files to scan (default: all tracked files)",
408
- )
409
- parser.add_argument(
410
- "--sweep-dir",
411
- help="Resume into an existing sweep directory",
412
- )
413
- parser.add_argument(
414
- "--skill",
415
- help="Run only this skill (passed through to warden --skill)",
416
- )
417
- args = parser.parse_args()
418
-
419
- # Check dependencies
420
- missing = check_dependencies()
421
- if missing:
422
- print(
423
- json.dumps({"error": f"Missing dependencies: {', '.join(missing)}"}),
424
- file=sys.stdout,
425
- )
426
- sys.exit(1)
427
-
428
- # Determine sweep dir and run ID
429
- if args.sweep_dir:
430
- sweep_dir = args.sweep_dir
431
- # Extract run ID from path (normalize to handle trailing slashes)
432
- run_id = os.path.basename(os.path.normpath(sweep_dir))
433
- else:
434
- run_id = generate_run_id()
435
- sweep_dir = os.path.join(".warden", "sweeps", run_id)
436
-
437
- # Setup
438
- create_sweep_dir(sweep_dir)
439
-
440
- # Only write manifest if it doesn't exist (for resume support)
441
- manifest_path = os.path.join(sweep_dir, "data", "manifest.json")
442
- if not os.path.exists(manifest_path):
443
- write_manifest(sweep_dir, run_id)
444
-
445
- ensure_github_label("warden", "5319E7", "Automated fix from Warden Sweep")
446
-
447
- # Enumerate files
448
- ignore_patterns = load_ignore_paths()
449
- specific_files = args.files if args.files else None
450
- files = enumerate_files(specific_files, ignore_patterns)
451
-
452
- if not files:
453
- print(
454
- json.dumps({
455
- "error": "No files to scan",
456
- "runId": run_id,
457
- "sweepDir": sweep_dir,
458
- }),
459
- file=sys.stdout,
460
- )
461
- sys.exit(1)
462
-
463
- # Load completed files for incrementality
464
- completed = load_completed_files(sweep_dir)
465
- remaining = [f for f in files if f not in completed]
466
-
467
- total = len(files)
468
- already_done = len(completed & set(files))
469
- scan_index_path = os.path.join(sweep_dir, "data", "scan-index.jsonl")
470
-
471
- if already_done > 0:
472
- print(
473
- f"Resuming: {already_done}/{total} files already scanned",
474
- file=sys.stderr,
475
- )
476
-
477
- # Scan remaining files concurrently
478
- scanned = already_done
479
- index_lock = threading.Lock()
480
-
481
- def _scan_and_record(file_path: str) -> dict[str, Any]:
482
- log_file = log_path_for_file(sweep_dir, file_path)
483
- entry = scan_file(file_path, log_file, skill=args.skill)
484
-
485
- with index_lock:
486
- with open(scan_index_path, "a") as f:
487
- f.write(json.dumps(entry) + "\n")
488
-
489
- return entry
490
-
491
- with ThreadPoolExecutor(max_workers=4) as pool:
492
- futures = {
493
- pool.submit(_scan_and_record, fp): fp for fp in remaining
494
- }
495
- for future in as_completed(futures):
496
- entry = future.result()
497
- scanned += 1
498
- file_path = entry.get("file", futures[future])
499
- if entry["status"] == "error":
500
- label = "TIMEOUT" if entry.get("error") == "timeout" else "ERROR"
501
- print(
502
- f"[{scanned}/{total}] {file_path} ({label}: {entry.get('error', 'unknown')})",
503
- file=sys.stderr,
504
- )
505
- else:
506
- count = entry.get("findingCount", 0)
507
- suffix = f"({count} finding{'s' if count != 1 else ''})" if count > 0 else ""
508
- print(
509
- f"[{scanned}/{total}] {file_path} {suffix}".rstrip(),
510
- file=sys.stderr,
511
- )
512
-
513
- # Extract findings
514
- script_dir = os.path.dirname(os.path.abspath(__file__))
515
- run_extract_findings(sweep_dir, script_dir)
516
-
517
- # Load findings for output
518
- findings, by_severity = load_findings_compact(sweep_dir)
519
-
520
- # Collect errors for output, deduplicating by file (last entry wins)
521
- # so that resumed scans don't include stale errors for files that later succeeded.
522
- # Scope to current file list so counts stay consistent with `scanned`.
523
- files_set = set(files)
524
- timeouts: list[dict[str, Any]] = []
525
- errors: list[dict[str, Any]] = []
526
- if os.path.exists(scan_index_path):
527
- last_status: dict[str, dict[str, Any]] = {}
528
- with open(scan_index_path) as f:
529
- for line in f:
530
- line = line.strip()
531
- if not line:
532
- continue
533
- try:
534
- entry = json.loads(line)
535
- file_path_key = entry.get("file", "")
536
- if file_path_key in files_set:
537
- last_status[file_path_key] = entry
538
- except json.JSONDecodeError:
539
- continue
540
- for entry in last_status.values():
541
- if entry.get("status") == "error":
542
- item = {
543
- "file": entry.get("file", ""),
544
- "error": entry.get("error", "unknown"),
545
- "exitCode": entry.get("exitCode", -1),
546
- }
547
- if entry.get("error") == "timeout":
548
- timeouts.append(item)
549
- else:
550
- errors.append(item)
551
-
552
- total_failed = len(timeouts) + len(errors)
553
-
554
- # Output JSON summary
555
- output = {
556
- "runId": run_id,
557
- "sweepDir": sweep_dir,
558
- "filesScanned": scanned - total_failed,
559
- "filesTimedOut": len(timeouts),
560
- "filesErrored": len(errors),
561
- "totalFindings": len(findings),
562
- "bySeverity": by_severity,
563
- "findingsPath": os.path.join(sweep_dir, "data", "all-findings.jsonl"),
564
- "findings": findings,
565
- "timeouts": timeouts,
566
- "errors": errors,
567
- }
568
-
569
- print(json.dumps(output, indent=2))
570
-
571
- # Fatal only if every file across all runs errored (no successful scans at all)
572
- successful = scanned - total_failed
573
- if successful == 0 and scanned > 0:
574
- update_manifest_phase(sweep_dir, "scan", "error")
575
- sys.exit(1)
576
-
577
- update_manifest_phase(sweep_dir, "scan", "complete")
578
-
579
- if total_failed > 0:
580
- sys.exit(2)
581
-
582
-
583
- if __name__ == "__main__":
584
- main()