stopslop 0.2.0 → 1.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/COMPATIBILITY.md +31 -0
- package/README.md +100 -9
- package/context7.json +22 -0
- package/dist/cli.js +30 -3
- package/dist/index.d.ts +32 -1
- package/dist/index.js +1 -1
- package/dist/src.js +0 -0
- package/docs/ci-and-automation.md +100 -0
- package/docs/configuration.md +257 -0
- package/docs/node-api.md +115 -0
- package/docs/score.md +134 -0
- package/docs/thresholds.md +105 -0
- package/llms.txt +27 -0
- package/package.json +48 -8
- package/schema.json +63 -0
|
@@ -0,0 +1,257 @@
|
|
|
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.
|
|
187
|
+
|
|
188
|
+
Every run lists what Knip finds missing from this configuration under **config
|
|
189
|
+
hints** (`configHints` in `--format json`): a workspace it never reached, an
|
|
190
|
+
entry pattern that matched nothing, and how many unused files each accounts
|
|
191
|
+
for. While Knip reports the project or a workspace as unconfigured, dead-code
|
|
192
|
+
findings are shown but excluded from the score — without entry points, live
|
|
193
|
+
files come back unreachable.
|
|
194
|
+
|
|
195
|
+
In a single-package project, `entry` and `project` belong directly under
|
|
196
|
+
`knip`. In a monorepo they belong under `knip.workspaces`; Knip ignores the
|
|
197
|
+
top-level forms once multiple workspaces are present.
|
|
198
|
+
|
|
199
|
+
One Knip default is changed: StopSlop sets `ignoreExportsUsedInFile` to `true`.
|
|
200
|
+
Knip otherwise reports an export nothing imports even when the symbol is used
|
|
201
|
+
inside its own file — a `export` keyword wider than it needs to be, which no
|
|
202
|
+
behaviour depends on. That is a style nit rather than the structural waste
|
|
203
|
+
StopSlop reports. Set it back to `false` under `knip` to get Knip's own default;
|
|
204
|
+
the granular per-symbol-type form works as documented upstream.
|
|
205
|
+
|
|
206
|
+
The package ships `schema.json`. Add
|
|
207
|
+
`"$schema": "https://unpkg.com/stopslop/schema.json"` for editor completion,
|
|
208
|
+
field descriptions, and validation. StopSlop consumes configuration only; it
|
|
209
|
+
does not infer or generate project settings. That project inspection is kept as
|
|
210
|
+
a boundary for future external tooling (such as an MCP server).
|
|
211
|
+
|
|
212
|
+
### `score` — object
|
|
213
|
+
|
|
214
|
+
Weights, floors, and budgets of the slop level. A signal contributes nothing
|
|
215
|
+
below its floor, rises linearly, and saturates at its budget. Full formula and
|
|
216
|
+
rationale (all anchors measured on a 41-repo benchmark of mature TypeScript
|
|
217
|
+
OSS): [score.md](score.md).
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"score": {
|
|
222
|
+
"weights": { "god": 0.35, "duplication": 0.25, "complexity": 0.2, "deadCode": 0.2 },
|
|
223
|
+
"budgets": { "god": 0.1, "duplication": 0.3, "complexity": 4, "deadCode": 4 },
|
|
224
|
+
"floors": { "god": 0, "duplication": 0.05, "complexity": 0, "deadCode": 0 }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Weights need not sum to 1 — the score divides by the sum of the weights that
|
|
230
|
+
actually ran. Disabling a check removes its signal from the formula entirely
|
|
231
|
+
rather than scoring it as clean. The "worse than N% of reference repos" anchor
|
|
232
|
+
is recomputed with your weights/floors/budgets, so a custom config re-ranks the
|
|
233
|
+
corpus with the same yardstick it applies to your repo.
|
|
234
|
+
|
|
235
|
+
## Engine knobs
|
|
236
|
+
|
|
237
|
+
### `hubFanInRatio` — number (0..1)
|
|
238
|
+
|
|
239
|
+
A member referenced by more than this fraction of the unit's methods is treated
|
|
240
|
+
as a "hub" (shared glue), removed from the cohesion graph, and the responsibility
|
|
241
|
+
groups are recomputed — so a class held together by one shared field still splits
|
|
242
|
+
into its real parts. Default **0.5**. Lower = more aggressive splitting.
|
|
243
|
+
|
|
244
|
+
### `ignore` — string[]
|
|
245
|
+
|
|
246
|
+
Extra glob patterns (matched against POSIX-relative paths) to exclude, on top of
|
|
247
|
+
the built-in excludes (`node_modules`, `dist`/`build`, tests, fixtures, `.d.ts`,
|
|
248
|
+
minified/bundled files, and generator subtrees such as openapi-generator).
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{ "ignore": ["**/*.generated.ts", "src/legacy/**"] }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Reference
|
|
255
|
+
|
|
256
|
+
The JSON report (`--json`) echoes the fully-resolved config under `thresholds`,
|
|
257
|
+
so you can confirm exactly what ran.
|
package/docs/node-api.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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, and
|
|
30
|
+
`configHints` lists what Knip reports as missing from `stopslop.json#knip`.
|
|
31
|
+
|
|
32
|
+
## Load `stopslop.json`
|
|
33
|
+
|
|
34
|
+
`loadConfig` reads `stopslop.json` from the analysis root and returns the
|
|
35
|
+
resolved defaults plus overrides:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { analyze, loadConfig } from 'stopslop';
|
|
39
|
+
|
|
40
|
+
const root = process.cwd();
|
|
41
|
+
const result = await analyze(root, loadConfig(root));
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
To build configuration in code, pass the user-facing shape to `resolveConfig`:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { analyze, resolveConfig, type StopSlopConfig } from 'stopslop';
|
|
48
|
+
|
|
49
|
+
const input: StopSlopConfig = {
|
|
50
|
+
cognitiveComplexity: 20,
|
|
51
|
+
duplicates: { minTokens: 100, minLines: 10 },
|
|
52
|
+
deadCode: { files: false },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const result = await analyze(process.cwd(), resolveConfig(input));
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Unknown fields and invalid values throw instead of being ignored.
|
|
59
|
+
|
|
60
|
+
## Compare with a Git revision
|
|
61
|
+
|
|
62
|
+
`analyzeGitBase` returns only findings absent from the selected revision while
|
|
63
|
+
retaining the current repository's complete score:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { analyzeGitBase, loadConfig } from 'stopslop';
|
|
67
|
+
|
|
68
|
+
const root = process.cwd();
|
|
69
|
+
const result = await analyzeGitBase(root, 'origin/main', loadConfig(root));
|
|
70
|
+
|
|
71
|
+
if (result.errors.length > 0) process.exitCode = 2;
|
|
72
|
+
else if (result.findings.length > 0) process.exitCode = 1;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The ref must resolve in the local Git repository. StopSlop creates a temporary
|
|
76
|
+
detached worktree; it does not switch the current branch or modify the index.
|
|
77
|
+
|
|
78
|
+
## Extend the analyzer list
|
|
79
|
+
|
|
80
|
+
Pass a third argument to append a custom analyzer to the built-in set:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import {
|
|
84
|
+
analyze,
|
|
85
|
+
defaultAnalyzers,
|
|
86
|
+
resolveConfig,
|
|
87
|
+
type Analyzer,
|
|
88
|
+
} from 'stopslop';
|
|
89
|
+
|
|
90
|
+
const custom: Analyzer = {
|
|
91
|
+
name: 'project-policy',
|
|
92
|
+
analyzeFile(file) {
|
|
93
|
+
return file.source.includes('DO NOT SHIP')
|
|
94
|
+
? [{
|
|
95
|
+
kind: 'unused-file',
|
|
96
|
+
file: file.relPath,
|
|
97
|
+
symbol: 'DO NOT SHIP',
|
|
98
|
+
line: 1,
|
|
99
|
+
severity: 'warn',
|
|
100
|
+
message: 'Unresolved project marker',
|
|
101
|
+
}]
|
|
102
|
+
: [];
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const result = await analyze(
|
|
107
|
+
process.cwd(),
|
|
108
|
+
resolveConfig(undefined),
|
|
109
|
+
[...defaultAnalyzers(), custom],
|
|
110
|
+
);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Custom analyzers currently return the published `Finding` union, so use an
|
|
114
|
+
existing finding kind for integrations. That surface is experimental and may
|
|
115
|
+
change in a minor release; see the [compatibility policy](../COMPATIBILITY.md).
|
package/docs/score.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
|
50
|
+
Knip reports the run as unconfigured (its `top-level-unconfigured` or
|
|
51
|
+
`workspace-unconfigured` config hint: more files unreachable than anything but
|
|
52
|
+
missing entry points explains) — its weight drops out of the
|
|
53
|
+
denominator instead of contributing zero. Not measuring something must never
|
|
54
|
+
read as a clean bill of health. The terminal report shows only the signals
|
|
55
|
+
that were actually measured, and says in a note why one is missing.
|
|
56
|
+
|
|
57
|
+
`multiple-exported-classes` is reported but not scored: it is a structural hint,
|
|
58
|
+
not a quantity of slop, and it correlates with the god-module signal already.
|
|
59
|
+
|
|
60
|
+
## Where the numbers come from
|
|
61
|
+
|
|
62
|
+
Honestly: the **weights are a judgement call**; the **floors and budgets are
|
|
63
|
+
measured**, not invented. Every non-zero anchor comes from a 41-repo / 9.4 MLOC
|
|
64
|
+
benchmark run over mature TypeScript OSS (angular, react, playwright, nextjs,
|
|
65
|
+
svelte, vue-core, nest, vite, material-ui and the like — the corpus ships in
|
|
66
|
+
`src/corpus.ts`), by one rule: a *floor* is what ordinary repos carry (~p25), a
|
|
67
|
+
*budget* is "past anything in the reference corpus" (~max/p90). The calibrated
|
|
68
|
+
defaults remain configurable (`score.weights`, `score.budgets`, `score.floors`).
|
|
69
|
+
|
|
70
|
+
- **Weights** encode what we claim slop *is*. Misplaced structure (god units) and
|
|
71
|
+
copy-paste are the expensive kinds — they are what an agent produces when it
|
|
72
|
+
keeps appending instead of refactoring, and they are what a human has to undo
|
|
73
|
+
by hand. A single hairy function or a stale export is cheaper to fix, so
|
|
74
|
+
complexity and dead code weigh less. `god` (0.35) > `duplication` (0.25) >
|
|
75
|
+
`complexity` = `deadCode` (0.20).
|
|
76
|
+
- **`god` budget = 0.1/KLOC** — corpus median 0.013, half the repos at exactly 0,
|
|
77
|
+
worst repo (playwright) at 0.071. No floor: a single god unit is already slop.
|
|
78
|
+
- **`duplication` floor = 5%, budget = 30%** — corpus p25 ≈ 5% (unavoidable
|
|
79
|
+
boilerplate of a living TS codebase), p90 ≈ 31% (mantine). The first-guess
|
|
80
|
+
budget (10%, the jscpd convention) sat *below the corpus median of ~13%*: the
|
|
81
|
+
signal saturated on ¾ of the corpus, became a constant +25 points for nearly
|
|
82
|
+
everyone, and compressed all 42 benchmark scores into 27..56 — a scale with
|
|
83
|
+
only two live bands. Measured beats conventional.
|
|
84
|
+
- **`complexity` budget = 4/KLOC** — the detection threshold is already the count
|
|
85
|
+
p95 of the calibration corpus ([thresholds.md](thresholds.md)); the corpus
|
|
86
|
+
maximum of repos crossing it is 3.31/KLOC (svelte — a compiler, i.e. honestly
|
|
87
|
+
complex). 4 = past anything in the corpus.
|
|
88
|
+
- **`deadCode` budget = 4/KLOC** — ≈ p75 of the sub-corpus where dead code was
|
|
89
|
+
actually measurable; one unused export/dependency every 250 lines means the
|
|
90
|
+
project has stopped removing what it stopped using. This is the noisiest
|
|
91
|
+
signal (it leans on Knip config quality, and a library's public API reads as
|
|
92
|
+
"unused exports"), which is why it carries the smallest weight and never
|
|
93
|
+
enters the corpus percentile below.
|
|
94
|
+
|
|
95
|
+
## "Worse than N% of reference repos"
|
|
96
|
+
|
|
97
|
+
Alongside the absolute score, the report anchors the repo against the reference
|
|
98
|
+
corpus: the share of the 41 benchmark repos with a strictly lower **core score**
|
|
99
|
+
— the score computed from the three signals measured identically everywhere
|
|
100
|
+
(god, duplication, complexity; dead code is excluded as infrastructure-dependent).
|
|
101
|
+
The comparison uses *your* weights/floors/budgets, so a custom config re-ranks
|
|
102
|
+
the corpus with the same yardstick it applies to your repo. It appears only when
|
|
103
|
+
all three core signals were measured (`--fast` has no percentile).
|
|
104
|
+
|
|
105
|
+
This is context, not a second verdict: "43/100, worse than 60% of reference
|
|
106
|
+
repos" tells you where the number sits among codebases nobody calls slop.
|
|
107
|
+
|
|
108
|
+
## What the score means — and what it does *not*
|
|
109
|
+
|
|
110
|
+
The score is **"how much uncleaned, suspicious code is left"**, not a verdict on
|
|
111
|
+
engineering quality. The intended reading:
|
|
112
|
+
|
|
113
|
+
- Clean it up and the number goes to zero: no god units, no duplication, no dead
|
|
114
|
+
code → 0, whether the code was written by a human or an agent. Fix the god
|
|
115
|
+
class, deduplicate, remove dead exports — the score follows.
|
|
116
|
+
- Its primary use is comparing a repo **with itself over time** (did the agent's
|
|
117
|
+
last week of commits push it up?). Cross-repo comparison is meaningful but
|
|
118
|
+
needs the caveat below.
|
|
119
|
+
- A genuinely complex domain scores high honestly: playwright tops the corpus
|
|
120
|
+
ranking because it really does carry the corpus's highest god-unit density
|
|
121
|
+
(0.071/KLOC) and near-maximal density of over-complex functions. The score
|
|
122
|
+
reports that; it does not know (or claim to know) that the complexity is
|
|
123
|
+
well-earned. High score = "a lot here would need justification", not "bad
|
|
124
|
+
engineers".
|
|
125
|
+
|
|
126
|
+
Other non-goals:
|
|
127
|
+
|
|
128
|
+
- `deadCode` anchors are the weakest: the signal depends on Knip configuration
|
|
129
|
+
and misreads a library's public API as unused exports (see above).
|
|
130
|
+
- It does not weigh a finding by how bad it is individually (a complexity-80
|
|
131
|
+
function counts the same as a complexity-16 one). Severity lives in the
|
|
132
|
+
findings, not the score.
|
|
133
|
+
- It is not a quality score. Clean code with zero slop signals can still be bad
|
|
134
|
+
code — stopslop only claims to measure *overproduction*.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Threshold calibration
|
|
2
|
+
|
|
3
|
+
Two lenses on the same corpus:
|
|
4
|
+
|
|
5
|
+
- **Count percentiles** — each entity counts once. "pXX = N" means XX% of
|
|
6
|
+
entities score ≤ N. This is what we use for defaults: a god finding should be
|
|
7
|
+
the worst ~1–5% of entities.
|
|
8
|
+
- **LOC-weighted percentiles** — the Alves/Ypma/Visser (ICSM 2010) methodology:
|
|
9
|
+
each entity weighted by LOC, so "pXX = N" means XX% of *code volume* sits in
|
|
10
|
+
entities ≤ N. Kept for reference; it skews high because large entities are
|
|
11
|
+
both more complex and hold more volume.
|
|
12
|
+
|
|
13
|
+
Corpus: 22 TypeScript repositories, 65,817 analyzed files (tests, fixtures,
|
|
14
|
+
generated and bundled code excluded). The per-repository counts are listed
|
|
15
|
+
below.
|
|
16
|
+
|
|
17
|
+
## Count percentiles (basis for defaults)
|
|
18
|
+
|
|
19
|
+
### Function
|
|
20
|
+
| metric | n | p50 | p75 | p90 | p95 | p99 |
|
|
21
|
+
|---|---|---|---|---|---|---|
|
|
22
|
+
| cognitive complexity | 108102 | 0 | 2 | 7 | 13 | 38 |
|
|
23
|
+
|
|
24
|
+
### Class (≥3 methods)
|
|
25
|
+
| metric | n | p50 | p75 | p90 | p95 | p99 |
|
|
26
|
+
|---|---|---|---|---|---|---|
|
|
27
|
+
| methods | 2940 | 6 | 12 | 23 | 32 | 64 |
|
|
28
|
+
| responsibility groups (≥2) | 2940 | 1 | 1 | 2 | 2 | 4 |
|
|
29
|
+
| Σ cognitive complexity | 2940 | 8 | 24 | 58 | 93 | 207 |
|
|
30
|
+
|
|
31
|
+
### Module (≥5 top-level fns)
|
|
32
|
+
| metric | n | p50 | p75 | p90 | p95 | p99 |
|
|
33
|
+
|---|---|---|---|---|---|---|
|
|
34
|
+
| top-level definitions | 2819 | 8 | 11 | 16 | 22 | 37 |
|
|
35
|
+
| responsibility groups (≥2) | 2819 | 1 | 1 | 2 | 2 | 3 |
|
|
36
|
+
| Σ cognitive complexity | 2819 | 21 | 54 | 109 | 167 | 354 |
|
|
37
|
+
|
|
38
|
+
## LOC-weighted percentiles (Alves, reference)
|
|
39
|
+
|
|
40
|
+
### Function
|
|
41
|
+
| metric | n | p50 | p75 | p90 | p95 | p99 |
|
|
42
|
+
|---|---|---|---|---|---|---|
|
|
43
|
+
| cognitive complexity | 108102 | 4 | 16 | 45 | 95 | 309 |
|
|
44
|
+
|
|
45
|
+
### Class (≥3 methods)
|
|
46
|
+
| metric | n | p50 | p75 | p90 | p95 | p99 |
|
|
47
|
+
|---|---|---|---|---|---|---|
|
|
48
|
+
| methods | 2940 | 15 | 30 | 57 | 74 | 111 |
|
|
49
|
+
| responsibility groups (≥2) | 2940 | 1 | 1 | 2 | 3 | 5 |
|
|
50
|
+
| Σ cognitive complexity | 2940 | 41 | 104 | 205 | 341 | 825 |
|
|
51
|
+
|
|
52
|
+
### Module (≥5 top-level fns)
|
|
53
|
+
| metric | n | p50 | p75 | p90 | p95 | p99 |
|
|
54
|
+
|---|---|---|---|---|---|---|
|
|
55
|
+
| top-level definitions | 2819 | 10 | 17 | 29 | 38 | 72 |
|
|
56
|
+
| responsibility groups (≥2) | 2819 | 1 | 1 | 2 | 2 | 4 |
|
|
57
|
+
| Σ cognitive complexity | 2819 | 51 | 128 | 280 | 455 | 1175 |
|
|
58
|
+
|
|
59
|
+
## Final defaults
|
|
60
|
+
|
|
61
|
+
A god finding is rare by construction, but the detector does not compare each
|
|
62
|
+
metric with an independent p95 cutoff. It has two alternative axes:
|
|
63
|
+
|
|
64
|
+
- **dispersion** requires members **and** responsibility groups **and** total
|
|
65
|
+
complexity together, so its member floor can stay sensitive;
|
|
66
|
+
- **size/WMC** requires both a high member count and high total complexity, even
|
|
67
|
+
when the unit is cohesive.
|
|
68
|
+
|
|
69
|
+
| gate | members | groups | Σ complexity | final setting |
|
|
70
|
+
|---|---:|---:|---:|---|
|
|
71
|
+
| cognitive complexity | — | — | 15 | `cognitiveComplexity: 15` |
|
|
72
|
+
| class dispersion | 12 | 3 | 90 | `godClass: 12 / 3 / 90` |
|
|
73
|
+
| class size/WMC | 30 | — | 200 | `godClass: 30 / 200` |
|
|
74
|
+
| module dispersion | 15 | 3 | 150 | `godModule: 15 / 3 / 150` |
|
|
75
|
+
| module size/WMC | 37 | — | 350 | `godModule: 37 / 350` |
|
|
76
|
+
|
|
77
|
+
The size thresholds sit near the count p95–p99 tail. On the dispersion axis,
|
|
78
|
+
three substantive groups are already rare (between class p95 and p99; module
|
|
79
|
+
p99), and the conjunction with complexity supplies the precision. Raising the
|
|
80
|
+
member floor itself to p95 would make the conjunction unnecessarily blind to
|
|
81
|
+
smaller units that clearly contain three unrelated responsibilities.
|
|
82
|
+
|
|
83
|
+
## Per-repo file counts
|
|
84
|
+
- angular: 1971 files
|
|
85
|
+
- angular-components: 1971 files
|
|
86
|
+
- chakra-ui: 2359 files
|
|
87
|
+
- create-react-app: 122 files
|
|
88
|
+
- gatsby: 3086 files
|
|
89
|
+
- ionic-framework: 508 files
|
|
90
|
+
- material-ui: 26447 files
|
|
91
|
+
- nest: 1306 files
|
|
92
|
+
- nextjs: 4498 files
|
|
93
|
+
- ng-bootstrap: 527 files
|
|
94
|
+
- ng-bootstrap-fresh: 527 files
|
|
95
|
+
- nx: 3330 files
|
|
96
|
+
- playwright: 788 files
|
|
97
|
+
- primeng: 2743 files
|
|
98
|
+
- react: 1711 files
|
|
99
|
+
- react-bootstrap: 440 files
|
|
100
|
+
- remix: 805 files
|
|
101
|
+
- shadcn-ui: 3473 files
|
|
102
|
+
- storybook: 2617 files
|
|
103
|
+
- svelte: 2299 files
|
|
104
|
+
- taiga-ui: 2770 files
|
|
105
|
+
- tanstack-table: 1519 files
|
package/llms.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# StopSlop
|
|
2
|
+
|
|
3
|
+
> Static analysis and CI gating for structural AI code slop in JavaScript and TypeScript. StopSlop finds cognitive complexity, god classes and modules, duplicate code, dead code, orphan features, and responsibility boundaries. It reports code patterns, not authorship.
|
|
4
|
+
|
|
5
|
+
Run without installing: `npx stopslop .`. Exit codes are `0` for a clear gate, `1` for detected findings, and `2` for an analysis error. Requires Node.js 20.19+ or 22.12+.
|
|
6
|
+
|
|
7
|
+
Dead-code settings live under `knip` in `stopslop.json` — a project's own `knip.json` or `package.json#knip` is never read. Nothing needs to be written up front: the run prints `config hints` from Knip naming the workspace or entry point that is missing, and dead code stays out of the score until they are resolved.
|
|
8
|
+
|
|
9
|
+
## Start here
|
|
10
|
+
|
|
11
|
+
- [Overview, installation, CLI and badge](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/README.md)
|
|
12
|
+
- [Configuration reference](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/docs/configuration.md)
|
|
13
|
+
- [CI baselines, Git comparison, SARIF and agent reports](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/docs/ci-and-automation.md)
|
|
14
|
+
- [Typed Node.js API](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/docs/node-api.md)
|
|
15
|
+
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
- [Score formula and interpretation](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/docs/score.md)
|
|
19
|
+
- [Default thresholds and calibration](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/docs/thresholds.md)
|
|
20
|
+
- [Architecture and methodology](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/ARCH.md)
|
|
21
|
+
- [Compatibility policy](https://raw.githubusercontent.com/BaryshevRS/stopslop/main/COMPATIBILITY.md)
|
|
22
|
+
|
|
23
|
+
## Package
|
|
24
|
+
|
|
25
|
+
- [npm package](https://www.npmjs.com/package/stopslop)
|
|
26
|
+
- [JSON Schema](https://unpkg.com/stopslop/schema.json)
|
|
27
|
+
- [GitHub repository](https://github.com/BaryshevRS/stopslop)
|