aislop 0.1.3 → 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,298 +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
83
-
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
55
+ ## Usage
93
56
 
94
- # Scan only staged files
95
- npx aislop scan --staged
96
-
97
- # CI-friendly JSON output
98
- npx aislop ci
99
-
100
- # Initialize config files
101
- npx aislop init
57
+ ### Scan your project
102
58
 
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 |
161
-
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 |
67
+ ### Fix issues automatically
207
68
 
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 |
69
+ ```bash
70
+ aislop fix # auto-fix formatting + lint issues
71
+ ```
217
72
 
218
- ---
73
+ ### Use in CI pipelines
219
74
 
220
- Every diagnostic contributes a weighted penalty:
75
+ ```bash
76
+ aislop ci # JSON output, exits 1 if score < threshold
77
+ ```
221
78
 
222
- | Severity | Penalty |
223
- |---|---|
224
- | Error | 3.0 |
225
- | Warning | 1.0 |
226
- | Info | 0.25 |
79
+ ### Other commands
227
80
 
228
- Penalties are multiplied by engine weight (configurable, security defaults to 2x). The final score uses logarithmic scaling with issue-density normalization (relative to source file count), so a few issues still matter but a single finding in an otherwise clean project remains proportional.
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
+ ```
229
87
 
230
- | Score | Label |
231
- |---|---|
232
- | 75 -- 100 | Healthy |
233
- | 50 -- 74 | Needs Work |
234
- | 0 -- 49 | Critical |
88
+ See [all commands and flags](docs/commands.md).
235
89
 
236
90
  ---
237
91
 
238
- ## Configuration
92
+ ## Use in your project
239
93
 
240
- Run `aislop init` to generate `.aislop/config.yml`:
241
-
242
- ```yaml
243
- version: 1
244
-
245
- engines:
246
- format: true
247
- lint: true
248
- code-quality: true
249
- ai-slop: true
250
- architecture: false # opt-in, needs rules.yml
251
- security: true
252
-
253
- quality:
254
- maxFunctionLoc: 80
255
- maxFileLoc: 400
256
- maxNesting: 5
257
- maxParams: 6
258
-
259
- security:
260
- audit: true
261
- auditTimeout: 25000
262
-
263
- scoring:
264
- weights:
265
- format: 0.5
266
- lint: 1.0
267
- code-quality: 1.5
268
- ai-slop: 1.0
269
- architecture: 1.0
270
- security: 2.0
271
- thresholds:
272
- good: 75
273
- ok: 50
94
+ ### Pre-commit hook
274
95
 
275
- ci:
276
- failBelow: 0 # set to e.g. 70 to fail CI below that score
277
- format: json
96
+ ```bash
97
+ npx aislop scan --staged
278
98
  ```
279
99
 
280
- ---
281
-
282
- ## CI / CD
283
-
284
100
  ### GitHub Actions
285
101
 
286
102
  ```yaml
287
- - uses: actions/setup-node@v4
103
+ - uses: actions/setup-node@v6
288
104
  with:
289
105
  node-version: 20
290
-
291
106
  - run: npx aislop ci
292
107
  ```
293
108
 
294
- ### Fail below a score threshold
109
+ ### Quality gate
295
110
 
296
- Set `ci.failBelow` in `.aislop/config.yml`:
111
+ Set a minimum score in `.aislop/config.yml`:
297
112
 
298
113
  ```yaml
299
114
  ci:
300
115
  failBelow: 70
301
116
  ```
302
117
 
303
- `aislop ci` exits with code 1 when the score is below the threshold.
304
-
305
- ### Pre-commit hook
306
-
307
- ```bash
308
- npx aislop scan --staged
309
- ```
118
+ `aislop ci` exits with code 1 when the score drops below the threshold. See [CI/CD docs](docs/ci.md) for more.
310
119
 
311
120
  ---
312
121
 
313
- ## Supported languages
122
+ ## Why aislop?
314
123
 
315
- | Language | Format | Lint | Code quality | AI slop | Security |
316
- |---|---|---|---|---|---|
317
- | TypeScript | Biome | oxlint | knip, complexity | All rules | All rules |
318
- | JavaScript | Biome | oxlint | knip, complexity | All rules | All rules |
319
- | Python | ruff | ruff | complexity | Imports, exceptions, comments | Secrets, audit |
320
- | Go | gofmt | golangci-lint | complexity | Exceptions, comments | Secrets, audit |
321
- | Rust | cargo fmt | clippy | complexity | Comments | Secrets, audit |
322
- | Ruby | rubocop | rubocop | complexity | Exceptions, comments | Secrets |
323
- | 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:
324
125
 
325
- ---
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
326
132
 
327
- ## Telemetry
133
+ ## What it catches
328
134
 
329
- `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).
330
136
 
331
- 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 |
332
145
 
333
- 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.
334
147
 
335
- ```bash
336
- # Environment variable (any of these)
337
- AISLOP_NO_TELEMETRY=1 aislop scan
338
- DO_NOT_TRACK=1 aislop scan
148
+ ---
339
149
 
