aislop 0.1.2 → 0.2.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/README.md CHANGED
@@ -3,55 +3,38 @@
3
3
  **Stop AI slop from shipping.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/aislop.svg)](https://www.npmjs.com/package/aislop)
6
+ [![npm downloads](https://img.shields.io/npm/dm/aislop.svg)](https://www.npmjs.com/package/aislop)
6
7
  [![CI](https://github.com/heavykenny/aislop/actions/workflows/ci.yml/badge.svg)](https://github.com/heavykenny/aislop/actions/workflows/ci.yml)
7
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
+ [![Node >= 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)
8
10
 
9
- AI coding assistants are fast, but they leave behind a trail of sloppy code: swallowed exceptions, trivial comments that restate the obvious, unused imports, `as any` casts, empty catch blocks, leftover `console.log` calls, and copy-paste duplication. **aislop** catches all of it in one command.
10
-
11
- `aislop` is a unified code-quality CLI for multi-language repositories. It runs formatting, linting, code-quality, AI-pattern, architecture, and security checks behind a single command and summarizes everything as one score out of 100.
11
+ `aislop` is a unified code-quality CLI that catches the lazy patterns AI coding tools leave behind. One command, one score out of 100.
12
12
 
13
13
  ```
14
14
  $ npx aislop scan
15
15
 
16
- aislop Scan v0.1.0
16
+ aislop Scan v0.2.0
17
17
 
18
18
  ✓ Project my-app (typescript)
19
19
  Source files: 142
20
20
 
21
- ✓ Formatting: done (0 issues)
22
- ✓ Linting: done (2 warnings)
23
- ✓ Code Quality: done (1 warning)
24
- ! Maintainability: done (4 warnings)
25
- ✓ Security: done (0 issues)
21
+ ✓ Formatting: done (0 issues)
22
+ ✓ Linting: done (2 warnings)
23
+ ✓ Code Quality: done (1 warning)
24
+ ! Maintainability: done (4 warnings)
25
+ ✓ Security: done (0 issues)
26
26
 
27
27
  Score: 80/100 (Healthy)
28
28
  ```
29
29
 
30
30
  ---
31
31
 
32
- ## Why aislop?
33
-
34
- Code generated by AI assistants often looks correct at first glance but is full of quality issues that pass review because they are spread across dozens of files. No single existing linter catches all of them. `aislop` was built to be the one tool that does:
35
-
36
- - **Catches AI-specific patterns** that ESLint and Prettier ignore: trivial comments (`// Import React`), thin wrappers, generic names (`data2`, `helper_1`), swallowed exceptions
37
- - **Multi-language** -- TypeScript, JavaScript, Python, Go, Rust, Ruby, PHP in one CLI
38
- - **Single score** -- one number to gate PRs, track in CI, and trend over time
39
- - **Zero config to start** -- run `npx aislop scan` and get results immediately
40
- - **Batteries included** -- ships with oxlint, biome, and knip; downloads ruff and golangci-lint automatically on install
41
-
42
- ---
43
-
44
32
  ## Installation
45
33
 
46
- ### Run without installing
47
-
48
34
  ```bash
35
+ # Run without installing
49
36
  npx aislop scan
50
- ```
51
37
 
52
- ### Install as a dev dependency
53
-
54
- ```bash
55
38
  # npm
56
39
  npm install --save-dev aislop
57
40
 
@@ -60,300 +43,143 @@ yarn add --dev aislop
60
43
 
61
44
  # pnpm
62
45
  pnpm add -D aislop
63
- ```
64
46
 
65
- ### Global install
66
-
67
- ```bash
47
+ # Global
68
48
  npm install -g aislop
69
- aislop scan
70
- ```
71
-
72
- `aislop` ships with Node-based tooling (oxlint, biome, knip) as package dependencies. On install it also downloads bundled binaries for **ruff** and **golangci-lint**. To skip those downloads:
73
-
74
- ```bash
75
- AISLOP_SKIP_TOOL_DOWNLOAD=1 npm install
76
49
  ```
77
50
 
78
- Some checks depend on tools already present on your machine: `gofmt`, `govulncheck`, `cargo`, `rubocop`, `phpcs`, `php-cs-fixer`. Run `aislop doctor` to see what is available.
51
+ Also available as [`@heavykenny/aislop`](docs/installation.md) on GitHub Packages.
79
52
 
80
53
  ---
81
54
 
82
- ## Quick start
55
+ ## Usage
83
56
 
84
- ```bash
85
- # Scan the current directory
86
- npx aislop scan
87
-
88
- # Auto-fix formatting and lint issues
89
- npx aislop fix
90
-
91
- # Scan only changed files (great for pre-commit)
92
- npx aislop scan --changes
93
-
94
- # Scan only staged files
95
- npx aislop scan --staged
57
+ ### Scan your project
96
58
 
97
- # CI-friendly JSON output
98
- npx aislop ci
99
-
100
- # Initialize config files
101
- npx aislop init
102
-
103
- # Interactive menu
104
- npx aislop
59
+ ```bash
60
+ aislop scan # scan current directory
61
+ aislop scan ./src # scan a specific directory
62
+ aislop scan --changes # only files changed from HEAD
63
+ aislop scan --staged # only staged files (pre-commit)
64
+ aislop scan --json # output JSON
105
65
  ```
106
66
 
107
- ---
108
-
109
- ## Commands
110
-
111
- | Command | What it does |
112
- |---|---|
113
- | `aislop` | Interactive TTY menu (falls back to `scan` in non-TTY) |
114
- | `aislop scan [dir]` | Run all enabled engines and print a scored report |
115
- | `aislop fix [dir]` | Apply safe auto-fixes (formatting + lint) |
116
- | `aislop ci [dir]` | Output JSON for CI pipelines |
117
- | `aislop init [dir]` | Create `.aislop/config.yml` and `.aislop/rules.yml` |
118
- | `aislop doctor [dir]` | Report which tools are installed and available |
119
- | `aislop rules [dir]` | List all built-in and configured rules |
120
-
121
- ### Flags
122
-
123
- | Flag | Description |
124
- |---|---|
125
- | `--changes` | Only scan files changed from `HEAD` |
126
- | `--staged` | Only scan staged files |
127
- | `-d, --verbose` | Show detailed per-file output |
128
- | `--json` | Output JSON instead of terminal UI |
129
- | `-v, --version` | Print version |
130
-
131
- ---
132
-
133
- ## What it catches
134
-
135
- `aislop` groups checks into six engines. Each engine runs in parallel for speed.
136
-
137
- ### Formatting
138
-
139
- Enforces consistent formatting using the best tool for each language.
140
-
141
- | Language | Tool |
142
- |---|---|
143
- | TypeScript / JavaScript | Biome |
144
- | Python | ruff format |
145
- | Go | gofmt |
146
- | Rust | cargo fmt |
147
- | Ruby | rubocop |
148
- | PHP | php-cs-fixer |
149
-
150
- ### Linting
151
-
152
- Catches bugs and bad practices.
153
-
154
- | Language | Tool |
155
- |---|---|
156
- | TypeScript / JavaScript | oxlint (bundled, with React/Next.js awareness) |
157
- | Python | ruff |
158
- | Go | golangci-lint |
159
- | Rust | clippy |
160
- | Ruby | rubocop |
67
+ ### Fix issues automatically
161
68
 
162
- ### Code quality
163
-
164
- Measures structural complexity and finds dead code.
165
-
166
- | Rule | What it checks |
167
- |---|---|
168
- | `complexity/function-too-long` | Functions exceeding configurable line limit (default: 80) |
169
- | `complexity/file-too-large` | Files exceeding configurable line limit (default: 400) |
170
- | `complexity/deep-nesting` | Control-flow nesting beyond threshold (default: 5) |
171
- | `complexity/too-many-params` | Functions with too many parameters (default: 6) |
172
- | `duplication/block` | Cross-file duplicate code blocks (12+ lines) |
173
- | `knip/files`, `knip/exports`, `knip/types` | Dead code via knip (JS/TS) |
174
-
175
- ### AI slop detection (Maintainability)
176
-
177
- The rules that make aislop unique. These catch the patterns AI assistants leave behind.
178
-
179
- | Rule | Severity | What it catches |
180
- |---|---|---|
181
- | `ai-slop/trivial-comment` | warning | Comments restating the code (`// Import React`, `// Return the value`) |
182
- | `ai-slop/swallowed-exception` | error | Empty catch blocks, catch blocks that only log (JS/TS/Python/Go/Ruby/Java) |
183
- | `ai-slop/thin-wrapper` | warning | Functions that only delegate to another function |
184
- | `ai-slop/generic-naming` | info | AI-generated names: `helper_1`, `data2`, `temp1` |
185
- | `ai-slop/unused-import` | warning | Unused imports (JS/TS and Python) |
186
- | `ai-slop/console-leftover` | warning | `console.log`/`debug`/`info` left in production code |
187
- | `ai-slop/todo-stub` | info | Unresolved TODO/FIXME/HACK comments |
188
- | `ai-slop/unreachable-code` | warning | Code after `return`/`throw` statements |
189
- | `ai-slop/constant-condition` | warning | `if (true)`, `if (false)`, `if (0)` |
190
- | `ai-slop/empty-function` | info | Empty function bodies |
191
- | `ai-slop/unsafe-type-assertion` | warning | `as any` in TypeScript |
192
- | `ai-slop/double-type-assertion` | warning | `as unknown as X` pattern |
193
- | `ai-slop/ts-directive` | info | `@ts-ignore` / `@ts-expect-error` usage |
194
-
195
- ### Security
196
-
197
- Finds secrets, risky constructs, and vulnerable dependencies.
198
-
199
- | Rule | What it catches |
200
- |---|---|
201
- | `security/hardcoded-secret` | API keys, AWS credentials, JWT tokens, database URLs, passwords |
202
- | `security/eval` | `eval()` usage (JS/TS/Python/Ruby/PHP) |
203
- | `security/innerhtml` | `.innerHTML` and `dangerouslySetInnerHTML` |
204
- | `security/sql-injection` | String concatenation in SQL queries |
205
- | `security/shell-injection` | User input in command execution |
206
- | `security/vulnerable-dependency` | npm/pip/cargo/go dependency audit |
207
-
208
- ### Architecture (opt-in)
209
-
210
- Custom import and path rules defined in `.aislop/rules.yml`.
211
-
212
- | Rule type | Example |
213
- |---|---|
214
- | `forbid_import` | Ban `axios` project-wide |
215
- | `forbid_import_from_path` | Controllers cannot import database modules |
216
- | `require_pattern` | Require error handling in API routes |
217
-
218
- ---
69
+ ```bash
70
+ aislop fix # auto-fix formatting + lint issues
71
+ ```
219
72
 
220
- ## Scoring
73
+ ### Use in CI pipelines
221
74
 
222
- Every diagnostic contributes a weighted penalty:
75
+ ```bash
76
+ aislop ci # JSON output, exits 1 if score < threshold
77
+ ```
223
78
 
224
- | Severity | Penalty |
225
- |---|---|
226
- | Error | 3.0 |
227
- | Warning | 1.0 |
228
- | Info | 0.25 |
79
+ ### Other commands
229
80
 
230
- Penalties are multiplied by engine weight (configurable, security defaults to 2x). The final score uses logarithmic scaling so a few issues cause a noticeable drop, but the score does not collapse to zero from minor findings.
81
+ ```bash
82
+ aislop init # create .aislop/config.yml
83
+ aislop doctor # check which tools are available
84
+ aislop rules # list all built-in rules
85
+ aislop # interactive menu
86
+ ```
231
87
 
232
- | Score | Label |
233
- |---|---|
234
- | 75 -- 100 | Healthy |
235
- | 50 -- 74 | Needs Work |
236
- | 0 -- 49 | Critical |
88
+ See [all commands and flags](docs/commands.md).
237
89
 
238
90
  ---
239
91
 
240
- ## Configuration
92
+ ## Use in your project
241
93
 
242
- Run `aislop init` to generate `.aislop/config.yml`:
243
-
244
- ```yaml
245
- version: 1
246
-
247
- engines:
248
- format: true
249
- lint: true
250
- code-quality: true
251
- ai-slop: true
252
- architecture: false # opt-in, needs rules.yml
253
- security: true
254
-
255
- quality:
256
- maxFunctionLoc: 80
257
- maxFileLoc: 400
258
- maxNesting: 5
259
- maxParams: 6
260
-
261
- security:
262
- audit: true
263
- auditTimeout: 25000
264
-
265
- scoring:
266
- weights:
267
- format: 0.5
268
- lint: 1.0
269
- code-quality: 1.5
270
- ai-slop: 1.0
271
- architecture: 1.0
272
- security: 2.0
273
- thresholds:
274
- good: 75
275
- ok: 50
94
+ ### Pre-commit hook
276
95
 
277
- ci:
278
- failBelow: 0 # set to e.g. 70 to fail CI below that score
279
- format: json
96
+ ```bash
97
+ npx aislop scan --staged
280
98
  ```
281
99
 
282
- ---
283
-
284
- ## CI / CD
285
-
286
100
  ### GitHub Actions
287
101
 
288
102
  ```yaml
289
- - uses: actions/setup-node@v4
103
+ - uses: actions/setup-node@v6
290
104
  with:
291
105
  node-version: 20
292
-
293
106
  - run: npx aislop ci
294
107
  ```
295
108
 
296
- ### Fail below a score threshold
109
+ ### Quality gate
297
110
 
298
- Set `ci.failBelow` in `.aislop/config.yml`:
111
+ Set a minimum score in `.aislop/config.yml`:
299
112
 
300
113
  ```yaml
301
114
  ci:
302
115
  failBelow: 70
303
116
  ```
304
117
 
305
- `aislop ci` exits with code 1 when the score is below the threshold.
306
-
307
- ### Pre-commit hook
308
-
309
- ```bash
310
- npx aislop scan --staged
311
- ```
118
+ `aislop ci` exits with code 1 when the score drops below the threshold. See [CI/CD docs](docs/ci.md) for more.
312
119
 
313
120
  ---
314
121
 
315
- ## Supported languages
122
+ ## Why aislop?
316
123
 
317
- | Language | Format | Lint | Code quality | AI slop | Security |
318
- |---|---|---|---|---|---|
319
- | TypeScript | Biome | oxlint | knip, complexity | All rules | All rules |
320
- | JavaScript | Biome | oxlint | knip, complexity | All rules | All rules |
321
- | Python | ruff | ruff | complexity | Imports, exceptions, comments | Secrets, audit |
322
- | Go | gofmt | golangci-lint | complexity | Exceptions, comments | Secrets, audit |
323
- | Rust | cargo fmt | clippy | complexity | Comments | Secrets, audit |
324
- | Ruby | rubocop | rubocop | complexity | Exceptions, comments | Secrets |
325
- | PHP | php-cs-fixer | -- | complexity | Comments | Secrets |
124
+ AI-generated code passes review because issues are spread across dozens of files. No single linter catches all of them. `aislop` does:
326
125
 
327
- ---
126
+ - **AI-specific pattern detection** — trivial comments, thin wrappers, generic names, swallowed exceptions, `as any` casts
127
+ - **Multi-language** — TypeScript, JavaScript, Python, Go, Rust, Ruby, PHP, Expo/React Native
128
+ - **Single score** — one number to gate PRs, track in CI, and trend over time
129
+ - **Zero config** — run `npx aislop scan` and get results immediately
130
+ - **Framework-aware** — auto-detects Next.js, React, Expo, Vite, Remix, Django, Flask, FastAPI
131
+ - **Batteries included** — ships with oxlint, biome, knip; downloads ruff and golangci-lint on install
328
132
 
329
- ## Telemetry
133
+ ## What it catches
330
134
 
331
- `aislop` collects anonymous usage analytics to help us understand how the tool is used and prioritize improvements. **No code, file paths, project names, or secrets are ever collected.**
135
+ Six engines run in parallel: **Formatting**, **Linting**, **Code Quality**, **AI Slop Detection**, **Security**, and **Architecture** (opt-in).
332
136
 
333
- What we collect: command run (scan/fix/ci), languages detected, score bucket, issue counts per engine, engine timing, OS, Node version, and aislop version.
137
+ | Engine | Examples |
138
+ |---|---|
139
+ | Formatting | Biome, ruff, gofmt, cargo fmt, rubocop, php-cs-fixer |
140
+ | Linting | oxlint, ruff, golangci-lint, clippy, expo-doctor |
141
+ | Code Quality | Function/file size limits, deep nesting, duplication, dead code, unused dependencies (knip) |
142
+ | AI Slop | Trivial comments, swallowed exceptions, unused imports, console leftovers, type assertion abuse, TODO stubs |
143
+ | Security | Hardcoded secrets, eval, innerHTML, SQL/shell injection, dependency audits |
144
+ | Architecture | Custom import bans, layering rules, required patterns |
334
145
 
335
- Telemetry is **off in CI** by default. To opt out anywhere:
146
+ See the full [rules reference](docs/rules.md) for all 30+ built-in rules.
336
147
 
337
- ```bash
338
- # Environment variable (any of these)
339
- AISLOP_NO_TELEMETRY=1 aislop scan
340
- DO_NOT_TRACK=1 aislop scan
148
+ ---
341
149
 
342
- # Or in .aislop/config.yml
343
- telemetry:
344
- enabled: false
345
- ```
150
+ ## Documentation
151
+
152
+ | Topic | Link |
153
+ |---|---|
154
+ | Installation | [docs/installation.md](docs/installation.md) |
155
+ | Commands & flags | [docs/commands.md](docs/commands.md) |
156
+ | Rules reference | [docs/rules.md](docs/rules.md) |
157
+ | Configuration | [docs/configuration.md](docs/configuration.md) |
158
+ | Scoring | [docs/scoring.md](docs/scoring.md) |
159
+ | CI / CD | [docs/ci.md](docs/ci.md) |
160
+ | Telemetry | [docs/telemetry.md](docs/telemetry.md) |
346
161
 
347
162
  ---
348
163
 
349
164
  ## Contributing
350
165
 
351
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project architecture, and how to add new rules or engines.
166
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and how to add new rules. AI coding assistants can find project context in [AGENTS.md](AGENTS.md).
167
+
168
+ ## Acknowledgments
169
+
170
+ `aislop` is built on top of excellent open-source projects:
171
+
172
+ - [Biome](https://biomejs.dev/) — formatting and linting for JS/TS
173
+ - [oxlint](https://oxc.rs/) — fast JavaScript/TypeScript linter
174
+ - [knip](https://knip.dev/) — unused files, exports, and dependencies
175
+ - [ruff](https://docs.astral.sh/ruff/) — Python linting and formatting
176
+ - [golangci-lint](https://golangci-lint.run/) — Go linting
177
+ - [expo-doctor](https://docs.expo.dev/) — Expo/React Native project health
352
178
 
353
- ## Security
179
+ ## Contributors
354
180
 
355
- See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
181
+ [![Contributors](https://contrib.rocks/image?repo=heavykenny/aislop)](https://github.com/heavykenny/aislop/graphs/contributors)
356
182
 
357
183
  ## License
358
184
 
359
- [MIT](LICENSE) -- see the [LICENSE](LICENSE) file.
185
+ [MIT](LICENSE)
package/dist/cli.js CHANGED
@@ -46,7 +46,8 @@ const DEFAULT_CONFIG = {
46
46
  thresholds: {
47
47
  good: 75,
48
48
  ok: 50
49
- }
49
+ },
50
+ smoothing: 10
50
51
  },
51
52
  ci: {
52
53
  failBelow: 0,
@@ -146,7 +147,8 @@ const ScoringSchema = z.object({
146
147
  thresholds: ThresholdsSchema.default(() => ({
147
148
  good: 75,
148
149
  ok: 50
149
- }))
150
+ })),
151
+ smoothing: z.number().nonnegative().default(10)
150
152
  });
151
153
  const CiSchema = z.object({
152
154
  failBelow: z.number().default(0),
@@ -178,7 +180,8 @@ const AislopConfigSchema = z.object({
178
180
  thresholds: {
179
181
  good: 75,
180
182
  ok: 50
181
- }
183
+ },
184
+ smoothing: 10
182
185
  })),
183
186
  ci: CiSchema.default(() => ({
184
187
  failBelow: 0,
@@ -1279,10 +1282,7 @@ const FUNCTION_PATTERNS = [
1279
1282
  ]
1280
1283
  }
1281
1284
  ];
1282
- const countParams = (paramStr) => {
1283
- if (!paramStr.trim()) return 0;
1284
- return paramStr.split(",").length;
1285
- };
1285
+ const countParams = (p) => p.trim() ? p.split(",").length : 0;
1286
1286
  const matchFunctionOnLine = (line, ext) => {
1287
1287
  for (let i = 0; i < FUNCTION_PATTERNS.length; i++) {
1288
1288
  const pattern = FUNCTION_PATTERNS[i];
@@ -1643,13 +1643,41 @@ const isToolInstalled = async (tool) => {
1643
1643
  //#region src/engines/code-quality/knip.ts
1644
1644
  const KNIP_MESSAGE_MAP = {
1645
1645
  files: "Unused file",
1646
+ dependencies: "Unused dependency",
1647
+ devDependencies: "Unused devDependency",
1648
+ unlisted: "Unlisted dependency",
1649
+ unresolved: "Unresolved import",
1650
+ binaries: "Unlisted binary",
1646
1651
  exports: "Unused export",
1647
1652
  types: "Unused type",
1648
1653
  duplicates: "Duplicate export"
1649
1654
  };
1655
+ const DEPENDENCY_TYPES = [
1656
+ "dependencies",
1657
+ "devDependencies",
1658
+ "unlisted",
1659
+ "unresolved",
1660
+ "binaries"
1661
+ ];
1662
+ const isDependencyType = (type) => DEPENDENCY_TYPES.includes(type);
1663
+ const getIssueItems = (fileIssue, issueType) => {
1664
+ const items = fileIssue[issueType];
1665
+ return Array.isArray(items) ? items : [];
1666
+ };
1667
+ const DEPENDENCY_HELP = {
1668
+ dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
1669
+ devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
1670
+ unlisted: "This package is imported in code but not declared in package.json. Run `npm install` to add it.",
1671
+ unresolved: "This import cannot be resolved. Check for typos or missing packages.",
1672
+ binaries: "This binary is used but its package is not in package.json."
1673
+ };
1650
1674
  const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
1651
1675
  const diagnostics = [];
1652
- const issues = issueType === "exports" ? fileIssue.exports ?? [] : issueType === "types" ? fileIssue.types ?? [] : fileIssue.duplicates ?? [];
1676
+ const issues = getIssueItems(fileIssue, issueType);
1677
+ const category = isDependencyType(issueType) ? "Dependencies" : "Dead Code";
1678
+ const severity = issueType === "unlisted" || issueType === "unresolved" ? "error" : "warning";
1679
+ const fixable = issueType === "dependencies" || issueType === "devDependencies";
1680
+ const help = DEPENDENCY_HELP[issueType] ?? "";
1653
1681
  for (const issue of issues) {
1654
1682
  const symbol = issue.name ?? issue.symbol ?? "unknown";
1655
1683
  const absolutePath = path.resolve(knipCwd, fileIssue.file);
@@ -1657,13 +1685,13 @@ const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
1657
1685
  filePath: path.relative(rootDir, absolutePath),
1658
1686
  engine: "code-quality",
1659
1687
  rule: `knip/${issueType}`,
1660
- severity: "warning",
1688
+ severity,
1661
1689
  message: `${KNIP_MESSAGE_MAP[issueType]}: ${symbol}`,
1662
- help: "",
1690
+ help,
1663
1691
  line: issue.line ?? 0,
1664
1692
  column: issue.col ?? 0,
1665
- category: "Dead Code",
1666
- fixable: false
1693
+ category,
1694
+ fixable
1667
1695
  });
1668
1696
  }
1669
1697
  return diagnostics;
@@ -1697,6 +1725,38 @@ const findKnipBin = (rootDirectory, monorepoRoot) => {
1697
1725
  }
1698
1726
  return null;
1699
1727
  };
1728
+ const runKnipDependencyCheck = async (rootDirectory) => {
1729
+ return (await runKnip(rootDirectory)).filter((d) => d.rule === "knip/dependencies" || d.rule === "knip/devDependencies");
1730
+ };
1731
+ const fixUnusedDependencies = async (rootDirectory) => {
1732
+ const diagnostics = await runKnipDependencyCheck(rootDirectory);
1733
+ if (diagnostics.length === 0) return;
1734
+ const pkgPath = path.join(rootDirectory, "package.json");
1735
+ if (!fs.existsSync(pkgPath)) return;
1736
+ const raw = fs.readFileSync(pkgPath, "utf-8");
1737
+ const pkg = JSON.parse(raw);
1738
+ const unusedDeps = /* @__PURE__ */ new Set();
1739
+ const unusedDevDeps = /* @__PURE__ */ new Set();
1740
+ for (const d of diagnostics) {
1741
+ const pkgName = d.message.replace(/^Unused (dev)?[Dd]ependency: /, "");
1742
+ if (d.rule === "knip/dependencies") unusedDeps.add(pkgName);
1743
+ if (d.rule === "knip/devDependencies") unusedDevDeps.add(pkgName);
1744
+ }
1745
+ let changed = false;
1746
+ if (pkg.dependencies) {
1747
+ for (const name of unusedDeps) if (name in pkg.dependencies) {
1748
+ delete pkg.dependencies[name];
1749
+ changed = true;
1750
+ }
1751
+ }
1752
+ if (pkg.devDependencies) {
1753
+ for (const name of unusedDevDeps) if (name in pkg.devDependencies) {
1754
+ delete pkg.devDependencies[name];
1755
+ changed = true;
1756
+ }
1757
+ }
1758
+ if (changed) fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
1759
+ };
1700
1760
  const runKnip = async (rootDirectory) => {
1701
1761
  const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
1702
1762
  if (!knipRuntime) return [];
@@ -1730,11 +1790,13 @@ const runKnip = async (rootDirectory) => {
1730
1790
  fixable: false
1731
1791
  });
1732
1792
  const issues = parsed.issues ?? [];
1733
- for (const fileIssue of issues) for (const type of [
1793
+ const issueTypes = [
1794
+ ...DEPENDENCY_TYPES,
1734
1795
  "exports",
1735
1796
  "types",
1736
1797
  "duplicates"
1737
- ]) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
1798
+ ];
1799
+ for (const fileIssue of issues) for (const type of issueTypes) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
1738
1800
  return diagnostics;
1739
1801
  } catch {
1740
1802
  return [];
@@ -3118,7 +3180,7 @@ const logger = {
3118
3180
  * Application version — injected at build time by tsdown from package.json.
3119
3181
  * The fallback should always match the "version" field in package.json.
3120
3182
  */
3121
- const APP_VERSION = "0.1.2";
3183
+ const APP_VERSION = "0.2.0";
3122
3184
 
3123
3185
  //#endregion
3124
3186
  //#region src/output/layout.ts
@@ -3344,7 +3406,12 @@ var ScanProgressRenderer = class {
3344
3406
  //#endregion
3345
3407
  //#region src/scoring/index.ts
3346
3408
  const PERFECT_SCORE$1 = 100;
3347
- const calculateScore = (diagnostics, weights, thresholds) => {
3409
+ const getEffectiveFileCount = (diagnostics, sourceFileCount) => {
3410
+ if (typeof sourceFileCount === "number" && sourceFileCount > 0) return sourceFileCount;
3411
+ const filesWithDiagnostics = new Set(diagnostics.map((d) => d.filePath)).size;
3412
+ return Math.max(1, filesWithDiagnostics);
3413
+ };
3414
+ const calculateScore = (diagnostics, weights, thresholds, sourceFileCount, smoothing) => {
3348
3415
  if (diagnostics.length === 0) return {
3349
3416
  score: PERFECT_SCORE$1,
3350
3417
  label: "Healthy"
@@ -3355,7 +3422,11 @@ const calculateScore = (diagnostics, weights, thresholds) => {
3355
3422
  const severityPenalty = d.severity === "error" ? 3 : d.severity === "warning" ? 1 : .25;
3356
3423
  deductions += severityPenalty * engineWeight;
3357
3424
  }
3358
- const score = Math.max(0, Math.round(PERFECT_SCORE$1 - PERFECT_SCORE$1 * Math.log1p(deductions) / Math.log1p(PERFECT_SCORE$1 + deductions)));
3425
+ const effectiveFileCount = getEffectiveFileCount(diagnostics, sourceFileCount);
3426
+ const smoothingConstant = typeof smoothing === "number" ? smoothing : 10;
3427
+ const issueDensity = Math.min(1, diagnostics.length / (effectiveFileCount + smoothingConstant));
3428
+ const scaledDeductions = deductions * Math.sqrt(issueDensity);
3429
+ const score = Math.max(0, Math.round(PERFECT_SCORE$1 - PERFECT_SCORE$1 * Math.log1p(scaledDeductions) / Math.log1p(PERFECT_SCORE$1 + scaledDeductions)));
3359
3430
  return {
3360
3431
  score,
3361
3432
  label: score >= thresholds.good ? "Healthy" : score >= thresholds.ok ? "Needs Work" : "Critical"
@@ -3683,9 +3754,13 @@ const getAnonymousId = () => {
3683
3754
  for (let i = 0; i < raw.length; i++) hash = hash * 33 ^ raw.charCodeAt(i);
3684
3755
  return `aislop_${(hash >>> 0).toString(36)}`;
3685
3756
  };
3757
+ /** Pending telemetry request — kept alive so Node doesn't exit before it completes. */
3758
+ let pendingRequest = null;
3686
3759
  /**
3687
3760
  * Fire-and-forget telemetry event to PostHog.
3688
- * Never throws, never blocks, never affects CLI output or exit code.
3761
+ * Never throws, never blocks CLI output.
3762
+ * The request is kept alive via `flushTelemetry()` so Node doesn't
3763
+ * exit before it completes.
3689
3764
  */
3690
3765
  const trackEvent = (event) => {
3691
3766
  const payload = {
@@ -3708,12 +3783,23 @@ const trackEvent = (event) => {
3708
3783
  },
3709
3784
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3710
3785
  };
3711
- fetch(`${POSTHOG_HOST}/capture/`, {
3786
+ pendingRequest = fetch(`${POSTHOG_HOST}/capture/`, {
3712
3787
  method: "POST",
3713
3788
  headers: { "Content-Type": "application/json" },
3714
3789
  body: JSON.stringify(payload),
3715
3790
  signal: AbortSignal.timeout(3e3)
3716
- }).catch(() => {});
3791
+ }).then(() => {}).catch(() => {});
3792
+ };
3793
+ /**
3794
+ * Wait for any pending telemetry request to complete.
3795
+ * Call this before `process.exit()` to ensure the event is delivered.
3796
+ * Times out after 3 seconds so it never hangs the CLI.
3797
+ */
3798
+ const flushTelemetry = async () => {
3799
+ if (pendingRequest) {
3800
+ await pendingRequest;
3801
+ pendingRequest = null;
3802
+ }
3717
3803
  };
3718
3804
 
3719
3805
  //#endregion
@@ -3771,7 +3857,7 @@ const scanCommand = async (directory, config, options) => {
3771
3857
  progressRenderer?.stop();
3772
3858
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
3773
3859
  const elapsedMs = performance.now() - startTime;
3774
- const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds);
3860
+ const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
3775
3861
  const exitCode = allDiagnostics.some((d) => d.severity === "error") || scoreResult.score < config.ci.failBelow ? 1 : 0;
3776
3862
  if (!isTelemetryDisabled(config.telemetry?.enabled)) {
3777
3863
  const engineIssues = {};
@@ -4060,6 +4146,9 @@ const fixCommand = async (directory, config, options = {
4060
4146
  if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python lint fixes", () => runRuffLint(context), () => fixRuffLint(resolvedDir), options));
4061
4147
  else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python lint fixes.");
4062
4148
  }
4149
+ if (config.engines["code-quality"]) {
4150
+ if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("Unused dependencies", () => runKnipDependencyCheck(resolvedDir), () => fixUnusedDependencies(resolvedDir), options));
4151
+ }
4063
4152
  if (steps.length === 0) logger.dim(" No applicable auto-fixers found for this project.");
4064
4153
  else {
4065
4154
  logger.break();
@@ -4139,6 +4228,11 @@ const BUILTIN_RULES = [
4139
4228
  engine: "code-quality",
4140
4229
  rules: [
4141
4230
  "knip/files",
4231
+ "knip/dependencies",
4232
+ "knip/devDependencies",
4233
+ "knip/unlisted",
4234
+ "knip/unresolved",
4235
+ "knip/binaries",
4142
4236
  "knip/exports",
4143
4237
  "knip/types",
4144
4238
  "complexity/file-too-large",
@@ -4424,7 +4518,10 @@ const program = new Command().name("aislop").description("The unified code quali
4424
4518
  verbose: Boolean(flags.verbose),
4425
4519
  json: Boolean(flags.json)
4426
4520
  });
4427
- if (exitCode !== 0) process.exit(exitCode);
4521
+ if (exitCode !== 0) {
4522
+ await flushTelemetry();
4523
+ process.exit(exitCode);
4524
+ }
4428
4525
  }).addHelpText("after", `
4429
4526
  ${highlighter.dim("Commands:")}
4430
4527
  aislop scan [dir] Full code quality scan
@@ -4451,7 +4548,10 @@ program.command("scan [directory]").description("Run full code quality scan").op
4451
4548
  verbose: Boolean(flags.verbose),
4452
4549
  json: Boolean(flags.json)
4453
4550
  });
4454
- if (exitCode !== 0) process.exit(exitCode);
4551
+ if (exitCode !== 0) {
4552
+ await flushTelemetry();
4553
+ process.exit(exitCode);
4554
+ }
4455
4555
  });
4456
4556
  program.command("fix [directory]").description("Auto-fix formatting and lint issues").option("-d, --verbose", "show detailed fix progress").action(async (directory = ".", _flags, command) => {
4457
4557
  const flags = command.optsWithGlobals();
@@ -4465,13 +4565,17 @@ program.command("doctor [directory]").description("Check installed tools and env
4465
4565
  });
4466
4566
  program.command("ci [directory]").description("CI-friendly JSON output with exit codes").action(async (directory = ".") => {
4467
4567
  const { exitCode } = await ciCommand(directory, loadConfig(directory));
4468
- if (exitCode !== 0) process.exit(exitCode);
4568
+ if (exitCode !== 0) {
4569
+ await flushTelemetry();
4570
+ process.exit(exitCode);
4571
+ }
4469
4572
  });
4470
4573
  program.command("rules [directory]").description("List all available rules").action(async (directory = ".") => {
4471
4574
  await rulesCommand(directory);
4472
4575
  });
4473
4576
  const main = async () => {
4474
4577
  await program.parseAsync();
4578
+ await flushTelemetry();
4475
4579
  };
4476
4580
  main();
4477
4581
 
@@ -3,7 +3,7 @@
3
3
  * Application version — injected at build time by tsdown from package.json.
4
4
  * The fallback should always match the "version" field in package.json.
5
5
  */
6
- const APP_VERSION = "0.1.2";
6
+ const APP_VERSION = "0.2.0";
7
7
 
8
8
  //#endregion
9
9
  //#region src/output/engine-info.ts
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ declare const AislopConfigSchema: z.ZodObject<{
30
30
  good: z.ZodDefault<z.ZodNumber>;
31
31
  ok: z.ZodDefault<z.ZodNumber>;
32
32
  }, z.core.$strip>>;
33
+ smoothing: z.ZodDefault<z.ZodNumber>;
33
34
  }, z.core.$strip>>;
34
35
  ci: z.ZodDefault<z.ZodObject<{
35
36
  failBelow: z.ZodDefault<z.ZodNumber>;
@@ -114,6 +115,6 @@ interface ScoreResult {
114
115
  declare const calculateScore: (diagnostics: Diagnostic[], weights: Record<string, number>, thresholds: {
115
116
  good: number;
116
117
  ok: number;
117
- }) => ScoreResult;
118
+ }, sourceFileCount?: number, smoothing?: number) => ScoreResult;
118
119
  //#endregion
119
120
  export { type AislopConfig, type Diagnostic, type EngineName, type EngineResult, type Framework, type Language, type ProjectInfo, type ScoreResult, type Severity, calculateScore, discoverProject, doctorCommand, fixCommand, initCommand, loadConfig, scanCommand };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-B4Eq4giL.js";
1
+ import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-DpU0WTTj.js";
2
2
  import { n as runSubprocess, t as isToolInstalled } from "./subprocess-99puEEGl.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs from "node:fs";
@@ -719,6 +719,170 @@ const fixRuffFormat = async (rootDirectory) => {
719
719
  if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `ruff format exited with code ${result.exitCode}`);
720
720
  };
721
721
 
722
+ //#endregion
723
+ //#region src/engines/code-quality/knip.ts
724
+ const KNIP_MESSAGE_MAP = {
725
+ files: "Unused file",
726
+ dependencies: "Unused dependency",
727
+ devDependencies: "Unused devDependency",
728
+ unlisted: "Unlisted dependency",
729
+ unresolved: "Unresolved import",
730
+ binaries: "Unlisted binary",
731
+ exports: "Unused export",
732
+ types: "Unused type",
733
+ duplicates: "Duplicate export"
734
+ };
735
+ const DEPENDENCY_TYPES = [
736
+ "dependencies",
737
+ "devDependencies",
738
+ "unlisted",
739
+ "unresolved",
740
+ "binaries"
741
+ ];
742
+ const isDependencyType = (type) => DEPENDENCY_TYPES.includes(type);
743
+ const getIssueItems = (fileIssue, issueType) => {
744
+ const items = fileIssue[issueType];
745
+ return Array.isArray(items) ? items : [];
746
+ };
747
+ const DEPENDENCY_HELP = {
748
+ dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
749
+ devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
750
+ unlisted: "This package is imported in code but not declared in package.json. Run `npm install` to add it.",
751
+ unresolved: "This import cannot be resolved. Check for typos or missing packages.",
752
+ binaries: "This binary is used but its package is not in package.json."
753
+ };
754
+ const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
755
+ const diagnostics = [];
756
+ const issues = getIssueItems(fileIssue, issueType);
757
+ const category = isDependencyType(issueType) ? "Dependencies" : "Dead Code";
758
+ const severity = issueType === "unlisted" || issueType === "unresolved" ? "error" : "warning";
759
+ const fixable = issueType === "dependencies" || issueType === "devDependencies";
760
+ const help = DEPENDENCY_HELP[issueType] ?? "";
761
+ for (const issue of issues) {
762
+ const symbol = issue.name ?? issue.symbol ?? "unknown";
763
+ const absolutePath = path.resolve(knipCwd, fileIssue.file);
764
+ diagnostics.push({
765
+ filePath: path.relative(rootDir, absolutePath),
766
+ engine: "code-quality",
767
+ rule: `knip/${issueType}`,
768
+ severity,
769
+ message: `${KNIP_MESSAGE_MAP[issueType]}: ${symbol}`,
770
+ help,
771
+ line: issue.line ?? 0,
772
+ column: issue.col ?? 0,
773
+ category,
774
+ fixable
775
+ });
776
+ }
777
+ return diagnostics;
778
+ };
779
+ const findMonorepoRoot = (directory) => {
780
+ let current = path.dirname(directory);
781
+ while (current !== path.dirname(current)) {
782
+ if (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || (() => {
783
+ const pkgPath = path.join(current, "package.json");
784
+ if (!fs.existsSync(pkgPath)) return false;
785
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
786
+ return Array.isArray(pkg.workspaces) || pkg.workspaces?.packages;
787
+ })()) return current;
788
+ current = path.dirname(current);
789
+ }
790
+ return null;
791
+ };
792
+ const KNIP_RELATIVE_BIN = path.join("node_modules", "knip", "bin", "knip.js");
793
+ const findKnipBin = (rootDirectory, monorepoRoot) => {
794
+ const localPath = path.join(rootDirectory, KNIP_RELATIVE_BIN);
795
+ if (fs.existsSync(localPath)) return {
796
+ binPath: localPath,
797
+ cwd: rootDirectory
798
+ };
799
+ if (monorepoRoot) {
800
+ const monorepoPath = path.join(monorepoRoot, KNIP_RELATIVE_BIN);
801
+ if (fs.existsSync(monorepoPath)) return {
802
+ binPath: monorepoPath,
803
+ cwd: monorepoRoot
804
+ };
805
+ }
806
+ return null;
807
+ };
808
+ const runKnipDependencyCheck = async (rootDirectory) => {
809
+ return (await runKnip(rootDirectory)).filter((d) => d.rule === "knip/dependencies" || d.rule === "knip/devDependencies");
810
+ };
811
+ const fixUnusedDependencies = async (rootDirectory) => {
812
+ const diagnostics = await runKnipDependencyCheck(rootDirectory);
813
+ if (diagnostics.length === 0) return;
814
+ const pkgPath = path.join(rootDirectory, "package.json");
815
+ if (!fs.existsSync(pkgPath)) return;
816
+ const raw = fs.readFileSync(pkgPath, "utf-8");
817
+ const pkg = JSON.parse(raw);
818
+ const unusedDeps = /* @__PURE__ */ new Set();
819
+ const unusedDevDeps = /* @__PURE__ */ new Set();
820
+ for (const d of diagnostics) {
821
+ const pkgName = d.message.replace(/^Unused (dev)?[Dd]ependency: /, "");
822
+ if (d.rule === "knip/dependencies") unusedDeps.add(pkgName);
823
+ if (d.rule === "knip/devDependencies") unusedDevDeps.add(pkgName);
824
+ }
825
+ let changed = false;
826
+ if (pkg.dependencies) {
827
+ for (const name of unusedDeps) if (name in pkg.dependencies) {
828
+ delete pkg.dependencies[name];
829
+ changed = true;
830
+ }
831
+ }
832
+ if (pkg.devDependencies) {
833
+ for (const name of unusedDevDeps) if (name in pkg.devDependencies) {
834
+ delete pkg.devDependencies[name];
835
+ changed = true;
836
+ }
837
+ }
838
+ if (changed) fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
839
+ };
840
+ const runKnip = async (rootDirectory) => {
841
+ const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
842
+ if (!knipRuntime) return [];
843
+ try {
844
+ const args = [
845
+ knipRuntime.binPath,
846
+ "--no-progress",
847
+ "--reporter",
848
+ "json",
849
+ "--no-exit-code"
850
+ ];
851
+ const result = await runSubprocess(process.execPath, args, {
852
+ cwd: knipRuntime.cwd,
853
+ timeout: 2e4,
854
+ env: { FORCE_COLOR: "0" }
855
+ });
856
+ if (!result.stdout) return [];
857
+ const parsed = JSON.parse(result.stdout);
858
+ const diagnostics = [];
859
+ const files = parsed.files ?? [];
860
+ for (const unusedFile of files) diagnostics.push({
861
+ filePath: path.relative(rootDirectory, path.resolve(knipRuntime.cwd, unusedFile)),
862
+ engine: "code-quality",
863
+ rule: "knip/files",
864
+ severity: "warning",
865
+ message: KNIP_MESSAGE_MAP.files,
866
+ help: "This file is not imported by any other file in the project.",
867
+ line: 0,
868
+ column: 0,
869
+ category: "Dead Code",
870
+ fixable: false
871
+ });
872
+ const issues = parsed.issues ?? [];
873
+ const issueTypes = [
874
+ ...DEPENDENCY_TYPES,
875
+ "exports",
876
+ "types",
877
+ "duplicates"
878
+ ];
879
+ for (const fileIssue of issues) for (const type of issueTypes) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
880
+ return diagnostics;
881
+ } catch {
882
+ return [];
883
+ }
884
+ };
885
+
722
886
  //#endregion
