stopslop 0.1.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.
package/dist/cli.js ADDED
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env node
2
+ import { a as loadConfig, d as readBaseline, f as writeBaseline, l as applyBaseline, n as analyzeGitBase, t as analyze, u as fingerprint } from "./src.js";
3
+ import { posix } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import pc from "picocolors";
6
+ //#region src/report/terminal.ts
7
+ const KIND_LABEL = {
8
+ "god-module": "god module",
9
+ "god-class": "god class",
10
+ "cognitive-complexity": "complex fn",
11
+ "multiple-exported-classes": "multi-class file",
12
+ "duplicate-code": "clone",
13
+ "unused-export": "unused export",
14
+ "unused-dependency": "unused dep",
15
+ "unused-file": "unused file",
16
+ "orphan-feature": "orphan feature"
17
+ };
18
+ const KIND_ORDER = Object.keys(KIND_LABEL);
19
+ /** Findings per kind shown before collapsing into a "+N more" line. */
20
+ const MAX_PER_KIND = 12;
21
+ function renderTerminal(result, details) {
22
+ const lines = [];
23
+ const { findings } = result;
24
+ lines.push("");
25
+ lines.push(pc.bold(pc.cyan(" stopslop")) + pc.dim(` ${result.fileCount} files ${formatKloc(result.loc)} ${result.elapsedMs}ms`));
26
+ lines.push("");
27
+ lines.push(renderScore(result));
28
+ if (result.gitBase !== void 0) lines.push(" " + pc.dim(`git base: ${result.gitBase.ref} (${result.gitBase.commit.slice(0, 12)}) · ${result.gitBase.suppressed} existing · ${findings.length} new`));
29
+ else if (result.baselineSuppressed !== void 0) lines.push(" " + pc.dim(`baseline: ${result.baselineSuppressed} accepted · ${findings.length} new`));
30
+ lines.push("");
31
+ if (findings.length === 0) {
32
+ const msg = result.gitBase !== void 0 ? `✓ no new slop since ${result.gitBase.ref}` : result.baselineSuppressed !== void 0 ? "✓ no new slop since baseline" : "✓ no slop signals";
33
+ lines.push(" " + pc.green(msg));
34
+ lines.push("");
35
+ lines.push(renderFooter(result).join("\n"));
36
+ return lines.join("\n");
37
+ }
38
+ const counts = countByKind(findings);
39
+ const chips = KIND_ORDER.filter((k) => counts[k] > 0).map((k) => `${pc.bold(String(counts[k]))} ${pluralize(KIND_LABEL[k], counts[k])}`);
40
+ lines.push(" " + chips.join(pc.dim(" · ")));
41
+ lines.push("");
42
+ const shown = {};
43
+ for (const f of findings) if ((shown[f.kind] = (shown[f.kind] ?? 0) + 1) <= MAX_PER_KIND) lines.push(renderFinding(f, details));
44
+ for (const kind of KIND_ORDER) {
45
+ const hidden = counts[kind] - MAX_PER_KIND;
46
+ if (hidden > 0) lines.push(" " + pc.dim(`… ${hidden} more ${pluralize(KIND_LABEL[kind], hidden)} (--json for all)`));
47
+ }
48
+ lines.push(...renderFooter(result));
49
+ return lines.join("\n");
50
+ }
51
+ /** The headline: one comparable number, with the densities it came from. */
52
+ function renderScore(result) {
53
+ const { score, level, densities, signalCount, totalSignals, percentile } = result.slop;
54
+ const paint = LEVEL_COLOR$1[level];
55
+ const partial = signalCount < totalSignals ? pc.yellow(` — ${signalCount}/${totalSignals} signals, see notes`) : "";
56
+ const anchor = percentile !== void 0 ? pc.dim(` · worse than ${percentile}% of reference repos`) : "";
57
+ const head = ` slop level: ${paint(pc.bold(level))} ${pc.dim(`(${score.toFixed(1)}/100)`)}${anchor}${partial}`;
58
+ const parts = [];
59
+ if (densities.god !== void 0) parts.push(`god units ${densities.god.toFixed(2)}/KLOC`);
60
+ if (densities.complexity !== void 0) parts.push(`complex fns ${densities.complexity.toFixed(2)}/KLOC`);
61
+ if (densities.duplication !== void 0) parts.push(`duplication ${(densities.duplication * 100).toFixed(1)}%`);
62
+ if (densities.deadCode !== void 0) parts.push(`dead code ${densities.deadCode.toFixed(2)}/KLOC`);
63
+ return parts.length > 0 ? head + "\n " + pc.dim(parts.join(" · ")) : head;
64
+ }
65
+ const LEVEL_COLOR$1 = {
66
+ clean: pc.green,
67
+ low: pc.green,
68
+ moderate: pc.yellow,
69
+ high: pc.red,
70
+ "legacy-grade": pc.red
71
+ };
72
+ function renderFooter(result) {
73
+ const lines = [""];
74
+ for (const note of result.notes) lines.push(" " + pc.dim(note));
75
+ if (result.config.preliminary) lines.push(" " + pc.yellow("thresholds are preliminary (pre-calibration) — see docs/thresholds.md"));
76
+ if (result.errors.length > 0) lines.push(" " + pc.dim(`${result.errors.length} file(s) skipped on error`));
77
+ lines.push(" " + pc.dim("clones: Clone Alert · dead code: Knip"));
78
+ lines.push("");
79
+ return lines;
80
+ }
81
+ function renderFinding(f, details) {
82
+ const tag = tagFor(f);
83
+ const loc = pc.dim(`${f.file}:${f.line}`);
84
+ const out = [` ${tag} ${f.message}`, ` ${loc}`];
85
+ if (f.kind === "duplicate-code") {
86
+ const rest = (f.data?.occurrences ?? []).slice(1);
87
+ const list = details ? rest : rest.slice(0, 3);
88
+ for (const o of list) out.push(" " + pc.dim(`also ${o.file}:${o.startLine}-${o.endLine}`));
89
+ if (!details && rest.length > list.length) out.push(" " + pc.dim(`also +${rest.length - list.length} more`));
90
+ return out.join("\n");
91
+ }
92
+ if (details && (f.kind === "god-module" || f.kind === "god-class")) {
93
+ const axis = f.data?.axis;
94
+ const groups = f.data?.groups ?? [];
95
+ const standalone = f.data?.standalone ?? [];
96
+ const hubs = f.data?.hubs ?? [];
97
+ if (hubs.length > 0) out.push(" " + pc.dim("cohesion held together by: ") + pc.yellow(hubs.join(", ")));
98
+ if (axis === "size" && groups.length < 3) {
99
+ out.push(" " + pc.dim("densely coupled — no clean split boundaries; decompose by responsibility manually"));
100
+ if (groups.length >= 2) {
101
+ out.push(" " + pc.dim("partial grouping:"));
102
+ groups.forEach((c, i) => out.push(" " + pc.dim(`${i + 1}.`) + " " + c.join(", ")));
103
+ }
104
+ } else {
105
+ out.push(" " + pc.dim(`${groups.length} responsibility groups (suggested split boundaries):`));
106
+ groups.forEach((c, i) => {
107
+ out.push(" " + pc.dim(`${i + 1}.`) + " " + c.join(", "));
108
+ });
109
+ if (standalone.length > 0) out.push(" " + pc.dim(`+ ${standalone.length} standalone: `) + pc.dim(previewList(standalone)));
110
+ }
111
+ }
112
+ return out.join("\n");
113
+ }
114
+ function formatKloc(loc) {
115
+ return loc >= 1e3 ? `${(loc / 1e3).toFixed(1)} KLOC` : `${loc} LOC`;
116
+ }
117
+ function pluralize(label, n) {
118
+ if (n === 1) return label;
119
+ return /(s|x|ch|sh)$/.test(label) ? `${label}es` : `${label}s`;
120
+ }
121
+ function previewList(names, max = 8) {
122
+ if (names.length <= max) return names.join(", ");
123
+ return names.slice(0, max).join(", ") + `, …+${names.length - max}`;
124
+ }
125
+ function tagFor(f) {
126
+ const label = ` ${KIND_LABEL[f.kind]} `;
127
+ switch (f.kind) {
128
+ case "god-module":
129
+ case "god-class": return pc.bgRed(pc.white(pc.bold(label)));
130
+ case "cognitive-complexity":
131
+ case "duplicate-code": return pc.bgYellow(pc.black(label));
132
+ case "unused-export":
133
+ case "unused-dependency":
134
+ case "unused-file": return pc.bgMagenta(pc.white(label));
135
+ case "orphan-feature": return pc.bgRed(pc.white(pc.bold(label)));
136
+ default: return pc.bgBlue(pc.white(label));
137
+ }
138
+ }
139
+ function countByKind(findings) {
140
+ const counts = Object.fromEntries(KIND_ORDER.map((k) => [k, 0]));
141
+ for (const f of findings) counts[f.kind]++;
142
+ return counts;
143
+ }
144
+ const KINDS = [
145
+ "god-module",
146
+ "god-class",
147
+ "duplicate-code",
148
+ "cognitive-complexity",
149
+ "unused-export",
150
+ "unused-dependency",
151
+ "unused-file",
152
+ "orphan-feature",
153
+ "multiple-exported-classes"
154
+ ];
155
+ /** Stable, versioned JSON for agents/CI. Sets replaced with sorted arrays. */
156
+ function toJson(result) {
157
+ return JSON.stringify({
158
+ schemaVersion: 6,
159
+ tool: "stopslop",
160
+ engines: {
161
+ duplicates: "clone-alert",
162
+ deadCode: "knip"
163
+ },
164
+ root: result.root,
165
+ fileCount: result.fileCount,
166
+ loc: result.loc,
167
+ elapsedMs: result.elapsedMs,
168
+ preliminaryThresholds: result.config.preliminary,
169
+ slop: result.slop,
170
+ ...result.baselineSuppressed === void 0 ? {} : { baseline: {
171
+ suppressed: result.baselineSuppressed,
172
+ new: result.findings.length
173
+ } },
174
+ ...result.gitBase === void 0 ? {} : { gitBase: {
175
+ ...result.gitBase,
176
+ new: result.findings.length
177
+ } },
178
+ thresholds: {
179
+ cognitiveComplexity: result.config.cognitiveComplexity,
180
+ godModule: result.config.godModule,
181
+ godClass: result.config.godClass,
182
+ multipleExportedClasses: result.config.multipleExportedClasses,
183
+ duplicates: result.config.duplicates,
184
+ deadCode: result.config.deadCode,
185
+ knip: result.config.knip,
186
+ score: result.config.score,
187
+ hubFanInRatio: result.config.hubFanInRatio
188
+ },
189
+ summary: summarize(result),
190
+ findings: result.findings,
191
+ notes: result.notes,
192
+ errors: result.errors
193
+ }, null, 2);
194
+ }
195
+ function summarize(result) {
196
+ const counts = Object.fromEntries(KINDS.map((k) => [k, 0]));
197
+ for (const f of result.findings) counts[f.kind] = (counts[f.kind] ?? 0) + 1;
198
+ return counts;
199
+ }
200
+ //#endregion
201
+ //#region src/report/sarif.ts
202
+ /** GitHub Code Scanning-compatible SARIF 2.1.0 report. */
203
+ function toSarif(result) {
204
+ const rules = [];
205
+ const seenRules = /* @__PURE__ */ new Set();
206
+ for (const finding of result.findings) {
207
+ if (seenRules.has(finding.kind)) continue;
208
+ seenRules.add(finding.kind);
209
+ rules.push({
210
+ id: finding.kind,
211
+ shortDescription: { text: describeKind(finding.kind) },
212
+ defaultConfiguration: { level: sarifLevel(finding.severity) }
213
+ });
214
+ }
215
+ const sarif = {
216
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
217
+ version: "2.1.0",
218
+ runs: [{
219
+ tool: { driver: {
220
+ name: "stopslop",
221
+ rules
222
+ } },
223
+ results: result.findings.map((finding) => ({
224
+ ruleId: finding.kind,
225
+ level: sarifLevel(finding.severity),
226
+ message: { text: finding.message },
227
+ locations: locationsFor(result.root, finding),
228
+ partialFingerprints: { primaryLocationLineHash: createHash("sha256").update(fingerprint(finding)).digest("hex") }
229
+ }))
230
+ }]
231
+ };
232
+ return `${JSON.stringify(sarif, null, 2)}\n`;
233
+ }
234
+ function sarifLevel(severity) {
235
+ return severity === "warn" ? "warning" : "note";
236
+ }
237
+ function describeKind(kind) {
238
+ const description = kind.replaceAll("-", " ");
239
+ return `${description.charAt(0).toUpperCase()}${description.slice(1)}`;
240
+ }
241
+ function locationsFor(root, finding) {
242
+ const primaryUri = artifactUri(root, finding.file);
243
+ if (!primaryUri || !isLine(finding.line)) return [];
244
+ const occurrences = Array.isArray(finding.data?.occurrences) ? finding.data.occurrences.flatMap((value) => {
245
+ const parsed = parseOccurrence(root, value);
246
+ return parsed ? [parsed] : [];
247
+ }) : [];
248
+ const primary = occurrences.find((occurrence) => occurrence.uri === primaryUri && occurrence.startLine === finding.line);
249
+ const locations = [location(primaryUri, finding.line, primary?.endLine)];
250
+ const seen = new Set([JSON.stringify([primaryUri, finding.line])]);
251
+ for (const occurrence of occurrences) {
252
+ const key = JSON.stringify([occurrence.uri, occurrence.startLine]);
253
+ if (seen.has(key)) continue;
254
+ seen.add(key);
255
+ locations.push(location(occurrence.uri, occurrence.startLine, occurrence.endLine));
256
+ }
257
+ return locations;
258
+ }
259
+ function parseOccurrence(root, value) {
260
+ if (!value || typeof value !== "object") return void 0;
261
+ const { file, startLine, endLine } = value;
262
+ if (typeof file !== "string" || !isLine(startLine)) return void 0;
263
+ const uri = artifactUri(root, file);
264
+ if (!uri) return void 0;
265
+ return {
266
+ uri,
267
+ startLine,
268
+ ...isLine(endLine) && endLine >= startLine ? { endLine } : {}
269
+ };
270
+ }
271
+ function location(uri, startLine, endLine) {
272
+ return { physicalLocation: {
273
+ artifactLocation: { uri },
274
+ region: {
275
+ startLine,
276
+ ...endLine === void 0 ? {} : { endLine }
277
+ }
278
+ } };
279
+ }
280
+ function artifactUri(root, file) {
281
+ const normalizedRoot = root.replaceAll("\\", "/");
282
+ const normalizedFile = file.replaceAll("\\", "/");
283
+ const relative = normalizedFile.startsWith("/") || /^[A-Za-z]:\//.test(normalizedFile) ? posix.relative(normalizedRoot, normalizedFile) : normalizedFile;
284
+ const normalized = posix.normalize(relative).replace(/^\.\//, "");
285
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/") || /^[A-Za-z][A-Za-z\d+.-]*:/.test(normalized)) return;
286
+ return normalized.split("/").map(encodeURIComponent).join("/");
287
+ }
288
+ function isLine(value) {
289
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
290
+ }
291
+ //#endregion
292
+ //#region src/report/badge.ts
293
+ const LEVEL_COLOR = {
294
+ clean: "brightgreen",
295
+ low: "green",
296
+ moderate: "yellow",
297
+ high: "orange",
298
+ "legacy-grade": "red"
299
+ };
300
+ /** shields.io endpoint JSON for a slop badge. */
301
+ function toBadge(result) {
302
+ const { score, level, signalCount, totalSignals } = result.slop;
303
+ const partial = signalCount < totalSignals ? "*" : "";
304
+ const payload = {
305
+ schemaVersion: 1,
306
+ label: "slop",
307
+ message: score === 0 ? "0 slop" : `${level} (${score.toFixed(0)}/100)${partial}`,
308
+ color: LEVEL_COLOR[level]
309
+ };
310
+ return `${JSON.stringify(payload, null, 2)}\n`;
311
+ }
312
+ //#endregion
313
+ //#region src/cli.ts
314
+ function parseArgs(argv) {
315
+ const args = {
316
+ path: ".",
317
+ format: "text",
318
+ details: false,
319
+ fast: false,
320
+ updateBaseline: false,
321
+ help: false
322
+ };
323
+ const positional = [];
324
+ for (let i = 0; i < argv.length; i++) {
325
+ const a = argv[i];
326
+ if (a === "--json") args.format = "json";
327
+ else if (a === "--format") args.format = parseFormat(optionValue("--format", argv[++i]));
328
+ else if (a.startsWith("--format=")) args.format = parseFormat(optionValue("--format", a.slice(9)));
329
+ else if (a === "--details" || a === "-d") args.details = true;
330
+ else if (a === "--fast") args.fast = true;
331
+ else if (a === "--help" || a === "-h") args.help = true;
332
+ else if (a === "--config") args.config = optionValue("--config", argv[++i]);
333
+ else if (a.startsWith("--config=")) args.config = optionValue("--config", a.slice(9));
334
+ else if (a === "--base") args.base = optionValue("--base", argv[++i]);
335
+ else if (a.startsWith("--base=")) args.base = optionValue("--base", a.slice(7));
336
+ else if (a === "--baseline") args.baseline = optionValue("--baseline", argv[++i]);
337
+ else if (a.startsWith("--baseline=")) args.baseline = optionValue("--baseline", a.slice(11));
338
+ else if (a === "--update-baseline") args.updateBaseline = true;
339
+ else if (a.startsWith("-")) throw new Error(`unknown option '${a}'`);
340
+ else positional.push(a);
341
+ }
342
+ if (positional.length > 1) throw new Error("expected at most one path");
343
+ if (positional[0]) args.path = positional[0];
344
+ return args;
345
+ }
346
+ function optionValue(option, raw) {
347
+ if (!raw || raw.startsWith("-")) throw new Error(`${option} requires a value`);
348
+ return raw;
349
+ }
350
+ function parseFormat(raw) {
351
+ if (raw === "text" || raw === "json" || raw === "sarif" || raw === "shields") return raw;
352
+ throw new Error(`unknown --format '${raw ?? ""}' (expected text, json, sarif, or shields)`);
353
+ }
354
+ const HELP = `stopslop — find AI-agent slop that linters miss
355
+
356
+ Usage:
357
+ stopslop [path] analyze a directory or file (default: .)
358
+
359
+ Options:
360
+ --format <fmt> text (default) · json · sarif · shields (badge JSON)
361
+ --json alias for --format json
362
+ --details, -d show cluster composition, hubs, all clone sites
363
+ --fast AST checks only: skip clones and dead code
364
+ --config <file> path to a stopslop.json config
365
+ --base <ref> gate only on findings absent from this Git revision
366
+ --baseline <file> gate only on findings not in this baseline file
367
+ --update-baseline write the current findings to --baseline and exit 0
368
+ --help, -h show this help
369
+
370
+ Checks: complexity, god module/class (own) · clones (Clone Alert) · dead code (Knip)
371
+ Exit codes: 0 clean · 1 findings · 2 error
372
+
373
+ Baseline: adopt an existing repo's findings, then fail only on new ones.
374
+ --base is mutually exclusive with --baseline and --update-baseline.
375
+ stopslop . --base main # gate vs Git
376
+ stopslop . --baseline .stopslop-baseline.json --update-baseline # record
377
+ stopslop . --baseline .stopslop-baseline.json # gate on new`;
378
+ async function main() {
379
+ const args = parseArgs(process.argv.slice(2));
380
+ if (args.help) {
381
+ process.stdout.write(HELP + "\n");
382
+ process.exit(0);
383
+ }
384
+ try {
385
+ validateArgs(args);
386
+ const config = loadConfig(args.path, args.config);
387
+ if (args.fast) {
388
+ config.duplicates.enabled = false;
389
+ config.deadCode.enabled = false;
390
+ }
391
+ const result = args.base ? await analyzeGitBase(args.path, args.base, config) : await analyze(args.path, config);
392
+ if (args.updateBaseline) {
393
+ writeBaseline(args.baseline, result.findings);
394
+ process.stderr.write(`stopslop: baseline written to ${args.baseline} (${result.findings.length} findings accepted)\n`);
395
+ process.exit(0);
396
+ }
397
+ let reported = result;
398
+ if (args.baseline) {
399
+ const { kept, suppressed } = applyBaseline(result.findings, readBaseline(args.baseline));
400
+ reported = {
401
+ ...result,
402
+ findings: kept,
403
+ baselineSuppressed: suppressed
404
+ };
405
+ }
406
+ if (args.format === "json") process.stdout.write(toJson(reported) + "\n");
407
+ else if (args.format === "sarif") process.stdout.write(toSarif(reported));
408
+ else if (args.format === "shields") {
409
+ process.stdout.write(toBadge(result));
410
+ process.exit(0);
411
+ } else process.stdout.write(renderTerminal(reported, args.details) + "\n");
412
+ process.exit(reported.findings.length > 0 ? 1 : 0);
413
+ } catch (err) {
414
+ process.stderr.write("stopslop: " + err.message + "\n");
415
+ process.exit(2);
416
+ }
417
+ }
418
+ function validateArgs(args) {
419
+ if (args.base && (args.baseline || args.updateBaseline)) throw new Error("--base is mutually exclusive with --baseline and --update-baseline");
420
+ if (args.updateBaseline && !args.baseline) throw new Error("--update-baseline requires --baseline <file>");
421
+ }
422
+ main().catch((err) => {
423
+ process.stderr.write("stopslop: " + err.message + "\n");
424
+ process.exit(2);
425
+ });
426
+ //#endregion
427
+ export {};