340
- # Or in .aislop/config.yml
341
- telemetry:
342
- enabled: false
343
- ```
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) |
344
161
 
345
162
  ---
346
163
 
347
164
  ## Contributing
348
165
 
349
- 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
350
178
 
351
- ## Security
179
+ ## Contributors
352
180
 
353
- See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
181
+ [![Contributors](https://contrib.rocks/image?repo=heavykenny/aislop)](https://github.com/heavykenny/aislop/graphs/contributors)
354
182
 
355
183
  ## License
356
184
 
357
- [MIT](LICENSE) -- see the [LICENSE](LICENSE) file.
185
+ [MIT](LICENSE)
package/dist/cli.js CHANGED
@@ -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.3";
3183
+ const APP_VERSION = "0.2.0";
3122
3184
 
3123
3185
  //#endregion
3124
3186
  //#region src/output/layout.ts
@@ -3692,9 +3754,13 @@ const getAnonymousId = () => {
3692
3754
  for (let i = 0; i < raw.length; i++) hash = hash * 33 ^ raw.charCodeAt(i);
3693
3755
  return `aislop_${(hash >>> 0).toString(36)}`;
3694
3756
  };
3757
+ /** Pending telemetry request — kept alive so Node doesn't exit before it completes. */
3758
+ let pendingRequest = null;
3695
3759
  /**
3696
3760
  * Fire-and-forget telemetry event to PostHog.
3697
- * 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.
3698
3764
  */
3699
3765
  const trackEvent = (event) => {
3700
3766
  const payload = {
@@ -3717,12 +3783,23 @@ const trackEvent = (event) => {
3717
3783
  },
3718
3784
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3719
3785
  };
3720
- fetch(`${POSTHOG_HOST}/capture/`, {
3786
+ pendingRequest = fetch(`${POSTHOG_HOST}/capture/`, {
3721
3787
  method: "POST",
3722
3788
  headers: { "Content-Type": "application/json" },
3723
3789
  body: JSON.stringify(payload),
3724
3790
  signal: AbortSignal.timeout(3e3)
3725
- }).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
+ }
3726
3803
  };
3727
3804
 
3728
3805
  //#endregion
@@ -4069,6 +4146,9 @@ const fixCommand = async (directory, config, options = {
4069
4146
  if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python lint fixes", () => runRuffLint(context), () => fixRuffLint(resolvedDir), options));
4070
4147
  else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python lint fixes.");
4071
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
+ }
4072
4152
  if (steps.length === 0) logger.dim(" No applicable auto-fixers found for this project.");
4073
4153
  else {
4074
4154
  logger.break();
@@ -4148,6 +4228,11 @@ const BUILTIN_RULES = [
4148
4228
  engine: "code-quality",
4149
4229
  rules: [
4150
4230
  "knip/files",
4231
+ "knip/dependencies",
4232
+ "knip/devDependencies",
4233
+ "knip/unlisted",
4234
+ "knip/unresolved",
4235
+ "knip/binaries",
4151
4236
  "knip/exports",
4152
4237
  "knip/types",
4153
4238
  "complexity/file-too-large",
@@ -4433,7 +4518,10 @@ const program = new Command().name("aislop").description("The unified code quali
4433
4518
  verbose: Boolean(flags.verbose),
4434
4519
  json: Boolean(flags.json)
4435
4520
  });
4436
- if (exitCode !== 0) process.exit(exitCode);
4521
+ if (exitCode !== 0) {
4522
+ await flushTelemetry();
4523
+ process.exit(exitCode);
4524
+ }
4437
4525
  }).addHelpText("after", `
4438
4526
  ${highlighter.dim("Commands:")}
4439
4527
  aislop scan [dir] Full code quality scan
@@ -4460,7 +4548,10 @@ program.command("scan [directory]").description("Run full code quality scan").op
4460
4548
  verbose: Boolean(flags.verbose),
4461
4549
  json: Boolean(flags.json)
4462
4550
  });
4463
- if (exitCode !== 0) process.exit(exitCode);
4551
+ if (exitCode !== 0) {
4552
+ await flushTelemetry();
4553
+ process.exit(exitCode);
4554
+ }
4464
4555
  });
4465
4556
  program.command("fix [directory]").description("Auto-fix formatting and lint issues").option("-d, --verbose", "show detailed fix progress").action(async (directory = ".", _flags, command) => {
4466
4557
  const flags = command.optsWithGlobals();
@@ -4474,13 +4565,17 @@ program.command("doctor [directory]").description("Check installed tools and env
4474
4565
  });
4475
4566
  program.command("ci [directory]").description("CI-friendly JSON output with exit codes").action(async (directory = ".") => {
4476
4567
  const { exitCode } = await ciCommand(directory, loadConfig(directory));
4477
- if (exitCode !== 0) process.exit(exitCode);
4568
+ if (exitCode !== 0) {
4569
+ await flushTelemetry();
4570
+ process.exit(exitCode);
4571
+ }
4478
4572
  });
4479
4573
  program.command("rules [directory]").description("List all available rules").action(async (directory = ".") => {
4480
4574
  await rulesCommand(directory);
4481
4575
  });
4482
4576
  const main = async () => {
4483
4577
  await program.parseAsync();
4578
+ await flushTelemetry();
4484
4579
  };
4485
4580
  main();
4486
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.3";
6
+ const APP_VERSION = "0.2.0";
7
7
 
8
8
  //#endregion
9
9
  //#region src/output/engine-info.ts
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-Bi8pE12U.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();
@@ -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 = {
@@ -4000,7 +4069,7 @@ const scanCommand = async (directory, config, options) => {
4000
4069
  });
4001
4070
  }
4002
4071
  if (options.json) {
4003
- const { buildJsonOutput } = await import("./json-66-1kHeg.js");
4072
+ const { buildJsonOutput } = await import("./json-UG8l_sLC.js");
4004
4073
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
4005
4074
  console.log(JSON.stringify(jsonOut, null, 2));
4006
4075
  return { exitCode };
@@ -1,4 +1,4 @@
1
- import { r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-Bi8pE12U.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.3",
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": {