723
887
  //#region src/engines/lint/oxlint-config.ts
724
888
  const createOxlintConfig = (options) => {
@@ -1078,9 +1242,13 @@ const getAnonymousId = () => {
1078
1242
  for (let i = 0; i < raw.length; i++) hash = hash * 33 ^ raw.charCodeAt(i);
1079
1243
  return `aislop_${(hash >>> 0).toString(36)}`;
1080
1244
  };
1245
+ /** Pending telemetry request — kept alive so Node doesn't exit before it completes. */
1246
+ let pendingRequest = null;
1081
1247
  /**
1082
1248
  * Fire-and-forget telemetry event to PostHog.
1083
- * Never throws, never blocks, never affects CLI output or exit code.
1249
+ * Never throws, never blocks CLI output.
1250
+ * The request is kept alive via `flushTelemetry()` so Node doesn't
1251
+ * exit before it completes.
1084
1252
  */
1085
1253
  const trackEvent = (event) => {
1086
1254
  const payload = {
@@ -1103,12 +1271,12 @@ const trackEvent = (event) => {
1103
1271
  },
1104
1272
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1105
1273
  };
1106
- fetch(`${POSTHOG_HOST}/capture/`, {
1274
+ pendingRequest = fetch(`${POSTHOG_HOST}/capture/`, {
1107
1275
  method: "POST",
1108
1276
  headers: { "Content-Type": "application/json" },
1109
1277
  body: JSON.stringify(payload),
1110
1278
  signal: AbortSignal.timeout(3e3)
1111
- }).catch(() => {});
1279
+ }).then(() => {}).catch(() => {});
1112
1280
  };
1113
1281
 
1114
1282
  //#endregion
@@ -1220,6 +1388,9 @@ const fixCommand = async (directory, config, options = {
1220
1388
  if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python lint fixes", () => runRuffLint(context), () => fixRuffLint(resolvedDir), options));
1221
1389
  else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python lint fixes.");
1222
1390
  }
1391
+ if (config.engines["code-quality"]) {
1392
+ if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("Unused dependencies", () => runKnipDependencyCheck(resolvedDir), () => fixUnusedDependencies(resolvedDir), options));
1393
+ }
1223
1394
  if (steps.length === 0) logger.dim(" No applicable auto-fixers found for this project.");
1224
1395
  else {
1225
1396
  logger.break();
@@ -1273,7 +1444,8 @@ const DEFAULT_CONFIG = {
1273
1444
  thresholds: {
1274
1445
  good: 75,
1275
1446
  ok: 50
1276
- }
1447
+ },
1448
+ smoothing: 10
1277
1449
  },
1278
1450
  ci: {
1279
1451
  failBelow: 0,
@@ -1373,7 +1545,8 @@ const ScoringSchema = z.object({
1373
1545
  thresholds: ThresholdsSchema.default(() => ({
1374
1546
  good: 75,
1375
1547
  ok: 50
1376
- }))
1548
+ })),
1549
+ smoothing: z.number().nonnegative().default(10)
1377
1550
  });
