@sentry/warden 0.13.0 → 0.14.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 (92) hide show
  1. package/agents.lock +7 -0
  2. package/dist/cli/args.d.ts +14 -12
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +44 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/commands/init.d.ts +0 -3
  7. package/dist/cli/commands/init.d.ts.map +1 -1
  8. package/dist/cli/commands/init.js +206 -19
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/logs.d.ts +19 -0
  11. package/dist/cli/commands/logs.d.ts.map +1 -0
  12. package/dist/cli/commands/logs.js +419 -0
  13. package/dist/cli/commands/logs.js.map +1 -0
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/main.js +54 -21
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/output/formatters.d.ts +2 -1
  18. package/dist/cli/output/formatters.d.ts.map +1 -1
  19. package/dist/cli/output/formatters.js +22 -19
  20. package/dist/cli/output/formatters.js.map +1 -1
  21. package/dist/cli/output/index.d.ts +1 -1
  22. package/dist/cli/output/index.d.ts.map +1 -1
  23. package/dist/cli/output/index.js +1 -1
  24. package/dist/cli/output/index.js.map +1 -1
  25. package/dist/cli/output/ink-runner.js +1 -1
  26. package/dist/cli/output/ink-runner.js.map +1 -1
  27. package/dist/cli/output/jsonl.d.ts +49 -13
  28. package/dist/cli/output/jsonl.d.ts.map +1 -1
  29. package/dist/cli/output/jsonl.js +137 -4
  30. package/dist/cli/output/jsonl.js.map +1 -1
  31. package/dist/cli/output/tasks.d.ts.map +1 -1
  32. package/dist/cli/output/tasks.js +1 -22
  33. package/dist/cli/output/tasks.js.map +1 -1
  34. package/dist/cli/terminal.d.ts.map +1 -1
  35. package/dist/cli/terminal.js +0 -2
  36. package/dist/cli/terminal.js.map +1 -1
  37. package/dist/config/schema.d.ts +49 -98
  38. package/dist/config/schema.d.ts.map +1 -1
  39. package/dist/config/schema.js +0 -12
  40. package/dist/config/schema.js.map +1 -1
  41. package/dist/evals/runner.d.ts.map +1 -1
  42. package/dist/evals/runner.js +0 -1
  43. package/dist/evals/runner.js.map +1 -1
  44. package/dist/evals/types.d.ts +9 -15
  45. package/dist/evals/types.d.ts.map +1 -1
  46. package/dist/output/github-checks.d.ts +1 -1
  47. package/dist/output/github-checks.d.ts.map +1 -1
  48. package/dist/output/github-checks.js +2 -6
  49. package/dist/output/github-checks.js.map +1 -1
  50. package/dist/output/issue-renderer.js +1 -1
  51. package/dist/output/issue-renderer.js.map +1 -1
  52. package/dist/sdk/analyze.d.ts.map +1 -1
  53. package/dist/sdk/analyze.js +13 -26
  54. package/dist/sdk/analyze.js.map +1 -1
  55. package/dist/sdk/auth.d.ts +16 -0
  56. package/dist/sdk/auth.d.ts.map +1 -0
  57. package/dist/sdk/auth.js +37 -0
  58. package/dist/sdk/auth.js.map +1 -0
  59. package/dist/sdk/errors.d.ts +5 -0
  60. package/dist/sdk/errors.d.ts.map +1 -1
  61. package/dist/sdk/errors.js +20 -0
  62. package/dist/sdk/errors.js.map +1 -1
  63. package/dist/sdk/prompt.js +1 -1
  64. package/dist/sdk/runner.d.ts +2 -1
  65. package/dist/sdk/runner.d.ts.map +1 -1
  66. package/dist/sdk/runner.js +3 -1
  67. package/dist/sdk/runner.js.map +1 -1
  68. package/dist/sdk/types.d.ts +0 -3
  69. package/dist/sdk/types.d.ts.map +1 -1
  70. package/dist/sdk/types.js.map +1 -1
  71. package/dist/types/index.d.ts +23 -24
  72. package/dist/types/index.d.ts.map +1 -1
  73. package/dist/types/index.js +19 -7
  74. package/dist/types/index.js.map +1 -1
  75. package/package.json +1 -1
  76. package/skills/warden/SKILL.md +76 -0
  77. package/skills/warden/references/cli-reference.md +142 -0
  78. package/skills/warden/references/config-schema.md +111 -0
  79. package/skills/warden/references/configuration.md +110 -0
  80. package/skills/warden/references/creating-skills.md +84 -0
  81. package/skills/warden-sweep/SKILL.md +407 -0
  82. package/skills/warden-sweep/scripts/_utils.py +37 -0
  83. package/skills/warden-sweep/scripts/extract_findings.py +219 -0
  84. package/skills/warden-sweep/scripts/find_reviewers.py +115 -0
  85. package/skills/warden-sweep/scripts/generate_report.py +271 -0
  86. package/skills/warden-sweep/scripts/index_prs.py +187 -0
  87. package/skills/warden-sweep/scripts/organize.py +315 -0
  88. package/skills/warden-sweep/scripts/scan.py +632 -0
  89. package/dist/sdk/session.d.ts +0 -43
  90. package/dist/sdk/session.d.ts.map +0 -1
  91. package/dist/sdk/session.js +0 -105
  92. package/dist/sdk/session.js.map +0 -1
