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 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 + 5 conformance tests
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; 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.
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. Parses `code-smells/report.md` as the work plan
251
- 2. Captures test baseline (runs tests before changes)
252
- 3. Applies fixes bottom-to-top within each file (so line numbers don't shift)
253
- 4. Writes regression tests for each testable fix
254
- 5. Runs `tsc --noEmit` after each file
255
- 6. Full verification loop: tsc + linter + test suite (max 5 iterations)
256
- 7. Compares test results with baseline — only fixes regressions it caused
257
- 8. Updates or deletes the report, keeps the remaining `code-smells/` artifacts, and asks before removing them
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-reviewer",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Install the TypeScript Code Reviewer skill for Claude Code, Codex, or Antigravity",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 scope mode
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. map the project tree in full, whatever the scope mode
123
- 7. read `tsconfig.json` and `references/tsconfig.md`, then audit the config flags
124
- 8. detect monorepo workspaces in `package.json` and `pnpm-workspace.yaml`, and every further tsconfig
125
- 9. audit the config that governs the files in scope, and name that config in the summary
126
- 10. read the linter config: `eslint.config.*`, `.eslintrc.*`, `biome.json`, `deno.json`
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`
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`
133
- 17. run `npx tsc --noEmit 2>&1 | head -200` over the full project, and report only the errors in the scoped files
134
- 18. run the linter: `npx eslint [files] --format json` or `npx biome check [files] --reporter json`
135
- 19. query the TypeScript LSP over MCP when it is reachable, then merge and deduplicate against the compiler output
136
- 20. triage every compiler and linter diagnostic through `severity_mapping`
137
- 21. read the reference file named in `domains` before each analysis pass
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
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. 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
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 3 decides what counts as a regression: a test failing before the fixes is pre-existing and stays out of the work
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. read `code-smells/report.md` and extract every issue into a work list
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
- 3. build the work plan
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
- 4. detect the test runner through the signals in `test_runners`, in the order listed there
30
- 5. 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`
31
- 6. 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
32
- 7. 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
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
- 8. record the baseline: total tests, passing, failing with the list, and the command used
37
- 9. 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
38
- 10. apply the fix the report describes
39
- 11. 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`
40
- 12. write a regression test for each testable fix: an incorrect cast, an injection sink, a floating promise, or a missing null check
41
- 13. 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
42
- 14. name the regression test file `<original-file>.reviewer-fixes.test.ts`, following the naming, the placement, and the framework the project already uses
43
- 15. label each test with the issue title, and keep it to the specific fix rather than the whole function
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
- 16. 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
52
- 17. 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
53
- 18. fix a new compiler error in the file just changed or in a file the change affects, then run the compiler again to confirm
54
- 19. revert the last fix in the file and mark the issue `[FIX FAILED: caused type errors]` when the same error survives 2 attempts
55
- 20. repeat steps 9..19 for each file in the work plan
56
- 21. run the linter over the changed files once every file is fixed
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
- 22. 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
64
- 23. run the full test suite and compare it against the baseline through `baseline_verdicts`
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
- 24. read the failure and the stack trace of each regression, and identify the fix that caused it from the diff of that file
69
- 25. fix the regression, or revert the fix that caused it and mark it `[FIX REVERTED: caused test regression in <test>]`
70
- 26. repeat the compiler, the linter, and the full suite until they are clean, with iterations <= 5
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
- 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
- 28. show the operator what a `needs-confirm` architecture finding would change, and describe the reorganization or the interface change
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. 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
+ 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 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
- }
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
- 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.`);
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
+ }