1378
1551
  const CiSchema = z.object({
1379
1552
  failBelow: z.number().default(0),
@@ -1405,7 +1578,8 @@ const AislopConfigSchema = z.object({
1405
1578
  thresholds: {
1406
1579
  good: 75,
1407
1580
  ok: 50
1408
- }
1581
+ },
1582
+ smoothing: 10
1409
1583
  })),
1410
1584
  ci: CiSchema.default(() => ({
1411
1585
  failBelow: 0,
@@ -2392,10 +2566,7 @@ const FUNCTION_PATTERNS = [
2392
2566
  ]
2393
2567
  }
2394
2568
  ];
2395
- const countParams = (paramStr) => {
2396
- if (!paramStr.trim()) return 0;
2397
- return paramStr.split(",").length;
2398
- };
2569
+ const countParams = (p) => p.trim() ? p.split(",").length : 0;
2399
2570
  const matchFunctionOnLine = (line, ext) => {
2400
2571
  for (let i = 0; i < FUNCTION_PATTERNS.length; i++) {
2401
2572
  const pattern = FUNCTION_PATTERNS[i];
@@ -2696,108 +2867,6 @@ const checkDuplication = async (context) => {
2696
2867
  return diagnostics;
2697
2868
  };
2698
2869
 
2699
- //#endregion
2700
- //#region src/engines/code-quality/knip.ts
2701
- const KNIP_MESSAGE_MAP = {
2702
- files: "Unused file",
2703
- exports: "Unused export",
2704
- types: "Unused type",
2705
- duplicates: "Duplicate export"
2706
- };
2707
- const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
2708
- const diagnostics = [];
2709
- const issues = issueType === "exports" ? fileIssue.exports ?? [] : issueType === "types" ? fileIssue.types ?? [] : fileIssue.duplicates ?? [];
2710
- for (const issue of issues) {
2711
- const symbol = issue.name ?? issue.symbol ?? "unknown";
2712
- const absolutePath = path.resolve(knipCwd, fileIssue.file);
2713
- diagnostics.push({
2714
- filePath: path.relative(rootDir, absolutePath),
2715
- engine: "code-quality",
2716
- rule: `knip/${issueType}`,
2717
- severity: "warning",
2718
- message: `${KNIP_MESSAGE_MAP[issueType]}: ${symbol}`,
2719
- help: "",
2720
- line: issue.line ?? 0,
2721
- column: issue.col ?? 0,
2722
- category: "Dead Code",
2723
- fixable: false
2724
- });
2725
- }
2726
- return diagnostics;
2727
- };
2728
- const findMonorepoRoot = (directory) => {
2729
- let current = path.dirname(directory);
2730
- while (current !== path.dirname(current)) {
2731
- if (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || (() => {
2732
- const pkgPath = path.join(current, "package.json");
2733
- if (!fs.existsSync(pkgPath)) return false;
2734
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
2735
- return Array.isArray(pkg.workspaces) || pkg.workspaces?.packages;
2736
- })()) return current;
2737
- current = path.dirname(current);
2738
- }
2739
- return null;
2740
- };
2741
- const KNIP_RELATIVE_BIN = path.join("node_modules", "knip", "bin", "knip.js");
2742
- const findKnipBin = (rootDirectory, monorepoRoot) => {
2743
- const localPath = path.join(rootDirectory, KNIP_RELATIVE_BIN);
2744
- if (fs.existsSync(localPath)) return {
2745
- binPath: localPath,
2746
- cwd: rootDirectory
2747
- };
2748
- if (monorepoRoot) {
2749
- const monorepoPath = path.join(monorepoRoot, KNIP_RELATIVE_BIN);
2750
- if (fs.existsSync(monorepoPath)) return {
2751
- binPath: monorepoPath,
2752
- cwd: monorepoRoot
2753
- };
2754
- }
2755
- return null;
2756
- };
2757
- const runKnip = async (rootDirectory) => {
2758
- const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
2759
- if (!knipRuntime) return [];
2760
- try {
2761
- const args = [
2762
- knipRuntime.binPath,
2763
- "--no-progress",
2764
- "--reporter",
2765
- "json",
2766
- "--no-exit-code"
2767
- ];
2768
- const result = await runSubprocess(process.execPath, args, {
2769
- cwd: knipRuntime.cwd,
2770
- timeout: 2e4,
2771
- env: { FORCE_COLOR: "0" }
2772
- });
2773
- if (!result.stdout) return [];
2774
- const parsed = JSON.parse(result.stdout);
2775
- const diagnostics = [];
2776
- const files = parsed.files ?? [];
2777
- for (const unusedFile of files) diagnostics.push({
2778
- filePath: path.relative(rootDirectory, path.resolve(knipRuntime.cwd, unusedFile)),
2779
- engine: "code-quality",
2780
- rule: "knip/files",
2781
- severity: "warning",
2782
- message: KNIP_MESSAGE_MAP.files,
2783
- help: "This file is not imported by any other file in the project.",
2784
- line: 0,
2785
- column: 0,
2786
- category: "Dead Code",
2787
- fixable: false
2788
- });
2789
- const issues = parsed.issues ?? [];
2790
- for (const fileIssue of issues) for (const type of [
2791
- "exports",
2792
- "types",
2793
- "duplicates"
2794
- ]) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
2795
- return diagnostics;
2796
- } catch {
2797
- return [];
2798
- }
2799
- };
2800
-
2801
2870
  //#endregion
2802
2871
  //#region src/engines/code-quality/index.ts
2803
2872
  const codeQualityEngine = {
@@ -3776,7 +3845,12 @@ var ScanProgressRenderer = class {
3776
3845
  //#endregion
3777
3846
  //#region src/scoring/index.ts
3778
3847
  const PERFECT_SCORE$1 = 100;
3779
- const calculateScore = (diagnostics, weights, thresholds) => {
3848
+ const getEffectiveFileCount = (diagnostics, sourceFileCount) => {
3849
+ if (typeof sourceFileCount === "number" && sourceFileCount > 0) return sourceFileCount;
3850
+ const filesWithDiagnostics = new Set(diagnostics.map((d) => d.filePath)).size;
3851
+ return Math.max(1, filesWithDiagnostics);
3852
+ };
3853
+ const calculateScore = (diagnostics, weights, thresholds, sourceFileCount, smoothing) => {
3780
3854
  if (diagnostics.length === 0) return {
3781
3855
  score: PERFECT_SCORE$1,
3782
3856
  label: "Healthy"
@@ -3787,7 +3861,11 @@ const calculateScore = (diagnostics, weights, thresholds) => {
3787
3861
  const severityPenalty = d.severity === "error" ? 3 : d.severity === "warning" ? 1 : .25;
3788
3862
  deductions += severityPenalty * engineWeight;
3789
3863
  }
3790
- const score = Math.max(0, Math.round(PERFECT_SCORE$1 - PERFECT_SCORE$1 * Math.log1p(deductions) / Math.log1p(PERFECT_SCORE$1 + deductions)));
3864
+ const effectiveFileCount = getEffectiveFileCount(diagnostics, sourceFileCount);
3865
+ const smoothingConstant = typeof smoothing === "number" ? smoothing : 10;
3866
+ const issueDensity = Math.min(1, diagnostics.length / (effectiveFileCount + smoothingConstant));
3867
+ const scaledDeductions = deductions * Math.sqrt(issueDensity);
3868
+ const score = Math.max(0, Math.round(PERFECT_SCORE$1 - PERFECT_SCORE$1 * Math.log1p(scaledDeductions) / Math.log1p(PERFECT_SCORE$1 + scaledDeductions)));
3791
3869
  return {
3792
3870
  score,
3793
3871
  label: score >= thresholds.good ? "Healthy" : score >= thresholds.ok ? "Needs Work" : "Critical"
@@ -3971,7 +4049,7 @@ const scanCommand = async (directory, config, options) => {
3971
4049
  progressRenderer?.stop();
3972
4050
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
3973
4051
  const elapsedMs = performance.now() - startTime;
3974
- const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds);
4052
+ const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
3975
4053
  const exitCode = allDiagnostics.some((d) => d.severity === "error") || scoreResult.score < config.ci.failBelow ? 1 : 0;
3976
4054
  if (!isTelemetryDisabled(config.telemetry?.enabled)) {
3977
4055
  const engineIssues = {};
@@ -3991,7 +4069,7 @@ const scanCommand = async (directory, config, options) => {
3991
4069
  });
3992
4070
  }
3993
4071
  if (options.json) {
3994
- const { buildJsonOutput } = await import("./json-BMSa_G7o.js");
4072
+ const { buildJsonOutput } = await import("./json-UG8l_sLC.js");
3995
4073
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
3996
4074
  console.log(JSON.stringify(jsonOut, null, 2));
3997
4075
  return { exitCode };
@@ -1,4 +1,4 @@
1
- import { r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-B4Eq4giL.js";
1
+ import { r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-DpU0WTTj.js";
2
2
 
3
3
  //#region src/output/json.ts
4
4
  const buildJsonOutput = (results, scoreResult, fileCount, elapsedMs) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aislop",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Stop AI slop from shipping. A unified code quality CLI that catches the lazy patterns AI coding tools leave behind.",
5
5
  "type": "module",
6
6
  "bin": {