ts-reviewer 2.0.1 → 3.0.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
@@ -10,7 +10,7 @@ Three modes, one skill:
10
10
 
11
11
  | Mode | What happens |
12
12
  |---|---|
13
- | **scan** | Analyzes the codebase and writes a prioritized report to `code-smells.md` |
13
+ | **scan** | Analyzes the codebase and writes a prioritized report to `code-smells/report.md` |
14
14
  | **fix** | Reads the report and applies fixes file-by-file with tsc/lint/test verification |
15
15
  | **auto** | Runs scan, asks you to confirm, fixes everything, deletes the report if clean |
16
16
 
@@ -81,7 +81,8 @@ Find issues in this project
81
81
  Audit the codebase for security and type safety problems
82
82
  ```
83
83
 
84
- Claude will analyze the project and write a report to `code-smells.md` in the project root.
84
+ Claude will analyze the project and write a report to `code-smells/report.md` in the project root.
85
+ With Architecture active, the same directory also holds project discovery, Knip, graph, metric, co-change, rule, and Mermaid artifacts.
85
86
 
86
87
  #### Domain flags
87
88
 
@@ -114,7 +115,7 @@ After reviewing the scan report, ask Claude to fix the issues:
114
115
  Fix the issues from the report
115
116
  ```
116
117
  ```
117
- Apply fixes from code-smells.md
118
+ Apply fixes from code-smells/report.md
118
119
  ```
119
120
 
120
121
  The fix workflow:
@@ -124,7 +125,7 @@ The fix workflow:
124
125
  4. Runs linter, fixes lint errors
125
126
  5. Runs full test suite, compares with baseline, fixes any regressions it caused
126
127
  6. Repeats verification up to 5 iterations
127
- 7. Updates the report: if all fixed → deletes `code-smells.md`; if some remain → keeps it as an audit trail with BEFORE/AFTER diffs for every fix
128
+ 7. Updates the report: if all fixed → deletes `code-smells/report.md`; if some remain → keeps it as an audit trail with BEFORE/AFTER diffs for every fix
128
129
 
129
130
  **Important:** fix never commits or stages anything. You review the changes and decide what to keep.
130
131
 
@@ -238,29 +239,30 @@ The test catches a check line that lost its severity, a block the profile does n
238
239
 
239
240
  ### Scan mode
240
241
 
241
- 1. **Discovery** — detects domain flags, maps the project, reads tsconfig.json, detects linter and test runner. If architecture is active, also maps module relationships and checks for `docs/adr/`.
242
+ 1. **Discovery** — detects domain flags, maps the project, reads tsconfig.json, detects linter and test runner, and asks once before downloading a missing architecture tool.
242
243
  2. **Diagnostics** — runs `tsc --noEmit`, linter, and LSP diagnostics (if available)
243
- 3. **Analysis** — specialized passes for each active domain (sub-agents in Claude Code, sequential in Claude.ai), each with its own checklist. Every finding passes an Evidence Protocol: verified against the actual file content, confidence-gated, version-gated against the project's TS/runtime.
244
- 4. **Report** — deduplicates, applies severity boost (scoped modes), consolidates recurring patterns, enforces a noise budget, writes `code-smells.md`. Architecture findings appear in a separate `## Architecture Opportunities` section at the end.
244
+ 3. **Architecture pre-pass** — when active, writes bounded Knip, graph, metric, co-change, rule, and Mermaid artifacts under `code-smells/`.
245
+ 4. **Analysis** — specialized passes judge the candidates against the active checklists; tool output is never a finding by itself.
246
+ 5. **Report** — deduplicates, applies severity boost (scoped modes), consolidates recurring patterns, enforces a noise budget, writes `code-smells/report.md`. Architecture findings appear in a separate `## Architecture Opportunities` section at the end.
245
247
 
246
248
  ### Fix mode
247
249
 
248
- 1. Parses `code-smells.md` as the work plan
250
+ 1. Parses `code-smells/report.md` as the work plan
249
251
  2. Captures test baseline (runs tests before changes)
250
252
  3. Applies fixes bottom-to-top within each file (so line numbers don't shift)
251
253
  4. Writes regression tests for each testable fix
252
254
  5. Runs `tsc --noEmit` after each file
253
255
  6. Full verification loop: tsc + linter + test suite (max 5 iterations)
254
256
  7. Compares test results with baseline — only fixes regressions it caused
255
- 8. Updates or deletes the report
257
+ 8. Updates or deletes the report, keeps the remaining `code-smells/` artifacts, and asks before removing them
256
258
 
257
259
  ## Tips
258
260
 
259
- - **Add `code-smells.md` to `.gitignore`** — it's a review artifact, not part of your source code.
261
+ - **Add `code-smells/` to `.gitignore`** — it contains review artifacts, not source code.
260
262
 
261
263
  - **Commit before running fix** — so you can `git diff` to review changes and `git checkout -- .` to revert if needed.
262
264
 
263
- - **Edit the report before fix** — since fix uses `code-smells.md` as its work plan, you can delete issues you don't want fixed, change severities, or add notes before running fix.
265
+ - **Edit the report before fix** — since fix uses `code-smells/report.md` as its work plan, you can delete issues you don't want fixed, change severities, or add notes before running fix.
264
266
 
265
267
  - **Scoped review for PRs** — `"review my branch against main"` is the most practical mode for day-to-day use. Full codebase audits are better suited for periodic health checks.
266
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-reviewer",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "description": "Install the TypeScript Code Reviewer skill for Claude Code, Codex, or Antigravity",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  "build": "tsc -p tsconfig.json",
23
23
  "prepublishOnly": "npm run build && npm test",
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
- "test": "npm run typecheck && node --test cnlp/skill-format.test.js",
25
+ "test": "npm run typecheck && node --test cnlp/skill-format.test.js tools.test.mjs",
26
26
  "dev": "npm run build && node --enable-source-maps dist/cli.js"
27
27
  },
