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.
@@ -0,0 +1,292 @@
1
+ import { KnipConfiguration } from "knip";
2
+
3
+ //#region src/types.d.ts
4
+ type FindingKind = 'cognitive-complexity' | 'god-module' | 'god-class' | 'multiple-exported-classes' | 'duplicate-code' | 'unused-export' | 'unused-dependency' | 'unused-file' | 'orphan-feature';
5
+ type Severity = 'info' | 'warn';
6
+ /** A single reported problem. Identity is `file + symbol` (see baseline, Stage 3). */
7
+ interface Finding {
8
+ kind: FindingKind;
9
+ file: string;
10
+ symbol: string;
11
+ line: number;
12
+ severity: Severity;
13
+ message: string;
14
+ /** Structured payload for --json consumers and --details rendering. */
15
+ data?: Record<string, unknown>;
16
+ /** True while thresholds are pre-calibration defaults. */
17
+ preliminary?: boolean;
18
+ }
19
+ /** A parsed source file with everything analyzers need, produced once per file. */
20
+ interface ParsedFile {
21
+ /** Relative path from the analysis root, POSIX separators. */
22
+ relPath: string;
23
+ absPath: string;
24
+ source: string;
25
+ /** oxc Program node (ESTree-like). Typed loosely; we treat nodes structurally. */
26
+ program: OxcNode;
27
+ /** Convert a 0-based offset into a 1-based line number. */
28
+ lineAt: (offset: number) => number;
29
+ }
30
+ /** Loose AST node. oxc returns ESTree-shaped nodes with byte/UTF-16 offsets. */
31
+ interface OxcNode {
32
+ type: string;
33
+ start: number;
34
+ end: number;
35
+ [key: string]: unknown;
36
+ }
37
+ interface Analyzer {
38
+ name: string;
39
+ /** Stage 1 analyzers: pure AST work, one file at a time. */
40
+ analyzeFile?(file: ParsedFile, ctx: AnalyzeContext): Finding[];
41
+ /** Stage 2 analyzers (Knip, Clone Alert): whole-project, async. */
42
+ analyzeProject?(ctx: ProjectContext): Promise<Finding[]>;
43
+ }
44
+ interface AnalyzeContext {
45
+ config: ResolvedConfig;
46
+ }
47
+ interface ProjectContext {
48
+ root: string;
49
+ files: ParsedFile[];
50
+ config: ResolvedConfig;
51
+ /** Non-fatal notes from an engine (e.g. "knip skipped: no package.json"). */
52
+ notes: string[];
53
+ /**
54
+ * What the Stage-2 engines actually measured. An engine that was disabled —
55
+ * or that bailed out at runtime — leaves its entry unset, and the score then
56
+ * drops that signal instead of scoring it as clean.
57
+ */
58
+ metrics: Partial<ProjectMetrics>;
59
+ }
60
+ /** Signals the score is computed from. Absent = not measured (≠ measured zero). */
61
+ interface ProjectMetrics {
62
+ /** Duplicated physical lines / total physical lines, 0..1. */
63
+ duplicationRatio: number;
64
+ /** Set only once Knip has actually produced results. */
65
+ deadCodeMeasured: boolean;
66
+ }
67
+ interface GodGate {
68
+ enabled: boolean;
69
+ /** Dispersion axis: several unrelated responsibility groups with real boundaries. */
70
+ minMembers: number;
71
+ minClusters: number;
72
+ minComplexity: number;
73
+ /** Size axis (WMC): a monolith too large to be one unit, even if fully connected. */
74
+ sizeMembers: number;
75
+ sizeComplexity: number;
76
+ }
77
+ interface CognitiveGate {
78
+ enabled: boolean;
79
+ /** Functions strictly above this cognitive-complexity score are reported. */
80
+ threshold: number;
81
+ }
82
+ interface MultiClassGate {
83
+ enabled: boolean;
84
+ /** Files with more than this many exported classes are reported. */
85
+ maxPerFile: number;
86
+ }
87
+ /** Clone Alert gate. */
88
+ interface DuplicatesGate {
89
+ enabled: boolean;
90
+ /** Minimum duplicated token span reported as a clone (Clone Alert default: 50). */
91
+ minTokens: number;
92
+ /** Minimum physical lines a clone must span — kills dense one-liner matches. */
93
+ minLines: number;
94
+ /** Ignore identifier names when matching — catches renamed copy-paste. */
95
+ ignoreIdentifiers: boolean;
96
+ /** Ignore literal values when matching. */
97
+ ignoreLiterals: boolean;
98
+ }
99
+ /** Knip gate. Each issue class can be turned off on its own. */
100
+ interface DeadCodeGate {
101
+ enabled: boolean;
102
+ /** Unused exports and exported types — high confidence. */
103
+ exports: boolean;
104
+ /** Unused (dev)dependencies in package.json — high confidence. */
105
+ dependencies: boolean;
106
+ /** Unused files — depends on entry-point detection; reported as info. */
107
+ files: boolean;
108
+ /** Symbols kept alive by nothing but their own paired test. */
109
+ orphans: boolean;
110
+ /** Treat the project as production-only (drops test-only usage). */
111
+ production: boolean;
112
+ }
113
+ /**
114
+ * Slop score: weighted sum of per-KLOC densities, each rising from its floor
115
+ * and saturating at its budget. See docs/score.md — the formula is published,
116
+ * not a black box.
117
+ */
118
+ interface ScoreConfig {
119
+ weights: ScoreSignals;
120
+ /** Density (per KLOC; duplication is a 0..1 ratio) that saturates a signal. */
121
+ budgets: ScoreSignals;
122
+ /**
123
+ * Density below which a signal contributes nothing. Zero for most signals;
124
+ * duplication has a real floor — a few percent of duplicated lines is the
125
+ * baseline of any living codebase (benchmark corpus p25 ≈ 5%), not slop.
126
+ */
127
+ floors: ScoreSignals;
128
+ }
129
+ interface ScoreSignals {
130
+ god: number;
131
+ complexity: number;
132
+ duplication: number;
133
+ deadCode: number;
134
+ }
135
+ interface ResolvedConfig {
136
+ cognitiveComplexity: CognitiveGate;
137
+ godModule: GodGate;
138
+ godClass: GodGate;
139
+ multipleExportedClasses: MultiClassGate;
140
+ duplicates: DuplicatesGate;
141
+ deadCode: DeadCodeGate;
142
+ /** Complete Knip configuration, sourced only from stopslop.json#knip. */
143
+ knip: KnipConfiguration;
144
+ score: ScoreConfig;
145
+ /** fan-in fraction above which a member is treated as a hub (0..1). */
146
+ hubFanInRatio: number;
147
+ ignore: string[];
148
+ /** Whether current thresholds are pre-calibration defaults. */
149
+ preliminary: boolean;
150
+ }
151
+ //#endregion
152
+ //#region src/score.d.ts
153
+ type SlopLevel = 'clean' | 'low' | 'moderate' | 'high' | 'legacy-grade';
154
+ interface SlopScore {
155
+ /** 0..100. */
156
+ score: number;
157
+ level: SlopLevel;
158
+ kloc: number;
159
+ loc: number;
160
+ /** Raw signal values: per-KLOC densities, duplication as a 0..1 ratio. */
161
+ densities: Partial<ScoreSignals>;
162
+ /** Each signal's contribution to the score, in points. */
163
+ contributions: Partial<ScoreSignals>;
164
+ /**
165
+ * How many signals actually fed the score, out of all of them. A partial
166
+ * score is NOT comparable with a full one: the missing weight is renormalized
167
+ * away, so an unmeasured signal is imputed from the others and pushes the
168
+ * number up. Callers must surface this.
169
+ */
170
+ signalCount: number;
171
+ totalSignals: number;
172
+ /**
173
+ * Share (0..100) of the reference corpus this repo is sloppier than, on the
174
+ * core signals (god + duplication + complexity) all 41 corpus repos measure
175
+ * identically. Absent when a core signal was not measured.
176
+ */
177
+ percentile?: number;
178
+ }
179
+ interface ScoreInput {
180
+ findings: Finding[];
181
+ /** Physical lines across the analyzed files. */
182
+ loc: number;
183
+ metrics: Partial<ProjectMetrics>;
184
+ config: ResolvedConfig;
185
+ }
186
+ declare function computeScore(input: ScoreInput): SlopScore;
187
+ /** Bands are labels for the score, not extra thresholds — same preliminary caveat. */
188
+ declare function levelFor(score: number): SlopLevel;
189
+ //#endregion
190
+ //#region src/config.d.ts
191
+ /**
192
+ * Default thresholds. First-pass calibrated on the 22-repo corpus (see
193
+ * docs/thresholds.md); still flagged PRELIMINARY until validated against the
194
+ * Stage-4 victim repo. The god gates are a conjunction (members AND clusters AND
195
+ * complexity), so `minMembers` is kept sensitive — precision comes from the
196
+ * cluster + complexity requirement, not from a high member floor.
197
+ * Full reference: docs/configuration.md.
198
+ */
199
+ declare const DEFAULT_CONFIG: ResolvedConfig;
200
+ /**
201
+ * User-facing config shape (`stopslop.json`). Every field optional and
202
+ * deep-merged over defaults. A check accepts `false` to disable it, or a
203
+ * partial object to override its thresholds.
204
+ */
205
+ interface StopSlopConfig {
206
+ /** Editor-only pointer to the published StopSlop JSON Schema. */
207
+ $schema?: string;
208
+ cognitiveComplexity?: number | false;
209
+ godModule?: Partial<Omit<GodGate, 'enabled'>> | false;
210
+ godClass?: Partial<Omit<GodGate, 'enabled'>> | false;
211
+ multipleExportedClasses?: Partial<Omit<MultiClassGate, 'enabled'>> | false;
212
+ duplicates?: Partial<Omit<DuplicatesGate, 'enabled'>> | false;
213
+ deadCode?: Partial<Omit<DeadCodeGate, 'enabled'>> | false;
214
+ /** Complete JSON-compatible Knip config. Project-local Knip configs are ignored. */
215
+ knip?: KnipConfiguration;
216
+ score?: {
217
+ weights?: Partial<ScoreSignals>;
218
+ budgets?: Partial<ScoreSignals>;
219
+ floors?: Partial<ScoreSignals>;
220
+ };
221
+ hubFanInRatio?: number;
222
+ ignore?: string[];
223
+ }
224
+ declare function resolveConfig(raw: StopSlopConfig | undefined): ResolvedConfig;
225
+ /** Load `stopslop.json` from `dir` (or an explicit path). Missing file → defaults. */
226
+ declare function loadConfig(dir: string, explicitPath?: string): ResolvedConfig;
227
+ //#endregion
228
+ //#region src/baseline.d.ts
229
+ /** One accepted finding, as persisted in the baseline file. */
230
+ interface BaselineRecord {
231
+ kind: Finding['kind'];
232
+ file: string;
233
+ symbol: string;
234
+ }
235
+ /**
236
+ * Stable identity of a finding. Identity, not content: a god class mutates on
237
+ * every edit (an agent keeps appending to it), so a content hash would break
238
+ * constantly — `kind + file + symbol` stays put while the class grows.
239
+ *
240
+ * Duplicates are the exception: their `symbol` is already a content fingerprint
241
+ * (see duplicates.ts), so they key on `kind + symbol` alone and a clone that
242
+ * moves between files stays baselined — the property the fingerprint exists for.
243
+ */
244
+ declare function fingerprint(f: Pick<Finding, 'kind' | 'file' | 'symbol'>): string;
245
+ /** Fingerprints of every finding recorded in the baseline file. */
246
+ declare function readBaseline(path: string): Set<string>;
247
+ /**
248
+ * Serialize findings to the baseline file. Deduplicated by fingerprint and sorted
249
+ * by (kind, file, symbol) — no line numbers, no messages — so the file stays a
250
+ * stable, reviewable, churn-free diff as the code around a finding shifts.
251
+ */
252
+ declare function writeBaseline(path: string, findings: Finding[]): void;
253
+ /** Split findings into the ones not in the baseline (reported) and the count suppressed. */
254
+ declare function applyBaseline(findings: Finding[], accepted: Set<string>): {
255
+ kept: Finding[];
256
+ suppressed: number;
257
+ };
258
+ //#endregion
259
+ //#region src/index.d.ts
260
+ interface AnalyzeResult {
261
+ root: string;
262
+ fileCount: number;
263
+ /** Physical lines across the analyzed files. */
264
+ loc: number;
265
+ findings: Finding[];
266
+ slop: SlopScore;
267
+ metrics: Partial<ProjectMetrics>;
268
+ errors: string[];
269
+ /** Non-fatal notes from the Stage-2 engines (skipped checks, and why). */
270
+ notes: string[];
271
+ config: ResolvedConfig;
272
+ elapsedMs: number;
273
+ /**
274
+ * When a baseline is applied, how many findings it suppressed (accepted
275
+ * legacy). `findings` then holds only the new ones. Unset without a baseline.
276
+ * The slop score is always the full, honest state and is never gated.
277
+ */
278
+ baselineSuppressed?: number;
279
+ /** Git revision used as a temporary baseline for this analysis. */
280
+ gitBase?: {
281
+ ref: string;
282
+ commit: string;
283
+ suppressed: number;
284
+ };
285
+ }
286
+ /** File-level (Stage 1) plus project-level (Stage 2: Clone Alert, Knip) analyzers. */
287
+ declare function defaultAnalyzers(): Analyzer[];
288
+ declare function analyze(root: string, config: ResolvedConfig, analyzers?: Analyzer[]): Promise<AnalyzeResult>;
289
+ /** Gate the current working tree against findings present at a Git revision. */
290
+ declare function analyzeGitBase(root: string, baseRef: string, config: ResolvedConfig, analyzers?: Analyzer[]): Promise<AnalyzeResult>;
291
+ //#endregion
292
+ export { AnalyzeResult, type Analyzer, type BaselineRecord, DEFAULT_CONFIG, type Finding, type ParsedFile, type ResolvedConfig, type SlopLevel, type SlopScore, type StopSlopConfig, analyze, analyzeGitBase, applyBaseline, computeScore, defaultAnalyzers, fingerprint, levelFor, loadConfig, readBaseline, resolveConfig, writeBaseline };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as loadConfig, c as levelFor, d as readBaseline, f as writeBaseline, i as DEFAULT_CONFIG, l as applyBaseline, n as analyzeGitBase, o as resolveConfig, r as defaultAnalyzers, s as computeScore, t as analyze, u as fingerprint } from "./src.js";
2
+ export { DEFAULT_CONFIG, analyze, analyzeGitBase, applyBaseline, computeScore, defaultAnalyzers, fingerprint, levelFor, loadConfig, readBaseline, resolveConfig, writeBaseline };
package/dist/src.js ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "stopslop",
3
+ "version": "0.1.0",
4
+ "description": "Detects AI-agent slop that linters miss: over-complex functions, god modules/classes, dumped responsibilities.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/BaryshevRS/stopslop.git"
10
+ },
11
+ "homepage": "https://github.com/BaryshevRS/stopslop#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BaryshevRS/stopslop/issues"
14
+ },
15
+ "bin": {
16
+ "stopslop": "dist/cli.js"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./schema.json": "./schema.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "schema.json"
28
+ ],
29
+ "engines": {
30
+ "node": "^20.19.0 || >=22.12.0"
31
+ },
32
+ "packageManager": "pnpm@10.1.0",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "prebuild": "pnpm schema:check",
38
+ "build": "tsdown",
39
+ "dev": "tsdown --watch",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "test:package": "node scripts/package-smoke.mjs",
43
+ "audit:prod": "pnpm audit --prod",
44
+ "ci:check": "pnpm test && pnpm typecheck && pnpm build",
45
+ "release:check": "pnpm ci:check && pnpm audit:prod && pnpm test:package",
46
+ "prepublishOnly": "pnpm release:check",
47
+ "calibrate": "tsx scripts/calibrate.ts",
48
+ "typecheck": "tsc --noEmit",
49
+ "schema:generate": "tsx scripts/generate-config-schema.ts",
50
+ "schema:check": "tsx scripts/generate-config-schema.ts --check"
51
+ },
52
+ "dependencies": {
53
+ "clone-alert": "^1.1.3",
54
+ "knip": "^6.26.0",
55
+ "oxc-parser": "^0.139.0",
56
+ "picocolors": "^1.1.1"
57
+ },
58
+ "devDependencies": {
59
+ "@arethetypeswrong/core": "^0.18.1",
60
+ "@types/node": "^22.10.0",
61
+ "publint": "^0.3.8",
62
+ "tsdown": "^0.21.10",
63
+ "tsx": "^4.19.0",
64
+ "typescript": "^5.7.0",
65
+ "vitest": "^3.0.0"
66
+ },
67
+ "keywords": [
68
+ "ai",
69
+ "slop",
70
+ "code-smell",
71
+ "god-class",
72
+ "god-module",
73
+ "cognitive-complexity",
74
+ "lcom",
75
+ "static-analysis"
76
+ ]
77
+ }