ts-reviewer 2.1.0 → 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 +13 -11
- package/package.json +2 -2
- package/ts-reviewer/SKILL.md +54 -33
- package/ts-reviewer/references/architecture.md +62 -17
- package/ts-reviewer/references/fix-workflow.md +8 -6
- package/ts-reviewer/tools/classify-run.mjs +26 -0
- package/ts-reviewer/tools/co-change.mjs +116 -0
- package/ts-reviewer/tools/discover-projects.mjs +159 -0
- package/ts-reviewer/tools/run-cruise.mjs +213 -0
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
|
|
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. **
|
|
244
|
-
4. **
|
|
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
|
|
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": "
|
|
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": [
|
package/ts-reviewer/SKILL.md
CHANGED
|
@@ -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
|
-
-
|
|
64
|
-
-
|
|
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
|
|
123
|
-
13.
|
|
124
|
-
14.
|
|
125
|
-
15.
|
|
126
|
-
16.
|
|
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
|
|
133
|
-
23.
|
|
134
|
-
24.
|
|
135
|
-
25.
|
|
136
|
-
26.
|
|
137
|
-
27.
|
|
138
|
-
28.
|
|
139
|
-
29.
|
|
140
|
-
30.
|
|
141
|
-
31.
|
|
142
|
-
32.
|
|
143
|
-
33.
|
|
144
|
-
34.
|
|
145
|
-
35.
|
|
146
|
-
36.
|
|
147
|
-
37.
|
|
148
|
-
38. fix
|
|
149
|
-
39.
|
|
150
|
-
40.
|
|
151
|
-
41.
|
|
152
|
-
42.
|
|
153
|
-
43.
|
|
154
|
-
44.
|
|
155
|
-
45.
|
|
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:
|
|
@@ -23,8 +23,8 @@ confidence_scale:
|
|
|
23
23
|
|
|
24
24
|
| Confidence | Criteria |
|
|
25
25
|
|---|---|
|
|
26
|
-
| strong | the pain repeats
|
|
27
|
-
| worth-exploring | the problem is confirmed
|
|
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
28
|
| speculative | a signal with no call, history, or test evidence behind it |
|
|
29
29
|
|
|
30
30
|
read_first:
|
|
@@ -35,23 +35,56 @@ read_first:
|
|
|
35
35
|
- dependency direction matters more than layer count: the domain owns the logic, and transport and storage are injected or isolated at the edge
|
|
36
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
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
|
|
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
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
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
41
|
- design a hard-to-reverse proposal twice: compare 2 different interfaces on caller knowledge, seam placement, and migration cost, then recommend 1
|
|
42
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
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
|
|
44
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
|
|
45
47
|
|
|
46
48
|
workflow:
|
|
47
|
-
1.
|
|
48
|
-
2.
|
|
49
|
-
3. list
|
|
50
|
-
4.
|
|
51
|
-
5.
|
|
52
|
-
6.
|
|
53
|
-
7.
|
|
54
|
-
8.
|
|
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
|
+
```
|
|
55
88
|
|
|
56
89
|
checks:
|
|
57
90
|
- shallow modules — understanding 1 concept requires bouncing across many tiny modules: Medium
|
|
@@ -76,6 +109,7 @@ checks:
|
|
|
76
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
|
|
77
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
|
|
78
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
|
|
79
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
|
|
80
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
|
|
81
115
|
- state ownership — fix: a shared mutable entity, by naming 1 owning module and turning each write into a named command on it
|
|
@@ -88,6 +122,7 @@ non_findings:
|
|
|
88
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
|
|
89
123
|
- a layer count: the direction of the dependencies is the finding, and a layer holding a real rule is not
|
|
90
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
|
|
91
126
|
- formatting, import ordering, and file naming: `references/code-quality.md` owns them, and they are not architecture findings
|
|
92
127
|
|
|
93
128
|
forbidden_behaviors:
|
|
@@ -102,7 +137,8 @@ forbidden_behaviors:
|
|
|
102
137
|
- do not leave a new structure standing beside the old one: every proposal names the step deleting what it replaces
|
|
103
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
|
|
104
139
|
- do not report architecture candidates without naming 1 top recommendation and the reason it goes first
|
|
105
|
-
- do not add
|
|
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
|
|
106
142
|
|
|
107
143
|
dependency_classification:
|
|
108
144
|
|
|
@@ -115,6 +151,9 @@ dependency_classification:
|
|
|
115
151
|
|
|
116
152
|
severity_mapping:
|
|
117
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
|
+
|
|
118
157
|
| Severity | Architecture criteria |
|
|
119
158
|
|---|---|
|
|
120
159
|
| Highest | an architecture issue directly causing a security vulnerability, data loss, or a production correctness bug |
|
|
@@ -124,6 +163,10 @@ severity_mapping:
|
|
|
124
163
|
|
|
125
164
|
fixability:
|
|
126
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
|
+
|
|
127
170
|
| Fixability | Meaning | Fix mode behaviour |
|
|
128
171
|
|---|---|---|
|
|
129
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 |
|
|
@@ -134,15 +177,17 @@ report_format:
|
|
|
134
177
|
```md
|
|
135
178
|
### TITLE — Severity
|
|
136
179
|
|
|
137
|
-
- **Confidence:** strong | worth-exploring
|
|
180
|
+
- **Confidence:** strong | worth-exploring
|
|
138
181
|
- **Files:** relative/path/a.ts, relative/path/b.ts
|
|
139
182
|
- **Problem:** why this causes friction now (not just a pattern name)
|
|
140
183
|
- **Evidence:** the imports, callers, `git log` path, or test the finding rests on
|
|
141
|
-
- **
|
|
142
|
-
- **
|
|
143
|
-
- **
|
|
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
|
|
144
189
|
- **Test strategy:** how tests would improve (what tests survive, what gets deleted, what's new)
|
|
145
|
-
- **Migration:** prefactor | vertical slices | expand-contract, and the step
|
|
190
|
+
- **Migration:** for deepening, merge, ownership move, or delete; prefactor | vertical slices | expand-contract, and the deletion step
|
|
146
191
|
- **Benefits:** locality gained, leverage gained, test impact
|
|
147
192
|
- **Trade-offs:** what gets harder, what is genuinely uncertain
|
|
148
193
|
- **Fixability:** auto | needs-confirm | report-only
|
|
@@ -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.
|
|
81
|
-
31.
|
|
82
|
-
32.
|
|
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
|