@@ -0,0 +1,407 @@
1
+ ---
2
+ name: warden-sweep
3
+ description: Full-repository code sweep. Scans every file with warden, verifies findings via deep tracing, creates draft PRs for validated issues. Use when asked to "sweep the repo", "scan everything", "find all bugs", "full codebase review", "batch code analysis", or run warden across the entire repository.
4
+ ---
5
+
6
+ # Warden Sweep
7
+
8
+ Full-repository code sweep: scan every file, verify findings with deep tracing, create draft PRs for validated issues.
9
+
10
+ **Requires**: `warden`, `gh`, `git`, `jq`, `uv`
11
+
12
+ **Important**: Run all scripts from the repository root using `${CLAUDE_SKILL_ROOT}`. Output goes to `.warden/sweeps/<run-id>/`.
13
+
14
+ ## Bundled Scripts
15
+
16
+ ### `scripts/scan.py`
17
+
18
+ Runs setup and scan in one call: generates run ID, creates sweep dir, checks deps, creates `warden` label, enumerates files, runs warden per file, extracts findings.
19
+
20
+ ```bash
21
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/scan.py [file ...]
22
+ --sweep-dir DIR # Resume into existing sweep dir
23
+ ```
24
+
25
+ ### `scripts/index_prs.py`
26
+
27
+ Fetches open warden-labeled PRs, builds file-to-PR dedup index, caches diffs for overlapping PRs.
28
+
29
+ ```bash
30
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/index_prs.py <sweep-dir>
31
+ ```
32
+
33
+ ### `scripts/organize.py`
34
+
35
+ Tags security findings, labels security PRs, updates finding reports with PR links, generates summary report, finalizes manifest.
36
+
37
+ ```bash
38
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/organize.py <sweep-dir>
39
+ ```
40
+
41
+ ### `scripts/extract_findings.py`
42
+
43
+ Parses warden JSONL log files and extracts normalized findings. Called automatically by `scan.py`.
44
+
45
+ ```bash
46
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/extract_findings.py <log-path-or-directory> -o <output.jsonl>
47
+ ```
48
+
49
+ ### `scripts/generate_report.py`
50
+
51
+ Builds `summary.md` and `report.json` from sweep data. Called automatically by `organize.py`.
52
+
53
+ ```bash
54
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/generate_report.py <sweep-dir>
55
+ ```
56
+
57
+ ### `scripts/find_reviewers.py`
58
+
59
+ Finds top 2 git contributors for a file (last 12 months).
60
+
61
+ ```bash
62
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/find_reviewers.py <file-path>
63
+ ```
64
+
65
+ Returns JSON: `{"reviewers": ["user1", "user2"]}`
66
+
67
+ ---
68
+
69
+ ## Phase 1: Scan
70
+
71
+ **Run** (1 tool call):
72
+
73
+ ```bash
74
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/scan.py
75
+ ```
76
+
77
+ To resume a partial scan:
78
+
79
+ ```bash
80
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/scan.py --sweep-dir .warden/sweeps/<run-id>
81
+ ```
82
+
83
+ Parse the JSON stdout. Save `runId` and `sweepDir` for subsequent phases.
84
+
85
+ **Report** to user:
86
+
87
+ ```
88
+ ## Scan Complete
89
+
90
+ Scanned **{filesScanned}** files, **{filesErrored}** errors.
91
+
92
+ ### Findings ({totalFindings} total)
93
+
94
+ | # | Severity | Skill | File | Title |
95
+ |---|----------|-------|------|-------|
96
+ | 1 | **HIGH** | security-review | `src/db/query.ts:42` | SQL injection in query builder |
97
+ ...
98
+ ```
99
+
100
+ Render every finding from the `findings` array. Bold severity for high and above.
101
+
102
+ **On failure**: If exit code 1, show the error JSON and stop. If exit code 2, show the partial results and note which files errored.
103
+
104
+ ---
105
+
106
+ ## Phase 2: Verify
107
+
108
+ Deep-trace each finding using Task subagents to qualify or disqualify.
109
+
110
+ **For each finding in `data/all-findings.jsonl`:**
111
+
112
+ Check if `data/verify/<finding-id>.json` already exists (incrementality). If it does, skip.
113
+
114
+ Launch a Task subagent (`subagent_type: "general-purpose"`) for each finding. Process findings sequentially (one at a time) to keep output organized.
115
+
116
+ **Task prompt for each finding:**
117
+
118
+ ```
119
+ Verify a code analysis finding. Determine if this is a TRUE issue or a FALSE POSITIVE.
120
+ Do NOT write or edit any files. Research only.
121
+
122
+ ## Finding
123
+ - Title: ${TITLE}
124
+ - Severity: ${SEVERITY} | Confidence: ${CONFIDENCE}
125
+ - Skill: ${SKILL}
126
+ - Location: ${FILE_PATH}:${START_LINE}-${END_LINE}
127
+ - Description: ${DESCRIPTION}
128
+ - Verification hint: ${VERIFICATION}
129
+
130
+ ## Instructions
131
+ 1. Read the file at the reported location. Examine at least 50 lines of surrounding context.
132
+ 2. Trace data flow to/from the flagged code using Grep/Glob.
133
+ 3. Check if the issue is mitigated elsewhere (guards, validation, try/catch upstream).
134
+ 4. Check if the issue is actually reachable in practice.
135
+
136
+ Return your verdict as JSON:
137
+ {
138
+ "findingId": "${FINDING_ID}",
139
+ "verdict": "verified" or "rejected",
140
+ "confidence": "high" or "medium" or "low",
141
+ "reasoning": "2-3 sentence explanation",
142
+ "traceNotes": "What code paths you examined"
143
+ }
144
+ ```
145
+
146
+ **Process results:**
147
+
148
+ Parse the JSON from the subagent response and:
149
+ - Write result to `data/verify/<finding-id>.json`
150
+ - Append to `data/verified.jsonl` or `data/rejected.jsonl`
151
+ - For verified findings, generate `findings/<finding-id>.md`:
152
+
153
+ ```markdown
154
+ # ${TITLE}
155
+
156
+ **ID**: ${FINDING_ID} | **Severity**: ${SEVERITY} | **Confidence**: ${CONFIDENCE}
157
+ **Skill**: ${SKILL} | **File**: ${FILE_PATH}:${START_LINE}
158
+
159
+ ## Description
160
+ ${DESCRIPTION}
161
+
162
+ ## Verification
163
+ **Verdict**: Verified (${VERIFICATION_CONFIDENCE})
164
+ **Reasoning**: ${REASONING}
165
+ **Code trace**: ${TRACE_NOTES}
166
+
167
+ ## Suggested Fix
168
+ ${FIX_DESCRIPTION}
169
+ ```diff
170
+ ${FIX_DIFF}
171
+ ```
172
+ ```
173
+
174
+ Update manifest: set `phases.verify` to `"complete"`.
175
+
176
+ **Report** to user after all verifications:
177
+
178
+ ```
179
+ ## Verification Complete
180
+
181
+ **{verified}** verified, **{rejected}** rejected.
182
+
183
+ ### Verified Findings
184
+
185
+ | # | Severity | Confidence | File | Title | Reasoning |
186
+ |---|----------|------------|------|-------|-----------|
187
+ | 1 | **HIGH** | high | `src/db/query.ts:42` | SQL injection in query builder | User input flows directly into... |
188
+ ...
189
+
190
+ ### Rejected ({rejected_count})
191
+
192
+ - `{findingId}` {file}: {reasoning}
193
+ ...
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Phase 3: Patch
199
+
200
+ For each verified finding, create a worktree, fix the code, and open a draft PR.
201
+
202
+ **Step 0: Index existing PRs** (1 tool call):
203
+
204
+ ```bash
205
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/index_prs.py ${SWEEP_DIR}
206
+ ```
207
+
208
+ Parse the JSON stdout. Use `fileIndex` for dedup checks.
209
+
210
+ **For each finding in `data/verified.jsonl`:**
211
+
212
+ Check if finding ID already exists in `data/patches.jsonl` (incrementality). If it does, skip.
213
+
214
+ **Dedup check**: Use the file index from `index_prs.py` output to determine if an existing open PR already addresses the same issue.
215
+
216
+ 1. **File match**: Look up the finding's file path in the `fileIndex`. If no PR touches that file, no conflict; proceed to Step 1.
217
+ 2. **Chunk overlap**: If a PR does touch the same file, read its cached diff from `data/pr-diffs/<number>.diff` and check whether the PR's changed hunks overlap with the finding's line range (startLine-endLine). Overlapping or adjacent hunks (within ~10 lines) indicate the same code region.
218
+ 3. **Same concern**: If the hunks overlap, compare the PR title and the finding title/description. Are they fixing the same kind of defect? A PR fixing an off-by-one error and a finding about a null check in the same function are different issues; both should proceed.
219
+
220
+ Skip the finding only when there is both chunk overlap AND the PR addresses the same concern. Record it in `data/patches.jsonl` with `"status": "existing"` and `"prUrl"` pointing to the matching PR, then continue to the next finding.
221
+
222
+ **Step 1: Create worktree**
223
+
224
+ ```bash
225
+ BRANCH="warden-sweep/${RUN_ID}/${FINDING_ID}"
226
+ WORKTREE="${SWEEP_DIR}/worktrees/${FINDING_ID}"
227
+ git worktree add "${WORKTREE}" -b "${BRANCH}"
228
+ ```
229
+
230
+ Each finding branches from the current HEAD to avoid merge conflicts between PRs.
231
+
232
+ **Step 2: Generate fix**
233
+
234
+ Launch a Task subagent (`subagent_type: "general-purpose"`) to apply the fix in the worktree:
235
+
236
+ ```
237
+ Fix a verified code issue and add test coverage. You are working in a git worktree at: ${WORKTREE}
238
+
239
+ ## Finding
240
+ - Title: ${TITLE}
241
+ - File: ${FILE_PATH}:${START_LINE}
242
+ - Description: ${DESCRIPTION}
243
+ - Verification: ${REASONING}
244
+ - Suggested Fix: ${FIX_DESCRIPTION}
245
+ ```diff
246
+ ${FIX_DIFF}
247
+ ```
248
+
249
+ ## Instructions
250
+ 1. Read the file at the reported location (use the worktree path: ${WORKTREE}/${FILE_PATH}).
251
+ 2. Apply the suggested fix. If the diff doesn't apply cleanly, adapt it while preserving intent.
252
+ 3. Write or update tests that verify the fix:
253
+ - Follow existing test patterns (co-located files, same framework)
254
+ - At minimum, write a test that would have caught the original bug
255
+ 4. Only modify the fix target and its test file.
256
+ 5. Do NOT run tests locally. CI will validate the changes.
257
+ 6. Stage and commit with this exact message:
258
+
259
+ fix: ${TITLE}
260
+
261
+ Warden finding ${FINDING_ID}
262
+ Severity: ${SEVERITY}
263
+
264
+ Co-Authored-By: Warden <noreply@getsentry.com>
265
+
266
+ Report what you changed: files modified, test files added/updated, any notes.
267
+ ```
268
+
269
+ **Step 3: Find reviewers**
270
+
271
+ ```bash
272
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/find_reviewers.py "${FILE_PATH}"
273
+ ```
274
+
275
+ **Step 4: Create draft PR**
276
+
277
+ ```bash
278
+ cd "${WORKTREE}"
279
+ git push -u origin "${BRANCH}"
280
+ ```
281
+
282
+ Create the PR with a 1-2 sentence "What" summary based on the finding and fix, followed by the finding description and verification reasoning:
283
+
284
+ ```bash
285
+ REVIEWERS=""
286
+ # If find_reviewers.py returned reviewers, build the flags
287
+ # e.g., REVIEWERS="--reviewer user1 --reviewer user2"
288
+
289
+ gh pr create --draft \
290
+ --label "warden" \
291
+ --title "fix: ${TITLE}" \
292
+ --body "$(cat <<'EOF'
293
+ ${FIX_WHAT_DESCRIPTION}
294
+
295
+ ${DESCRIPTION}
296
+
297
+ ${REASONING}
298
+
299
+ Automated fix for Warden finding ${FINDING_ID} (${SEVERITY}, detected by ${SKILL}).
300
+
301
+ > This PR was auto-generated by a Warden Sweep (run ${RUN_ID}).
302
+ > The finding has been validated through automated deep tracing,
303
+ > but human confirmation is requested as this is batch work.
304
+ EOF
305
+ )" ${REVIEWERS}
306
+ ```
307
+
308
+ Save the PR URL.
309
+
310
+ **Step 5: Record and cleanup**
311
+
312
+ Append to `data/patches.jsonl`:
313
+ ```json
314
+ {"findingId": "...", "prUrl": "https://...", "branch": "...", "reviewers": ["user1", "user2"], "filesChanged": ["..."], "status": "created|existing|error"}
315
+ ```
316
+
317
+ Remove the worktree:
318
+ ```bash
319
+ cd "$(git rev-parse --show-toplevel)"
320
+ git worktree remove "${WORKTREE}" --force
321
+ ```
322
+
323
+ **Error handling**: On failure at any step, write to `data/patches.jsonl` with `"status": "error"` and `"error": "..."`, clean up the worktree, and continue to the next finding.
324
+
325
+ Update manifest: set `phases.patch` to `"complete"`.
326
+
327
+ **Report** to user after all patches:
328
+
329
+ ```
330
+ ## PRs Created
331
+
332
+ **{created}** created, **{skipped}** skipped (existing), **{failed}** failed.
333
+
334
+ | # | Finding | PR | Status |
335
+ |---|---------|-----|--------|
336
+ | 1 | `security-review-a1b2c3d4` SQL injection in query builder | #142 | created |
337
+ | 2 | `code-review-e5f6g7h8` Null pointer in handler | - | existing (#138) |
338
+ ...
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Phase 4: Organize
344
+
345
+ **Run** (1 tool call):
346
+
347
+ ```bash
348
+ uv run ${CLAUDE_SKILL_ROOT}/scripts/organize.py ${SWEEP_DIR}
349
+ ```
350
+
351
+ Parse the JSON stdout.
352
+
353
+ **Report** to user:
354
+
355
+ ```
356
+ ## Sweep Complete
357
+
358
+ | Metric | Count |
359
+ |--------|-------|
360
+ | Files scanned | {filesScanned} |
361
+ | Findings verified | {verified} |
362
+ | PRs created | {prsCreated} |
363
+ | Security findings | {securityFindings} |
364
+
365
+ Full report: `{summaryPath}`
366
+ ```
367
+
368
+ **On failure**: Show the error and note which steps completed.
369
+
370
+ ---
371
+
372
+ ## Resuming a Sweep
373
+
374
+ Each phase is incremental. To resume from where you left off:
375
+
376
+ 1. Check `data/manifest.json` to see which phases are complete
377
+ 2. For scan: pass `--sweep-dir` to `scan.py`
378
+ 3. For verify: existing `data/verify/<id>.json` files are skipped
379
+ 4. For patch: existing entries in `data/patches.jsonl` are skipped
380
+ 5. For organize: safe to re-run (idempotent)
381
+
382
+ ## Output Directory Structure
383
+
384
+ ```
385
+ .warden/sweeps/<run-id>/
386
+ summary.md # Stats, key findings, PR links
387
+ findings/ # One markdown per verified finding
388
+ <finding-id>.md
389
+ security/ # Security-specific view
390
+ index.jsonl # Security findings index
391
+ <finding-id>.md # Copies of security findings
392
+ data/ # Structured data for tooling
393
+ manifest.json # Run metadata, phase state
394
+ scan-index.jsonl # Per-file scan tracking
395
+ all-findings.jsonl # Every finding from scan
396
+ verified.jsonl # Findings that passed verification
397
+ rejected.jsonl # Findings that failed verification
398
+ patches.jsonl # Finding -> PR URL -> reviewers
399
+ existing-prs.json # Cached open warden PRs
400
+ report.json # Machine-readable summary
401
+ verify/ # Individual verification results
402
+ <finding-id>.json
403
+ logs/ # Warden JSONL logs per file
404
+ <hash>.jsonl
405
+ pr-diffs/ # Cached PR diffs for dedup
406
+ <number>.diff
407
+ ```
@@ -0,0 +1,37 @@
1
+ """Shared utilities for warden-sweep scripts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ from typing import Any
8
+
9
+
10
+ def run_cmd(
11
+ args: list[str], timeout: int = 30, cwd: str | None = None
12
+ ) -> subprocess.CompletedProcess[str]:
13
+ """Run a command and return the result."""
14
+ return subprocess.run(
15
+ args,
16
+ capture_output=True,
17
+ text=True,
18
+ timeout=timeout,
19
+ cwd=cwd,
20
+ )
21
+
22
+
23
+ def read_jsonl(path: str) -> list[dict[str, Any]]:
24
+ """Read a JSONL file and return list of parsed objects."""
25
+ entries: list[dict[str, Any]] = []
26
+ if not os.path.exists(path):
27
+ return entries
28
+ with open(path) as f:
29
+ for line in f:
30
+ line = line.strip()
31
+ if not line:
32
+ continue
33
+ try:
34
+ entries.append(json.loads(line))
35
+ except json.JSONDecodeError:
36
+ continue
37
+ return entries
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.9"
4
+ # ///
5
+ """
6
+ Extract individual findings from warden JSONL log files.
7
+
8
+ Usage:
9
+ python extract_findings.py <log-path-or-directory> -o <output.jsonl>
10
+ python extract_findings.py .warden/logs/ --scan-index data/scan-index.jsonl -o findings.jsonl
11
+
12
+ Reads warden JSONL logs (one skill record per line, summary as last line),
13
+ extracts each finding as a standalone record with a stable ID, and writes
14
+ one finding per line to the output file.
15
+
16
+ Finding ID format: <skill>-<sha256(title+path+line)[:8]>
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+
29
+ def generate_finding_id(skill: str, title: str, path: str, line: int | None) -> str:
30
+ """Generate a stable, deterministic finding ID."""
31
+ raw = f"{title}:{path}:{line or 0}"
32
+ digest = hashlib.sha256(raw.encode()).hexdigest()[:8]
33
+ # Sanitize skill name for use in ID
34
+ safe_skill = skill.replace("/", "-").replace(" ", "-").lower()
35
+ return f"{safe_skill}-{digest}"
36
+
37
+
38
+ def parse_jsonl_log(log_path: str) -> list[dict[str, Any]]:
39
+ """Parse a warden JSONL log file and extract individual findings.
40
+
41
+ Each non-summary line has the shape:
42
+ {
43
+ "run": {...},
44
+ "skill": "...",
45
+ "findings": [{...}, ...],
46
+ ...
47
+ }
48
+
49
+ The last line is a summary record with "type": "summary" which we skip.
50
+ """
51
+ findings = []
52
+ try:
53
+ with open(log_path) as f:
54
+ for line in f:
55
+ line = line.strip()
56
+ if not line:
57
+ continue
58
+ try:
59
+ record = json.loads(line)
60
+ except json.JSONDecodeError:
61
+ continue
62
+
63
+ # Skip summary records
64
+ if record.get("type") == "summary":
65
+ continue
66
+
67
+ skill = record.get("skill", "unknown")
68
+ run_meta = record.get("run", {})
69
+ record_findings = record.get("findings", [])
70
+
71
+ for finding in record_findings:
72
+ location = finding.get("location", {})
73
+ file_path = location.get("path", "")
74
+ start_line = location.get("startLine")
75
+ end_line = location.get("endLine")
76
+
77
+ finding_id = generate_finding_id(
78
+ skill=skill,
79
+ title=finding.get("title", ""),
80
+ path=file_path,
81
+ line=start_line,
82
+ )
83
+
84
+ normalized = {
85
+ "findingId": finding_id,
86
+ "file": file_path,
87
+ "skill": skill,
88
+ "severity": finding.get("severity", "info"),
89
+ "confidence": finding.get("confidence"),
90
+ "title": finding.get("title", ""),
91
+ "description": finding.get("description", ""),
92
+ "verification": finding.get("verification"),
93
+ "location": {
94
+ "path": file_path,
95
+ "startLine": start_line,
96
+ "endLine": end_line,
97
+ },
98
+ "suggestedFix": finding.get("suggestedFix"),
99
+ "logPath": log_path,
100
+ "runId": run_meta.get("runId", ""),
101
+ }
102
+
103
+ findings.append(normalized)
104
+
105
+ except (OSError, IOError) as e:
106
+ print(f"Error reading {log_path}: {e}", file=sys.stderr)
107
+
108
+ return findings
109
+
110
+
111
+ def collect_log_paths(source: str, scan_index: str | None = None) -> list[str]:
112
+ """Collect log file paths from a directory or scan index."""
113
+ paths: list[str] = []
114
+
115
+ if scan_index and os.path.exists(scan_index):
116
+ # Read log paths from scan-index.jsonl
117
+ seen = set()
118
+ total_entries = 0
119
+ missing = 0
120
+ with open(scan_index) as f:
121
+ for line in f:
122
+ line = line.strip()
123
+ if not line:
124
+ continue
125
+ try:
126
+ entry = json.loads(line)
127
+ except json.JSONDecodeError:
128
+ continue
129
+ if entry.get("status") != "complete":
130
+ continue
131
+ total_entries += 1
132
+ log_path = entry.get("logPath", "")
133
+ if log_path and log_path not in seen:
134
+ seen.add(log_path)
135
+ if os.path.isfile(log_path):
136
+ paths.append(log_path)
137
+ else:
138
+ missing += 1
139
+ if missing > 0:
140
+ print(
141
+ f"Warning: {missing} log path(s) from scan-index not found on disk",
142
+ file=sys.stderr,
143
+ )
144
+ # Only use scan-index results if we actually found logs;
145
+ # fall through to source directory otherwise
146
+ if paths:
147
+ return paths
148
+ if total_entries > 0:
149
+ print(
150
+ "Warning: scan-index had entries but no valid log paths; "
151
+ "falling back to source directory",
152
+ file=sys.stderr,
153
+ )
154
+
155
+ source_path = Path(source)
156
+ if source_path.is_file():
157
+ return [str(source_path)]
158
+
159
+ if source_path.is_dir():
160
+ for f in sorted(source_path.glob("*.jsonl")):
161
+ paths.append(str(f))
162
+ return paths
163
+
164
+ print(f"Source not found: {source}", file=sys.stderr)
165
+ return paths
166
+
167
+
168
+ def main():
169
+ parser = argparse.ArgumentParser(
170
+ description="Extract findings from warden JSONL logs"
171
+ )
172
+ parser.add_argument(
173
+ "source",
174
+ help="Path to a JSONL log file or directory of log files",
175
+ )
176
+ parser.add_argument(
177
+ "-o", "--output",
178
+ required=True,
179
+ help="Output path for normalized findings JSONL",
180
+ )
181
+ parser.add_argument(
182
+ "--scan-index",
183
+ help="Path to scan-index.jsonl (uses log paths from completed scans)",
184
+ )
185
+ args = parser.parse_args()
186
+
187
+ log_paths = collect_log_paths(args.source, args.scan_index)
188
+ if not log_paths:
189
+ print("No log files found.", file=sys.stderr)
190
+ sys.exit(1)
191
+
192
+ all_findings: list[dict[str, Any]] = []
193
+ seen_ids: set[str] = set()
194
+
195
+ for log_path in log_paths:
196
+ findings = parse_jsonl_log(log_path)
197
+ for f in findings:
198
+ fid = f["findingId"]
199
+ if fid not in seen_ids:
200
+ seen_ids.add(fid)
201
+ all_findings.append(f)
202
+
203
+ # Write output
204
+ os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
205
+ with open(args.output, "w") as out:
206
+ for finding in all_findings:
207
+ out.write(json.dumps(finding) + "\n")
208
+
209
+ print(
210
+ json.dumps({
211
+ "logsProcessed": len(log_paths),
212
+ "findingsExtracted": len(all_findings),
213
+ "outputPath": args.output,
214
+ })
215
+ )
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()