stopslop 0.1.2 → 1.0.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 CHANGED
@@ -51,7 +51,7 @@ function renderTerminal(result, details) {
51
51
  /** The headline: one comparable number, with the densities it came from. */
52
52
  function renderScore(result) {
53
53
  const { score, level, densities, signalCount, totalSignals, percentile } = result.slop;
54
- const paint = LEVEL_COLOR$1[level];
54
+ const paint = LEVEL_COLOR[level];
55
55
  const partial = signalCount < totalSignals ? pc.yellow(` — ${signalCount}/${totalSignals} signals, see notes`) : "";
56
56
  const anchor = percentile !== void 0 ? pc.dim(` · worse than ${percentile}% of reference repos`) : "";
57
57
  const head = ` slop level: ${paint(pc.bold(level))} ${pc.dim(`(${score.toFixed(1)}/100)`)}${anchor}${partial}`;
@@ -62,7 +62,7 @@ function renderScore(result) {
62
62
  if (densities.deadCode !== void 0) parts.push(`dead code ${densities.deadCode.toFixed(2)}/KLOC`);
63
63
  return parts.length > 0 ? head + "\n " + pc.dim(parts.join(" · ")) : head;
64
64
  }
65
- const LEVEL_COLOR$1 = {
65
+ const LEVEL_COLOR = {
66
66
  clean: pc.green,
67
67
  low: pc.green,
68
68
  moderate: pc.yellow,
@@ -290,24 +290,15 @@ function isLine(value) {
290
290
  }
291
291
  //#endregion
292
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. */
293
+ /** shields.io endpoint JSON for the AI-slop gate. */
301
294
  function toBadge(result) {
302
- const { score, level, signalCount, totalSignals } = result.slop;
303
- const partial = signalCount < totalSignals ? "*" : "";
304
- const payload = {
295
+ const clear = result.findings.length === 0;
296
+ return `${JSON.stringify({
305
297
  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`;
298
+ label: "AI slop",
299
+ message: clear ? "clear" : "detected",
300
+ color: clear ? "brightgreen" : "red"
301
+ }, null, 2)}\n`;
311
302
  }
312
303
  //#endregion
313
304
  //#region src/analysis-completeness.ts
@@ -413,7 +404,7 @@ async function main() {
413
404
  if (args.format === "json") process.stdout.write(toJson(reported) + "\n");
414
405
  else if (args.format === "sarif") process.stdout.write(toSarif(reported));
415
406
  else if (args.format === "shields") {
416
- process.stdout.write(toBadge(result));
407
+ process.stdout.write(toBadge(reported));
417
408
  process.exit(0);
418
409
  } else process.stdout.write(renderTerminal(reported, args.details) + "\n");
419
410
  process.exit(reported.findings.length > 0 ? 1 : 0);
package/dist/src.js CHANGED
Binary file
@@ -0,0 +1,100 @@
1
+ # CI and automation
2
+
3
+ StopSlop has three stable exit codes: `0` means the gate is clear, `1` means
4
+ findings were detected, and `2` means analysis or configuration failed. Pin the
5
+ package version in `devDependencies` so local runs and CI use the same rules.
6
+
7
+ ## Reject new findings with a committed baseline
8
+
9
+ Create the baseline once and review it like source code:
10
+
11
+ ```bash
12
+ npx stopslop . \
13
+ --baseline .stopslop-baseline.json \
14
+ --update-baseline
15
+ git add .stopslop-baseline.json
16
+ ```
17
+
18
+ Then use the same file in CI:
19
+
20
+ ```yaml
21
+ - name: Reject new AI slop
22
+ run: npx stopslop . --baseline .stopslop-baseline.json
23
+ ```
24
+
25
+ The baseline suppresses accepted findings from the gate. It never removes them
26
+ from the full-repository score.
27
+
28
+ ## Compare a pull request with its Git base
29
+
30
+ This mode needs the base revision to exist locally:
31
+
32
+ ```yaml
33
+ - uses: actions/checkout@v7
34
+ with:
35
+ fetch-depth: 0
36
+
37
+ - name: Reject AI slop added by this change
38
+ run: npx stopslop . --base origin/main
39
+ ```
40
+
41
+ `--base` and `--baseline` are alternative gates. Do not pass them together.
42
+
43
+ ## Publish SARIF without hiding the gate failure
44
+
45
+ The scan may exit `1` after writing valid SARIF. Let the upload step run, then
46
+ propagate the original result. The workflow or job needs permission to publish
47
+ code-scanning results:
48
+
49
+ ```yaml
50
+ permissions:
51
+ contents: read
52
+ security-events: write
53
+ ```
54
+
55
+ Then add the scan, upload, and enforcement steps:
56
+
57
+ ```yaml
58
+ - name: Scan with StopSlop
59
+ id: stopslop
60
+ continue-on-error: true
61
+ run: npx stopslop . --base origin/main --format sarif > stopslop.sarif
62
+
63
+ - name: Upload StopSlop SARIF
64
+ if: always() && hashFiles('stopslop.sarif') != ''
65
+ uses: github/codeql-action/upload-sarif@v4
66
+ with:
67
+ sarif_file: stopslop.sarif
68
+
69
+ - name: Enforce StopSlop gate
70
+ if: steps.stopslop.outcome == 'failure'
71
+ run: exit 1
72
+ ```
73
+
74
+ An exit code of `2` does not emit a report, preventing an incomplete analysis
75
+ from being published as a trustworthy result.
76
+
77
+ ## Give structured findings to a coding agent
78
+
79
+ ```bash
80
+ npx stopslop . --format json > slop-report.json
81
+ ```
82
+
83
+ The JSON schema version is included in the document. Findings carry stable
84
+ `kind`, `file`, `symbol`, `line`, `message`, and optional structured `data`
85
+ fields. God-unit data includes responsibility groups; duplicate-code data
86
+ includes clone locations.
87
+
88
+ ## Generate a binary Shields badge
89
+
90
+ Use the same baseline as CI:
91
+
92
+ ```bash
93
+ npx stopslop . \
94
+ --baseline .stopslop-baseline.json \
95
+ --format shields > stopslop-badge.json
96
+ ```
97
+
98
+ The badge says `AI slop | clear` when the gate has no unaccepted findings and
99
+ `AI slop | detected` otherwise. Accepted legacy remains visible in the terminal
100
+ and JSON score.
@@ -0,0 +1,252 @@
1
+ # Configuration
2
+
3
+ stopslop reads `stopslop.json` from the analyzed directory (or `--config <path>`).
4
+ Every field is optional and merged over the defaults, so a config only needs the
5
+ knobs you want to change. Defaults are calibrated on a 22-repo TypeScript corpus
6
+ (see [thresholds.md](thresholds.md)).
7
+
8
+ The CLI validates StopSlop-owned fields at runtime as well as publishing the
9
+ schema: unknown properties, wrong types, and out-of-range values are errors
10
+ rather than silently ignored configuration.
11
+
12
+ ## Full example (all defaults)
13
+
14
+ ```json
15
+ {
16
+ "$schema": "https://unpkg.com/stopslop/schema.json",
17
+ "cognitiveComplexity": 15,
18
+ "godModule": { "minMembers": 15, "minClusters": 3, "minComplexity": 150, "sizeMembers": 37, "sizeComplexity": 350 },
19
+ "godClass": { "minMembers": 12, "minClusters": 3, "minComplexity": 90, "sizeMembers": 30, "sizeComplexity": 200 },
20
+ "multipleExportedClasses": { "maxPerFile": 1 },
21
+ "duplicates": { "minTokens": 50, "minLines": 5, "ignoreIdentifiers": false, "ignoreLiterals": false },
22
+ "deadCode": { "exports": true, "dependencies": true, "files": true, "orphans": true, "production": false },
23
+ "knip": {
24
+ "workspaces": {
25
+ ".": { "entry": ["src/index.ts"] },
26
+ "packages/*": { "entry": ["src/index.ts"], "project": ["src/**/*.ts"] }
27
+ }
28
+ },
29
+ "score": {
30
+ "weights": { "god": 0.35, "duplication": 0.25, "complexity": 0.2, "deadCode": 0.2 },
31
+ "budgets": { "god": 0.1, "duplication": 0.3, "complexity": 4, "deadCode": 4 },
32
+ "floors": { "god": 0, "duplication": 0.05, "complexity": 0, "deadCode": 0 }
33
+ },
34
+ "hubFanInRatio": 0.5,
35
+ "ignore": []
36
+ }
37
+ ```
38
+
39
+ ## Disabling a check
40
+
41
+ Set any check to `false` to turn it off entirely:
42
+
43
+ ```json
44
+ {
45
+ "godModule": false,
46
+ "multipleExportedClasses": false
47
+ }
48
+ ```
49
+
50
+ ## Metrics
51
+
52
+ ### `cognitiveComplexity` — number | false
53
+
54
+ Functions whose cognitive complexity (Campbell / SonarSource spec) is **strictly
55
+ above** this value are reported. Default **15** (the Sonar community default; on
56
+ the corpus it sits between count p95 ≈ 13 and p99 ≈ 38). Lower = stricter.
57
+
58
+ ```json
59
+ { "cognitiveComplexity": 20 }
60
+ ```
61
+
62
+ ### `godClass` / `godModule` — object | false
63
+
64
+ A class (or a module's top-level definitions) is flagged on **either** of two
65
+ axes — see [ARCH.md](../ARCH.md) for the reasoning.
66
+
67
+ | field | meaning | default (class / module) |
68
+ |---|---|---|
69
+ | `minMembers` | dispersion axis: minimum methods / top-level definitions | 12 / 15 |
70
+ | `minClusters` | dispersion axis: minimum **substantive** responsibility groups (≥2 members) | 3 / 3 |
71
+ | `minComplexity` | dispersion axis: minimum total (Σ) cognitive complexity | 90 / 150 |
72
+ | `sizeMembers` | size/WMC axis: method count that alone signals a monolith | 30 / 37 |
73
+ | `sizeComplexity` | size/WMC axis: total complexity (WMC) that alone signals a monolith | 200 / 350 |
74
+
75
+ - **Dispersion** fires when `members ≥ minMembers AND groups ≥ minClusters AND
76
+ Σcomplexity ≥ minComplexity` — a class doing several unrelated things, with
77
+ clean split boundaries in the report.
78
+ - **Size (WMC)** fires when `members ≥ sizeMembers AND Σcomplexity ≥
79
+ sizeComplexity` — a densely-connected monolith too large to be one unit, even
80
+ if fully cohesive. Reported honestly as "no automatic split boundaries".
81
+
82
+ Raise `size*` for fewer, higher-confidence size findings; raise `minClusters` to
83
+ demand more distinct responsibilities before flagging dispersion.
84
+
85
+ ```json
86
+ {
87
+ "godClass": { "sizeMembers": 45, "sizeComplexity": 300 },
88
+ "godModule": false
89
+ }
90
+ ```
91
+
92
+ ### `multipleExportedClasses` — object | false
93
+
94
+ Flags files exporting **more than** `maxPerFile` classes. Default `maxPerFile`
95
+ **1** (i.e. flag 2+). Informational severity.
96
+
97
+ ```json
98
+ { "multipleExportedClasses": { "maxPerFile": 2 } }
99
+ ```
100
+
101
+ ### `duplicates` — object | false
102
+
103
+ Copy-paste detection, powered by [Clone Alert](https://www.npmjs.com/package/clone-alert).
104
+ Only the files stopslop discovered are fed to it, so the ignore rules above apply
105
+ here too.
106
+
107
+ | field | meaning | default |
108
+ |---|---|---|
109
+ | `minTokens` | minimum duplicated token span reported as a clone | 50 |
110
+ | `minLines` | minimum physical lines a clone must span (its tightest occurrence) | 5 |
111
+ | `ignoreIdentifiers` | normalize identifier names before matching (Type-2 clones) | `false` |
112
+ | `ignoreLiterals` | normalize literal values before matching | `false` |
113
+
114
+ The default is **exact matching (Type-1 clones)** — the reported percentage is
115
+ real copy-paste. Set `ignoreIdentifiers: true` to also catch Type-2 clones (an
116
+ agent copy-pasting a block and renaming the variables); the cost is that
117
+ structurally-similar boilerplate matches too, which inflates the duplication
118
+ ratio on component libraries by several points. `minLines` keeps dense
119
+ one-liners (config tables, long template strings) from matching on token count
120
+ alone; turn it up to only see substantial clones.
121
+
122
+ ```json
123
+ { "duplicates": { "minTokens": 100, "minLines": 10, "ignoreIdentifiers": true } }
124
+ ```
125
+
126
+ ### `deadCode` — object | false
127
+
128
+ Unused code, powered by [Knip](https://knip.dev) via its programmatic session API
129
+ (no CLI spawn). This section controls which Knip findings StopSlop reports and
130
+ whether Knip runs in production mode. It is StopSlop policy, not Knip's own
131
+ configuration.
132
+
133
+ | field | meaning | default |
134
+ |---|---|---|
135
+ | `exports` | report unused exports and unused exported types (warn) | `true` |
136
+ | `dependencies` | report unused (dev)dependencies from package.json (warn) | `true` |
137
+ | `files` | report files unreachable from any entry point (info) | `true` |
138
+ | `orphans` | report functions kept alive by nothing but their own paired test (warn) | `true` |
139
+ | `production` | analyze as production-only: usage from tests no longer counts | `false` |
140
+
141
+ Unused files are **info**, not warn, on purpose: they depend on entry-point
142
+ detection, which misfires on projects with unconventional entries. Verify before
143
+ deleting — or configure `entry` under `stopslop.json#knip`.
144
+
145
+ An **orphan feature** is a function or class no production code calls, imported
146
+ by exactly one file: its own paired test. The trace of a generated feature that
147
+ was never wired in — a plain unused export has an innocent explanation ready,
148
+ this one has almost none. The finding names both halves to delete, source symbol
149
+ and test file, because removing either alone breaks the build or leaves a green
150
+ test standing over nothing. It is reported and deliberately never scored: the
151
+ point is to get the pair deleted, and a weight in the number removes no code.
152
+
153
+ Symbols still used inside their own file are excluded, as are shared test
154
+ helpers, stories, and non-callable constants — every one of them a way for a
155
+ live symbol to look like an orphan. Orphans need Knip to see the test files at
156
+ all, which means either a test-runner plugin it recognises or `entry` patterns
157
+ covering the tests.
158
+
159
+ ```json
160
+ { "deadCode": { "files": false, "production": true } }
161
+ ```
162
+
163
+ ### `knip` — object
164
+
165
+ The complete JSON-compatible [Knip configuration](https://knip.dev/reference/configuration).
166
+ This is the **only** Knip configuration StopSlop uses. It never reads, merges,
167
+ or executes project `knip.json`, `.knip.json`, or `knip.config.*` files. The
168
+ root `package.json` is still project metadata, but its `knip` field is discarded
169
+ and never merged. Move any settings StopSlop should use into this property.
170
+
171
+ ```json
172
+ {
173
+ "knip": {
174
+ "ignoreDependencies": ["optional-adapter"],
175
+ "workspaces": {
176
+ ".": { "entry": ["src/index.ts"] },
177
+ "packages/*": { "entry": ["src/index.ts"], "project": ["src/**/*.ts"] },
178
+ "packages/cli": { "entry": ["src/cli.ts"] }
179
+ }
180
+ }
181
+ }
182
+ ```
183
+
184
+ Workspace keys and overrides follow Knip unchanged: `.` configures the root,
185
+ glob keys select groups of packages, and a more specific key can override a
186
+ group. In a detected monorepo without `knip.workspaces`, findings are still
187
+ shown but excluded from the dead-code score because entry-point detection is
188
+ not trustworthy enough to grade the repository.
189
+
190
+ In a single-package project, `entry` and `project` belong directly under
191
+ `knip`. In a monorepo they belong under `knip.workspaces`; Knip ignores the
192
+ top-level forms once multiple workspaces are present.
193
+
194
+ One Knip default is changed: StopSlop sets `ignoreExportsUsedInFile` to `true`.
195
+ Knip otherwise reports an export nothing imports even when the symbol is used
196
+ inside its own file — a `export` keyword wider than it needs to be, which no
197
+ behaviour depends on. That is a style nit rather than the structural waste
198
+ StopSlop reports. Set it back to `false` under `knip` to get Knip's own default;
199
+ the granular per-symbol-type form works as documented upstream.
200
+
201
+ The package ships `schema.json`. Add
202
+ `"$schema": "https://unpkg.com/stopslop/schema.json"` for editor completion,
203
+ field descriptions, and validation. StopSlop consumes configuration only; it
204
+ does not infer or generate project settings. That project inspection is kept as
205
+ a boundary for future external tooling (such as an MCP server).
206
+
207
+ ### `score` — object
208
+
209
+ Weights, floors, and budgets of the slop level. A signal contributes nothing
210
+ below its floor, rises linearly, and saturates at its budget. Full formula and
211
+ rationale (all anchors measured on a 41-repo benchmark of mature TypeScript
212
+ OSS): [score.md](score.md).
213
+
214
+ ```json
215
+ {
216
+ "score": {
217
+ "weights": { "god": 0.35, "duplication": 0.25, "complexity": 0.2, "deadCode": 0.2 },
218
+ "budgets": { "god": 0.1, "duplication": 0.3, "complexity": 4, "deadCode": 4 },
219
+ "floors": { "god": 0, "duplication": 0.05, "complexity": 0, "deadCode": 0 }
220
+ }
221
+ }
222
+ ```
223
+
224
+ Weights need not sum to 1 — the score divides by the sum of the weights that
225
+ actually ran. Disabling a check removes its signal from the formula entirely
226
+ rather than scoring it as clean. The "worse than N% of reference repos" anchor
227
+ is recomputed with your weights/floors/budgets, so a custom config re-ranks the
228
+ corpus with the same yardstick it applies to your repo.
229
+
230
+ ## Engine knobs
231
+
232
+ ### `hubFanInRatio` — number (0..1)
233
+
234
+ A member referenced by more than this fraction of the unit's methods is treated
235
+ as a "hub" (shared glue), removed from the cohesion graph, and the responsibility
236
+ groups are recomputed — so a class held together by one shared field still splits
237
+ into its real parts. Default **0.5**. Lower = more aggressive splitting.
238
+
239
+ ### `ignore` — string[]
240
+
241
+ Extra glob patterns (matched against POSIX-relative paths) to exclude, on top of
242
+ the built-in excludes (`node_modules`, `dist`/`build`, tests, fixtures, `.d.ts`,
243
+ minified/bundled files, and generator subtrees such as openapi-generator).
244
+
245
+ ```json
246
+ { "ignore": ["**/*.generated.ts", "src/legacy/**"] }
247
+ ```
248
+
249
+ ## Reference
250
+
251
+ The JSON report (`--json`) echoes the fully-resolved config under `thresholds`,
252
+ so you can confirm exactly what ran.
@@ -0,0 +1,114 @@
1
+ # Node.js API
2
+
3
+ StopSlop ships a typed ESM API for tools that need findings and metrics in
4
+ memory instead of a CLI report. It requires Node.js `^20.19.0 || >=22.12.0`.
5
+
6
+ ## Analyze with defaults
7
+
8
+ Resolve configuration before calling `analyze`:
9
+
10
+ ```ts
11
+ import { analyze, resolveConfig } from 'stopslop';
12
+
13
+ const result = await analyze(process.cwd(), resolveConfig(undefined));
14
+
15
+ for (const finding of result.findings) {
16
+ console.log(finding.kind, finding.file, finding.line, finding.message);
17
+ }
18
+
19
+ console.log({
20
+ score: result.slop.score,
21
+ level: result.slop.level,
22
+ errors: result.errors,
23
+ notes: result.notes,
24
+ });
25
+ ```
26
+
27
+ `findings` contains the issues that should fail the current gate. `slop` always
28
+ describes the complete analyzed repository. Check `errors` before trusting a
29
+ result; `notes` explains non-fatal skips by project-level engines.
30
+
31
+ ## Load `stopslop.json`
32
+
33
+ `loadConfig` reads `stopslop.json` from the analysis root and returns the
34
+ resolved defaults plus overrides:
35
+
36
+ ```ts
37
+ import { analyze, loadConfig } from 'stopslop';
38
+
39
+ const root = process.cwd();
40
+ const result = await analyze(root, loadConfig(root));
41
+ ```
42
+
43
+ To build configuration in code, pass the user-facing shape to `resolveConfig`:
44
+
45
+ ```ts
46
+ import { analyze, resolveConfig, type StopSlopConfig } from 'stopslop';
47
+
48
+ const input: StopSlopConfig = {
49
+ cognitiveComplexity: 20,
50
+ duplicates: { minTokens: 100, minLines: 10 },
51
+ deadCode: { files: false },
52
+ };
53
+
54
+ const result = await analyze(process.cwd(), resolveConfig(input));
55
+ ```
56
+
57
+ Unknown fields and invalid values throw instead of being ignored.
58
+
59
+ ## Compare with a Git revision
60
+
61
+ `analyzeGitBase` returns only findings absent from the selected revision while
62
+ retaining the current repository's complete score:
63
+
64
+ ```ts
65
+ import { analyzeGitBase, loadConfig } from 'stopslop';
66
+
67
+ const root = process.cwd();
68
+ const result = await analyzeGitBase(root, 'origin/main', loadConfig(root));
69
+
70
+ if (result.errors.length > 0) process.exitCode = 2;
71
+ else if (result.findings.length > 0) process.exitCode = 1;
72
+ ```
73
+
74
+ The ref must resolve in the local Git repository. StopSlop creates a temporary
75
+ detached worktree; it does not switch the current branch or modify the index.
76
+
77
+ ## Extend the analyzer list
78
+
79
+ Pass a third argument to append a custom analyzer to the built-in set:
80
+
81
+ ```ts
82
+ import {
83
+ analyze,
84
+ defaultAnalyzers,
85
+ resolveConfig,
86
+ type Analyzer,
87
+ } from 'stopslop';
88
+
89
+ const custom: Analyzer = {
90
+ name: 'project-policy',
91
+ analyzeFile(file) {
92
+ return file.source.includes('DO NOT SHIP')
93
+ ? [{
94
+ kind: 'unused-file',
95
+ file: file.relPath,
96
+ symbol: 'DO NOT SHIP',
97
+ line: 1,
98
+ severity: 'warn',
99
+ message: 'Unresolved project marker',
100
+ }]
101
+ : [];
102
+ },
103
+ };
104
+
105
+ const result = await analyze(
106
+ process.cwd(),
107
+ resolveConfig(undefined),
108
+ [...defaultAnalyzers(), custom],
109
+ );
110
+ ```
111
+
112
+ Custom analyzers currently return the published `Finding` union, so use an
113
+ existing finding kind for integrations. Treat that surface as pre-1.0; see the
114
+ [compatibility policy](../COMPATIBILITY.md).
package/docs/score.md ADDED
@@ -0,0 +1,133 @@
1
+ # Slop level: the formula
2
+
3
+ The slop level is one number in `0..100` plus a band label. It exists so that a
4
+ repo can be compared **with itself over time** and **with other repos**, which
5
+ raw finding counts cannot do: a 200 KLOC monorepo will always "win" on absolute
6
+ counts without being any sloppier per line written.
7
+
8
+ Nothing here is a black box, and nothing here is science. The **densities are
9
+ the facts**; the score is a weighted aggregate over them, with heuristic weights
10
+ and budgets that you can change.
11
+
12
+ ## The formula
13
+
14
+ ```
15
+ score = 100 · Σ wᵢ · sat(xᵢ) / Σ wᵢ
16
+ sat(x) = clamp((x − floorᵢ) / (budgetᵢ − floorᵢ), 0, 1)
17
+ ```
18
+
19
+ where `i` ranges over the **enabled** signals:
20
+
21
+ | signal | xᵢ (density) | weight wᵢ | floor | budget |
22
+ |---|---|---|---|---|
23
+ | `god` | (god modules + god classes) / KLOC | 0.35 | 0 | 0.1 |
24
+ | `duplication` | duplicated lines / total lines (0..1) | 0.25 | 0.05 | 0.30 |
25
+ | `complexity` | over-complex functions / KLOC | 0.20 | 0 | 4 |
26
+ | `deadCode` | (unused exports + deps) / KLOC | 0.20 | 0 | 4 |
27
+
28
+ Below the floor a signal contributes nothing — that density is the baseline of
29
+ any living codebase, not slop. At the budget the contribution saturates.
30
+
31
+ Only `warn`-severity findings feed the score. `info` findings — unused *files*,
32
+ which hang entirely on Knip's entry-point detection — are reported but never
33
+ scored: a misdetected entry point must not outweigh a real god class.
34
+
35
+ Bands: `clean` <10 · `low` <25 · `moderate` <50 · `high` <75 · `legacy-grade` ≥75.
36
+ The top band is `legacy-grade`, not "severe": a high score means the code reads
37
+ like accumulated legacy — true whether a team wrote it over a decade or an agent
38
+ produced it last week. It is a description, not a verdict on the engineers.
39
+
40
+ Three properties are deliberate:
41
+
42
+ - **Size independence.** Every signal is a rate, not a count. Four god classes in
43
+ 20 KLOC score exactly like one in 5 KLOC.
44
+ - **Saturation.** `min(1, …)` caps each signal at its budget: a repo with 100 god
45
+ modules is not "20× worse" than one with 5 — it is simply maxed out on that
46
+ axis, and the other axes still matter. Without the cap, one pathological file
47
+ would drown out everything else.
48
+ - **A check that did not run leaves the formula.** If you turn off `deadCode` — or
49
+ if Knip bails out because the project's dependencies are not installed, or the
50
+ project is a monorepo without `stopslop.json#knip.workspaces` (where Knip's
51
+ entry-point detection produces a landslide of false "unused") — its weight drops out of the
52
+ denominator instead of contributing zero. Not measuring something must never
53
+ read as a clean bill of health. The terminal report shows only the signals
54
+ that were actually measured, and says in a note why one is missing.
55
+
56
+ `multiple-exported-classes` is reported but not scored: it is a structural hint,
57
+ not a quantity of slop, and it correlates with the god-module signal already.
58
+
59
+ ## Where the numbers come from
60
+
61
+ Honestly: the **weights are a judgement call**; the **floors and budgets are
62
+ measured**, not invented. Every non-zero anchor comes from a 41-repo / 9.4 MLOC
63
+ benchmark run over mature TypeScript OSS (angular, react, playwright, nextjs,
64
+ svelte, vue-core, nest, vite, material-ui and the like — the corpus ships in
65
+ `src/corpus.ts`), by one rule: a *floor* is what ordinary repos carry (~p25), a
66
+ *budget* is "past anything in the reference corpus" (~max/p90). The calibrated
67
+ defaults remain configurable (`score.weights`, `score.budgets`, `score.floors`).
68
+
69
+ - **Weights** encode what we claim slop *is*. Misplaced structure (god units) and
70
+ copy-paste are the expensive kinds — they are what an agent produces when it
71
+ keeps appending instead of refactoring, and they are what a human has to undo
72
+ by hand. A single hairy function or a stale export is cheaper to fix, so
73
+ complexity and dead code weigh less. `god` (0.35) > `duplication` (0.25) >
74
+ `complexity` = `deadCode` (0.20).
75
+ - **`god` budget = 0.1/KLOC** — corpus median 0.013, half the repos at exactly 0,
76
+ worst repo (playwright) at 0.071. No floor: a single god unit is already slop.
77
+ - **`duplication` floor = 5%, budget = 30%** — corpus p25 ≈ 5% (unavoidable
78
+ boilerplate of a living TS codebase), p90 ≈ 31% (mantine). The first-guess
79
+ budget (10%, the jscpd convention) sat *below the corpus median of ~13%*: the
80
+ signal saturated on ¾ of the corpus, became a constant +25 points for nearly
81
+ everyone, and compressed all 42 benchmark scores into 27..56 — a scale with
82
+ only two live bands. Measured beats conventional.
83
+ - **`complexity` budget = 4/KLOC** — the detection threshold is already the count
84
+ p95 of the calibration corpus ([thresholds.md](thresholds.md)); the corpus
85
+ maximum of repos crossing it is 3.31/KLOC (svelte — a compiler, i.e. honestly
86
+ complex). 4 = past anything in the corpus.
87
+ - **`deadCode` budget = 4/KLOC** — ≈ p75 of the sub-corpus where dead code was
88
+ actually measurable; one unused export/dependency every 250 lines means the
89
+ project has stopped removing what it stopped using. This is the noisiest
90
+ signal (it leans on Knip config quality, and a library's public API reads as
91
+ "unused exports"), which is why it carries the smallest weight and never
92
+ enters the corpus percentile below.
93
+
94
+ ## "Worse than N% of reference repos"
95
+
96
+ Alongside the absolute score, the report anchors the repo against the reference
97
+ corpus: the share of the 41 benchmark repos with a strictly lower **core score**
98
+ — the score computed from the three signals measured identically everywhere
99
+ (god, duplication, complexity; dead code is excluded as infrastructure-dependent).
100
+ The comparison uses *your* weights/floors/budgets, so a custom config re-ranks
101
+ the corpus with the same yardstick it applies to your repo. It appears only when
102
+ all three core signals were measured (`--fast` has no percentile).
103
+
104
+ This is context, not a second verdict: "43/100, worse than 60% of reference
105
+ repos" tells you where the number sits among codebases nobody calls slop.
106
+
107
+ ## What the score means — and what it does *not*
108
+
109
+ The score is **"how much uncleaned, suspicious code is left"**, not a verdict on
110
+ engineering quality. The intended reading:
111
+
112
+ - Clean it up and the number goes to zero: no god units, no duplication, no dead
113
+ code → 0, whether the code was written by a human or an agent. Fix the god
114
+ class, deduplicate, remove dead exports — the score follows.
115
+ - Its primary use is comparing a repo **with itself over time** (did the agent's
116
+ last week of commits push it up?). Cross-repo comparison is meaningful but
117
+ needs the caveat below.
118
+ - A genuinely complex domain scores high honestly: playwright tops the corpus
119
+ ranking because it really does carry the corpus's highest god-unit density
120
+ (0.071/KLOC) and near-maximal density of over-complex functions. The score
121
+ reports that; it does not know (or claim to know) that the complexity is
122
+ well-earned. High score = "a lot here would need justification", not "bad
123
+ engineers".
124
+
125
+ Other non-goals:
126
+
127
+ - `deadCode` anchors are the weakest: the signal depends on Knip configuration
128
+ and misreads a library's public API as unused exports (see above).
129
+ - It does not weigh a finding by how bad it is individually (a complexity-80
130
+ function counts the same as a complexity-16 one). Severity lives in the
131
+ findings, not the score.
132
+ - It is not a quality score. Clean code with zero slop signals can still be bad
133
+ code — stopslop only claims to measure *overproduction*.