28
28
  "keywords": [
@@ -28,13 +28,15 @@ inputs:
28
28
  - the reference checklists under `references/`
29
29
 
30
30
  preconditions:
31
- - `code-smells.md` exists before fix mode runs: it is the work plan, and fix stops with an error when it is absent
31
+ - `code-smells/report.md` exists before fix mode runs: it is the work plan, and fix stops with an error when it is absent
32
32
 
33
33
  scope:
34
34
  - `.ts`, `.mts`, and `.cts` files are reviewed alike
35
35
  - `.d.ts` files are reviewed by the Type Safety and Config domains only: a declaration has no runtime behavior
36
36
  - `.tsx` is out of scope
37
37
  - the analysis scope in a scoped mode is the diff file list, and the reading scope is wider
38
+ - `--affected` widens Architecture evidence to modules reaching a changed module, and does not widen the analysis scope
39
+ - anchor each finding to 1 file, and set `in_diff: true` only when that file is in the diff list
38
40
  - read as read-only context: `tsconfig.json`, the configs it extends, and `package.json`
39
41
  - read as read-only context: the files the scoped files import 1 level deep, and the shared types in `types.ts`, `*.d.ts`, `interfaces/`, `shared/`
40
42
 
@@ -57,11 +59,15 @@ forbidden_behaviors:
57
59
  - do not emit the same file and line twice
58
60
  - do not boost severity in `full` scope mode: all code is treated alike
59
61
  - do not flag a config issue in a scoped mode unless `tsconfig.json` is in the diff
62
+ - do not download and execute a missing analysis tool before the operator approves it once at discovery
63
+ - do not run `npm install` or `npm uninstall` for analysis: use an approved pinned-major `npx -y` command, or record the pre-pass as skipped
60
64
 
61
65
  outputs:
62
- - `code-smells.md` in the project root: the scan report, and the work plan fix reads
63
- - the `## Architecture Opportunities` section of that report, only when Architecture is active and at least 1 candidate is found
64
- - the audit trail in `code-smells.md` when any issue remains: BEFORE/AFTER for each fixed issue, a status tag for each failed, reverted, or skipped issue, the original entry for each untouched issue
66
+ - `code-smells/report.md` in the project root: the scan report, and the work plan fix reads
67
+ - `code-smells/knip.json`, `projects.json`, `co-change.md`, `cruise-summary.md`, `metrics.md`, and graph and diagram directories when Architecture is active
68
+ - `code-smells/suggested.dependency-cruiser.cjs` when prose declares dependency rules and no machine-readable declaration owns them
69
+ - the `## Architecture Opportunities` section of the report only when Architecture is active and at least 1 confirmed finding exists
70
+ - the audit trail in `code-smells/report.md` when any issue remains: BEFORE/AFTER for each fixed issue, a status tag for each failed, reverted, or skipped issue, the original entry for each untouched issue
65
71
  - a regression test for each fix that is testable
66
72
 
67
73
  run_modes:
@@ -119,40 +125,44 @@ workflow:
119
125
  9. audit the config that governs the files in scope, and name that config in the summary
120
126
  10. read the linter config: `eslint.config.*`, `.eslintrc.*`, `biome.json`, `deno.json`
121
127
  11. read `package.json` for the dependencies and the module type, and verify the TypeScript version, `engines.node`, and `@types/node` against `target_stack`
122
- 12. identify the entry points: `index.ts`, `main.ts`, the `exports` of `package.json`
123
- 13. collect the context files named in `scope:` when the scope mode is scoped
124
- 14. map the module relationships when Architecture is active: circular imports, deep relative imports, barrel-file cycles, feature slices, public entry points
125
- 15. scan `docs/adr/` for architectural decisions before proposing a change, and skip it silently when the directory is absent
126
- 16. report the discovery summary in the shape of `discovery_summary`
128
+ 12. identify declared entry points from `package.json#exports`, `main`, `bin`, and the `start`, `dev`, and `serve` scripts
129
+ 13. when Architecture is active, inspect local Knip and dependency-cruiser binaries and ask once before running either missing tool through pinned-major `npx -y`
130
+ 14. collect the context files named in `scope:` when the scope mode is scoped
131
+ 15. identify feature slices and public entry points when Architecture is active, leaving graph discovery to its mechanical pre-pass
132
+ 16. collect machine-readable dependency rules and prose from ADR directories, `ARCHITECTURE.md`, README, and `CONTRIBUTING.md`
127
133
  17. run `npx tsc --noEmit 2>&1 | head -200` over the full project, and report only the errors in the scoped files
128
134
  18. run the linter: `npx eslint [files] --format json` or `npx biome check [files] --reporter json`
129
135
  19. query the TypeScript LSP over MCP when it is reachable, then merge and deduplicate against the compiler output
130
136
  20. triage every compiler and linter diagnostic through `severity_mapping`
131
137
  21. read the reference file named in `domains` before each analysis pass
132
- 22. run only the passes whose domain is in the active domain set, as sub-agents shaped by `subagent_template` or one domain at a time
133
- 23. give every agent all the scoped files when scoped files <= 20, and split by directory above that, with the shared types visible to every agent
134
- 24. re-read the exact lines in the current file state before a finding enters the report
135
- 25. read the callers to verify a data flow a finding rests on, or mark its problem statement with "if <condition>" and cap its severity at Medium
136
- 26. downgrade a flagged non-High pattern that appears 5+ times across the codebase by 1 level, and report it once as a Recurring Pattern
137
- 27. boost a finding carrying `in_diff: true` by 1 level in a scoped mode, and mark it `High [boosted, was Medium — new code]`
138
- 28. deduplicate the findings on the same file, line, and issue, keeping 1
139
- 29. merge the findings 2 domains raise on the same file and line into 1 entry attributing both categories, at the higher severity
140
- 30. consolidate 3+ identical issues into 1 Recurring Pattern entry
141
- 31. keep the top 15 by severity and impact when a single domain produces more than 25 Medium or Low findings, and consolidate the rest into Recurring Pattern entries with their counts
142
- 32. write `code-smells.md` in the shape of `report_format`
143
- 33. sort by severity group, then category, then file path, and place `in_diff: true` before pre-existing in a scoped mode
144
- 34. show the top 10 and summarize the rest in a table when Medium and Low together hold more than 15 issues
145
- 35. recommend that the operator adds `code-smells.md` to `.gitignore`: it is a review artifact
146
- 36. read `references/fix-workflow.md` before fix mode executes: it holds the complete protocol
147
- 37. detect the test runner and run the baseline tests
148
- 38. fix the issues file by file, and run `tsc --noEmit` after each file
149
- 39. run the linter and fix the lint errors it reports
150
- 40. run the full test suite, compare it against the baseline, and fix the regressions
151
- 41. repeat the compiler, linter, and test verification with verification iterations <= 5
152
- 42. update `code-smells.md`: remove what is fixed, mark what failed
153
- 43. show the scan summary in auto mode, and ask the operator whether to proceed with the fix
154
- 44. re-scan after the fix in auto mode with full scan-fix cycles <= 2, and stop when issues persist after the second
155
- 45. delete `code-smells.md` and report success when every issue is fixed
138
+ 22. run the mechanical pre-pass in `references/architecture.md` when Architecture is active, passing the approved tool decision and scoped base
139
+ 23. report the discovery summary in the shape of `discovery_summary`, including skipped and clean mechanical results
140
+ 24. run only the passes whose domain is in the active domain set, as sub-agents shaped by `subagent_template` or one domain at a time
141
+ 25. give every agent all the scoped files when scoped files <= 20, and split by directory above that, with the shared types visible to every agent
142
+ 26. re-read the exact lines in the current file state before a finding enters the report
143
+ 27. read the callers to verify a data flow a finding rests on, or mark its problem statement with "if <condition>" and cap its severity at Medium
144
+ 28. downgrade a flagged non-High pattern that appears 5+ times across the codebase by 1 level, and report it once as a Recurring Pattern
145
+ 29. boost a finding carrying `in_diff: true` by 1 level in a scoped mode, and mark it `High [boosted, was Medium — new code]`
146
+ 30. deduplicate the findings on the same file, line, and issue, keeping 1
147
+ 31. merge the findings 2 domains raise on the same file and line into 1 entry attributing both categories, at the higher severity
148
+ 32. consolidate 3+ identical issues into 1 Recurring Pattern entry
149
+ 33. keep the top 15 by severity and impact when a single domain produces more than 25 Medium or Low findings, and consolidate the rest into Recurring Pattern entries with their counts
150
+ 34. write `code-smells/report.md` in the shape of `report_format`
151
+ 35. sort by severity group, then category, then file path, and place `in_diff: true` before pre-existing in a scoped mode
152
+ 36. show the top 10 and summarize the rest in a table when Medium and Low together hold more than 15 issues
153
+ 37. recommend that the operator adds `code-smells/` to `.gitignore`: it holds review artifacts
154
+ 38. read `references/fix-workflow.md` before fix mode executes: it holds the complete protocol
155
+ 39. detect the test runner and run the baseline tests
156
+ 40. fix the issues file by file, and run `tsc --noEmit` after each file
157
+ 41. run the linter and fix the lint errors it reports
158
+ 42. run the full test suite, compare it against the baseline, and fix the regressions
159
+ 43. repeat the compiler, linter, and test verification with verification iterations <= 5
160
+ 44. rerun the Architecture mechanical pre-pass on the fixed tree when Architecture is active
161
+ 45. update `code-smells/report.md`: remove what is fixed, mark what failed
162
+ 46. show the scan summary in auto mode, and ask the operator whether to proceed with the fix
163
+ 47. re-scan after the fix in auto mode with full scan-fix cycles <= 2, and stop when issues persist after the second
164
+ 48. delete `code-smells/report.md` and report success when every issue is fixed
165
+ 49. retain the remaining `code-smells/` artifacts, state what they contain, and remove them only after the operator confirms
156
166
 
157
167
  scope_commands:
158
168
  ```bash
@@ -161,6 +171,7 @@ npx glob '**/*.{ts,mts,cts}' --ignore '**/node_modules/**'
161
171
  # or: git ls-files '*.ts' '*.mts' '*.cts'
162
172
 
163
173
  # uncommitted — staged, unstaged, and untracked
174
+ BASE=HEAD
164
175
  git diff --name-only HEAD -- '*.ts' '*.mts' '*.cts'
165
176
  git ls-files --others --exclude-standard -- '*.ts' '*.mts' '*.cts'
166
177
 
@@ -170,6 +181,7 @@ git diff --name-only "$BASE"...HEAD -- '*.ts' '*.mts' '*.cts'
170
181
  git diff --name-only HEAD -- '*.ts' '*.mts' '*.cts'
171
182
 
172
183
  # commits:N — the last N commits
184
+ BASE=HEAD~N
173
185
  git diff --name-only HEAD~N..HEAD -- '*.ts' '*.mts' '*.cts'
174
186
 
175
187
  # the changed hunks, for the severity boost in a scoped mode
@@ -211,6 +223,15 @@ Strict mode: yes / partial / no
211
223
  Linter: eslint / biome / none
212
224
  Test runner: vitest / jest / mocha / node:test / none
213
225
  Files in scope: <N> .ts files (+ <M> context files)
226
+ Architecture projects: <config and source roots, when active>
227
+ Architecture tools: <local or approved npx versions, when active>
228
+ Mechanical results: <module and edge counts, cycles, orphans, and co-change pairs; name clean zeros>
229
+ Skipped pre-passes: <tool and reason, or none>
230
+ Speculative candidates: <names only, or none>
231
+ Declared dependency rules:
232
+ | Rule | Source file:line | Directories |
233
+ |---|---|---|
234
+ | <rule, or none> | <path:line> | <mapping> |
214
235
  ```
215
236
 
216
237
  subagent_template:
@@ -278,17 +299,7 @@ report_format:
278
299
 
279
300
  ## Architecture Opportunities
280
301
 
281
- ### TITLE — Severity
282
-
283
- - **Files:** relative/path/a.ts, relative/path/b.ts
284
- - **Problem:** why this causes friction now
285
- - **Proposed deepening:** what would change
286
- - **Interface shape:** rough sketch of new interface
287
- - **Dependency category:** in-process | local-substitutable | remote-owned | true-external
288
- - **Test strategy:** how tests improve
289
- - **Benefits:** locality, leverage, test impact
290
- - **Trade-offs:** what gets harder
291
- - **Fixability:** auto | needs-confirm | report-only
302
+ <1 entry per candidate, in the shape the `report_format` block of references/architecture.md gives>
292
303
 
293
304
  ---
294
305
  ````
@@ -5,6 +5,8 @@ purpose:
5
5
  scope:
6
6
  - circular imports and deep relative imports are also flagged by the Code Quality domain in every scan mode, and this file adds the refactoring guidance behind them
7
7
  - a wire type reaching a domain module is also a `references/boundary-validation.md` check
8
+ - state modelling, a discriminated union, and a branded type belong to `references/type-safety.md`, and this file names only the module that owns the state
9
+ - the module-scope singleton and the import-time side effect belong to `references/code-quality.md`, and the composition-root check here names where an adapter is built
8
10
 
9
11
  glossary:
10
12
  - use these terms in every architecture finding, and do not substitute "component", "service", "API", or "boundary"
@@ -17,6 +19,14 @@ glossary:
17
19
  - `leverage` — what a caller gets from depth: more capability per unit of interface it has to learn
18
20
  - locality — what a maintainer gets from depth: change, bugs, and knowledge concentrated in 1 place
19
21
 
22
+ confidence_scale:
23
+
24
+ | Confidence | Criteria |
25
+ |---|---|
26
+ | strong | the pain repeats and the owner is named, or a quoted dependency rule maps 1:1 onto directories |
27
+ | worth-exploring | the problem is confirmed but the interface, migration, inferred rule, or layer mapping needs its own design |
28
+ | speculative | a signal with no call, history, or test evidence behind it |
29
+
20
30
  read_first:
21
31
  - depth is a property of the interface and not of the implementation: a deep module can be internally complex, and what counts is how small its interface is against what it hides
22
32
  - the deletion test: imagine deleting the module, and if the complexity vanishes it was a pass-through, while if it reappears across the callers it was earning its keep
@@ -24,25 +34,74 @@ read_first:
24
34
  - depth is missing as often as it is overdone, so flag a missing abstraction where it already hurts
25
35
  - dependency direction matters more than layer count: the domain owns the logic, and transport and storage are injected or isolated at the edge
26
36
  - 1 adapter is a hypothetical seam and 2 adapters are a real seam: do not introduce a port until at least 2 adapters are justified, production and test at the minimum
37
+ - an architecture finding rests on evidence: named files and symbols, an import or call direction, a repeated change path in `git log`, a duplicated rule, or a test no correct seam can reach
38
+ - state the observation apart from the inference, and list an evidence-free candidate as speculative in `discovery_summary` rather than reporting it as a defect
39
+ - a defect no test can reach through a correct seam is an architecture finding, and not a reason to write a test past the interface
40
+ - before proposing a module, a port, or a container, check in order: drop the need, an existing module in the repository, the standard library, a platform feature, an installed dependency
41
+ - design a hard-to-reverse proposal twice: compare 2 different interfaces on caller knowledge, seam placement, and migration cost, then recommend 1
42
+ - a cycle is unclear ownership and not a bad graph: move the type or the rule to its owner, and `import type` or a lazy import hides the direction rather than fixing it
43
+ - a path alias shortens an import path and is not an architecture boundary: ownership and a controlled export list are
44
+ - under `--arch`, Knip narrows the observed interface and confirms orphans, while dead-code findings remain owned by Dependency Hygiene
45
+ - apply an exception stated with a dependency rule before reporting it, and cap a path the declaration marks legacy or deprecated at Low
46
+ - pick the simplest form a deepened module can take: a pure function for a transformation, a reducer for events over 1 state, a state machine when a command is valid only in some states, a class only when it owns long-lived state
27
47
 
28
48
  workflow:
29
- 1. grep for feature-module imports inside the `shared`, `common`, `core`, and `utils` directories, which is the inverted direction
30
- 2. list the importers of each module, and treat a module whose every export has exactly 1 importer as a pass-through candidate for the deletion test
31
- 3. treat an interface or type re-exported unchanged through 3+ files as a pass-through chain
32
- 4. check whether the exports of a utility file imported by most modules share 1 domain concept
33
- 5. read `git log --name-only` and treat a feature whose commits consistently touch 4+ directories as scattered logic
34
- 6. run these checks rather than guessing from file names
49
+ 1. locate machine-readable dependency rules first, then prose in ADRs, `ARCHITECTURE.md`, README, and `CONTRIBUTING.md`, in that order
50
+ 2. resolve conflicting ADRs only through an explicit supersede, and ask when the conflict would produce a High finding
51
+ 3. list each extracted rule, its source line, and its directory mapping in `discovery_summary` before using it
52
+ 4. write prose-derived rules to `code-smells/suggested.dependency-cruiser.cjs`, and do not add them to the project during scan
53
+ 5. when no declaration exists, report observed structure and offer a declaration rather than inventing layers
54
+ 6. run the mechanical pre-pass with the commands below against 1 unchanged tree before the semantic pass
55
+ 7. classify Knip and dependency-cruiser by parseable output rather than exit code, since both tools use non-zero and zero exits for reportable candidates
56
+ 8. record a project with source files and `totalCruised == 0` as a skipped pre-pass, and do not report its mechanical zeros
57
+ 9. give the semantic pass `code-smells/metrics.md` rather than a raw graph
58
+ 10. cross each co-change pair with the graph: an edge is weak evidence, while no edge across directories is an implicit-contract candidate
59
+ 11. read ESLint boundary rules from linter output without converting them, since the linter already executed those rules
60
+ 12. consolidate all violating edges for 1 declared rule into 1 candidate, after removing declared exceptions
61
+ 13. trace `min(declared entry points, 3)` scenarios, ranked by the number of modules each entry point reaches
62
+ 14. grep for feature-module imports inside the `shared`, `common`, `core`, and `utils` directories, which is the inverted direction
63
+ 15. apply the deletion test only to a module whose whole export set has 1 importer and that wraps or re-exports another module
64
+ 16. treat an interface or type re-exported unchanged through 3+ files as a pass-through chain
65
+ 17. inspect the top 20 modules and folders by fan-in for mixed concepts, and use folder instability only to rank layer checks
66
+ 18. name the module owning the rule and the module owning the state for each candidate, and mark the candidate speculative when neither is identifiable
67
+ 19. after triage, create 2..3 focused Mermaid diagrams tied to the top findings, and do not create a whole-project diagram
68
+ 20. generate SVG only after `dot -V` succeeds, and keep Mermaid when Graphviz is unavailable
69
+ 21. run these checks rather than reporting a cycle, hub, orphan, co-change pair, or Knip entry directly
70
+ ```bash
71
+ # SKILL is the directory this file was loaded from; the main workflow approved missing tools before this pass.
72
+ SKILL=<the directory this file was loaded from>
73
+ KNIP=$([ -x node_modules/.bin/knip ] && echo node_modules/.bin/knip || echo "npx -y knip@6.31.0")
74
+
75
+ mkdir -p code-smells
76
+
77
+ node "$SKILL/tools/discover-projects.mjs" > code-smells/projects.json
78
+ $KNIP --reporter json > code-smells/knip.json
79
+ node "$SKILL/tools/co-change.mjs" --projects code-smells/projects.json --out code-smells/co-change.md
80
+ node "$SKILL/tools/run-cruise.mjs" --projects code-smells/projects.json --out code-smells
81
+
82
+ # In a scoped mode, BASE is the comparison ref computed by scope_commands.
83
+ node "$SKILL/tools/run-cruise.mjs" --projects code-smells/projects.json --out code-smells --base "$BASE"
84
+
85
+ # After triage, create one diagram for a top finding.
86
+ node "$SKILL/tools/run-cruise.mjs" --projects code-smells/projects.json --out code-smells --focus '<module regex>' --name <finding>
87
+ ```
35
88
 
36
89
  checks:
37
90
  - shallow modules — understanding 1 concept requires bouncing across many tiny modules: Medium
38
91
  - shallow modules — a module interface nearly as complex as its implementation: Medium
39
92
  - shallow modules — a pass-through wrapper, manager, helper, or service that hides no complexity: Medium, apply the deletion test
40
93
  - shallow modules — pure functions extracted for testability alone while the real bugs sit in the orchestration: Medium, the extraction bought no locality
94
+ - shallow modules — a DTO type, a domain type, and a mapper between them, where this repository owns both types and their shapes and meanings are identical: Low, the mapper is a pass-through
95
+ - shallow modules — note: a mapper for a type an external contract owns is not a pass-through, whatever the shapes match, and the dependency direction check below owns that case
41
96
  - coupling and seams — coupling leaking across a module interface, where callers have to know implementation details: High
97
+ - coupling and seams — retry, ordering, or compensation decided inside a pure domain computation instead of the orchestrator owning the side effects: Medium
42
98
  - coupling and seams — tests that reach into module internals, or that mock many neighbours to test 1 thing: High
43
99
  - coupling and seams — a public API exporting implementation details, config, ordering constraints, or internal error modes: Medium, a caller should not need them
44
100
  - import and module structure — a barrel-file cycle or a circular import chain that reordering cannot resolve: High
45
101
  - import and module structure — a deep relative import, `../../../`, marking a module not co-located with what it depends on: Low, suggest a path alias or co-location
102
+ - import and module structure — an import reaching into the internal files of another package instead of its declared entry point: Medium, import from the path `package.json#exports` names
103
+ - import and module structure — a barrel re-exporting a whole tree with `export *`, so the module owning a symbol is not identifiable at the import: Low, name the exports explicitly
104
+ - import and module structure — note: a barrel that also closes a cycle is the barrel-file cycle check above, at High, and it is reported once and not twice
46
105
  - locality — a shared utility module mixing unrelated domain concepts: Medium
47
106
  - locality — feature logic split by technical layer, a controller, service, and repo per feature, so that 1 feature change touches 4+ files: Medium
48
107
  - under-engineering — 1 business rule, the same constants and branching, implemented in 2+ modules: Medium, deepen 1 module to own the rule
@@ -50,7 +109,21 @@ checks:
50
109
  - under-engineering — event or string-keyed indirection between 2 modules that only ever talk to each other: Low, a direct typed call is checkable
51
110
  - dependency direction — a domain or computation module importing IO directly, `node:fs`, `node:http`, a DB client, or `fetch`, where the classification puts that IO behind a seam: Medium
52
111
  - dependency direction — a shared or leaf module, `utils/`, `types/`, or `core/`, importing from a feature module: High, the inverted direction is how import cycles start
112
+ - dependency direction — an import edge contradicting a layer rule the project declares in an ADR or ARCHITECTURE.md: High, quote the rule with its source line and list the violating edges
53
113
  - dependency direction — a wire or DTO type from an external API imported deep into a domain module instead of mapped at the seam that owns the external contract: Medium
114
+ - state ownership — 2+ modules mutating 1 entity, cache, or record with no single owning module: High, the write paths cannot be tested or reasoned about apart
115
+ - state ownership — fix: a shared mutable entity, by naming 1 owning module and turning each write into a named command on it
116
+ - composition root — an infrastructure adapter, an HTTP client, a DB client, or a config read, constructed inside a domain module instead of passed in from the entry point: Medium
117
+ - composition root — a service locator or a DI container wiring 1 dependency graph: Low, pass the value or the function the module needs
118
+ - distributed side effects — a multi-step write path with no idempotency key, no ordering rule, and no compensation, where a retry leaves the effects partly applied: High
119
+ - distributed side effects — note: flag it from a named failure scenario, a retry, a timeout, or a crash between 2 steps, and not from the shape of the code
120
+
121
+ non_findings:
122
+ - a module with exactly 1 importer that hides an ordering, an invariant, or a decision: 1 importer starts the deletion test and is not a finding by itself
123
+ - a layer count: the direction of the dependencies is the finding, and a layer holding a real rule is not
124
+ - a port with 1 production adapter whose test double checks the same contract: the double is the second adapter
125
+ - duplicated trees whose intent is documented, excluded by tool policy, or maintained in lockstep: report at most the synchronized-edit cost at Low, not the decision
126
+ - formatting, import ordering, and file naming: `references/code-quality.md` owns them, and they are not architecture findings
54
127
 
55
128
  forbidden_behaviors:
56
129
  - do not invent a domain term: use a function, type, file, or package name that already exists, and prefer one used in adjacent code
@@ -59,6 +132,13 @@ forbidden_behaviors:
59
132
  - do not expose an internal seam through the interface because a test wants it
60
133
  - do not accept a test that has to change whenever the implementation changes: it is testing past the interface
61
134
  - do not touch code for a `needs-confirm` change before showing a diff or a migration plan and getting an explicit go-ahead
135
+ - do not recommend microservices, event sourcing, CQRS, hexagonal architecture, or DDD tactical patterns without a named defect, change scenario, or failure the current shape caused
136
+ - do not propose a rewrite when a staged migration reaches the same shape
137
+ - do not leave a new structure standing beside the old one: every proposal names the step deleting what it replaces
138
+ - do not propose a change contradicting a decision under `docs/adr/` silently: name the ADR and the conflict, and leave the decision to the operator
139
+ - do not report architecture candidates without naming 1 top recommendation and the reason it goes first
140
+ - do not add a dependency as the fix for 1 import rule: use `package.json#exports`, the project's linter, or the workspace layout
141
+ - do not treat an approved `npx` graph tool used during review as a project dependency: it changes neither `package.json` nor the lockfile
62
142
 
63
143
  dependency_classification:
64
144
 
@@ -71,6 +151,9 @@ dependency_classification:
71
151
 
72
152
  severity_mapping:
73
153
 
154
+ - map a project-declared severity to the finding: `error` to High, `warn` to Medium, and `info` to Low
155
+ - copy the declared rule comment into Evidence, since it states the project's rationale and accepted debt
156
+
74
157
  | Severity | Architecture criteria |
75
158
  |---|---|
76
159
  | Highest | an architecture issue directly causing a security vulnerability, data loss, or a production correctness bug |
@@ -80,6 +163,10 @@ severity_mapping:
80
163
 
81
164
  fixability:
82
165
 
166
+ - an `enforce` change is `report-only`, since scan does not write a project config
167
+ - a `delete` change is `auto` only when no module imports the target
168
+ - a deepening, merge, or ownership move is `needs-confirm`
169
+
83
170
  | Fixability | Meaning | Fix mode behaviour |
84
171
  |---|---|---|
85
172
  | `auto` | a local change: import path cleanup, a narrow circular-import break, a local module merge with co-located tests | applied in the normal fix loop |
@@ -90,13 +177,19 @@ report_format:
90
177
  ```md
91
178
  ### TITLE — Severity
92
179
 
180
+ - **Confidence:** strong | worth-exploring
93
181
  - **Files:** relative/path/a.ts, relative/path/b.ts
94
182
  - **Problem:** why this causes friction now (not just a pattern name)
95
- - **Proposed deepening:** plain-English description of what would change
96
- - **Interface shape:** rough sketch of the new interface (types, methods, key invariants)
97
- - **Dependency category:** in-process | local-substitutable | remote-owned | true-external
183
+ - **Evidence:** the imports, callers, `git log` path, or test the finding rests on
184
+ - **Change type:** deepening | ownership move | merge | delete | enforce
185
+ - **Proposed change:** plain-English description of what would change
186
+ - **Interface shape:** for deepening or merge only; rough sketch of the new interface and key invariants
187
+ - **Dependency category:** for deepening only; in-process | local-substitutable | remote-owned | true-external
188
+ - **Rule:** for enforce only; the exact rule text and the file where it would live
98
189
  - **Test strategy:** how tests would improve (what tests survive, what gets deleted, what's new)
190
+ - **Migration:** for deepening, merge, ownership move, or delete; prefactor | vertical slices | expand-contract, and the deletion step
99
191
  - **Benefits:** locality gained, leverage gained, test impact
100
192
  - **Trade-offs:** what gets harder, what is genuinely uncertain
101
193
  - **Fixability:** auto | needs-confirm | report-only
194
+ - **Top recommendation:** on exactly 1 entry in the report, the reason this candidate goes first
102
195
  ```
@@ -7,13 +7,13 @@ scope:
7
7
  - the testing strategy for an architecture fix is owned by `references/architecture.md`
8
8
 
9
9
  read_first:
10
- - `code-smells.md` exists in the project root, and a missing report stops the run with the error "No scan report found. Run scan first."
10
+ - `code-smells/report.md` exists in the project root, and a missing report stops the run with the error "No scan report found at code-smells/report.md. Run scan first; the report path changed in v3."
11
11
  - the project is inside a git repository, so a change can be reverted
12
12
  - recommend that the operator commits or stashes uncommitted work before the fix runs, which leaves `git diff` and `git checkout -- .` usable to review and revert
13
13
  - the baseline taken in step 3 decides what counts as a regression: a test failing before the fixes is pre-existing and stays out of the work
14
14
 
15
15
  workflow:
16
- 1. read `code-smells.md` and extract every issue into a work list
16
+ 1. read `code-smells/report.md` and extract every issue into a work list
17
17
  2. group the issues by file path, and sort them by line number descending inside each file, so a fix lower in the file does not shift the lines above it
18
18
  3. build the work plan
19
19
  ```
@@ -77,14 +77,16 @@ Iteration 3: Fixed tsc error. All tests pass. Clean.
77
77
  27. stop fixing once the fifth iteration ends with the compiler, the linter, or the suite still not clean, leave the code as it stands, and add a Stabilization section to the report listing the unresolved regressions for the operator
78
78
  28. show the operator what a `needs-confirm` architecture finding would change, and describe the reorganization or the interface change
79
79
  29. apply an approved `needs-confirm` finding through the same file-by-file compiler loop, and mark a rejected one `[SKIPPED: user rejected]`
80
- 30. delete `code-smells.md` when every issue is fixed, and tell the operator "All N issues fixed. Report deleted. Run scan again to verify."
81
- 31. rewrite `code-smells.md` in the shape of `report_format` when any issue remains, carrying both what was fixed and what was not
82
- 32. leave every change in the working tree, unstaged and uncommitted, including the new regression test files
80
+ 30. rerun Knip, dependency-cruiser, and co-change through the Architecture workflow when the report contains architecture findings
81
+ 31. delete `code-smells/report.md` when every issue is fixed, and tell the operator "All N issues fixed. Report deleted. Run scan again to verify."
82
+ 32. keep `code-smells/` after deleting the report, state what it contains, and ask before removing the directory
83
+ 33. rewrite `code-smells/report.md` in the shape of `report_format` when any issue remains, carrying both what was fixed and what was not
84
+ 34. leave every change in the working tree, unstaged and uncommitted, including the new regression test files
83
85
 
84
86
  forbidden_behaviors:
85
87
  - do not commit and do not stage: the operator reviews and decides
86
88
  - do not delete a file unless the report flagged the whole file as dead code
87
- - do not modify a file outside the issues in `code-smells.md`, apart from a cascading change such as an import updated after a type rename
89
+ - do not modify a file outside the issues in `code-smells/report.md`, apart from a cascading change such as an import updated after a type rename
88
90
  - do not change what the code does: a fix changes how it does it, and only a security fix intentionally changes behaviour, such as validation that now rejects malicious input
89
91
  - do not refactor a whole file because of 1 issue: fix exactly what the report names
90
92
  - do not apply an ambiguous or risky fix: mark it `[SKIPPED: requires manual review]` and move on, since skipping costs less than breaking the build
@@ -0,0 +1,26 @@
1
+ // How a tool run is read. Neither the exit code nor the green tick means what it looks like:
2
+ // Knip exits 1 with findings, dependency-cruiser exits 0 with violations, and a cruise that
3
+ // walked zero modules prints "no dependency violations found" over an empty graph.
4
+ export function classifyRun({ label = "", exitCode, stdout = "" }) {
5
+ const text = String(stdout);
6
+ const trimmed = text.trim();
7
+ let parsed = null;
8
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
9
+ try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; }
10
+ }
11
+ const output = parsed !== null
12
+ ? (parsed === undefined ? "invalid JSON" : "valid JSON")
13
+ : (trimmed ? "text" : "empty");
14
+
15
+ const cruised = /(\d+) modules(?:, (\d+) dependencies)? cruised/.exec(text);
16
+ const zeroCruise = label.startsWith("depcruise")
17
+ && ((cruised && cruised[1] === "0") || parsed?.summary?.totalCruised === 0);
18
+
19
+ const reading = zeroCruise ? "failed: 0 modules cruised"
20
+ : output === "empty" && exitCode !== 0 ? "failed"
21
+ : exitCode === 0 && output !== "empty" ? "ok"
22
+ : exitCode !== 0 && output !== "empty" ? "findings, not a failure"
23
+ : "clean";
24
+
25
+ return { output, reading };
26
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Co-change hotspots for the architecture pre-pass. No dependency: git plus this file.
3
+ // Usage: node co-change.mjs [--repo <dir>] [--src <dir>] [--out <file>] [--scope <list-file>]
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ const WINDOW = "12.months";
9
+ const MAX_COMMITS = 500;
10
+ const MAX_FILES_PER_COMMIT = 25; // above this a commit is mechanical: a lockfile bump, a format sweep, a mass rename
11
+ const MIN_CO_CHANGES = 5;
12
+ const MIN_RATIO = 0.5;
13
+ const TOP_N = 15;
14
+ const EXTENSIONS = ["*.ts", "*.mts", "*.cts"];
15
+
16
+ function arg(name, fallback) {
17
+ const i = process.argv.indexOf(`--${name}`);
18
+ return i === -1 ? fallback : process.argv[i + 1];
19
+ }
20
+
21
+ const repo = path.resolve(arg("repo", process.cwd()));
22
+ // One or more source roots, comma-separated — the `sourceRoot` values discover-projects.mjs returns.
23
+ // A monorepo needs all of them: measured from the repository root every file in `packages/*` shares
24
+ // one top-level directory, and the cross-directory gate would reject every pair.
25
+ // Deduplicated: two configs commonly resolve the same root, and a repeated root would count a file
26
+ // once per copy.
27
+ const projectsFile = arg("projects", "");
28
+ const rootsArg = projectsFile
29
+ ? JSON.parse(readFileSync(path.resolve(repo, projectsFile), "utf8")).projects.flatMap((p) => p.sourceRoots).join(",")
30
+ : arg("src", "");
31
+ const roots = [...new Set(rootsArg.split(",").map((r) => r.trim().replace(/\/*$/, "")).filter(Boolean))];
32
+ const out = arg("out", "");
33
+ const scopeFile = arg("scope", "");
34
+
35
+ const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
36
+
37
+ // Files that exist right now. A pair naming a deleted file is history, not structure.
38
+ const existing = new Set(git(["ls-files", "--", ...EXTENSIONS]).split("\n").filter(Boolean));
39
+
40
+ // Optional: the analysis scope of a scoped run, one path per line. Used at output time only —
41
+ // intersecting during counting would erase every pair in a mode whose scope is three files.
42
+ const scope = scopeFile
43
+ ? new Set(readFileSync(scopeFile, "utf8").split("\n").map((l) => l.trim()).filter(Boolean))
44
+ : null;
45
+
46
+ const rootOf = (f) => (roots.length ? roots.find((r) => f.startsWith(r + "/")) ?? null : "");
47
+ const inSrc = (f) => rootOf(f) !== null;
48
+ const topDir = (f) => {
49
+ const root = rootOf(f);
50
+ const rest = root ? f.slice(root.length + 1) : f;
51
+ const parts = rest.split("/");
52
+ // The root itself is part of the identity: `packages/a/src/x` and `packages/b/src/x` are not
53
+ // the same directory just because both are called `src`.
54
+ return parts.length > 1 ? `${root}/${parts[0]}` : `${root}/.`;
55
+ };
56
+
57
+ const log = git([
58
+ "log", "--no-merges", `--since=${WINDOW}`, `-n${MAX_COMMITS}`,
59
+ "--format=%H", "--name-only", "--", ...EXTENSIONS,
60
+ ]);
61
+
62
+ const commits = [];
63
+ let current = null;
64
+ for (const line of log.split("\n")) {
65
+ if (!line.trim()) continue;
66
+ if (/^[0-9a-f]{40}$/.test(line)) { current = []; commits.push(current); continue; }
67
+ if (current) current.push(line);
68
+ }
69
+
70
+ const changes = new Map();
71
+ const pairs = new Map();
72
+ let mechanical = 0;
73
+
74
+ for (const commit of commits) {
75
+ const touched = [...new Set(commit)];
76
+ // The size gate reads the commit as it was, before the existence and src filters —
77
+ // otherwise a mass rename shrinks into eligibility once its deleted paths drop out.
78
+ if (touched.length > MAX_FILES_PER_COMMIT) { mechanical++; continue; }
79
+ const files = touched.filter((f) => existing.has(f) && inSrc(f)).sort();
80
+ if (files.length < 2) continue;
81
+ for (const f of files) changes.set(f, (changes.get(f) ?? 0) + 1);
82
+ for (let i = 0; i < files.length; i++) {
83
+ for (let j = i + 1; j < files.length; j++) {
84
+ const key = `${files[i]}\t${files[j]}`;
85
+ pairs.set(key, (pairs.get(key) ?? 0) + 1);
86
+ }
87
+ }
88
+ }
89
+
90
+ const ranked = [...pairs]
91
+ .map(([key, co]) => {
92
+ const [a, b] = key.split("\t");
93
+ return { a, b, co, ratio: co / Math.min(changes.get(a), changes.get(b)) };
94
+ })
95
+ .filter((p) => p.co >= MIN_CO_CHANGES && p.ratio >= MIN_RATIO && topDir(p.a) !== topDir(p.b))
96
+ .filter((p) => !scope || scope.has(p.a) || scope.has(p.b))
97
+ .sort((x, y) => y.co - x.co || y.ratio - x.ratio || x.a.localeCompare(y.a))
98
+ .slice(0, TOP_N);
99
+
100
+ const report = [
101
+ `# Co-change pairs`,
102
+ ``,
103
+ `Window: last ${WINDOW.replace(".", " ")}, at most ${MAX_COMMITS} commits, merges excluded.`,
104
+ `Commits read: ${commits.length}. Dropped as mechanical (> ${MAX_FILES_PER_COMMIT} files): ${mechanical}.`,
105
+ `Gates: co-changes >= ${MIN_CO_CHANGES}, ratio >= ${MIN_RATIO}, different top-level directory.`,
106
+ ``,
107
+ ranked.length ? `| Co-changes | Ratio | File A | File B |` : `No pair passed the gates.`,
108
+ ranked.length ? `|---|---|---|---|` : ``,
109
+ ...ranked.map((p) => `| ${p.co} | ${p.ratio.toFixed(2)} | \`${p.a}\` | \`${p.b}\` |`),
110
+ ].filter((l) => l !== "").join("\n");
111
+
112
+ if (out) {
113
+ mkdirSync(path.dirname(path.resolve(repo, out)), { recursive: true });
114
+ writeFileSync(path.resolve(repo, out), report + "\n", "utf8");
115
+ }
116
+ console.log(report);
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // Architecture projects for the pre-pass: which tsconfig owns a source set, and where that set lives.
3
+ // Usage: node discover-projects.mjs [--repo <dir>] → JSON on stdout
4
+ import { execFileSync, spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ function arg(name, fallback) {
9
+ const i = process.argv.indexOf(`--${name}`);
10
+ return i === -1 ? fallback : process.argv[i + 1];
11
+ }
12
+
13
+ const repo = path.resolve(arg("repo", process.cwd()));
14
+
15
+ // ponytail: every tsconfig tracked by git is found by the glob, which is a superset of what a
16
+ // transitive `references` walk reaches. Walk the references only if untracked configs ever matter.
17
+ const configs = execFileSync("git", ["ls-files", "tsconfig*.json", "*/tsconfig*.json", "**/tsconfig*.json"], {
18
+ cwd: repo, encoding: "utf8",
19
+ }).split("\n").filter(Boolean).sort();
20
+
21
+ // TSC_BIN lets a caller point at an already-installed compiler; otherwise the project's own
22
+ // typescript is used, and only a project without one reaches for the network.
23
+ const localTsc = process.env.TSC_BIN || path.join(repo, "node_modules", "typescript", "bin", "tsc");
24
+ const runTsc = (args) => {
25
+ const r = existsSync(localTsc)
26
+ ? spawnSync(process.execPath, [localTsc, ...args], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
27
+ // `-p typescript` names the package; `tsc` names the binary inside it. `npx -y tsc` resolves
28
+ // to an unrelated package of that name.
29
+ : spawnSync("npx", ["-y", "-p", "typescript", "tsc", ...args], { cwd: repo, encoding: "utf8", shell: true, maxBuffer: 64 * 1024 * 1024 });
30
+ // A non-zero status with usable output is a result, not a failure: tsc reports type errors
31
+ // while still listing the files it resolved.
32
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
33
+ };
34
+
35
+ const isSource = (f) =>
36
+ !f.startsWith("node_modules/") && !f.includes("/node_modules/") &&
37
+ !f.startsWith("../") && !/(^|\/)lib\.[^/]*\.d\.ts$/.test(f) && /\.(ts|mts|cts|tsx)$/.test(f);
38
+
39
+ const commonDir = (files) => {
40
+ const dirs = files.map((f) => path.posix.dirname(f).split("/"));
41
+ const first = dirs[0] ?? [];
42
+ let i = 0;
43
+ outer: for (; i < first.length; i++) {
44
+ for (const d of dirs) if (d[i] !== first[i]) break outer;
45
+ }
46
+ return first.slice(0, i).join("/") || ".";
47
+ };
48
+
49
+ // A config whose files share no directory above the repository root spans several areas. The
50
+ // repository root is useless as a `--collapse` base and as a co-change boundary, so the project
51
+ // carries its top-level directories instead of one meaningless root.
52
+ const sourceRootsOf = (files) => {
53
+ const common = commonDir(files);
54
+ if (common !== ".") return [common];
55
+ return [...new Set(files.map((f) => f.split("/")[0]))].sort();
56
+ };
57
+
58
+ const workspaces = () => {
59
+ const out = [];
60
+ const pkg = path.join(repo, "package.json");
61
+ if (existsSync(pkg)) {
62
+ const w = JSON.parse(readFileSync(pkg, "utf8")).workspaces;
63
+ if (Array.isArray(w)) out.push(...w);
64
+ else if (w?.packages) out.push(...w.packages);
65
+ }
66
+ const pnpm = path.join(repo, "pnpm-workspace.yaml");
67
+ if (existsSync(pnpm)) {
68
+ for (const line of readFileSync(pnpm, "utf8").split("\n")) {
69
+ const m = line.match(/^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/);
70
+ if (m) out.push(m[1]);
71
+ }
72
+ }
73
+ return out;
74
+ };
75
+
76
+ // ponytail: regex over the raw text, because `--showConfig` resolves `extends` away and a
77
+ // tsconfig is JSONC, which JSON.parse will not read.
78
+ const rawOf = new Map(configs.map((c) => [c, readFileSync(path.join(repo, c), "utf8")]));
79
+ const declaresOwnSources = (config) => /"(include|files|references)"\s*:/.test(rawOf.get(config) ?? "");
80
+
81
+ // A config is a base only when another config extends it AND it declares no source set of its own.
82
+ // Being extended is not disqualifying by itself: a root tsconfig can be both the production
83
+ // project and the options other configs inherit, and dropping it skips the product.
84
+ const extended = new Set();
85
+ for (const config of configs) {
86
+ for (const m of (rawOf.get(config) ?? "").matchAll(/"extends"\s*:\s*"([^"]+)"/g)) {
87
+ if (m[1].startsWith(".")) {
88
+ extended.add(path.posix.normalize(path.posix.join(path.posix.dirname(config), m[1])));
89
+ }
90
+ }
91
+ }
92
+ const bases = new Set([...extended].filter((c) => !declaresOwnSources(c)));
93
+
94
+ const projects = [];
95
+ const metadata = [];
96
+
97
+ for (const config of configs) {
98
+ if (bases.has(config) || bases.has(config.replace(/\.json$/, ""))) {
99
+ metadata.push({ config, reason: "extended by another config: it supplies options, not sources" });
100
+ continue;
101
+ }
102
+ const listed = runTsc(["-p", config, "--listFilesOnly"]);
103
+ const files = listed.stdout.split("\n").map((l) => l.trim().replace(/\\/g, "/")).filter(Boolean)
104
+ .map((f) => (path.isAbsolute(f) ? path.posix.relative(repo.replace(/\\/g, "/"), f) : f))
105
+ .filter(isSource)
106
+ .sort();
107
+
108
+ if (files.length === 0) {
109
+ metadata.push({
110
+ config,
111
+ // A solution config aggregating `references` resolves nothing and exits clean; anything else
112
+ // is a config tsc could not read, and the reason belongs in the report rather than one word.
113
+ reason: listed.status === 0
114
+ ? "resolves no source files: a solution config aggregating references"
115
+ : `tsc could not read it: ${(listed.stderr || listed.stdout).split("\n").filter(Boolean).slice(0, 2).join(" / ") || "no diagnostic"}`,
116
+ exitCode: listed.status,
117
+ });
118
+ continue;
119
+ }
120
+
121
+ const roots = sourceRootsOf(files);
122
+ projects.push({
123
+ config,
124
+ sourceRoots: roots,
125
+ absoluteSourceRoots: roots.map((r) => path.resolve(repo, r)),
126
+ fileCount: files.length,
127
+ files,
128
+ });
129
+ }
130
+
131
+ // A test, browser, or tooling config whose file set is contained in another project's is a variant:
132
+ // it contributes unique files only, and gets no aggregate metrics run of its own. Two configs
133
+ // resolving the *same* set are duplicates of each other, and the tie is broken deterministically —
134
+ // shortest path, then lexicographic — so that two runs of one review agree.
135
+ const rank = (c) => [c.split("/").length, c.length, c];
136
+ const beats = (a, b) => {
137
+ const [x, y] = [rank(a), rank(b)];
138
+ return x[0] !== y[0] ? x[0] < y[0] : x[1] !== y[1] ? x[1] < y[1] : x[2] < y[2];
139
+ };
140
+ for (const p of projects) {
141
+ const key = p.files.join("\n");
142
+ p.variantOf = projects
143
+ .filter((o) => {
144
+ if (o === p) return false;
145
+ const same = o.files.join("\n") === key;
146
+ if (same) return beats(o.config, p.config);
147
+ return o.files.length > p.files.length && p.files.every((f) => o.files.includes(f));
148
+ })
149
+ .map((o) => o.config)[0] ?? null;
150
+ }
151
+
152
+ const result = {
153
+ repo,
154
+ workspaces: workspaces(),
155
+ projects: projects.filter((p) => !p.variantOf).map(({ files, ...rest }) => rest),
156
+ variants: projects.filter((p) => p.variantOf).map(({ files, ...rest }) => rest),
157
+ metadata,
158
+ };
159
+ console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // Runs dependency-cruiser over every architecture project discover-projects.mjs found.
3
+ //
4
+ // node run-cruise.mjs --projects code-smells/projects.json --out code-smells [--base <ref>]
5
+ //
6
+ // It exists so that no shell has to hold a ruleset path, a regex, a list of source roots, or a
7
+ // loop: every one of those was a quoting bug waiting in a bash block.
8
+ import { spawnSync } from "node:child_process";
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import { classifyRun } from "./classify-run.mjs";
14
+
15
+ const TOOL = "dependency-cruiser@18.1.0";
16
+ const EXCLUDE = "^(node_modules|node:)";
17
+ const PROJECT_DECLARATIONS = [".dependency-cruiser.cjs", ".dependency-cruiser.js", ".dependency-cruiser.json"];
18
+
19
+ const arg = (n, d) => { const i = process.argv.indexOf(`--${n}`); return i === -1 ? d : process.argv[i + 1]; };
20
+ const repo = path.resolve(arg("repo", process.cwd()));
21
+ const projectsFile = path.resolve(repo, arg("projects", "code-smells/projects.json"));
22
+ const outDir = path.resolve(repo, arg("out", "code-smells"));
23
+ const base = arg("base", "");
24
+ const declarations = [
25
+ ...PROJECT_DECLARATIONS.map((name) => path.join(repo, name)),
26
+ path.join(outDir, "suggested.dependency-cruiser.cjs"),
27
+ ];
28
+
29
+ const { projects } = JSON.parse(readFileSync(projectsFile, "utf8"));
30
+ mkdirSync(path.join(outDir, "graphs"), { recursive: true });
31
+ mkdirSync(path.join(outDir, "diagrams"), { recursive: true });
32
+
33
+ // ── the ruleset ─────────────────────────────────────────────────────────────
34
+ // A project declaration supplies rules. Its `options` describe the area that project chose to
35
+ // cover, and on any other path they cruise nothing — so only `forbidden` is carried over.
36
+ const MINIMUM = [
37
+ { name: "no-circular", severity: "warn", from: {}, to: { circular: true } },
38
+ { name: "no-orphans", severity: "warn", from: { orphan: true }, to: {} },
39
+ { name: "not-to-unresolvable", severity: "error", from: {}, to: { couldNotResolve: true } },
40
+ ];
41
+
42
+ const declaration = declarations.find((candidate) => existsSync(candidate));
43
+ let forbidden = MINIMUM;
44
+ let rulesFrom = "the minimum ruleset";
45
+ if (declaration) {
46
+ try {
47
+ const theirs = createRequire(path.join(repo, "noop.cjs"))(declaration);
48
+ if (Array.isArray(theirs.forbidden) && theirs.forbidden.length) {
49
+ forbidden = theirs.forbidden;
50
+ rulesFrom = `${path.basename(declaration)} (${theirs.forbidden.length} rules), options from the pre-pass`;
51
+ }
52
+ } catch (e) {
53
+ rulesFrom = `${path.basename(declaration)} could not be read (${e.message}); fell back to the minimum ruleset`;
54
+ }
55
+ }
56
+
57
+ // `exclude: "^(node_modules|node:)"` does NOT drop Node builtins: dependency-cruiser normalises
58
+ // `node:fs/promises` to the source `fs/promises` and flags it `coreModule`, so nothing starting
59
+ // with `node:` is ever in the graph to match. Scoping the graph to the project's own source roots
60
+ // is exact and drops builtins, node_modules and every external package in one rule.
61
+ const includeOnly = `^(${[...new Set(projects.flatMap((p) => p.sourceRoots))].map((r) => r.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})/`;
62
+
63
+ const ruleset = path.join(tmpdir(), `arch-ruleset-${process.pid}.cjs`);
64
+ writeFileSync(ruleset, `module.exports = ${JSON.stringify({
65
+ forbidden,
66
+ options: {
67
+ doNotFollow: { path: "node_modules" },
68
+ exclude: EXCLUDE,
69
+ includeOnly,
70
+ tsPreCompilationDeps: true,
71
+ },
72
+ }, null, 2)};\n`, "utf8");
73
+
74
+ // ── running the tool ────────────────────────────────────────────────────────
75
+ // `npx` is a .cmd on Windows and needs a shell, which is why every argument is quoted here rather
76
+ // than being handed to the shell bare. The exclude pattern is not passed on the command line at
77
+ // all: it lives in the ruleset above, so no regex ever meets a shell.
78
+ // dependency-cruiser reads a tsconfig with the TypeScript compiler, and resolves `typescript` from
79
+ // its own install location — which, run from the npx cache, is not the project. NODE_PATH is what
80
+ // makes the project's copy visible; without it the cruise silently returns zero modules.
81
+ const projectModules = path.join(repo, "node_modules");
82
+ const nodePath = existsSync(path.join(projectModules, "typescript"))
83
+ ? projectModules
84
+ : process.env.NODE_PATH || projectModules;
85
+ const localCruise = path.join(projectModules, ".bin", process.platform === "win32" ? "depcruise.cmd" : "depcruise");
86
+
87
+ function cruise(label, extraArgs, project) {
88
+ const toolArgs = ["--config", ruleset, "--ts-config", project.config, ...extraArgs, ...project.sourceRoots];
89
+ const args = existsSync(localCruise) ? toolArgs : ["-y", TOOL, ...toolArgs];
90
+ const shell = process.platform === "win32";
91
+ const started = Date.now();
92
+ const r = spawnSync(existsSync(localCruise) ? localCruise : "npx", shell ? args.map((a) => JSON.stringify(a)) : args, {
93
+ cwd: repo, encoding: "utf8", shell, maxBuffer: 256 * 1024 * 1024,
94
+ env: { ...process.env, NODE_PATH: nodePath },
95
+ });
96
+ const run = { label, exitCode: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ms: Date.now() - started };
97
+ return { ...run, ...classifyRun(run) };
98
+ }
99
+
100
+ // --focus is the post-triage mode: one diagram of one finding's neighbourhood, and nothing else.
101
+ // It runs after the semantic pass, because until a finding exists there is no module to focus on.
102
+ const focus = arg("focus", "");
103
+ if (focus) {
104
+ const name = arg("name", "focus");
105
+ for (const project of projects) {
106
+ const run = cruise(`depcruise focus ${project.config}`,
107
+ ["--output-type", "mermaid", "--focus", focus, "--focus-depth", arg("depth", "1")], project);
108
+ if (run.stdout.trim().split("\n").length > 2) {
109
+ writeFileSync(path.join(outDir, "diagrams", `${name}.mmd`), run.stdout, "utf8");
110
+ console.log(`Wrote ${path.join(outDir, "diagrams", `${name}.mmd`)} from ${project.config}`);
111
+ process.exit(0);
112
+ }
113
+ }
114
+ console.log(`No project matched ${focus}: no diagram written.`);
115
+ process.exit(0);
116
+ }
117
+
118
+ const results = [];
119
+ for (const project of projects) {
120
+ const id = project.config.replace(/[/.]/g, "_");
121
+
122
+ const metrics = cruise(`depcruise metrics ${project.config}`, ["--metrics", "--output-type", "json"], project);
123
+ const graph = metrics.output === "valid JSON" ? JSON.parse(metrics.stdout) : null;
124
+ if (graph) writeFileSync(path.join(outDir, "graphs", `${id}.json`), metrics.stdout, "utf8");
125
+
126
+ const err = cruise(`depcruise err ${project.config}`, ["--output-type", "err"], project);
127
+
128
+ const collapse = `^${project.sourceRoots[0]}/[^/]+/`;
129
+ const diagram = cruise(`depcruise mermaid ${project.config}`, ["--output-type", "mermaid", "--collapse", collapse], project);
130
+ if (diagram.stdout.trim()) writeFileSync(path.join(outDir, "diagrams", `${id}.mmd`), diagram.stdout, "utf8");
131
+
132
+ const affected = base
133
+ ? cruise(`depcruise affected ${project.config}`, ["--affected", base, "--output-type", "text"], project)
134
+ : null;
135
+
136
+ results.push({ project, graph, runs: [metrics, err, diagram, ...(affected ? [affected] : [])] });
137
+ }
138
+
139
+ // Derived, bounded tables are the semantic pass input. The raw graphs stay on disk.
140
+ const table = (headers, rows) => [
141
+ `| ${headers.join(" | ")} |`,
142
+ `|${headers.map(() => "---").join("|")}|`,
143
+ ...rows.map((row) => `| ${row.map((cell) => String(cell).replaceAll("|", "\\|")).join(" | ")} |`),
144
+ ];
145
+ const moduleRows = new Map();
146
+ const folderRows = new Map();
147
+ const cycles = new Set();
148
+ const orphans = new Set();
149
+ const crossDirectory = new Set();
150
+ const violations = new Set();
151
+ const projectRows = [];
152
+ for (const { project, graph } of results) {
153
+ if (!graph) continue;
154
+ const modules = graph.modules ?? [];
155
+ const roots = [...project.sourceRoots].sort((a, b) => b.length - a.length);
156
+ const area = (source) => {
157
+ const root = roots.find((candidate) => source === candidate || source.startsWith(`${candidate}/`));
158
+ const rest = root ? source.slice(root.length).replace(/^\//, "") : source;
159
+ return `${root ? `${root}/` : ""}${rest.split("/")[0]}`;
160
+ };
161
+ for (const module of modules) {
162
+ const fanIn = module.dependents?.length ?? 0;
163
+ const previous = moduleRows.get(module.source);
164
+ if (!previous || previous[1] < fanIn) moduleRows.set(module.source, [module.source, fanIn, module.dependencies?.length ?? 0, module.instability ?? ""]);
165
+ if (module.orphan) orphans.add(module.source);
166
+ for (const dependency of module.dependencies ?? []) {
167
+ if (!dependency.resolved) continue;
168
+ if (dependency.circular) cycles.add(`${module.source} → ${dependency.resolved}`);
169
+ if (area(module.source) !== area(dependency.resolved)) crossDirectory.add(`${module.source} → ${dependency.resolved}`);
170
+ }
171
+ }
172
+ for (const folder of graph.folders ?? []) {
173
+ const previous = folderRows.get(folder.name);
174
+ if (!previous || previous[2] < folder.afferentCouplings) {
175
+ folderRows.set(folder.name, [folder.name, folder.moduleCount, folder.afferentCouplings, folder.efferentCouplings, folder.instability]);
176
+ }
177
+ }
178
+ for (const violation of graph.summary?.violations ?? []) {
179
+ violations.add(`${violation.rule?.name ?? "unnamed"} | ${violation.from ?? ""} | ${violation.to ?? ""}`);
180
+ }
181
+ projectRows.push([project.config, modules.length, graph.summary?.totalDependenciesCruised ?? 0, modules.filter((m) => m.orphan).length]);
182
+ }
183
+ const topModules = [...moduleRows.values()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 20);
184
+ const topFolders = [...folderRows.values()].sort((a, b) => b[2] - a[2] || a[0].localeCompare(b[0])).slice(0, 20);
185
+ const metricsLines = [
186
+ "# Architecture metrics", "",
187
+ "## Projects", "", ...table(["Project", "Modules", "Edges", "Orphans"], projectRows), "",
188
+ "## Top modules by fan-in", "", ...table(["Module", "Fan-in", "Fan-out", "Instability"], topModules), "",
189
+ "## Top folders by fan-in", "", ...table(["Folder", "Modules", "Afferent", "Efferent", "Instability"], topFolders), "",
190
+ "## Cycles", "", ...(cycles.size ? [...cycles].slice(0, 20).map((edge) => `- ${edge}`) : ["- none"]), "",
191
+ "## Orphans", "", ...(orphans.size ? [...orphans].slice(0, 20).map((source) => `- ${source}`) : ["- none"]), "",
192
+ "## Declared rule violations", "", ...(violations.size ? [...violations].slice(0, 50).map((item) => `- ${item}`) : ["- none"]), "",
193
+ "## Cross-directory edges", "", ...(crossDirectory.size ? [...crossDirectory].slice(0, 50).map((edge) => `- ${edge}`) : ["- none"]), "",
194
+ ];
195
+ writeFileSync(path.join(outDir, "metrics.md"), metricsLines.join("\n"), "utf8");
196
+
197
+ // ── summary ─────────────────────────────────────────────────────────────────
198
+ const lines = [`# Cruise summary`, ``, `Ruleset: ${rulesFrom}`, ``,
199
+ `| Project | Source roots | Step | Exit | Reading |`, `|---|---|---|---|---|`];
200
+ let failures = 0;
201
+ for (const { project, runs } of results) {
202
+ for (const run of runs) {
203
+ if (run.reading.startsWith("failed")) failures++;
204
+ lines.push(`| ${project.config} | ${project.sourceRoots.join(", ")} | ${run.label.split(" ")[1]} | ${run.exitCode} | ${run.reading} |`);
205
+ }
206
+ }
207
+ lines.push(``, failures
208
+ ? `**${failures} step(s) failed.** A cruise of zero modules is a failed pre-pass, not a clean result: record it as skipped and do not read the empty graph as an absence of coupling.`
209
+ : `All steps produced a graph. Zero findings from these runs are reportable as mechanical zeros.`);
210
+
211
+ writeFileSync(path.join(outDir, "cruise-summary.md"), lines.join("\n") + "\n", "utf8");
212
+ console.log(lines.join("\n"));
213
+ process.exitCode = 0; // a failed step is reported in the summary, never by this script's exit code