slopfighter 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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/slopfighter.mjs +3 -0
- package/package.json +53 -0
- package/src/cli.mjs +139 -0
- package/src/fixer.mjs +28 -0
- package/src/installer.mjs +101 -0
- package/src/reporter.mjs +66 -0
- package/src/rules/_util.mjs +31 -0
- package/src/rules/always-true-conditional.mjs +45 -0
- package/src/rules/commented-out-code.mjs +61 -0
- package/src/rules/const-string-concat.mjs +37 -0
- package/src/rules/dead-imports.mjs +58 -0
- package/src/rules/else-after-return.mjs +43 -0
- package/src/rules/excessive-jsdoc.mjs +50 -0
- package/src/rules/explicit-any.mjs +30 -0
- package/src/rules/future-proof-naming.mjs +46 -0
- package/src/rules/index.mjs +39 -0
- package/src/rules/over-defensive-null-check.mjs +81 -0
- package/src/rules/padding-comments.mjs +131 -0
- package/src/rules/redundant-await.mjs +51 -0
- package/src/rules/redundant-boolean-cast.mjs +65 -0
- package/src/rules/redundant-error-rethrow.mjs +44 -0
- package/src/rules/return-undefined.mjs +35 -0
- package/src/rules/single-method-class.mjs +49 -0
- package/src/rules/trivial-arrow-wrapper.mjs +70 -0
- package/src/rules/unnecessary-async.mjs +61 -0
- package/src/rules/useless-try-catch.mjs +62 -0
- package/src/scanner.mjs +122 -0
- package/src/score.mjs +44 -0
- package/templates/SKILL.md +67 -0
- package/templates/agents-section.md +7 -0
- package/templates/cursor-command.md +23 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — initial release
|
|
4
|
+
|
|
5
|
+
First public release. 18 rules across 4 categories, scored 0–100 (A–F).
|
|
6
|
+
|
|
7
|
+
### Rules
|
|
8
|
+
- **Bloat**: `padding-comments`, `excessive-jsdoc`, `commented-out-code`
|
|
9
|
+
- **Wasted runtime**: `unnecessary-async`, `redundant-await`, `useless-try-catch`, `redundant-error-rethrow`, `redundant-boolean-cast`, `trivial-arrow-wrapper`, `return-undefined`, `const-string-concat`
|
|
10
|
+
- **Premature abstraction**: `future-proof-naming`, `single-method-class`
|
|
11
|
+
- **Type laziness / dead code**: `explicit-any`, `dead-imports`, `over-defensive-null-check`, `always-true-conditional`, `else-after-return`
|
|
12
|
+
|
|
13
|
+
### CLI
|
|
14
|
+
- `slopfighter scan [path]` — human or JSON report
|
|
15
|
+
- `slopfighter score [path]` — number only, CI-friendly
|
|
16
|
+
- `slopfighter fix [path] --safe` — auto-fix unambiguous findings
|
|
17
|
+
- `slopfighter install` — drop a skill into Claude Code / Cursor / AGENTS.md
|
|
18
|
+
|
|
19
|
+
### Tested
|
|
20
|
+
- 103 unit + integration tests
|
|
21
|
+
- Validated against 30 trending TypeScript repos created Feb–May 2026
|
|
22
|
+
- Cross-platform: Linux, macOS, Windows; Node 18/20/22
|
|
23
|
+
|
|
24
|
+
### Known limitations
|
|
25
|
+
- JS/TS only (Python, Go, Rust on the roadmap via tree-sitter)
|
|
26
|
+
- No type-aware mode — `--strict` flag is reserved for future opt-in type-checker path
|
|
27
|
+
- Some rules use heuristics, not full semantic analysis; FP-rate ~5–15% per rule
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 slopfighter contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# slopfighter
|
|
2
|
+
|
|
3
|
+
[](https://github.com/wemdio2/slopfighter/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/slopfighter)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**Anti-AI-slop refactor tool.** Detects the 18 structural bloat patterns LLMs love to generate. JS/TS, zero config, no API keys, no telemetry, runs locally in seconds.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx slopfighter scan .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
src/foo.ts
|
|
15
|
+
42:10 warn Comment "Get user name" just restates "getUserName" (padding-comments) [fixable]
|
|
16
|
+
56:1 warn import "format" is never used in this file (dead-imports)
|
|
17
|
+
88:14 info drop the explicit `undefined` (return-undefined) [fixable]
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
slop score: 47/100 (D) [█████████░░░░░░░░░░░]
|
|
21
|
+
errors: 0 warns: 9 infos: 4
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why this exists
|
|
25
|
+
|
|
26
|
+
We scanned **29 trending TypeScript repos** created between February and May 2026 — 32,473 files, 79,733 findings, 15 minutes.
|
|
27
|
+
|
|
28
|
+
- **Average score: 39/100 (F).** 17 of 29 repos failed.
|
|
29
|
+
- **78% of all slop is three patterns:** commented-out code blocks (31.8%), `async` functions with no `await` (25.5%), explicit `any` (21.0%).
|
|
30
|
+
- Worst offender: a 54k-star Claude orchestration repo at **4.93 findings per file**.
|
|
31
|
+
- Cleanest: a markdown-based skill repo with **0**.
|
|
32
|
+
|
|
33
|
+
Full report → [`bench/report.md`](bench/report.md) · Interactive visualization → [`bench/index.html`](bench/index.html) (open locally or enable GitHub Pages → `https://wemdio2.github.io/slopfighter/bench/`). Reproducible: `node bench/run.mjs && node bench/visualize.mjs`.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
### As a CLI
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx slopfighter scan . # scan current dir
|
|
41
|
+
npx slopfighter scan src/foo.ts # scan one file
|
|
42
|
+
npx slopfighter score . --json # CI-friendly score only
|
|
43
|
+
npx slopfighter fix . --safe # apply unambiguous auto-fixes
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Exit code 0 if `score >= 60`, else 1 — so `npx slopfighter score .` works as a CI gate.
|
|
47
|
+
|
|
48
|
+
### As an AI agent skill
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx slopfighter install # auto-detect .claude/.cursor/AGENTS.md, install where present
|
|
52
|
+
npx slopfighter install --force # install into all targets
|
|
53
|
+
npx slopfighter install --only claude # only Claude Code skill
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This drops a `SKILL.md` into `.claude/skills/slopfighter/`, a slash command into `.cursor/commands/slopfighter.md`, and a guarded section into `AGENTS.md`. After install, your agent runs slopfighter automatically after each edit batch and surfaces top findings.
|
|
57
|
+
|
|
58
|
+
### As a GitHub Action
|
|
59
|
+
|
|
60
|
+
Copy [`.github/workflows/slopfighter.yml`](.github/workflows/slopfighter.yml) into your repo. On every PR, a bot will comment with the score and top issues. Free for public repos.
|
|
61
|
+
|
|
62
|
+
## The rules
|
|
63
|
+
|
|
64
|
+
| Rule | Severity | Catches |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `padding-comments` | warn | `// Get user name` over `getUserName()` |
|
|
67
|
+
| `useless-try-catch` | warn | `catch (e) { throw e }` or log-then-rethrow |
|
|
68
|
+
| `dead-imports` | warn | Imported names never referenced |
|
|
69
|
+
| `explicit-any` | warn | `: any` annotations |
|
|
70
|
+
| `future-proof-naming` | warn | `XManager`/`XHelper`/`XProvider` class with one method |
|
|
71
|
+
| `commented-out-code` | warn | 2+ consecutive lines of commented code |
|
|
72
|
+
| `unnecessary-async` | info | `async` function with no `await` |
|
|
73
|
+
| `trivial-arrow-wrapper` | info | `(x) => fn(x)` instead of `fn` |
|
|
74
|
+
| `redundant-await` | info | `return await x` outside try/catch |
|
|
75
|
+
| `return-undefined` | info | `return undefined` (= `return`) |
|
|
76
|
+
| `redundant-error-rethrow` | warn | `throw new Error(err.message)` (strips stack) |
|
|
77
|
+
| `excessive-jsdoc` | info | 6+ line JSDoc over a 1–3 line body |
|
|
78
|
+
| `over-defensive-null-check` | info | `x !== null && x !== undefined && x` |
|
|
79
|
+
| `always-true-conditional` | warn | `if (true)` / `if (x === x)` |
|
|
80
|
+
| `single-method-class` | info | Class with one `this`-free method |
|
|
81
|
+
| `else-after-return` | info | `else` after a returning `if` |
|
|
82
|
+
| `redundant-boolean-cast` | info | `!!x` / `Boolean(x)` in boolean context |
|
|
83
|
+
| `const-string-concat` | info | `"a" + "b"` (merge or use a template) |
|
|
84
|
+
|
|
85
|
+
Customize: `--rules a,b,c` (run only listed), `--ignore <substr>` (skip matching paths, repeatable).
|
|
86
|
+
|
|
87
|
+
## What this is NOT
|
|
88
|
+
|
|
89
|
+
- Not a security scanner. Use Semgrep/Snyk for that.
|
|
90
|
+
- Not a type checker. Use `tsc --noEmit`.
|
|
91
|
+
- Not a formatter. Use Prettier/Biome.
|
|
92
|
+
- Not a semantic analyzer. It won't tell you your code is wrong, only that it's bloated.
|
|
93
|
+
|
|
94
|
+
The rules are heuristic. False-positive rate is ~5–15% per rule depending on pattern. The point isn't perfection — it's reducing the structural noise that AI-generated code accumulates so reviewers can focus on logic.
|
|
95
|
+
|
|
96
|
+
## How it works (under the hood)
|
|
97
|
+
|
|
98
|
+
We parse files with TypeScript's compiler API into an AST, then walk the tree applying 18 rule functions. A few rules (e.g. `commented-out-code`) are line-based; the rest are structural. No type checker is loaded, which keeps it ~10–100× faster than type-aware linters at the cost of some precision.
|
|
99
|
+
|
|
100
|
+
Score: `100 * exp(-perFilePenalty / 3)`, where penalty weights `error: 5, warn: 2, info: 1`. Per-file normalization means a clean 2000-file repo isn't punished by volume.
|
|
101
|
+
|
|
102
|
+
## Bench
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
git clone <this-repo> && cd slopfighter && npm install
|
|
106
|
+
node bench/run.mjs
|
|
107
|
+
cat bench/report.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Takes 15–30 min cold (clones 30 repos), 30 sec cached. Skip individual repos by editing `bench/repos.txt`.
|
|
111
|
+
|
|
112
|
+
## Contributing
|
|
113
|
+
|
|
114
|
+
PRs welcome — especially new rules. Each rule lives in `src/rules/<id>.mjs` and exports `{ id, severity, description, check(ctx) }`. Add 2+ positive and 2+ negative test cases to `test/unit-rules.mjs`. The full test suite is `npm test`.
|
|
115
|
+
|
|
116
|
+
Rule ideas we'd love but haven't built yet: `bloated-tests` (mocks without assertions), `magic-numbers`, `nested-ternary-pyramid`, Python/Go via tree-sitter, type-aware mode (`--strict`).
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slopfighter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Anti-AI-slop refactor tool. Detects the structural bloat patterns LLMs love to generate — padding comments, useless try/catch, dead imports, explicit any, commented-out code, unnecessary async, and 12 more.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"slopfighter": "./bin/slopfighter.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"templates",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node test/run.mjs",
|
|
19
|
+
"scan": "node bin/slopfighter.mjs scan",
|
|
20
|
+
"bench": "node bench/run.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ai",
|
|
24
|
+
"slop",
|
|
25
|
+
"linter",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"cursor",
|
|
28
|
+
"code-quality",
|
|
29
|
+
"typescript",
|
|
30
|
+
"javascript",
|
|
31
|
+
"vibe-coding",
|
|
32
|
+
"ai-coding",
|
|
33
|
+
"agent-skill",
|
|
34
|
+
"refactor"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/wemdio2/slopfighter.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/wemdio2/slopfighter",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/wemdio2/slopfighter/issues"
|
|
43
|
+
},
|
|
44
|
+
"author": "",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"typescript": "^5.4.0",
|
|
48
|
+
"picocolors": "^1.0.0"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { scanPath } from './scanner.mjs';
|
|
4
|
+
import { renderReport, renderScore } from './reporter.mjs';
|
|
5
|
+
import { computeScore } from './score.mjs';
|
|
6
|
+
import { applyFixes } from './fixer.mjs';
|
|
7
|
+
import { runInstall } from './installer.mjs';
|
|
8
|
+
|
|
9
|
+
const VERSION = '0.1.0';
|
|
10
|
+
|
|
11
|
+
const HELP = `slopfighter v${VERSION} — anti-AI-slop refactor tool
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
slopfighter scan [path] Scan files and report findings
|
|
15
|
+
slopfighter score [path] Print only the slop score (0-100)
|
|
16
|
+
slopfighter fix [path] --safe Apply safe auto-fixes
|
|
17
|
+
slopfighter install [root] Install the skill into .claude / .cursor / AGENTS.md
|
|
18
|
+
slopfighter --help Show this help
|
|
19
|
+
slopfighter --version Show version
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--json Output JSON instead of human report
|
|
23
|
+
--strict Enable noisier rules
|
|
24
|
+
--rules a,b,c Run only these rules
|
|
25
|
+
--ignore pattern Skip files matching glob (repeatable)
|
|
26
|
+
--only a,b,c install only into these targets (claude,cursor,agents)
|
|
27
|
+
--skip a,b,c install skips these targets
|
|
28
|
+
--force install into all targets even if not auto-detected
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
slopfighter scan ./src
|
|
32
|
+
slopfighter score . --json
|
|
33
|
+
slopfighter fix ./src --safe
|
|
34
|
+
slopfighter install # detects .claude/.cursor/AGENTS.md and writes
|
|
35
|
+
slopfighter install --force # writes to every target
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export async function runCli(argv) {
|
|
39
|
+
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
40
|
+
process.stdout.write(HELP);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
44
|
+
process.stdout.write(`slopfighter ${VERSION}\n`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const command = argv[0];
|
|
49
|
+
const rest = argv.slice(1);
|
|
50
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
51
|
+
const target = path.resolve(positional[0] || '.');
|
|
52
|
+
const flags = parseFlags(rest);
|
|
53
|
+
|
|
54
|
+
if (command === 'scan') {
|
|
55
|
+
await cmdScan(target, flags);
|
|
56
|
+
} else if (command === 'score') {
|
|
57
|
+
await cmdScore(target, flags);
|
|
58
|
+
} else if (command === 'fix') {
|
|
59
|
+
await cmdFix(target, flags);
|
|
60
|
+
} else if (command === 'install') {
|
|
61
|
+
await cmdInstall(target, flags);
|
|
62
|
+
} else {
|
|
63
|
+
process.stderr.write(pc.red(`Unknown command: ${command}\n\n`));
|
|
64
|
+
process.stdout.write(HELP);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseFlags(rest) {
|
|
70
|
+
const flags = {
|
|
71
|
+
json: false, strict: false, safe: false, rules: null, ignore: [],
|
|
72
|
+
only: null, skip: null, force: false,
|
|
73
|
+
};
|
|
74
|
+
for (let i = 0; i < rest.length; i++) {
|
|
75
|
+
const a = rest[i];
|
|
76
|
+
if (a === '--json') flags.json = true;
|
|
77
|
+
else if (a === '--strict') flags.strict = true;
|
|
78
|
+
else if (a === '--safe') flags.safe = true;
|
|
79
|
+
else if (a === '--force') flags.force = true;
|
|
80
|
+
else if (a === '--rules') flags.rules = (rest[++i] || '').split(',').filter(Boolean);
|
|
81
|
+
else if (a === '--ignore') flags.ignore.push(rest[++i] || '');
|
|
82
|
+
else if (a === '--only') flags.only = rest[++i] || null;
|
|
83
|
+
else if (a === '--skip') flags.skip = rest[++i] || null;
|
|
84
|
+
}
|
|
85
|
+
return flags;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cmdScan(target, flags) {
|
|
89
|
+
const findings = await scanPath(target, flags);
|
|
90
|
+
const score = computeScore(findings);
|
|
91
|
+
if (flags.json) {
|
|
92
|
+
process.stdout.write(JSON.stringify({ score, findings }, null, 2) + '\n');
|
|
93
|
+
} else {
|
|
94
|
+
process.stdout.write(renderReport(findings, score, target));
|
|
95
|
+
}
|
|
96
|
+
process.exitCode = score.value < 60 ? 1 : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function cmdScore(target, flags) {
|
|
100
|
+
const findings = await scanPath(target, flags);
|
|
101
|
+
const score = computeScore(findings);
|
|
102
|
+
if (flags.json) {
|
|
103
|
+
process.stdout.write(JSON.stringify(score, null, 2) + '\n');
|
|
104
|
+
} else {
|
|
105
|
+
process.stdout.write(renderScore(score) + '\n');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function cmdInstall(root, flags) {
|
|
110
|
+
const result = await runInstall(root, {
|
|
111
|
+
only: flags.only,
|
|
112
|
+
skip: flags.skip,
|
|
113
|
+
force: flags.force,
|
|
114
|
+
});
|
|
115
|
+
if (result.message) {
|
|
116
|
+
process.stdout.write(pc.yellow(result.message) + '\n');
|
|
117
|
+
if (!result.installed?.length) process.exitCode = 1;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write(`${pc.green('Installed slopfighter skill')} into:\n`);
|
|
121
|
+
for (const i of result.installed) {
|
|
122
|
+
const rel = path.relative(process.cwd(), i.file) || i.file;
|
|
123
|
+
process.stdout.write(` ${pc.dim('•')} ${i.label.padEnd(12)} ${pc.cyan(rel)}\n`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function cmdFix(target, flags) {
|
|
128
|
+
if (!flags.safe) {
|
|
129
|
+
process.stderr.write(pc.yellow('Refusing to fix without --safe (auto-fix is opt-in).\n'));
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const findings = await scanPath(target, flags);
|
|
134
|
+
const result = await applyFixes(findings);
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
`${pc.green('Fixed')} ${result.fixed} issue(s) in ${result.files} file(s). ` +
|
|
137
|
+
`${pc.dim(`${result.skipped} unsafe issue(s) left for manual review.`)}\n`
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/fixer.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export async function applyFixes(findings) {
|
|
4
|
+
const byFile = new Map();
|
|
5
|
+
let skipped = 0;
|
|
6
|
+
for (const f of findings) {
|
|
7
|
+
if (!f.fixable || !f.fix) { skipped++; continue; }
|
|
8
|
+
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
|
9
|
+
byFile.get(f.file).push(f);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let fixed = 0;
|
|
13
|
+
for (const [file, items] of byFile) {
|
|
14
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
15
|
+
// apply from bottom to top to keep offsets stable
|
|
16
|
+
const sorted = items
|
|
17
|
+
.filter((i) => i.fix && Number.isInteger(i.fix.start) && Number.isInteger(i.fix.end))
|
|
18
|
+
.sort((a, b) => b.fix.start - a.fix.start);
|
|
19
|
+
let out = source;
|
|
20
|
+
for (const item of sorted) {
|
|
21
|
+
out = out.slice(0, item.fix.start) + (item.fix.replacement || '') + out.slice(item.fix.end);
|
|
22
|
+
fixed++;
|
|
23
|
+
}
|
|
24
|
+
if (out !== source) fs.writeFileSync(file, out, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { fixed, files: byFile.size, skipped };
|
|
28
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Installs the slopfighter skill into the project's AI-tool surfaces.
|
|
2
|
+
// Defaults to all known targets present in the project; --only / --skip filter.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TEMPLATE_DIR = path.join(__dirname, '..', 'templates');
|
|
9
|
+
|
|
10
|
+
const TARGETS = {
|
|
11
|
+
claude: {
|
|
12
|
+
label: 'Claude Code',
|
|
13
|
+
detect: (root) => fs.existsSync(path.join(root, '.claude')),
|
|
14
|
+
install(root) {
|
|
15
|
+
const dest = path.join(root, '.claude', 'skills', 'slopfighter', 'SKILL.md');
|
|
16
|
+
writeFile(dest, readTemplate('SKILL.md'));
|
|
17
|
+
return dest;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
cursor: {
|
|
21
|
+
label: 'Cursor',
|
|
22
|
+
detect: (root) => fs.existsSync(path.join(root, '.cursor')),
|
|
23
|
+
install(root) {
|
|
24
|
+
const dest = path.join(root, '.cursor', 'commands', 'slopfighter.md');
|
|
25
|
+
writeFile(dest, readTemplate('cursor-command.md'));
|
|
26
|
+
return dest;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
agents: {
|
|
30
|
+
label: 'AGENTS.md',
|
|
31
|
+
detect: (root) => fs.existsSync(path.join(root, 'AGENTS.md')) ||
|
|
32
|
+
fs.existsSync(path.join(root, 'CLAUDE.md')) ||
|
|
33
|
+
fs.existsSync(path.join(root, '.cursorrules')),
|
|
34
|
+
install(root) {
|
|
35
|
+
const dest = path.join(root, 'AGENTS.md');
|
|
36
|
+
const snippet = readTemplate('agents-section.md');
|
|
37
|
+
upsertSection(dest, snippet);
|
|
38
|
+
return dest;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export async function runInstall(root, opts = {}) {
|
|
44
|
+
const only = opts.only ? new Set(opts.only.split(',')) : null;
|
|
45
|
+
const skip = opts.skip ? new Set(opts.skip.split(',')) : new Set();
|
|
46
|
+
const force = !!opts.force;
|
|
47
|
+
|
|
48
|
+
const planned = [];
|
|
49
|
+
for (const [key, t] of Object.entries(TARGETS)) {
|
|
50
|
+
if (only && !only.has(key)) continue;
|
|
51
|
+
if (skip.has(key)) continue;
|
|
52
|
+
const present = t.detect(root);
|
|
53
|
+
if (!present && !force && !only) continue;
|
|
54
|
+
planned.push({ key, target: t, present });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (planned.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
installed: [],
|
|
60
|
+
message:
|
|
61
|
+
'No AI-tool config found in this project. Pass `--force` to install into all targets, or `--only claude,cursor,agents`.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const installed = [];
|
|
66
|
+
for (const p of planned) {
|
|
67
|
+
const file = p.target.install(root);
|
|
68
|
+
installed.push({ key: p.key, label: p.target.label, file });
|
|
69
|
+
}
|
|
70
|
+
return { installed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readTemplate(name) {
|
|
74
|
+
return fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeFile(dest, content) {
|
|
78
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
79
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SECTION_START = '<!-- slopfighter:start';
|
|
83
|
+
const SECTION_END = '<!-- slopfighter:end -->';
|
|
84
|
+
function upsertSection(file, snippet) {
|
|
85
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
86
|
+
if (existing.includes(SECTION_START)) {
|
|
87
|
+
const startIdx = existing.indexOf(SECTION_START);
|
|
88
|
+
const endIdx = existing.indexOf(SECTION_END, startIdx);
|
|
89
|
+
if (endIdx === -1) {
|
|
90
|
+
// malformed — append
|
|
91
|
+
fs.writeFileSync(file, existing.trimEnd() + '\n\n' + snippet.trim() + '\n', 'utf8');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const before = existing.slice(0, startIdx);
|
|
95
|
+
const after = existing.slice(endIdx + SECTION_END.length);
|
|
96
|
+
fs.writeFileSync(file, (before + snippet.trim() + after).replace(/\n{3,}/g, '\n\n'), 'utf8');
|
|
97
|
+
} else {
|
|
98
|
+
const sep = existing.length === 0 || existing.endsWith('\n\n') ? '' : '\n\n';
|
|
99
|
+
fs.writeFileSync(file, existing + sep + snippet.trim() + '\n', 'utf8');
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/reporter.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
|
|
4
|
+
const SEVERITY_COLOR = {
|
|
5
|
+
error: pc.red,
|
|
6
|
+
warn: pc.yellow,
|
|
7
|
+
info: pc.cyan,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const GRADE_COLOR = {
|
|
11
|
+
A: pc.green,
|
|
12
|
+
B: pc.green,
|
|
13
|
+
C: pc.yellow,
|
|
14
|
+
D: pc.yellow,
|
|
15
|
+
F: pc.red,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function renderReport(findings, score, target) {
|
|
19
|
+
if (findings.length === 0) {
|
|
20
|
+
return `${pc.green('✓')} No slop detected in ${pc.dim(target)}\n` + renderScore(score) + '\n';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const byFile = groupByFile(findings);
|
|
24
|
+
let out = '';
|
|
25
|
+
for (const [file, items] of byFile) {
|
|
26
|
+
const rel = path.relative(process.cwd(), file) || file;
|
|
27
|
+
out += `\n${pc.bold(rel)}\n`;
|
|
28
|
+
for (const f of items) {
|
|
29
|
+
const sev = (SEVERITY_COLOR[f.severity] || pc.white)(f.severity.padEnd(5));
|
|
30
|
+
const loc = pc.dim(`${f.line}:${f.column}`);
|
|
31
|
+
const ruleId = pc.dim(`(${f.ruleId})`);
|
|
32
|
+
const fixable = f.fixable ? pc.green(' [fixable]') : '';
|
|
33
|
+
out += ` ${loc} ${sev} ${f.message} ${ruleId}${fixable}\n`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
out += '\n' + renderScore(score) + '\n';
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderScore(score) {
|
|
41
|
+
const color = GRADE_COLOR[score.grade] || pc.white;
|
|
42
|
+
const bar = renderBar(score.value);
|
|
43
|
+
const breakdown =
|
|
44
|
+
`errors: ${pc.red(score.counts.error || 0)} ` +
|
|
45
|
+
`warns: ${pc.yellow(score.counts.warn || 0)} ` +
|
|
46
|
+
`infos: ${pc.cyan(score.counts.info || 0)}`;
|
|
47
|
+
return `${pc.bold('slop score:')} ${color(`${score.value}/100 (${score.grade})`)} ${bar}\n${breakdown}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderBar(value) {
|
|
51
|
+
const width = 20;
|
|
52
|
+
const filled = Math.round((value / 100) * width);
|
|
53
|
+
return pc.dim('[') + pc.green('█'.repeat(filled)) + pc.dim('░'.repeat(width - filled) + ']');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function groupByFile(findings) {
|
|
57
|
+
const m = new Map();
|
|
58
|
+
for (const f of findings) {
|
|
59
|
+
if (!m.has(f.file)) m.set(f.file, []);
|
|
60
|
+
m.get(f.file).push(f);
|
|
61
|
+
}
|
|
62
|
+
for (const arr of m.values()) {
|
|
63
|
+
arr.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
64
|
+
}
|
|
65
|
+
return m;
|
|
66
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function walk(node, visit) {
|
|
2
|
+
visit(node);
|
|
3
|
+
node.forEachChild((c) => walk(c, visit));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function lineCol(sourceFile, pos) {
|
|
7
|
+
const lc = sourceFile.getLineAndCharacterOfPosition(pos);
|
|
8
|
+
return { line: lc.line + 1, column: lc.character + 1 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function nodeRange(sourceFile, node) {
|
|
12
|
+
const start = lineCol(sourceFile, node.getStart(sourceFile));
|
|
13
|
+
const end = lineCol(sourceFile, node.getEnd());
|
|
14
|
+
return { line: start.line, column: start.column, endLine: end.line, endColumn: end.column };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function rangeFromOffsets(sourceFile, startPos, endPos) {
|
|
18
|
+
const start = lineCol(sourceFile, startPos);
|
|
19
|
+
const end = lineCol(sourceFile, endPos);
|
|
20
|
+
return { line: start.line, column: start.column, endLine: end.line, endColumn: end.column };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// turn a CamelCaseName into a "camel case name" lower-case phrase
|
|
24
|
+
export function deCamel(name) {
|
|
25
|
+
return name
|
|
26
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
27
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
28
|
+
.replace(/[_-]+/g, ' ')
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { walk, nodeRange } from './_util.mjs';
|
|
2
|
+
|
|
3
|
+
// if (true) {...} — pure padding.
|
|
4
|
+
// if (x == x) — same.
|
|
5
|
+
// Skipped: while (true) / for(;;) since they are legitimate event loops.
|
|
6
|
+
export default {
|
|
7
|
+
id: 'always-true-conditional',
|
|
8
|
+
severity: 'warn',
|
|
9
|
+
description: 'if-conditions that are statically true (or trivially redundant)',
|
|
10
|
+
check(ctx) {
|
|
11
|
+
const { sourceFile, ts } = ctx;
|
|
12
|
+
const findings = [];
|
|
13
|
+
|
|
14
|
+
walk(sourceFile, (node) => {
|
|
15
|
+
if (node.kind !== ts.SyntaxKind.IfStatement) return;
|
|
16
|
+
const cond = node.expression;
|
|
17
|
+
if (isAlwaysTrue(cond, ts)) {
|
|
18
|
+
const range = nodeRange(sourceFile, cond);
|
|
19
|
+
findings.push({
|
|
20
|
+
message: 'if-condition is always true — drop the wrapper',
|
|
21
|
+
line: range.line,
|
|
22
|
+
column: range.column,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return findings;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function isAlwaysTrue(node, ts) {
|
|
31
|
+
if (!node) return false;
|
|
32
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
33
|
+
if (node.kind === ts.SyntaxKind.NumericLiteral && node.text !== '0') return true;
|
|
34
|
+
if (
|
|
35
|
+
node.kind === ts.SyntaxKind.BinaryExpression &&
|
|
36
|
+
(node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken ||
|
|
37
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken) &&
|
|
38
|
+
node.left.kind === ts.SyntaxKind.Identifier &&
|
|
39
|
+
node.right.kind === ts.SyntaxKind.Identifier &&
|
|
40
|
+
node.left.text === node.right.text
|
|
41
|
+
) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|