Skill Tracer
Skills › gstack › gstack › slop-diff.ts

slop-diff.ts

198 lines · scripts/slop-diff.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/**
3 * slop-diff: show NEW slop-scan findings introduced on this branch.
4 *
5 * Runs slop-scan on HEAD and on the merge-base, then diffs the results
6 * to show only findings that were added. Line-number-insensitive comparison
7 * so shifting code doesn't create false positives.
8 *
9 * Usage:
10 * bun run slop:diff # diff against main
11 * bun run slop:diff origin/release # diff against another base
12 */
14import { spawnSync } from "child_process";
15import * as fs from "fs";
16import * as os from "os";
17import * as path from "path";
19const base = process.argv[2] || "main";
21// 1. Find changed files
22const diffResult = spawnSync("git", ["diff", "--name-only", `${base}...HEAD`], {
23 encoding: "utf-8",
24 timeout: 10000,
25});
26const changedFiles = new Set(
27 (diffResult.stdout || "").trim().split("\n").filter(Boolean),
28);
29if (changedFiles.size === 0) {
30 console.log("No files changed vs", base, "— nothing to check.");
31 process.exit(0);
34// 2. Run slop-scan on HEAD
35const scanHead = spawnSync("npx", ["slop-scan", "scan", ".", "--json"], {
36 encoding: "utf-8",
37 timeout: 120000,
38 shell: process.platform === "win32",
39});
40if (!scanHead.stdout) {
41 console.log("slop-scan not available. Install: npm i -g slop-scan");
42 process.exit(0);
44let headReport: any;
45try {
46 headReport = JSON.parse(scanHead.stdout);
47} catch {
48 console.log("slop-scan returned invalid JSON.");
49 process.exit(0);
52// 3. Get base branch findings using git stash approach
53// Check out base versions of changed files, scan, then restore
54const mergeBase = spawnSync("git", ["merge-base", base, "HEAD"], {
55 encoding: "utf-8",
56 timeout: 5000,
57}).stdout?.trim();
59// Fingerprint: strip line numbers so shifting code doesn't create false positives
60// "line 142: empty catch, boundary=none" -> "empty catch, boundary=none"
61function stripLineNum(evidence: string): string {
62 return evidence.replace(/^line \d+: /, "").replace(/ at line \d+ /, " ");
65// Count evidence items per (rule, file, stripped-evidence) for the base
66const baseCounts = new Map<string, number>();
68if (mergeBase) {
69 // Create temp worktree for base scan
70 const tmpWorktree = path.join(os.tmpdir(), `slop-base-${Date.now()}`);
71 const wtResult = spawnSync(
72 "git",
73 ["worktree", "add", "--detach", tmpWorktree, mergeBase],
74 {
75 encoding: "utf-8",
76 timeout: 30000,
77 },
78 );
80 if (wtResult.status === 0) {
81 // Copy slop-scan config if it exists
82 const configFile = "slop-scan.config.json";
83 if (fs.existsSync(configFile)) {
84 try {
85 fs.copyFileSync(configFile, path.join(tmpWorktree, configFile));
86 } catch {}
87 }
89 const scanBase = spawnSync(
90 "npx",
91 ["slop-scan", "scan", tmpWorktree, "--json"],
92 {
93 encoding: "utf-8",
94 timeout: 120000,
95 shell: process.platform === "win32",
96 },
97 );
99 if (scanBase.stdout) {
100 try {
101 const baseReport = JSON.parse(scanBase.stdout);
102 for (const f of baseReport.findings) {
103 // Remap worktree paths back to repo-relative
104 const realPath = f.path.replace(tmpWorktree + "/", "");
105 if (!changedFiles.has(realPath)) continue;
106 for (const ev of f.evidence || []) {
107 const key = `${f.ruleId}|${realPath}|${stripLineNum(ev)}`;
108 baseCounts.set(key, (baseCounts.get(key) || 0) + 1);
109 }
110 }
111 } catch {}
112 }
114 // Clean up worktree
115 spawnSync("git", ["worktree", "remove", "--force", tmpWorktree], {
116 timeout: 10000,
117 });
118 }
121// 4. Find genuinely new findings
122// For each evidence item on HEAD, check if the base had the same (rule, file, stripped-evidence).
123// Use counts to handle duplicates: if base had 2 and HEAD has 3, that's 1 new.
124const headCounts = new Map<string, { count: number; evidence: string[] }>();
125const headFindings = headReport.findings.filter((f: any) =>
126 changedFiles.has(f.path),
127);
129for (const f of headFindings) {
130 for (const ev of f.evidence || []) {
131 const key = `${f.ruleId}|${f.path}|${stripLineNum(ev)}`;
132 const entry = headCounts.get(key) || { count: 0, evidence: [] };
133 entry.count++;
134 entry.evidence.push(ev);
135 headCounts.set(key, entry);
136 }
139// Compute net new
140type NewFinding = { ruleId: string; filePath: string; evidence: string };
141const newFindings: NewFinding[] = [];
142let removedCount = 0;
144for (const [key, entry] of headCounts) {
145 const baseCount = baseCounts.get(key) || 0;
146 const netNew = entry.count - baseCount;
147 if (netNew > 0) {
148 const [ruleId, filePath] = key.split("|");
149 // Take the last N evidence items as the "new" ones
150 for (const ev of entry.evidence.slice(-netNew)) {
151 newFindings.push({ ruleId, filePath, evidence: ev });
152 }
153 }
156for (const [key, baseCount] of baseCounts) {
157 const headCount = headCounts.get(key)?.count || 0;
158 if (headCount < baseCount) removedCount += baseCount - headCount;
161// 5. Print results
162if (newFindings.length === 0) {
163 if (removedCount > 0) {
164 console.log(
165 `\n slop-scan: no new findings. Removed ${removedCount} pre-existing findings.\n`,
166 );
167 } else {
168 console.log(
169 `\n slop-scan: no new findings in ${changedFiles.size} changed files.\n`,
170 );
171 }
172 process.exit(0);
175console.log(
176 `\n── slop-scan: ${newFindings.length} new findings (+${newFindings.length} / -${removedCount}) ──\n`,
177);
179// Group by file, then by rule
180const grouped = new Map<string, Map<string, string[]>>();
181for (const { ruleId, filePath, evidence } of newFindings) {
182 if (!grouped.has(filePath)) grouped.set(filePath, new Map());
183 const rules = grouped.get(filePath)!;
184 if (!rules.has(ruleId)) rules.set(ruleId, []);
185 rules.get(ruleId)!.push(evidence);
188for (const [filePath, rules] of grouped) {
189 console.log(` ${filePath}`);
190 for (const [ruleId, evidence] of rules) {
191 console.log(` ${ruleId}:`);
192 for (const ev of evidence) {
193 console.log(` ${ev}`);
194 }
195 }
198console.log(`\n Net: +${newFindings.length} new, -${removedCount} removed\n`);