ts-reviewer 3.0.0 → 3.0.1

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
@@ -228,7 +228,7 @@ A check line looks like this:
228
228
  The format is enforced, not merely recommended:
229
229
 
230
230
  ```bash
231
- npm test # typecheck + 5 conformance tests
231
+ npm test # typecheck + conformance and tool tests
232
232
  ```
233
233
 
234
234
  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.
@@ -241,20 +241,23 @@ The test catches a check line that lost its severity, a block the profile does n
241
241
 
242
242
  1. **Discovery** — detects domain flags, maps the project, reads tsconfig.json, detects linter and test runner, and asks once before downloading a missing architecture tool.
243
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/`.
244
+ 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.
245
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.
246
+ 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.
247
+
248
+ 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
249
 
248
250
  ### Fix mode
249
251
 
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
252
+ 1. Validates `code-smells/report.md` and stops before changing code when the report is invalid
253
+ 2. Parses the report as the work plan
254
+ 3. Captures test baseline (runs tests before changes)
255
+ 4. Applies fixes bottom-to-top within each file (so line numbers don't shift)
256
+ 5. Writes regression tests for each testable fix
257
+ 6. Runs `tsc --noEmit` after each file
258
+ 7. Runs the full verification loop: tsc + linter + test suite (max 5 iterations)
259
+ 8. Compares test results with baseline — only fixes regressions it caused
260
+ 9. Updates or deletes the report, keeps the remaining `code-smells/` artifacts, and asks before removing them
258
261
 
259
262
  ## Tips
260
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-reviewer",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Install the TypeScript Code Reviewer skill for Claude Code, Codex, or Antigravity",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,6 +61,7 @@ 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
64
65
 
65
66
  outputs:
66
67
  - `code-smells/report.md` in the project root: the scan report, and the work plan fix reads
@@ -148,21 +149,29 @@ workflow:
148
149
  32. consolidate 3+ identical issues into 1 Recurring Pattern entry
149
150
  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
151
  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
152
+ 35. validate the report against `report_format` after writing it, and treat a `warning:` line as a pre-pass outcome the report cannot correct
153
+ ```bash
154
+ # SKILL is the directory this file was loaded from.
155
+ SKILL=<the directory this file was loaded from>
156
+ node "$SKILL/tools/validate-report.mjs" --repo . --report code-smells/report.md
157
+ ```
158
+ 36. correct every named error and retry with report validation iterations <= 2
159
+ 37. 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
160
+ 38. sort by severity group, then category, then file path, and place `in_diff: true` before pre-existing in a scoped mode
161
+ 39. show the top 10 and summarize the rest in a table when Medium and Low together hold more than 15 issues
162
+ 40. recommend that the operator adds `code-smells/` to `.gitignore`: it holds review artifacts
163
+ 41. read `references/fix-workflow.md` before fix mode executes: it holds the complete protocol
164
+ 42. detect the test runner and run the baseline tests
165
+ 43. fix the issues file by file, and run `tsc --noEmit` after each file
166
+ 44. run the linter and fix the lint errors it reports
167
+ 45. run the full test suite, compare it against the baseline, and fix the regressions
168
+ 46. repeat the compiler, linter, and test verification with verification iterations <= 5
169
+ 47. rerun the Architecture mechanical pre-pass on the fixed tree when Architecture is active
170
+ 48. update `code-smells/report.md`: remove what is fixed, mark what failed
171
+ 49. show the scan summary in auto mode, and ask the operator whether to proceed with the fix
172
+ 50. re-scan after the fix in auto mode with full scan-fix cycles <= 2, and stop when issues persist after the second
173
+ 51. delete `code-smells/report.md` and report success when every issue is fixed
174
+ 52. retain the remaining `code-smells/` artifacts, state what they contain, and remove them only after the operator confirms
166
175
 
167
176
  scope_commands:
168
177
  ```bash
