ts-reviewer 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/package.json +1 -1
- package/ts-reviewer/SKILL.md +124 -47
- package/ts-reviewer/references/architecture.md +16 -14
- package/ts-reviewer/references/fix-workflow.md +41 -35
- package/ts-reviewer/tools/run-cruise.mjs +28 -15
- package/ts-reviewer/tools/validate-report.mjs +324 -0
package/README.md
CHANGED
|
@@ -84,6 +84,8 @@ Audit the codebase for security and type safety problems
|
|
|
84
84
|
Claude will analyze the project and write a report to `code-smells/report.md` in the project root.
|
|
85
85
|
With Architecture active, the same directory also holds project discovery, Knip, graph, metric, co-change, rule, and Mermaid artifacts.
|
|
86
86
|
|
|
87
|
+
The analysis passes run in waves. `--agents N` sets how many run at once (default 3; `--agents 1` runs them one at a time in the main agent). Each pass writes its own findings file under `code-smells/passes/`, and the queue in `code-smells/passes/queue.md` tracks which passes are done, so an interrupted scan does not lose finished work.
|
|
88
|
+
|
|
87
89
|
#### Domain flags
|
|
88
90
|
|
|
89
91
|
By default, only the nine core domains run. Use flags to control which domains are active:
|
|
@@ -197,6 +199,12 @@ cnlp/ # the CNL-P format the skill files are wri
|
|
|
197
199
|
|
|
198
200
|
ts-reviewer/
|
|
199
201
|
├── SKILL.md # Main skill file — mode routing, workflow orchestration
|
|
202
|
+
├── tools/ # Mechanical pre-pass and report validator — plain Node, no dependencies
|
|
203
|
+
│ ├── discover-projects.mjs # Finds the TypeScript projects and their source roots
|
|
204
|
+
│ ├── co-change.mjs # Git co-change pairs across directory boundaries
|
|
205
|
+
│ ├── run-cruise.mjs # dependency-cruiser graphs, metrics, and Mermaid diagrams per project
|
|
206
|
+
│ ├── classify-run.mjs # Reads a tool run by its output, not its exit code
|
|
207
|
+
│ └── validate-report.mjs # Checks code-smells/report.md against the report contract
|
|
200
208
|
└── references/
|
|
201
209
|
├── type-safety.md # Checklist: any, unknown, casts, !, exhaustiveness, branded types
|
|
202
210
|
├── security.md # Checklist: trust boundaries, injection, SSRF, pollution, ReDoS
|
|
@@ -228,7 +236,7 @@ A check line looks like this:
|
|
|
228
236
|
The format is enforced, not merely recommended:
|
|
229
237
|
|
|
230
238
|
```bash
|
|
231
|
-
npm test # typecheck +
|
|
239
|
+
npm test # typecheck + conformance and tool tests
|
|
232
240
|
```
|
|
233
241
|
|
|
234
242
|
The test catches a check line that lost its severity, a block the profile does not declare, blocks out of order, a banned vague word, and a line over the limit — each reported with its file and line number.
|
|
@@ -240,26 +248,33 @@ The test catches a check line that lost its severity, a block the profile does n
|
|
|
240
248
|
### Scan mode
|
|
241
249
|
|
|
242
250
|
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.
|
|
243
|
-
2. **Diagnostics** — runs `tsc --noEmit`, linter, and LSP diagnostics (if available)
|
|
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;
|
|
246
|
-
5. **Report** — deduplicates, applies severity boost (scoped modes), consolidates recurring patterns, enforces a noise budget, writes `code-smells/report.md
|
|
251
|
+
2. **Diagnostics** — runs `tsc --noEmit`, linter, and LSP diagnostics (if available); compiler and linter output is cached under `code-smells/passes/` and reused on a resume of the same commit.
|
|
252
|
+
3. **Architecture pre-pass** — when active, writes bounded Knip, graph, metric, co-change, rule, and Mermaid artifacts under `code-smells/`, with project coverage and bounded failure diagnostics.
|
|
253
|
+
4. **Analysis** — specialized passes judge the candidates against the active checklists, running in waves of `--agents` at a time; each pass writes its own `code-smells/passes/<id>.jsonl`, and `passes/queue.md` marks which are done, so a stopped run resumes from the last checkpoint. Tool output is never a finding by itself.
|
|
254
|
+
5. **Report** — deduplicates, applies severity boost (scoped modes), consolidates recurring patterns, enforces a noise budget, writes `code-smells/report.md`, and validates its contract before the scan succeeds. Architecture findings appear in a separate `## Architecture Opportunities` section at the end.
|
|
255
|
+
|
|
256
|
+
Validate a report directly with `node ts-reviewer/tools/validate-report.mjs --repo . --report code-smells/report.md`. It checks headings, counts, finding anchors, architecture fields, and linked artifacts without adding a dependency. An **error** is a defect of the report that rewriting it fixes; a **warning** names an outcome of the mechanical pre-pass — a graph with no diagram, say — that the report cannot fix, and warnings do not fail the run.
|
|
247
257
|
|
|
248
258
|
### Fix mode
|
|
249
259
|
|
|
250
|
-
1.
|
|
251
|
-
2.
|
|
252
|
-
3.
|
|
253
|
-
4.
|
|
254
|
-
5.
|
|
255
|
-
6.
|
|
256
|
-
7.
|
|
257
|
-
8.
|
|
260
|
+
1. Validates `code-smells/report.md` and stops before changing code when the report is invalid
|
|
261
|
+
2. Parses the report as the work plan
|
|
262
|
+
3. Captures test baseline (runs tests before changes)
|
|
263
|
+
4. Applies fixes bottom-to-top within each file (so line numbers don't shift)
|
|
264
|
+
5. Writes regression tests for each testable fix
|
|
265
|
+
6. Runs `tsc --noEmit` after each file
|
|
266
|
+
7. Runs the full verification loop: tsc + linter + test suite (max 5 iterations)
|
|
267
|
+
8. Compares test results with baseline — only fixes regressions it caused
|
|
268
|
+
9. Updates or deletes the report, keeps the remaining `code-smells/` artifacts, and asks before removing them
|
|
258
269
|
|
|
259
270
|
## Tips
|
|
260
271
|
|
|
261
272
|
- **Add `code-smells/` to `.gitignore`** — it contains review artifacts, not source code.
|
|
262
273
|
|
|
274
|
+
- **Resume an interrupted scan** — run the same scan again. When `code-smells/passes/queue.md` exists, the skill asks whether to resume (finished passes are skipped, cached `tsc` and linter output is reused on the same commit) or restart from scratch.
|
|
275
|
+
|
|
276
|
+
- **Claude Code users** — `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` in `settings.json` under `env` caps sub-agents for every session on the host. It is independent of `--agents`, which caps one review run and works in every supported agent.
|
|
277
|
+
|
|
263
278
|
- **Commit before running fix** — so you can `git diff` to review changes and `git checkout -- .` to revert if needed.
|
|
264
279
|
|
|
265
280
|
- **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.
|
package/package.json
CHANGED
package/ts-reviewer/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ target_stack:
|
|
|
23
23
|
- a pattern below this stack is a finding, and a feature above it is never recommended
|
|
24
24
|
|
|
25
25
|
inputs:
|
|
26
|
-
- the request, which carries the run mode, the domain set, and the
|
|
26
|
+
- the request, which carries the run mode, the domain set, the scope mode, and the wave size
|
|
27
27
|
- the project `tsconfig.json`, `package.json`, and linter config
|
|
28
28
|
- the reference checklists under `references/`
|
|
29
29
|
|
|
@@ -61,9 +61,13 @@ forbidden_behaviors:
|
|
|
61
61
|
- do not flag a config issue in a scoped mode unless `tsconfig.json` is in the diff
|
|
62
62
|
- do not download and execute a missing analysis tool before the operator approves it once at discovery
|
|
63
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
|
|
64
|
+
- do not rename a report section, field, severity, confidence, or domain: `report_format` and `domains` hold exact identifiers
|
|
65
|
+
- do not start a wave before every pass of the previous wave is marked `done`, `pending`, or `failed` in the queue
|
|
66
|
+
- do not read a finding from an agent reply: the `.jsonl` file under `code-smells/passes/` is the record, and a reply holds 1 line
|
|
64
67
|
|
|
65
68
|
outputs:
|
|
66
69
|
- `code-smells/report.md` in the project root: the scan report, and the work plan fix reads
|
|
70
|
+
- `code-smells/passes/`: the pass queue, the cached diagnostics, and 1 JSONL file per pass
|
|
67
71
|
- `code-smells/knip.json`, `projects.json`, `co-change.md`, `cruise-summary.md`, `metrics.md`, and graph and diagram directories when Architecture is active
|
|
68
72
|
- `code-smells/suggested.dependency-cruiser.cjs` when prose declares dependency rules and no machine-readable declaration owns them
|
|
69
73
|
- the `## Architecture Opportunities` section of the report only when Architecture is active and at least 1 confirmed finding exists
|
|
@@ -119,50 +123,65 @@ workflow:
|
|
|
119
123
|
3. identify the scope mode from `scope_modes`, and default to `full` when the request names none
|
|
120
124
|
4. build the file list with the command in `scope_commands` for that scope mode
|
|
121
125
|
5. ask whether to fall back to `full` when a scoped mode yields 0 files
|
|
122
|
-
6.
|
|
123
|
-
7.
|
|
124
|
-
8.
|
|
125
|
-
9.
|
|
126
|
-
10.
|
|
127
|
-
11.
|
|
128
|
-
12.
|
|
129
|
-
13.
|
|
130
|
-
14.
|
|
131
|
-
15.
|
|
132
|
-
16. collect
|
|
133
|
-
17.
|
|
134
|
-
18.
|
|
135
|
-
19.
|
|
136
|
-
20.
|
|
137
|
-
21.
|
|
138
|
-
22.
|
|
139
|
-
23.
|
|
140
|
-
24.
|
|
141
|
-
25.
|
|
142
|
-
26.
|
|
143
|
-
27.
|
|
144
|
-
28.
|
|
145
|
-
29.
|
|
146
|
-
30.
|
|
147
|
-
31.
|
|
148
|
-
32.
|
|
149
|
-
33.
|
|
150
|
-
34.
|
|
151
|
-
35.
|
|
152
|
-
36.
|
|
153
|
-
37.
|
|
154
|
-
38.
|
|
155
|
-
39.
|
|
156
|
-
40.
|
|
157
|
-
41.
|
|
158
|
-
42.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
126
|
+
6. ask once whether to resume or restart when `code-smells/passes/queue.md` exists, and delete `code-smells/passes/` on restart
|
|
127
|
+
7. warn when the `HEAD` in the queue header differs from the current `HEAD` on a resume: the line re-read below catches a stale line
|
|
128
|
+
8. map the project tree in full, whatever the scope mode
|
|
129
|
+
9. read `tsconfig.json` and `references/tsconfig.md`, then audit the config flags
|
|
130
|
+
10. detect monorepo workspaces in `package.json` and `pnpm-workspace.yaml`, and every further tsconfig
|
|
131
|
+
11. audit the config that governs the files in scope, and name that config in the summary
|
|
132
|
+
12. read the linter config: `eslint.config.*`, `.eslintrc.*`, `biome.json`, `deno.json`
|
|
133
|
+
13. read `package.json` for the dependencies and the module type, and verify the TypeScript version, `engines.node`, and `@types/node` against `target_stack`
|
|
134
|
+
14. identify declared entry points from `package.json#exports`, `main`, `bin`, and the `start`, `dev`, and `serve` scripts
|
|
135
|
+
15. when Architecture is active, inspect local Knip and dependency-cruiser binaries and ask once before running either missing tool through pinned-major `npx -y`
|
|
136
|
+
16. collect the context files named in `scope:` when the scope mode is scoped
|
|
137
|
+
17. identify feature slices and public entry points when Architecture is active, leaving graph discovery to its mechanical pre-pass
|
|
138
|
+
18. collect machine-readable dependency rules and prose from ADR directories, `ARCHITECTURE.md`, README, and `CONTRIBUTING.md`
|
|
139
|
+
19. run `npx tsc --noEmit 2>&1 | head -200` over the full project into `code-smells/passes/tsc.log`, and report only the errors in the scoped files
|
|
140
|
+
20. run the linter into `code-smells/passes/lint.json`: `npx eslint [files] --format json` or `npx biome check [files] --reporter json`
|
|
141
|
+
21. reuse `tsc.log` and `lint.json` on a resume when the queue `HEAD` matches and the tree is clean, and rerun both otherwise
|
|
142
|
+
22. query the TypeScript LSP over MCP when it is reachable, then merge and deduplicate against the compiler output
|
|
143
|
+
23. triage every compiler and linter diagnostic through `severity_mapping`
|
|
144
|
+
24. read the reference file named in `domains` before each analysis pass
|
|
145
|
+
25. run the mechanical pre-pass in `references/architecture.md` when Architecture is active, passing the approved tool decision and scoped base
|
|
146
|
+
26. report the discovery summary in the shape of `discovery_summary`, including skipped and clean mechanical results
|
|
147
|
+
27. build the pass list: 1 pass per active domain, split by directory when scoped files > 20, with the shared types visible to every pass
|
|
148
|
+
28. write `code-smells/passes/queue.md` in the shape of `pass_queue`, in its domain order, and skip a pass marked `done` on a resume
|
|
149
|
+
29. run the pending passes in waves of the wave size, as sub-agents shaped by `subagent_template`, or in the main agent when the wave size is 1
|
|
150
|
+
30. wait for every agent of a wave, then mark each pass `done` when the last line of its file is the `done` line, and `pending` otherwise
|
|
151
|
+
31. mark a pass `failed` after 2 attempts without a `done` line, name it in the discovery summary, and report its domain as not run
|
|
152
|
+
32. read the findings of every `done` pass from its `.jsonl` file before the re-read below
|
|
153
|
+
33. re-read the exact lines in the current file state before a finding enters the report
|
|
154
|
+
34. 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
|
|
155
|
+
35. downgrade a flagged non-High pattern that appears 5+ times across the codebase by 1 level, and report it once as a Recurring Pattern
|
|
156
|
+
36. boost a finding carrying `in_diff: true` by 1 level in a scoped mode, and mark it `High [boosted, was Medium — new code]`
|
|
157
|
+
37. deduplicate the findings on the same file, line, and issue, keeping 1
|
|
158
|
+
38. merge the findings 2 domains raise on the same file and line into 1 entry attributing both categories, at the higher severity
|
|
159
|
+
39. consolidate 3+ identical issues into 1 Recurring Pattern entry
|
|
160
|
+
40. 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
|
|
161
|
+
41. write `code-smells/report.md` in the shape of `report_format`
|
|
162
|
+
42. validate the report against `report_format` after writing it, and treat a `warning:` line as a pre-pass outcome the report cannot correct
|
|
163
|
+
```bash
|
|
164
|
+
# SKILL is the directory this file was loaded from.
|
|
165
|
+
SKILL=<the directory this file was loaded from>
|
|
166
|
+
node "$SKILL/tools/validate-report.mjs" --repo . --report code-smells/report.md
|
|
167
|
+
```
|
|
168
|
+
43. correct every named error and retry with report validation iterations <= 2
|
|
169
|
+
44. keep `code-smells/report.md` when the second validation fails, write `> unvalidated: <the first error>` under its title, and name the errors to the operator
|
|
170
|
+
45. sort by severity group, then category, then file path, and place `in_diff: true` before pre-existing in a scoped mode
|
|
171
|
+
46. show the top 10 and summarize the rest in a table when Medium and Low together hold more than 15 issues
|
|
172
|
+
47. recommend that the operator adds `code-smells/` to `.gitignore`: it holds review artifacts
|
|
173
|
+
48. read `references/fix-workflow.md` before fix mode executes: it holds the complete protocol
|
|
174
|
+
49. detect the test runner and run the baseline tests
|
|
175
|
+
50. fix the issues file by file, and run `tsc --noEmit` after each file
|
|
176
|
+
51. run the linter and fix the lint errors it reports
|
|
177
|
+
52. run the full test suite, compare it against the baseline, and fix the regressions
|
|
178
|
+
53. repeat the compiler, linter, and test verification with verification iterations <= 5
|
|
179
|
+
54. rerun the Architecture mechanical pre-pass on the fixed tree when Architecture is active
|
|
180
|
+
55. update `code-smells/report.md`: remove what is fixed, mark what failed
|
|
181
|
+
56. show the scan summary in auto mode, and ask the operator whether to proceed with the fix
|
|
182
|
+
57. re-scan after the fix in auto mode with full scan-fix cycles <= 2, and stop when issues persist after the second
|
|
183
|
+
58. delete `code-smells/report.md` and report success when every issue is fixed
|
|
184
|
+
59. retain the remaining `code-smells/` artifacts, state what they contain, and remove them only after the operator confirms
|
|
166
185
|
|
|
167
186
|
scope_commands:
|
|
168
187
|
```bash
|
|
@@ -223,8 +242,11 @@ Strict mode: yes / partial / no
|
|
|
223
242
|
Linter: eslint / biome / none
|
|
224
243
|
Test runner: vitest / jest / mocha / node:test / none
|
|
225
244
|
Files in scope: <N> .ts files (+ <M> context files)
|
|
245
|
+
Agents per wave: <N>
|
|
246
|
+
Resumed: <done>/<total> passes from code-smells/passes/queue.md, or no
|
|
226
247
|
Architecture projects: <config and source roots, when active>
|
|
227
248
|
Architecture tools: <local or approved npx versions, when active>
|
|
249
|
+
Architecture coverage: <successful>/<selected>, when active
|
|
228
250
|
Mechanical results: <module and edge counts, cycles, orphans, and co-change pairs; name clean zeros>
|
|
229
251
|
Skipped pre-passes: <tool and reason, or none>
|
|
230
252
|
Speculative candidates: <names only, or none>
|
|
@@ -242,6 +264,9 @@ Read the reference checklist: [REFERENCE_PATH]
|
|
|
242
264
|
Review these files: [FILE_LIST]
|
|
243
265
|
Context files (read-only, do NOT report issues): [CONTEXT_FILE_LIST]
|
|
244
266
|
Scope mode: [full|uncommitted|branch|commits:N]
|
|
267
|
+
Write every finding as 1 JSONL line to: [OUTPUT_PATH]
|
|
268
|
+
Append 1 last line when every file is reviewed: {"done": true, "findings": N, "files": M}
|
|
269
|
+
Reply with 1 line: the pass id, the findings count, the files count. The file is the result; the reply is not.
|
|
245
270
|
|
|
246
271
|
Output JSONL, one object per line:
|
|
247
272
|
{
|
|
@@ -259,7 +284,31 @@ Output JSONL, one object per line:
|
|
|
259
284
|
}
|
|
260
285
|
```
|
|
261
286
|
|
|
287
|
+
pass_queue:
|
|
288
|
+
- `--agents N` in the request sets the wave size, and the default is 3
|
|
289
|
+
- `--agents 1` runs 1 pass at a time in the main agent, with no sub-agent
|
|
290
|
+
- the pass id is the domain slug, or `<domain slug>.<directory slug>` for a split pass
|
|
291
|
+
- the domain order: Security, Type Safety, Async Patterns, Error Handling, Boundary Validation, Config, Dependency Hygiene, Modernization, Code Quality, Architecture
|
|
292
|
+
- a status is `pending`, `done`, or `failed`, and `Attempts` counts the waves the pass ran in
|
|
293
|
+
```markdown
|
|
294
|
+
# Pass queue
|
|
295
|
+
|
|
296
|
+
HEAD: <sha>
|
|
297
|
+
Scope: <mode>
|
|
298
|
+
Agents per wave: <N>
|
|
299
|
+
|
|
300
|
+
| Pass | Domain | Files | Status | Attempts | Findings |
|
|
301
|
+
|---|---|---|---|---|---|
|
|
302
|
+
| security | Security | 42 | done | 1 | 7 |
|
|
303
|
+
| type-safety.src-auth | Type Safety | 12 | pending | 1 | |
|
|
304
|
+
```
|
|
305
|
+
|
|
262
306
|
report_format:
|
|
307
|
+
- the `##` sections of the block below are the whole set, in that order, and a heading outside it is a renamed section
|
|
308
|
+
- Discovery, Pre-existing Issues, Architecture Opportunities, Verification, and Generated artifacts are optional, and the other 5 are always present
|
|
309
|
+
- `Total issues` counts the `###` findings, the summary-table rows, and the Architecture Opportunities entries, and the severity breakdown counts the same 3
|
|
310
|
+
- a `Recurring Patterns` row is a pattern rather than an issue, and no row of that table is counted
|
|
311
|
+
- a summary table is read by its `Category` and `Location` columns, and a pattern table by its `Pattern` and `Occurrences` columns
|
|
263
312
|
````markdown
|
|
264
313
|
# TypeScript Code Review Report
|
|
265
314
|
|
|
@@ -268,6 +317,7 @@ report_format:
|
|
|
268
317
|
**Stack:** TypeScript 5.9.x / ES2024 / Node 24 — matches / <deviation>
|
|
269
318
|
**Scope:** Full / Uncommitted / Branch `x` vs `y` / Last N commits
|
|
270
319
|
**Files analyzed:** N (+ M context)
|
|
320
|
+
**Architecture coverage:** N/M (when active)
|
|
271
321
|
**Total issues:** N (X highest, Y high, Z medium, W low)
|
|
272
322
|
**Severity-boosted:** N (scoped modes only)
|
|
273
323
|
|
|
@@ -275,6 +325,10 @@ report_format:
|
|
|
275
325
|
|
|
276
326
|
<2-3 sentences on codebase health and key patterns>
|
|
277
327
|
|
|
328
|
+
## Discovery
|
|
329
|
+
|
|
330
|
+
<optional; the `discovery_summary` block as it was reported>
|
|
331
|
+
|
|
278
332
|
## Highest + High Issues
|
|
279
333
|
|
|
280
334
|
### TITLE — Severity [boosted info if applicable]
|
|
@@ -282,7 +336,7 @@ report_format:
|
|
|
282
336
|
**Category:** cat | **File:** `path` | **Line:** N | **Auto-fixable:** Yes/No | **New code:** Yes/No
|
|
283
337
|
|
|
284
338
|
```typescript
|
|
285
|
-
// snippet
|
|
339
|
+
// snippet: 3-7 lines copied from the file, within its own length of the stated line
|
|
286
340
|
```
|
|
287
341
|
|
|
288
342
|
**Problem:** explanation
|
|
@@ -293,13 +347,36 @@ report_format:
|
|
|
293
347
|
|
|
294
348
|
## Medium Issues
|
|
295
349
|
## Low Issues
|
|
350
|
+
|
|
351
|
+
| Issue | Category | Location | Fix |
|
|
352
|
+
|---|---|---|---|
|
|
353
|
+
| title | cat | `path:N` | recommendation |
|
|
354
|
+
|
|
296
355
|
## Recurring Patterns
|
|
356
|
+
|
|
357
|
+
| Pattern | Occurrences | Severity treatment |
|
|
358
|
+
|---|---|---|
|
|
359
|
+
| name | N | what happened to its members |
|
|
360
|
+
|
|
297
361
|
## Config Issues
|
|
362
|
+
|
|
363
|
+
<findings in the shape above, or prose naming where the config findings already sit>
|
|
364
|
+
|
|
298
365
|
## Pre-existing Issues (scoped modes only)
|
|
299
366
|
|
|
300
367
|
## Architecture Opportunities
|
|
301
368
|
|
|
302
|
-
<1 entry per candidate, in the shape the `report_format` block of references/architecture.md gives>
|
|
369
|
+
<optional; 1 entry per candidate, in the shape the `report_format` block of references/architecture.md gives>
|
|
370
|
+
|
|
371
|
+
## Verification
|
|
372
|
+
|
|
373
|
+
| Check | Result |
|
|
374
|
+
|---|---|
|
|
375
|
+
| the command that ran | what it reported |
|
|
376
|
+
|
|
377
|
+
## Generated artifacts
|
|
378
|
+
|
|
379
|
+
- `<file under code-smells/>` — what it holds
|
|
303
380
|
|
|
304
381
|
---
|
|
305
382
|
````
|
|
@@ -54,19 +54,20 @@ workflow:
|
|
|
54
54
|
6. run the mechanical pre-pass with the commands below against 1 unchanged tree before the semantic pass
|
|
55
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
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.
|
|
58
|
-
10.
|
|
59
|
-
11.
|
|
60
|
-
12.
|
|
61
|
-
13.
|
|
62
|
-
14.
|
|
63
|
-
15.
|
|
64
|
-
16.
|
|
65
|
-
17.
|
|
66
|
-
18.
|
|
67
|
-
19.
|
|
68
|
-
20.
|
|
69
|
-
21.
|
|
57
|
+
9. state Architecture coverage as successful projects over selected projects, and list each failed config with its bounded diagnostic
|
|
58
|
+
10. give the semantic pass `code-smells/metrics.md` rather than a raw graph
|
|
59
|
+
11. cross each co-change pair with the graph: an edge is weak evidence, while no edge across directories is an implicit-contract candidate
|
|
60
|
+
12. read ESLint boundary rules from linter output without converting them, since the linter already executed those rules
|
|
61
|
+
13. consolidate all violating edges for 1 declared rule into 1 candidate, after removing declared exceptions
|
|
62
|
+
14. trace `min(declared entry points, 3)` scenarios, ranked by the number of modules each entry point reaches
|
|
63
|
+
15. grep for feature-module imports inside the `shared`, `common`, `core`, and `utils` directories, which is the inverted direction
|
|
64
|
+
16. apply the deletion test only to a module whose whole export set has 1 importer and that wraps or re-exports another module
|
|
65
|
+
17. treat an interface or type re-exported unchanged through 3+ files as a pass-through chain
|
|
66
|
+
18. inspect the top 20 modules and folders by fan-in for mixed concepts, and use folder instability only to rank layer checks
|
|
67
|
+
19. name the module owning the rule and the module owning the state for each candidate, and mark the candidate speculative when neither is identifiable
|
|
68
|
+
20. keep each collapsed project overview, then create 2..3 focused Mermaid diagrams tied to the top findings after triage
|
|
69
|
+
21. generate SVG only after `dot -V` succeeds, and keep Mermaid when Graphviz is unavailable
|
|
70
|
+
22. run these checks rather than reporting a cycle, hub, orphan, co-change pair, or Knip entry directly
|
|
70
71
|
```bash
|
|
71
72
|
# SKILL is the directory this file was loaded from; the main workflow approved missing tools before this pass.
|
|
72
73
|
SKILL=<the directory this file was loaded from>
|
|
@@ -137,6 +138,7 @@ forbidden_behaviors:
|
|
|
137
138
|
- do not leave a new structure standing beside the old one: every proposal names the step deleting what it replaces
|
|
138
139
|
- 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
140
|
- do not report architecture candidates without naming 1 top recommendation and the reason it goes first
|
|
141
|
+
- do not claim the repository has no cycles, orphans, or coupling issues below 100% Architecture coverage: name the successful projects
|
|
140
142
|
- 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
143
|
- do not treat an approved `npx` graph tool used during review as a project dependency: it changes neither `package.json` nor the lockfile
|
|
142
144
|
|
|
@@ -190,6 +192,6 @@ report_format:
|
|
|
190
192
|
- **Migration:** for deepening, merge, ownership move, or delete; prefactor | vertical slices | expand-contract, and the deletion step
|
|
191
193
|
- **Benefits:** locality gained, leverage gained, test impact
|
|
192
194
|
- **Trade-offs:** what gets harder, what is genuinely uncertain
|
|
193
|
-
- **Fixability:** auto | needs-confirm | report-only
|
|
195
|
+
- **Fixability:** auto | needs-confirm | report-only, and report-only for enforce, since a new rule fails the build before the edges it names are gone
|
|
194
196
|
- **Top recommendation:** on exactly 1 entry in the report, the reason this candidate goes first
|
|
195
197
|
```
|
|
@@ -10,12 +10,18 @@ read_first:
|
|
|
10
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
|
-
- the baseline taken in step
|
|
13
|
+
- the baseline taken in step 9 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.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
1. validate the report and stop without changing code when it reports an error, since a `warning:` line names a pre-pass outcome rather than a defect of the report
|
|
17
|
+
```bash
|
|
18
|
+
# SKILL is the directory this file was loaded from.
|
|
19
|
+
SKILL=<the directory this file was loaded from>
|
|
20
|
+
node "$SKILL/tools/validate-report.mjs" --repo . --report code-smells/report.md
|
|
21
|
+
```
|
|
22
|
+
2. read `code-smells/report.md` and extract every issue into a work list
|
|
23
|
+
3. 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
|
|
24
|
+
4. build the work plan
|
|
19
25
|
```
|
|
20
26
|
File: src/auth/token.ts
|
|
21
27
|
- Line 142: [High] Unsafe `as` cast — type-safety
|
|
@@ -26,21 +32,21 @@ File: src/api/handler.ts
|
|
|
26
32
|
- Line 201: [Highest] eval() with user input — security
|
|
27
33
|
- Line 55: [Medium] Missing exhaustive check — type-safety
|
|
28
34
|
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
5. detect the test runner through the signals in `test_runners`, in the order listed there
|
|
36
|
+
6. detect the test file convention: `*.test.ts` against `*.spec.ts`, a `__tests__/` directory against co-location, and the framework imports, `describe` and `it` against `test`
|
|
37
|
+
7. warn the operator with "No test runner detected. Fixes will be applied without test verification." when no test infrastructure is found, skip every test step, and still run the compiler and the linter
|
|
38
|
+
8. run the full test suite before any change, and write the log to the OS temp directory, or to the project root when temp is unavailable
|
|
33
39
|
```bash
|
|
34
40
|
<test_command> 2>&1 | tee "$TMPDIR/ts-reviewer-baseline.log"
|
|
35
41
|
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
9. record the baseline: total tests, passing, failing with the list, and the command used
|
|
43
|
+
10. process 1 file at a time, reading its current state before each fix, since an earlier fix in the same file has shifted the lines
|
|
44
|
+
11. apply the fix the report describes
|
|
45
|
+
12. update every reference to a renamed or replaced symbol across the codebase, its imports and its usages, when the fix replaces a pattern such as `enum` with `as const`
|
|
46
|
+
13. write a regression test for each testable fix: an incorrect cast, an injection sink, a floating promise, or a missing null check
|
|
47
|
+
14. skip the regression test for an untestable fix: a naming or formatting change, a config flag, a modernization that keeps the behaviour, or a complexity split
|
|
48
|
+
15. name the regression test file `<original-file>.reviewer-fixes.test.ts`, following the naming, the placement, and the framework the project already uses
|
|
49
|
+
16. label each test with the issue title, and keep it to the specific fix rather than the whole function
|
|
44
50
|
```typescript
|
|
45
51
|
describe('ts-reviewer fixes: src/auth/token.ts', () => {
|
|
46
52
|
it('should not use unsafe cast for token payload (type-safety)', () => {
|
|
@@ -48,40 +54,40 @@ describe('ts-reviewer fixes: src/auth/token.ts', () => {
|
|
|
48
54
|
});
|
|
49
55
|
});
|
|
50
56
|
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
17. tell the operator that the `.reviewer-fixes` files are candidates to rename and merge into the existing suites: the naming is a handoff convention and not a permanent home
|
|
58
|
+
18. run `npx tsc --noEmit 2>&1` after each file, and use `--incremental` or check every 3..5 files instead where the work plan > 30 files and a full typecheck is slow
|
|
59
|
+
19. fix a new compiler error in the file just changed or in a file the change affects, then run the compiler again to confirm
|
|
60
|
+
20. revert the last fix in the file and mark the issue `[FIX FAILED: caused type errors]` when the same error survives 2 attempts
|
|
61
|
+
21. repeat steps 10..20 for each file in the work plan
|
|
62
|
+
22. run the linter over the changed files once every file is fixed
|
|
57
63
|
```bash
|
|
58
64
|
# ESLint
|
|
59
65
|
npx eslint [changed_files] --format json 2>/dev/null
|
|
60
66
|
# or Biome
|
|
61
67
|
npx biome check [changed_files] --reporter json 2>/dev/null
|
|
62
68
|
```
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
23. apply `npx eslint --fix [files]` or `npx biome check --fix [files]`, fix the rest by hand, and run the linter again to confirm it is clean
|
|
70
|
+
24. run the full test suite and compare it against the baseline through `baseline_verdicts`
|
|
65
71
|
```bash
|
|
66
72
|
<test_command> 2>&1 | tee "$TMPDIR/ts-reviewer-postfix.log"
|
|
67
73
|
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
25. read the failure and the stack trace of each regression, and identify the fix that caused it from the diff of that file
|
|
75
|
+
26. fix the regression, or revert the fix that caused it and mark it `[FIX REVERTED: caused test regression in <test>]`
|
|
76
|
+
27. repeat the compiler, the linter, and the full suite until they are clean, with iterations <= 5
|
|
71
77
|
```
|
|
72
78
|
Iteration 1: Fixed 12/15 issues. 2 test regressions found.
|
|
73
79
|
Iteration 2: Fixed 2 regressions. 1 new tsc error.
|
|
74
80
|
Iteration 3: Fixed tsc error. All tests pass. Clean.
|
|
75
81
|
-> Done at iteration 3.
|
|
76
82
|
```
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
28. 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
|
|
84
|
+
29. show the operator what a `needs-confirm` architecture finding would change, and describe the reorganization or the interface change
|
|
85
|
+
30. apply an approved `needs-confirm` finding through the same file-by-file compiler loop, and mark a rejected one `[SKIPPED: user rejected]`
|
|
86
|
+
31. rerun Knip, dependency-cruiser, and co-change through the Architecture workflow when the report contains architecture findings
|
|
87
|
+
32. 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."
|
|
88
|
+
33. keep `code-smells/` after deleting the report, state what it contains, and ask before removing the directory
|
|
89
|
+
34. rewrite `code-smells/report.md` in the shape of `report_format` when any issue remains, carrying both what was fixed and what was not
|
|
90
|
+
35. leave every change in the working tree, unstaged and uncommitted, including the new regression test files
|
|
85
91
|
|
|
86
92
|
forbidden_behaviors:
|
|
87
93
|
- do not commit and do not stage: the operator reviews and decides
|
|
@@ -84,8 +84,13 @@ const nodePath = existsSync(path.join(projectModules, "typescript"))
|
|
|
84
84
|
: process.env.NODE_PATH || projectModules;
|
|
85
85
|
const localCruise = path.join(projectModules, ".bin", process.platform === "win32" ? "depcruise.cmd" : "depcruise");
|
|
86
86
|
|
|
87
|
+
// `--ts-config` is absolute on purpose, and a relative path here is not equivalent even though the
|
|
88
|
+
// cwd is the repository: TypeScript resolves the config's own `include` globs against the cwd when
|
|
89
|
+
// the path is relative, so a nested `templates/x/tsconfig.json` with `include: ["src"]` looks for
|
|
90
|
+
// `<repo>/src` and exits TS18003, no inputs found. Every nested project on the first real run failed
|
|
91
|
+
// this way, and every project that passed sat at the repository root.
|
|
87
92
|
function cruise(label, extraArgs, project) {
|
|
88
|
-
const toolArgs = ["--config", ruleset, "--ts-config", project.config, ...extraArgs, ...project.sourceRoots];
|
|
93
|
+
const toolArgs = ["--config", ruleset, "--ts-config", path.resolve(repo, project.config), ...extraArgs, ...project.sourceRoots];
|
|
89
94
|
const args = existsSync(localCruise) ? toolArgs : ["-y", TOOL, ...toolArgs];
|
|
90
95
|
const shell = process.platform === "win32";
|
|
91
96
|
const started = Date.now();
|
|
@@ -149,9 +154,14 @@ const orphans = new Set();
|
|
|
149
154
|
const crossDirectory = new Set();
|
|
150
155
|
const violations = new Set();
|
|
151
156
|
const projectRows = [];
|
|
157
|
+
// A project counts as covered when it produced a graph, and nothing else: the graph is what the
|
|
158
|
+
// semantic pass reads, while a failed mermaid or affected step costs a diagram and not an analysis.
|
|
159
|
+
// One predicate, so metrics.md, cruise-summary.md, and the report's coverage cannot disagree.
|
|
152
160
|
for (const { project, graph } of results) {
|
|
161
|
+
const modules = graph?.modules ?? [];
|
|
162
|
+
projectRows.push([project.config, graph ? "ok" : "failed", modules.length,
|
|
163
|
+
graph?.summary?.totalDependenciesCruised ?? 0, modules.filter((m) => m.orphan).length]);
|
|
153
164
|
if (!graph) continue;
|
|
154
|
-
const modules = graph.modules ?? [];
|
|
155
165
|
const roots = [...project.sourceRoots].sort((a, b) => b.length - a.length);
|
|
156
166
|
const area = (source) => {
|
|
157
167
|
const root = roots.find((candidate) => source === candidate || source.startsWith(`${candidate}/`));
|
|
@@ -178,13 +188,12 @@ for (const { project, graph } of results) {
|
|
|
178
188
|
for (const violation of graph.summary?.violations ?? []) {
|
|
179
189
|
violations.add(`${violation.rule?.name ?? "unnamed"} | ${violation.from ?? ""} | ${violation.to ?? ""}`);
|
|
180
190
|
}
|
|
181
|
-
projectRows.push([project.config, modules.length, graph.summary?.totalDependenciesCruised ?? 0, modules.filter((m) => m.orphan).length]);
|
|
182
191
|
}
|
|
183
192
|
const topModules = [...moduleRows.values()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 20);
|
|
184
193
|
const topFolders = [...folderRows.values()].sort((a, b) => b[2] - a[2] || a[0].localeCompare(b[0])).slice(0, 20);
|
|
185
194
|
const metricsLines = [
|
|
186
195
|
"# Architecture metrics", "",
|
|
187
|
-
"## Projects", "", ...table(["Project", "Modules", "Edges", "Orphans"], projectRows), "",
|
|
196
|
+
"## Projects", "", ...table(["Project", "Status", "Modules", "Edges", "Orphans"], projectRows), "",
|
|
188
197
|
"## Top modules by fan-in", "", ...table(["Module", "Fan-in", "Fan-out", "Instability"], topModules), "",
|
|
189
198
|
"## Top folders by fan-in", "", ...table(["Folder", "Modules", "Afferent", "Efferent", "Instability"], topFolders), "",
|
|
190
199
|
"## Cycles", "", ...(cycles.size ? [...cycles].slice(0, 20).map((edge) => `- ${edge}`) : ["- none"]), "",
|
|
@@ -195,18 +204,22 @@ const metricsLines = [
|
|
|
195
204
|
writeFileSync(path.join(outDir, "metrics.md"), metricsLines.join("\n"), "utf8");
|
|
196
205
|
|
|
197
206
|
// ── summary ─────────────────────────────────────────────────────────────────
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
const summaryRows = [];
|
|
208
|
+
let successful = 0;
|
|
209
|
+
for (const { project, graph, runs } of results) {
|
|
210
|
+
if (graph) successful++;
|
|
211
|
+
const failed = runs.filter((run) => run.reading.startsWith("failed"));
|
|
212
|
+
const diagnostics = [...new Set(failed.flatMap((run) => `${run.stderr}\n${run.stdout}`.split(/\r?\n/))
|
|
213
|
+
.map((line) => line.trim()).filter(Boolean))].slice(0, 3).join(" / ").slice(0, 500);
|
|
214
|
+
summaryRows.push([project.config, project.sourceRoots.join(", "), graph ? "ok" : "failed",
|
|
215
|
+
failed.map((run) => run.label.split(" ")[1]).join(", ") || "none", diagnostics || "none"]);
|
|
206
216
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
:
|
|
217
|
+
const failures = results.length - successful;
|
|
218
|
+
const lines = [`# Cruise summary`, ``, `Ruleset: ${rulesFrom}`, ``,
|
|
219
|
+
`Coverage: ${successful}/${results.length} projects`, ``,
|
|
220
|
+
...table(["Project", "Source roots", "Status", "Failed steps", "Diagnostics"], summaryRows), ``, failures
|
|
221
|
+
? `**${failures} project(s) failed.** Record them as skipped and do not read their empty graphs as an absence of coupling.`
|
|
222
|
+
: `All projects produced graphs. Zero findings from these runs are reportable as mechanical zeros.`];
|
|
210
223
|
|
|
211
224
|
writeFileSync(path.join(outDir, "cruise-summary.md"), lines.join("\n") + "\n", "utf8");
|
|
212
225
|
console.log(lines.join("\n"));
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Checks code-smells/report.md against the `report_format` block of SKILL.md, and nothing beyond it:
|
|
3
|
+
// the spec is the contract, this script is only the reader that enforces it.
|
|
4
|
+
//
|
|
5
|
+
// node validate-report.mjs --repo . --report code-smells/report.md
|
|
6
|
+
//
|
|
7
|
+
// An error is a defect of the report text, which rewriting the report can correct. A warning names
|
|
8
|
+
// an outcome of the mechanical pre-pass, which it cannot — a missing diagram is not a bad report,
|
|
9
|
+
// and failing the run over one would burn the two validation attempts the workflow allows.
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const DOMAINS = new Set([
|
|
14
|
+
"Type Safety", "Security", "Async Patterns", "Modernization", "Code Quality", "Config",
|
|
15
|
+
"Boundary Validation", "Error Handling", "Dependency Hygiene", "Architecture",
|
|
16
|
+
]);
|
|
17
|
+
const SEVERITIES = ["Highest", "High", "Medium", "Low"];
|
|
18
|
+
// The whole section set, in the order report_format gives it. Anything else is a renamed section.
|
|
19
|
+
const SECTIONS = [
|
|
20
|
+
{ name: "Summary", required: true },
|
|
21
|
+
{ name: "Discovery", required: false },
|
|
22
|
+
{ name: "Highest + High Issues", required: true },
|
|
23
|
+
{ name: "Medium Issues", required: true },
|
|
24
|
+
{ name: "Low Issues", required: true },
|
|
25
|
+
{ name: "Recurring Patterns", required: true },
|
|
26
|
+
{ name: "Config Issues", required: true },
|
|
27
|
+
{ name: "Pre-existing Issues (scoped modes only)", required: false },
|
|
28
|
+
{ name: "Architecture Opportunities", required: false },
|
|
29
|
+
{ name: "Verification", required: false },
|
|
30
|
+
{ name: "Generated artifacts", required: false },
|
|
31
|
+
];
|
|
32
|
+
const ISSUE_SECTIONS = [
|
|
33
|
+
"Highest + High Issues", "Medium Issues", "Low Issues", "Recurring Patterns", "Config Issues",
|
|
34
|
+
"Pre-existing Issues (scoped modes only)",
|
|
35
|
+
];
|
|
36
|
+
const SECTION_SEVERITY = { "Medium Issues": "Medium", "Low Issues": "Low" };
|
|
37
|
+
const COMMON_ARCH_FIELDS = [
|
|
38
|
+
"Confidence", "Files", "Problem", "Evidence", "Change type", "Proposed change",
|
|
39
|
+
"Test strategy", "Benefits", "Trade-offs", "Fixability",
|
|
40
|
+
];
|
|
41
|
+
const CONDITIONAL_ARCH_FIELDS = {
|
|
42
|
+
deepening: ["Interface shape", "Dependency category", "Migration"],
|
|
43
|
+
merge: ["Interface shape", "Migration"],
|
|
44
|
+
"ownership move": ["Migration"],
|
|
45
|
+
delete: ["Migration"],
|
|
46
|
+
enforce: ["Rule"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const value = (name, fallback) => {
|
|
50
|
+
const at = process.argv.indexOf(`--${name}`);
|
|
51
|
+
return at >= 0 && process.argv[at + 1] ? process.argv[at + 1] : fallback;
|
|
52
|
+
};
|
|
53
|
+
const repo = path.resolve(value("repo", "."));
|
|
54
|
+
const reportArg = value("report", "code-smells/report.md");
|
|
55
|
+
const report = path.isAbsolute(reportArg) ? reportArg : path.resolve(repo, reportArg);
|
|
56
|
+
if (!existsSync(report)) {
|
|
57
|
+
console.error(`${path.relative(repo, report) || report}:1: report does not exist`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lines = readFileSync(report, "utf8").split(/\r?\n/);
|
|
62
|
+
const errors = [];
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const fail = (line, message) => errors.push({ line: Math.max(1, line), message });
|
|
65
|
+
const warn = (line, message) => warnings.push({ line: Math.max(1, line), message });
|
|
66
|
+
const insideRepo = (target) => {
|
|
67
|
+
const relative = path.relative(repo, target);
|
|
68
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
69
|
+
};
|
|
70
|
+
const checkRepoPath = (raw, line) => {
|
|
71
|
+
if (path.isAbsolute(raw)) {
|
|
72
|
+
fail(line, `path must be relative: ${raw}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const target = path.resolve(repo, raw);
|
|
76
|
+
if (!insideRepo(target)) { fail(line, `path leaves the repository: ${raw}`); return null; }
|
|
77
|
+
if (!existsSync(target)) { fail(line, `path does not exist: ${raw}`); return null; }
|
|
78
|
+
return target;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const firstContent = lines.findIndex((line) => line.trim());
|
|
82
|
+
if (lines[firstContent] !== "# TypeScript Code Review Report") fail(firstContent + 1, "expected exact report title");
|
|
83
|
+
|
|
84
|
+
// ── sections ────────────────────────────────────────────────────────────────
|
|
85
|
+
const h2s = lines.flatMap((line, index) => {
|
|
86
|
+
const match = line.match(/^## (.+)$/);
|
|
87
|
+
return match ? [{ name: match[1], index }] : [];
|
|
88
|
+
});
|
|
89
|
+
const actualSections = h2s.map(({ name }) => name);
|
|
90
|
+
const known = new Set(SECTIONS.map((section) => section.name));
|
|
91
|
+
const firstHeading = (h2s[0]?.index ?? 0) + 1;
|
|
92
|
+
for (const { name, index } of h2s) if (!known.has(name)) fail(index + 1, `unknown section: ${name}`);
|
|
93
|
+
for (const { name, required } of SECTIONS) {
|
|
94
|
+
if (required && !actualSections.includes(name)) fail(firstHeading, `missing section: ${name}`);
|
|
95
|
+
}
|
|
96
|
+
const present = actualSections.filter((name) => known.has(name));
|
|
97
|
+
const expected = SECTIONS.map(({ name }) => name).filter((name) => actualSections.includes(name));
|
|
98
|
+
if (present.join("\n") !== expected.join("\n")) {
|
|
99
|
+
fail(firstHeading, `sections are out of order or repeated; expected: ${expected.join(" > ")}`);
|
|
100
|
+
}
|
|
101
|
+
const sections = new Map(h2s.map((heading, index) => [heading.name, {
|
|
102
|
+
start: heading.index + 1,
|
|
103
|
+
end: h2s[index + 1]?.index ?? lines.length,
|
|
104
|
+
line: heading.index + 1,
|
|
105
|
+
}]));
|
|
106
|
+
|
|
107
|
+
const metadata = ["Project", "Reviewed", "Stack", "Scope", "Files analyzed", "Total issues"];
|
|
108
|
+
for (const name of metadata) {
|
|
109
|
+
const matches = lines.flatMap((line, index) => line.startsWith(`**${name}:**`) ? [index] : []);
|
|
110
|
+
if (matches.length !== 1) fail(matches[0] + 1 || 1, `metadata ${name} must appear exactly once`);
|
|
111
|
+
}
|
|
112
|
+
const totalLine = lines.findIndex((line) => line.startsWith("**Total issues:**"));
|
|
113
|
+
const totalMatch = lines[totalLine]?.match(/^\*\*Total issues:\*\* (\d+) \((\d+) highest, (\d+) high, (\d+) medium, (\d+) low\)$/);
|
|
114
|
+
if (!totalMatch) fail(totalLine + 1, "Total issues has an invalid shape");
|
|
115
|
+
|
|
116
|
+
// ── findings ────────────────────────────────────────────────────────────────
|
|
117
|
+
// A table in an issue section is read by its header: the summary table of workflow step 39 carries
|
|
118
|
+
// issues, and the Recurring Patterns table carries patterns whose members are counted where they sit.
|
|
119
|
+
const cells = (line) => line.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
120
|
+
const isSummaryTable = (header) => header.includes("Category") && header.includes("Location");
|
|
121
|
+
const isPatternTable = (header) => header.includes("Pattern") && header.includes("Occurrences");
|
|
122
|
+
|
|
123
|
+
const counts = new Map(SEVERITIES.map((severity) => [severity, 0]));
|
|
124
|
+
let issueCount = 0;
|
|
125
|
+
const duplicates = new Map();
|
|
126
|
+
const headingPattern = /^### .+ — (Highest|High|Medium|Low)(?: \[.+\])?$/;
|
|
127
|
+
|
|
128
|
+
for (const sectionName of ISSUE_SECTIONS) {
|
|
129
|
+
const section = sections.get(sectionName);
|
|
130
|
+
if (!section) continue;
|
|
131
|
+
const headings = [];
|
|
132
|
+
for (let index = section.start; index < section.end; index++) {
|
|
133
|
+
if (lines[index].startsWith("### ")) headings.push(index);
|
|
134
|
+
}
|
|
135
|
+
for (let at = 0; at < headings.length; at++) {
|
|
136
|
+
const index = headings[at];
|
|
137
|
+
const end = headings[at + 1] ?? section.end;
|
|
138
|
+
const heading = lines[index].match(headingPattern);
|
|
139
|
+
if (!heading) {
|
|
140
|
+
fail(index + 1, "finding heading must end with an exact severity");
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const severity = heading[1];
|
|
144
|
+
if (sectionName === "Highest + High Issues" && !["Highest", "High"].includes(severity)) fail(index + 1, "severity does not match its section");
|
|
145
|
+
if (sectionName === "Medium Issues" && severity !== "Medium") fail(index + 1, "severity does not match its section");
|
|
146
|
+
if (sectionName === "Low Issues" && severity !== "Low") fail(index + 1, "severity does not match its section");
|
|
147
|
+
counts.set(severity, counts.get(severity) + 1);
|
|
148
|
+
issueCount++;
|
|
149
|
+
|
|
150
|
+
const block = lines.slice(index + 1, end);
|
|
151
|
+
const metaAt = block.findIndex((line) => line.startsWith("**Category:**"));
|
|
152
|
+
const meta = block[metaAt]?.match(/^\*\*Category:\*\* (.+?) \| \*\*File:\*\* `([^`]+)` \| \*\*Line:\*\* (\d+) \| \*\*Auto-fixable:\*\* (Yes|No) \| \*\*New code:\*\* (Yes|No)$/);
|
|
153
|
+
if (!meta) {
|
|
154
|
+
fail(index + 1, "finding metadata is missing or has the wrong field names");
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const categories = meta[1].split(/\s*(?:,|&)\s*/);
|
|
158
|
+
for (const category of categories) {
|
|
159
|
+
if (!DOMAINS.has(category)) fail(index + metaAt + 2, `unknown category: ${category}`);
|
|
160
|
+
}
|
|
161
|
+
const source = checkRepoPath(meta[2], index + metaAt + 2);
|
|
162
|
+
const sourceLine = Number(meta[3]);
|
|
163
|
+
// Workflow step 31 merges what two domains raise on one file and line into one entry naming
|
|
164
|
+
// both categories, so a second entry on that line is the merge that did not happen.
|
|
165
|
+
const key = `${meta[2]}:${sourceLine}`.toLowerCase();
|
|
166
|
+
if (duplicates.has(key)) fail(index + 1, `duplicate finding; first entry is at line ${duplicates.get(key)}`);
|
|
167
|
+
else duplicates.set(key, index + 1);
|
|
168
|
+
if (!block.some((line) => line.startsWith("**Problem:** "))) fail(index + 1, "finding has no Problem field");
|
|
169
|
+
if (!block.some((line) => line.startsWith("**Fix:** "))) fail(index + 1, "finding has no Fix field");
|
|
170
|
+
|
|
171
|
+
const fence = block.findIndex((line) => /^```[^`]*$/.test(line));
|
|
172
|
+
const fenceEnd = fence >= 0 ? block.findIndex((line, at) => at > fence && line === "```") : -1;
|
|
173
|
+
if (fence < 0 || fenceEnd < 0) {
|
|
174
|
+
fail(index + 1, "finding has no fenced snippet");
|
|
175
|
+
} else if (source && statSync(source).isFile()) {
|
|
176
|
+
const sourceLines = readFileSync(source, "utf8").split(/\r?\n/);
|
|
177
|
+
if (sourceLine < 1 || sourceLine > sourceLines.length) fail(index + metaAt + 2, `line is outside the file: ${sourceLine}`);
|
|
178
|
+
else {
|
|
179
|
+
// A snippet runs 3-7 lines and the stated line sits anywhere inside it, so the window is the
|
|
180
|
+
// snippet's own length on either side, and any one of its lines anchors it to the file.
|
|
181
|
+
const snippet = block.slice(fence + 1, fenceEnd).map((line) => line.trim()).filter(Boolean);
|
|
182
|
+
const reach = snippet.length;
|
|
183
|
+
const window = new Set(sourceLines.slice(Math.max(0, sourceLine - 1 - reach), sourceLine + reach).map((line) => line.trim()));
|
|
184
|
+
if (reach && !snippet.some((line) => window.has(line))) {
|
|
185
|
+
fail(index + fence + 2, `snippet matches no line within ${reach} line(s) of line ${sourceLine}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let counting = false;
|
|
192
|
+
let fenced = false;
|
|
193
|
+
for (let index = section.start; index < section.end; index++) {
|
|
194
|
+
if (lines[index].startsWith("```")) { fenced = !fenced; continue; }
|
|
195
|
+
if (fenced) continue;
|
|
196
|
+
if (/^\|[-:| ]+\|$/.test(lines[index])) {
|
|
197
|
+
const header = cells(lines[index - 1] ?? "");
|
|
198
|
+
counting = isSummaryTable(header);
|
|
199
|
+
if (!counting && !isPatternTable(header)) {
|
|
200
|
+
fail(index, "table header must carry Category and Location, or Pattern and Occurrences");
|
|
201
|
+
} else if (counting && !SECTION_SEVERITY[sectionName]) {
|
|
202
|
+
fail(index, "a summary table belongs in Medium Issues or Low Issues");
|
|
203
|
+
counting = false;
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (lines[index].startsWith("|") && lines[index].endsWith("|")) {
|
|
208
|
+
if (!counting) continue;
|
|
209
|
+
const severity = SECTION_SEVERITY[sectionName];
|
|
210
|
+
counts.set(severity, counts.get(severity) + 1);
|
|
211
|
+
issueCount++;
|
|
212
|
+
} else if (lines[index].trim()) counting = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── architecture opportunities ──────────────────────────────────────────────
|
|
217
|
+
const architecture = sections.get("Architecture Opportunities");
|
|
218
|
+
let architectureEntries = 0;
|
|
219
|
+
let topRecommendations = 0;
|
|
220
|
+
if (architecture) {
|
|
221
|
+
const headings = [];
|
|
222
|
+
for (let index = architecture.start; index < architecture.end; index++) if (lines[index].startsWith("### ")) headings.push(index);
|
|
223
|
+
if (!headings.length) fail(architecture.line, "Architecture Opportunities has no entries");
|
|
224
|
+
for (let at = 0; at < headings.length; at++) {
|
|
225
|
+
const index = headings[at];
|
|
226
|
+
const end = headings[at + 1] ?? architecture.end;
|
|
227
|
+
const heading = lines[index].match(headingPattern);
|
|
228
|
+
if (!heading) { fail(index + 1, "architecture heading must end with an exact severity"); continue; }
|
|
229
|
+
counts.set(heading[1], counts.get(heading[1]) + 1);
|
|
230
|
+
issueCount++;
|
|
231
|
+
architectureEntries++;
|
|
232
|
+
const fields = new Map();
|
|
233
|
+
for (let line = index + 1; line < end; line++) {
|
|
234
|
+
const match = lines[line].match(/^- \*\*([^*]+):\*\* (.+)$/);
|
|
235
|
+
if (!match) continue;
|
|
236
|
+
if (fields.has(match[1])) fail(line + 1, `duplicate architecture field: ${match[1]}`);
|
|
237
|
+
fields.set(match[1], { value: match[2], line: line + 1 });
|
|
238
|
+
}
|
|
239
|
+
for (const field of COMMON_ARCH_FIELDS) if (!fields.has(field)) fail(index + 1, `missing architecture field: ${field}`);
|
|
240
|
+
const confidence = fields.get("Confidence");
|
|
241
|
+
if (confidence && !["strong", "worth-exploring"].includes(confidence.value)) fail(confidence.line, `invalid Confidence: ${confidence.value}`);
|
|
242
|
+
const change = fields.get("Change type");
|
|
243
|
+
const conditional = change ? CONDITIONAL_ARCH_FIELDS[change.value] : null;
|
|
244
|
+
if (change && !conditional) fail(change.line, `invalid Change type: ${change.value}`);
|
|
245
|
+
for (const field of conditional ?? []) if (!fields.has(field)) fail(index + 1, `missing architecture field: ${field}`);
|
|
246
|
+
const allowed = new Set([...COMMON_ARCH_FIELDS, ...(conditional ?? []), "Top recommendation"]);
|
|
247
|
+
for (const [field, data] of fields) if (!allowed.has(field)) fail(data.line, `field does not apply to ${change?.value ?? "this change"}: ${field}`);
|
|
248
|
+
const fixability = fields.get("Fixability");
|
|
249
|
+
if (fixability && !["auto", "needs-confirm", "report-only"].includes(fixability.value)) fail(fixability.line, `invalid Fixability: ${fixability.value}`);
|
|
250
|
+
if (change?.value === "enforce" && fixability?.value !== "report-only") fail(fixability?.line ?? index + 1, "enforce changes must be report-only");
|
|
251
|
+
if (fields.has("Top recommendation")) topRecommendations++;
|
|
252
|
+
const files = fields.get("Files");
|
|
253
|
+
if (files) for (const file of files.value.split(",").map((item) => item.trim().replaceAll("`", ""))) checkRepoPath(file, files.line);
|
|
254
|
+
}
|
|
255
|
+
if (architectureEntries && topRecommendations !== 1) fail(architecture.line, "architecture findings require exactly one Top recommendation");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (totalMatch) {
|
|
259
|
+
const stated = totalMatch.slice(2).map(Number);
|
|
260
|
+
if (Number(totalMatch[1]) !== issueCount) fail(totalLine + 1, `Total issues says ${totalMatch[1]}, found ${issueCount}`);
|
|
261
|
+
SEVERITIES.forEach((severity, index) => {
|
|
262
|
+
if (stated[index] !== counts.get(severity)) fail(totalLine + 1, `${severity} count says ${stated[index]}, found ${counts.get(severity)}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── linked artifacts ────────────────────────────────────────────────────────
|
|
267
|
+
const reportDir = path.dirname(report);
|
|
268
|
+
for (let index = 0; index < lines.length; index++) {
|
|
269
|
+
for (const match of lines[index].matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
|
|
270
|
+
let link;
|
|
271
|
+
try { link = decodeURI(match[1].replace(/^<|>$/g, "")).split("#")[0]; }
|
|
272
|
+
catch { fail(index + 1, `artifact link has invalid escaping: ${match[1]}`); continue; }
|
|
273
|
+
if (/^(?:https?:|mailto:|#)/.test(link)) continue;
|
|
274
|
+
if (!/\.(?:json|mmd|md|svg)$/i.test(link)) continue;
|
|
275
|
+
const targets = [path.resolve(reportDir, link), path.resolve(repo, link)];
|
|
276
|
+
const target = targets.find((candidate) => insideRepo(candidate) && existsSync(candidate));
|
|
277
|
+
if (!target) fail(index + 1, `linked artifact does not exist: ${link}`);
|
|
278
|
+
else if (path.extname(target) === ".json") {
|
|
279
|
+
try { JSON.parse(readFileSync(target, "utf8")); } catch { fail(index + 1, `linked JSON is invalid: ${link}`); }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── architecture coverage ───────────────────────────────────────────────────
|
|
285
|
+
// The claim the report makes about how much of the tree was analysed is the one number a reader
|
|
286
|
+
// trusts when it says there are no cycles, so an overclaim is an error and everything else is not.
|
|
287
|
+
const coverageLine = lines.findIndex((line) => line.startsWith("**Architecture coverage:**"));
|
|
288
|
+
const coverage = lines[coverageLine]?.match(/^\*\*Architecture coverage:\*\* (\d+)\/(\d+)$/);
|
|
289
|
+
const architectureActive = coverageLine >= 0 || Boolean(architecture);
|
|
290
|
+
if (architectureActive) {
|
|
291
|
+
if (!coverage) fail(coverageLine + 1 || 1, "Architecture coverage must have the shape successful/selected");
|
|
292
|
+
const at = coverageLine + 1 || 1;
|
|
293
|
+
const core = ["projects.json", "co-change.md", "cruise-summary.md", "metrics.md"];
|
|
294
|
+
for (const artifact of core) if (!existsSync(path.join(reportDir, artifact))) warn(at, `missing architecture artifact: ${artifact}`);
|
|
295
|
+
const graphDir = path.join(reportDir, "graphs");
|
|
296
|
+
const graphFiles = existsSync(graphDir) && statSync(graphDir).isDirectory()
|
|
297
|
+
? readdirSync(graphDir).filter((file) => file.endsWith(".json")) : [];
|
|
298
|
+
for (const file of graphFiles) {
|
|
299
|
+
try { JSON.parse(readFileSync(path.join(graphDir, file), "utf8")); } catch { warn(at, `invalid graph JSON: graphs/${file}`); }
|
|
300
|
+
const diagram = path.join(reportDir, "diagrams", `${path.basename(file, ".json")}.mmd`);
|
|
301
|
+
if (!existsSync(diagram)) warn(at, `successful graph has no overview diagram: ${path.basename(diagram)}`);
|
|
302
|
+
}
|
|
303
|
+
const projectsPath = path.join(reportDir, "projects.json");
|
|
304
|
+
if (coverage && existsSync(projectsPath)) {
|
|
305
|
+
try {
|
|
306
|
+
const discovery = JSON.parse(readFileSync(projectsPath, "utf8"));
|
|
307
|
+
const projects = Array.isArray(discovery) ? discovery : discovery.projects;
|
|
308
|
+
const [successful, selected] = coverage.slice(1).map(Number);
|
|
309
|
+
if (!Array.isArray(projects)) fail(at, "projects.json must contain a projects array");
|
|
310
|
+
else if (selected !== projects.length) fail(at, `Architecture coverage selects ${selected} of the ${projects.length} discovered projects`);
|
|
311
|
+
if (successful > graphFiles.length) fail(at, `Architecture coverage claims ${successful} analysed project(s) over ${graphFiles.length} graph(s)`);
|
|
312
|
+
else if (successful < graphFiles.length) warn(at, `Architecture coverage claims ${successful} analysed project(s) under ${graphFiles.length} graph(s)`);
|
|
313
|
+
} catch { fail(at, "projects.json is invalid JSON"); }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const display = path.relative(repo, report) || path.basename(report);
|
|
318
|
+
for (const warning of warnings) console.error(`${display}:${warning.line}: warning: ${warning.message}`);
|
|
319
|
+
if (errors.length) {
|
|
320
|
+
for (const error of errors) console.error(`${display}:${error.line}: ${error.message}`);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
} else {
|
|
323
|
+
console.log(`Validated ${display}: ${issueCount} issue(s), ${warnings.length} warning(s).`);
|
|
324
|
+
}
|