@@ -225,6 +234,7 @@ Test runner: vitest / jest / mocha / node:test / none
225
234
  Files in scope: <N> .ts files (+ <M> context files)
226
235
  Architecture projects: <config and source roots, when active>
227
236
  Architecture tools: <local or approved npx versions, when active>
237
+ Architecture coverage: <successful>/<selected>, when active
228
238
  Mechanical results: <module and edge counts, cycles, orphans, and co-change pairs; name clean zeros>
229
239
  Skipped pre-passes: <tool and reason, or none>
230
240
  Speculative candidates: <names only, or none>
@@ -260,6 +270,11 @@ Output JSONL, one object per line:
260
270
  ```
261
271
 
262
272
  report_format:
273
+ - the `##` sections of the block below are the whole set, in that order, and a heading outside it is a renamed section
274
+ - Discovery, Pre-existing Issues, Architecture Opportunities, Verification, and Generated artifacts are optional, and the other 5 are always present
275
+ - `Total issues` counts the `###` findings, the summary-table rows, and the Architecture Opportunities entries, and the severity breakdown counts the same 3
276
+ - a `Recurring Patterns` row is a pattern rather than an issue, and no row of that table is counted
277
+ - a summary table is read by its `Category` and `Location` columns, and a pattern table by its `Pattern` and `Occurrences` columns
263
278
  ````markdown
264
279
  # TypeScript Code Review Report
265
280
 
@@ -268,6 +283,7 @@ report_format:
268
283
  **Stack:** TypeScript 5.9.x / ES2024 / Node 24 — matches / <deviation>
269
284
  **Scope:** Full / Uncommitted / Branch `x` vs `y` / Last N commits
270
285
  **Files analyzed:** N (+ M context)
286
+ **Architecture coverage:** N/M (when active)
271
287
  **Total issues:** N (X highest, Y high, Z medium, W low)
272
288
  **Severity-boosted:** N (scoped modes only)
273
289
 
@@ -275,6 +291,10 @@ report_format:
275
291
 
276
292
  <2-3 sentences on codebase health and key patterns>
277
293
 
294
+ ## Discovery
295
+
296
+ <optional; the `discovery_summary` block as it was reported>
297
+
278
298
  ## Highest + High Issues
279
299
 
280
300
  ### TITLE — Severity [boosted info if applicable]
@@ -282,7 +302,7 @@ report_format:
282
302
  **Category:** cat | **File:** `path` | **Line:** N | **Auto-fixable:** Yes/No | **New code:** Yes/No
283
303
 
284
304
  ```typescript
285
- // snippet
305
+ // snippet: 3-7 lines copied from the file, within its own length of the stated line
286
306
  ```
287
307
 
288
308
  **Problem:** explanation
@@ -293,13 +313,36 @@ report_format:
293
313
 
294
314
  ## Medium Issues
295
315
  ## Low Issues
316
+
317
+ | Issue | Category | Location | Fix |
318
+ |---|---|---|---|
319
+ | title | cat | `path:N` | recommendation |
320
+
296
321
  ## Recurring Patterns
322
+
323
+ | Pattern | Occurrences | Severity treatment |
324
+ |---|---|---|
325
+ | name | N | what happened to its members |
326
+
297
327
  ## Config Issues
328
+
329
+ <findings in the shape above, or prose naming where the config findings already sit>
330
+
298
331
  ## Pre-existing Issues (scoped modes only)
299
332
 
300
333
  ## Architecture Opportunities
301
334
 
302
- <1 entry per candidate, in the shape the `report_format` block of references/architecture.md gives>
335
+ <optional; 1 entry per candidate, in the shape the `report_format` block of references/architecture.md gives>
336
+
337
+ ## Verification
338
+
339
+ | Check | Result |
340
+ |---|---|
341
+ | the command that ran | what it reported |
342
+
343
+ ## Generated artifacts
344
+
345
+ - `<file under code-smells/>` — what it holds
303
346
 
304
347
  ---
305
348
  ````
@@ -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
+ }