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 +14 -11
- package/package.json +1 -1
- package/ts-reviewer/SKILL.md +60 -17
- package/ts-reviewer/references/architecture.md +16 -14
- package/ts-reviewer/references/fix-workflow.md +41 -35
- package/ts-reviewer/tools/run-cruise.mjs +28 -15
- package/ts-reviewer/tools/validate-report.mjs +324 -0
package/README.md
CHANGED
|
@@ -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 +
|
|
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
|
|
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.
|
|
251
|
-
2.
|
|
252
|
-
3.
|
|
253
|
-
4.
|
|
254
|
-
5.
|
|
255
|
-
6.
|
|
256
|
-
7.
|
|
257
|
-
8.
|
|
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
package/ts-reviewer/SKILL.md
CHANGED
|
@@ -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.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
58
|
-
10.
|
|
59
|
-
11.
|
|
60
|
-
12.
|
|
61
|
-
13.
|
|
62
|
-
14.
|
|
63
|
-
15.
|
|
64
|
-
16.
|
|
65
|
-
17.
|
|
66
|
-
18.
|
|
67
|
-
19.
|
|
68
|
-
20.
|
|
69
|
-
21.
|
|
57
|
+
9. state Architecture coverage as successful projects over selected projects, and list each failed config with its bounded diagnostic
|
|
58
|
+
10. give the semantic pass `code-smells/metrics.md` rather than a raw graph
|
|
59
|
+
11. cross each co-change pair with the graph: an edge is weak evidence, while no edge across directories is an implicit-contract candidate
|
|
60
|
+
12. read ESLint boundary rules from linter output without converting them, since the linter already executed those rules
|
|
61
|
+
13. consolidate all violating edges for 1 declared rule into 1 candidate, after removing declared exceptions
|
|
62
|
+
14. trace `min(declared entry points, 3)` scenarios, ranked by the number of modules each entry point reaches
|
|
63
|
+
15. grep for feature-module imports inside the `shared`, `common`, `core`, and `utils` directories, which is the inverted direction
|
|
64
|
+
16. apply the deletion test only to a module whose whole export set has 1 importer and that wraps or re-exports another module
|
|
65
|
+
17. treat an interface or type re-exported unchanged through 3+ files as a pass-through chain
|
|
66
|
+
18. inspect the top 20 modules and folders by fan-in for mixed concepts, and use folder instability only to rank layer checks
|
|
67
|
+
19. name the module owning the rule and the module owning the state for each candidate, and mark the candidate speculative when neither is identifiable
|
|
68
|
+
20. keep each collapsed project overview, then create 2..3 focused Mermaid diagrams tied to the top findings after triage
|
|
69
|
+
21. generate SVG only after `dot -V` succeeds, and keep Mermaid when Graphviz is unavailable
|
|
70
|
+
22. run these checks rather than reporting a cycle, hub, orphan, co-change pair, or Knip entry directly
|
|
70
71
|
```bash
|
|
71
72
|
# SKILL is the directory this file was loaded from; the main workflow approved missing tools before this pass.
|
|
72
73
|
SKILL=<the directory this file was loaded from>
|
|
@@ -137,6 +138,7 @@ forbidden_behaviors:
|
|
|
137
138
|
- do not leave a new structure standing beside the old one: every proposal names the step deleting what it replaces
|
|
138
139
|
- do not propose a change contradicting a decision under `docs/adr/` silently: name the ADR and the conflict, and leave the decision to the operator
|
|
139
140
|
- do not report architecture candidates without naming 1 top recommendation and the reason it goes first
|
|
141
|
+
- do not claim the repository has no cycles, orphans, or coupling issues below 100% Architecture coverage: name the successful projects
|
|
140
142
|
- do not add a dependency as the fix for 1 import rule: use `package.json#exports`, the project's linter, or the workspace layout
|
|
141
143
|
- do not treat an approved `npx` graph tool used during review as a project dependency: it changes neither `package.json` nor the lockfile
|
|
142
144
|
|
|
@@ -190,6 +192,6 @@ report_format:
|
|
|
190
192
|
- **Migration:** for deepening, merge, ownership move, or delete; prefactor | vertical slices | expand-contract, and the deletion step
|
|
191
193
|
- **Benefits:** locality gained, leverage gained, test impact
|
|
192
194
|
- **Trade-offs:** what gets harder, what is genuinely uncertain
|
|
193
|
-
- **Fixability:** auto | needs-confirm | report-only
|
|
195
|
+
- **Fixability:** auto | needs-confirm | report-only, and report-only for enforce, since a new rule fails the build before the edges it names are gone
|
|
194
196
|
- **Top recommendation:** on exactly 1 entry in the report, the reason this candidate goes first
|
|
195
197
|
```
|
|
@@ -10,12 +10,18 @@ read_first:
|
|
|
10
10
|
- `code-smells/report.md` exists in the project root, and a missing report stops the run with the error "No scan report found at code-smells/report.md. Run scan first; the report path changed in v3."
|
|
11
11
|
- the project is inside a git repository, so a change can be reverted
|
|
12
12
|
- recommend that the operator commits or stashes uncommitted work before the fix runs, which leaves `git diff` and `git checkout -- .` usable to review and revert
|
|
13
|
-
- the baseline taken in step
|
|
13
|
+
- the baseline taken in step 9 decides what counts as a regression: a test failing before the fixes is pre-existing and stays out of the work
|
|
14
14
|
|
|
15
15
|
workflow:
|
|
16
|
-
1.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
1. validate the report and stop without changing code when it reports an error, since a `warning:` line names a pre-pass outcome rather than a defect of the report
|
|
17
|
+
```bash
|
|
18
|
+
# SKILL is the directory this file was loaded from.
|
|
19
|
+
SKILL=<the directory this file was loaded from>
|
|
20
|
+
node "$SKILL/tools/validate-report.mjs" --repo . --report code-smells/report.md
|
|
21
|
+
```
|
|
22
|
+
2. read `code-smells/report.md` and extract every issue into a work list
|
|
23
|
+
3. group the issues by file path, and sort them by line number descending inside each file, so a fix lower in the file does not shift the lines above it
|
|
24
|
+
4. build the work plan
|
|
19
25
|
```
|
|
20
26
|
File: src/auth/token.ts
|
|
21
27
|
- Line 142: [High] Unsafe `as` cast — type-safety
|
|
@@ -26,21 +32,21 @@ File: src/api/handler.ts
|
|
|
26
32
|
- Line 201: [Highest] eval() with user input — security
|
|
27
33
|
- Line 55: [Medium] Missing exhaustive check — type-safety
|
|
28
34
|
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
5. detect the test runner through the signals in `test_runners`, in the order listed there
|
|
36
|
+
6. detect the test file convention: `*.test.ts` against `*.spec.ts`, a `__tests__/` directory against co-location, and the framework imports, `describe` and `it` against `test`
|
|
37
|
+
7. warn the operator with "No test runner detected. Fixes will be applied without test verification." when no test infrastructure is found, skip every test step, and still run the compiler and the linter
|
|
38
|
+
8. run the full test suite before any change, and write the log to the OS temp directory, or to the project root when temp is unavailable
|
|
33
39
|
```bash
|
|
34
40
|
<test_command> 2>&1 | tee "$TMPDIR/ts-reviewer-baseline.log"
|
|
35
41
|
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
9. record the baseline: total tests, passing, failing with the list, and the command used
|
|
43
|
+
10. process 1 file at a time, reading its current state before each fix, since an earlier fix in the same file has shifted the lines
|
|
44
|
+
11. apply the fix the report describes
|
|
45
|
+
12. update every reference to a renamed or replaced symbol across the codebase, its imports and its usages, when the fix replaces a pattern such as `enum` with `as const`
|
|
46
|
+
13. write a regression test for each testable fix: an incorrect cast, an injection sink, a floating promise, or a missing null check
|
|
47
|
+
14. skip the regression test for an untestable fix: a naming or formatting change, a config flag, a modernization that keeps the behaviour, or a complexity split
|
|
48
|
+
15. name the regression test file `<original-file>.reviewer-fixes.test.ts`, following the naming, the placement, and the framework the project already uses
|
|
49
|
+
16. label each test with the issue title, and keep it to the specific fix rather than the whole function
|
|
44
50
|
```typescript
|
|
45
51
|
describe('ts-reviewer fixes: src/auth/token.ts', () => {
|
|
46
52
|
it('should not use unsafe cast for token payload (type-safety)', () => {
|
|
@@ -48,40 +54,40 @@ describe('ts-reviewer fixes: src/auth/token.ts', () => {
|
|
|
48
54
|
});
|
|
49
55
|
});
|
|
50
56
|
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
17. tell the operator that the `.reviewer-fixes` files are candidates to rename and merge into the existing suites: the naming is a handoff convention and not a permanent home
|
|
58
|
+
18. run `npx tsc --noEmit 2>&1` after each file, and use `--incremental` or check every 3..5 files instead where the work plan > 30 files and a full typecheck is slow
|
|
59
|
+
19. fix a new compiler error in the file just changed or in a file the change affects, then run the compiler again to confirm
|
|
60
|
+
20. revert the last fix in the file and mark the issue `[FIX FAILED: caused type errors]` when the same error survives 2 attempts
|
|
61
|
+
21. repeat steps 10..20 for each file in the work plan
|
|
62
|
+
22. run the linter over the changed files once every file is fixed
|
|
57
63
|
```bash
|
|
58
64
|
# ESLint
|
|
59
65
|
npx eslint [changed_files] --format json 2>/dev/null
|
|
60
66
|
# or Biome
|
|
61
67
|
npx biome check [changed_files] --reporter json 2>/dev/null
|
|
62
68
|
```
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
23. apply `npx eslint --fix [files]` or `npx biome check --fix [files]`, fix the rest by hand, and run the linter again to confirm it is clean
|
|
70
|
+
24. run the full test suite and compare it against the baseline through `baseline_verdicts`
|
|
65
71
|
```bash
|
|
66
72
|
<test_command> 2>&1 | tee "$TMPDIR/ts-reviewer-postfix.log"
|
|
67
73
|
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
25. read the failure and the stack trace of each regression, and identify the fix that caused it from the diff of that file
|
|
75
|
+
26. fix the regression, or revert the fix that caused it and mark it `[FIX REVERTED: caused test regression in <test>]`
|
|
76
|
+
27. repeat the compiler, the linter, and the full suite until they are clean, with iterations <= 5
|
|
71
77
|
```
|
|
72
78
|
Iteration 1: Fixed 12/15 issues. 2 test regressions found.
|
|
73
79
|
Iteration 2: Fixed 2 regressions. 1 new tsc error.
|
|
74
80
|
Iteration 3: Fixed tsc error. All tests pass. Clean.
|
|
75
81
|
-> Done at iteration 3.
|
|
76
82
|
```
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
28. stop fixing once the fifth iteration ends with the compiler, the linter, or the suite still not clean, leave the code as it stands, and add a Stabilization section to the report listing the unresolved regressions for the operator
|
|
84
|
+
29. show the operator what a `needs-confirm` architecture finding would change, and describe the reorganization or the interface change
|
|
85
|
+
30. apply an approved `needs-confirm` finding through the same file-by-file compiler loop, and mark a rejected one `[SKIPPED: user rejected]`
|
|
86
|
+
31. rerun Knip, dependency-cruiser, and co-change through the Architecture workflow when the report contains architecture findings
|
|
87
|
+
32. delete `code-smells/report.md` when every issue is fixed, and tell the operator "All N issues fixed. Report deleted. Run scan again to verify."
|
|
88
|
+
33. keep `code-smells/` after deleting the report, state what it contains, and ask before removing the directory
|
|
89
|
+
34. rewrite `code-smells/report.md` in the shape of `report_format` when any issue remains, carrying both what was fixed and what was not
|
|
90
|
+
35. leave every change in the working tree, unstaged and uncommitted, including the new regression test files
|
|
85
91
|
|
|
86
92
|
forbidden_behaviors:
|
|
87
93
|
- do not commit and do not stage: the operator reviews and decides
|
|
@@ -84,8 +84,13 @@ const nodePath = existsSync(path.join(projectModules, "typescript"))
|
|
|
84
84
|
: process.env.NODE_PATH || projectModules;
|
|
85
85
|
const localCruise = path.join(projectModules, ".bin", process.platform === "win32" ? "depcruise.cmd" : "depcruise");
|
|
86
86
|
|
|
87
|
+
// `--ts-config` is absolute on purpose, and a relative path here is not equivalent even though the
|
|
88
|
+
// cwd is the repository: TypeScript resolves the config's own `include` globs against the cwd when
|
|
89
|
+
// the path is relative, so a nested `templates/x/tsconfig.json` with `include: ["src"]` looks for
|
|
90
|
+
// `<repo>/src` and exits TS18003, no inputs found. Every nested project on the first real run failed
|
|
91
|
+
// this way, and every project that passed sat at the repository root.
|
|
87
92
|
function cruise(label, extraArgs, project) {
|
|
88
|
-
const toolArgs = ["--config", ruleset, "--ts-config", project.config, ...extraArgs, ...project.sourceRoots];
|
|
93
|
+
const toolArgs = ["--config", ruleset, "--ts-config", path.resolve(repo, project.config), ...extraArgs, ...project.sourceRoots];
|
|
89
94
|
const args = existsSync(localCruise) ? toolArgs : ["-y", TOOL, ...toolArgs];
|
|
90
95
|
const shell = process.platform === "win32";
|
|
91
96
|
const started = Date.now();
|
|
@@ -149,9 +154,14 @@ const orphans = new Set();
|
|
|
149
154
|
const crossDirectory = new Set();
|
|
150
155
|
const violations = new Set();
|
|
151
156
|
const projectRows = [];
|
|
157
|
+
// A project counts as covered when it produced a graph, and nothing else: the graph is what the
|
|
158
|
+
// semantic pass reads, while a failed mermaid or affected step costs a diagram and not an analysis.
|
|
159
|
+
// One predicate, so metrics.md, cruise-summary.md, and the report's coverage cannot disagree.
|
|
152
160
|
for (const { project, graph } of results) {
|
|
161
|
+
const modules = graph?.modules ?? [];
|
|
162
|
+
projectRows.push([project.config, graph ? "ok" : "failed", modules.length,
|
|
163
|
+
graph?.summary?.totalDependenciesCruised ?? 0, modules.filter((m) => m.orphan).length]);
|
|
153
164
|
if (!graph) continue;
|
|
154
|
-
const modules = graph.modules ?? [];
|
|
155
165
|
const roots = [...project.sourceRoots].sort((a, b) => b.length - a.length);
|
|
156
166
|
const area = (source) => {
|
|
157
167
|
const root = roots.find((candidate) => source === candidate || source.startsWith(`${candidate}/`));
|
|
@@ -178,13 +188,12 @@ for (const { project, graph } of results) {
|
|
|
178
188
|
for (const violation of graph.summary?.violations ?? []) {
|
|
179
189
|
violations.add(`${violation.rule?.name ?? "unnamed"} | ${violation.from ?? ""} | ${violation.to ?? ""}`);
|
|
180
190
|
}
|
|
181
|
-
projectRows.push([project.config, modules.length, graph.summary?.totalDependenciesCruised ?? 0, modules.filter((m) => m.orphan).length]);
|
|
182
191
|
}
|
|
183
192
|
const topModules = [...moduleRows.values()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 20);
|
|
184
193
|
const topFolders = [...folderRows.values()].sort((a, b) => b[2] - a[2] || a[0].localeCompare(b[0])).slice(0, 20);
|
|
185
194
|
const metricsLines = [
|
|
186
195
|
"# Architecture metrics", "",
|
|
187
|
-
"## Projects", "", ...table(["Project", "Modules", "Edges", "Orphans"], projectRows), "",
|
|
196
|
+
"## Projects", "", ...table(["Project", "Status", "Modules", "Edges", "Orphans"], projectRows), "",
|
|
188
197
|
"## Top modules by fan-in", "", ...table(["Module", "Fan-in", "Fan-out", "Instability"], topModules), "",
|
|
189
198
|
"## Top folders by fan-in", "", ...table(["Folder", "Modules", "Afferent", "Efferent", "Instability"], topFolders), "",
|
|
190
199
|
"## Cycles", "", ...(cycles.size ? [...cycles].slice(0, 20).map((edge) => `- ${edge}`) : ["- none"]), "",
|
|
@@ -195,18 +204,22 @@ const metricsLines = [
|
|
|
195
204
|
writeFileSync(path.join(outDir, "metrics.md"), metricsLines.join("\n"), "utf8");
|
|
196
205
|
|
|
197
206
|
// ── summary ─────────────────────────────────────────────────────────────────
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
const summaryRows = [];
|
|
208
|
+
let successful = 0;
|
|
209
|
+
for (const { project, graph, runs } of results) {
|
|
210
|
+
if (graph) successful++;
|
|
211
|
+
const failed = runs.filter((run) => run.reading.startsWith("failed"));
|
|
212
|
+
const diagnostics = [...new Set(failed.flatMap((run) => `${run.stderr}\n${run.stdout}`.split(/\r?\n/))
|
|
213
|
+
.map((line) => line.trim()).filter(Boolean))].slice(0, 3).join(" / ").slice(0, 500);
|
|
214
|
+
summaryRows.push([project.config, project.sourceRoots.join(", "), graph ? "ok" : "failed",
|
|
215
|
+
failed.map((run) => run.label.split(" ")[1]).join(", ") || "none", diagnostics || "none"]);
|
|
206
216
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
:
|
|
217
|
+
const failures = results.length - successful;
|
|
218
|
+
const lines = [`# Cruise summary`, ``, `Ruleset: ${rulesFrom}`, ``,
|
|
219
|
+
`Coverage: ${successful}/${results.length} projects`, ``,
|
|
220
|
+
...table(["Project", "Source roots", "Status", "Failed steps", "Diagnostics"], summaryRows), ``, failures
|
|
221
|
+
? `**${failures} project(s) failed.** Record them as skipped and do not read their empty graphs as an absence of coupling.`
|
|
222
|
+
: `All projects produced graphs. Zero findings from these runs are reportable as mechanical zeros.`];
|
|
210
223
|
|
|
211
224
|
writeFileSync(path.join(outDir, "cruise-summary.md"), lines.join("\n") + "\n", "utf8");
|
|
212
225
|
console.log(lines.join("\n"));
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Checks code-smells/report.md against the `report_format` block of SKILL.md, and nothing beyond it:
|
|
3
|
+
// the spec is the contract, this script is only the reader that enforces it.
|
|
4
|
+
//
|
|
5
|
+
// node validate-report.mjs --repo . --report code-smells/report.md
|
|
6
|
+
//
|
|
7
|
+
// An error is a defect of the report text, which rewriting the report can correct. A warning names
|
|
8
|
+
// an outcome of the mechanical pre-pass, which it cannot — a missing diagram is not a bad report,
|
|
9
|
+
// and failing the run over one would burn the two validation attempts the workflow allows.
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const DOMAINS = new Set([
|
|
14
|
+
"Type Safety", "Security", "Async Patterns", "Modernization", "Code Quality", "Config",
|
|
15
|
+
"Boundary Validation", "Error Handling", "Dependency Hygiene", "Architecture",
|
|
16
|
+
]);
|
|
17
|
+
const SEVERITIES = ["Highest", "High", "Medium", "Low"];
|
|
18
|
+
// The whole section set, in the order report_format gives it. Anything else is a renamed section.
|
|
19
|
+
const SECTIONS = [
|
|
20
|
+
{ name: "Summary", required: true },
|
|
21
|
+
{ name: "Discovery", required: false },
|
|
22
|
+
{ name: "Highest + High Issues", required: true },
|
|
23
|
+
{ name: "Medium Issues", required: true },
|
|
24
|
+
{ name: "Low Issues", required: true },
|
|
25
|
+
{ name: "Recurring Patterns", required: true },
|
|
26
|
+
{ name: "Config Issues", required: true },
|
|
27
|
+
{ name: "Pre-existing Issues (scoped modes only)", required: false },
|
|
28
|
+
{ name: "Architecture Opportunities", required: false },
|
|
29
|
+
{ name: "Verification", required: false },
|
|
30
|
+
{ name: "Generated artifacts", required: false },
|
|
31
|
+
];
|
|
32
|
+
const ISSUE_SECTIONS = [
|
|
33
|
+
"Highest + High Issues", "Medium Issues", "Low Issues", "Recurring Patterns", "Config Issues",
|
|
34
|
+
"Pre-existing Issues (scoped modes only)",
|
|
35
|
+
];
|
|
36
|
+
const SECTION_SEVERITY = { "Medium Issues": "Medium", "Low Issues": "Low" };
|
|
37
|
+
const COMMON_ARCH_FIELDS = [
|
|
38
|
+
"Confidence", "Files", "Problem", "Evidence", "Change type", "Proposed change",
|
|
39
|
+
"Test strategy", "Benefits", "Trade-offs", "Fixability",
|
|
40
|
+
];
|
|
41
|
+
const CONDITIONAL_ARCH_FIELDS = {
|
|
42
|
+
deepening: ["Interface shape", "Dependency category", "Migration"],
|
|
43
|
+
merge: ["Interface shape", "Migration"],
|
|
44
|
+
"ownership move": ["Migration"],
|
|
45
|
+
delete: ["Migration"],
|
|
46
|
+
enforce: ["Rule"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const value = (name, fallback) => {
|
|
50
|
+
const at = process.argv.indexOf(`--${name}`);
|
|
51
|
+
return at >= 0 && process.argv[at + 1] ? process.argv[at + 1] : fallback;
|
|
52
|
+
};
|
|
53
|
+
const repo = path.resolve(value("repo", "."));
|
|
54
|
+
const reportArg = value("report", "code-smells/report.md");
|
|
55
|
+
const report = path.isAbsolute(reportArg) ? reportArg : path.resolve(repo, reportArg);
|
|
56
|
+
if (!existsSync(report)) {
|
|
57
|
+
console.error(`${path.relative(repo, report) || report}:1: report does not exist`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lines = readFileSync(report, "utf8").split(/\r?\n/);
|
|
62
|
+
const errors = [];
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const fail = (line, message) => errors.push({ line: Math.max(1, line), message });
|
|
65
|
+
const warn = (line, message) => warnings.push({ line: Math.max(1, line), message });
|
|
66
|
+
const insideRepo = (target) => {
|
|
67
|
+
const relative = path.relative(repo, target);
|
|
68
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
69
|
+
};
|
|
70
|
+
const checkRepoPath = (raw, line) => {
|
|
71
|
+
if (path.isAbsolute(raw)) {
|
|
72
|
+
fail(line, `path must be relative: ${raw}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const target = path.resolve(repo, raw);
|
|
76
|
+
if (!insideRepo(target)) { fail(line, `path leaves the repository: ${raw}`); return null; }
|
|
77
|
+
if (!existsSync(target)) { fail(line, `path does not exist: ${raw}`); return null; }
|
|
78
|
+
return target;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const firstContent = lines.findIndex((line) => line.trim());
|
|
82
|
+
if (lines[firstContent] !== "# TypeScript Code Review Report") fail(firstContent + 1, "expected exact report title");
|
|
83
|
+
|
|
84
|
+
// ── sections ────────────────────────────────────────────────────────────────
|
|
85
|
+
const h2s = lines.flatMap((line, index) => {
|
|
86
|
+
const match = line.match(/^## (.+)$/);
|
|
87
|
+
return match ? [{ name: match[1], index }] : [];
|
|
88
|
+
});
|
|
89
|
+
const actualSections = h2s.map(({ name }) => name);
|
|
90
|
+
const known = new Set(SECTIONS.map((section) => section.name));
|
|
91
|
+
const firstHeading = (h2s[0]?.index ?? 0) + 1;
|
|
92
|
+
for (const { name, index } of h2s) if (!known.has(name)) fail(index + 1, `unknown section: ${name}`);
|
|
93
|
+
for (const { name, required } of SECTIONS) {
|
|
94
|
+
if (required && !actualSections.includes(name)) fail(firstHeading, `missing section: ${name}`);
|
|
95
|
+
}
|
|
96
|
+
const present = actualSections.filter((name) => known.has(name));
|
|
97
|
+
const expected = SECTIONS.map(({ name }) => name).filter((name) => actualSections.includes(name));
|
|
98
|
+
if (present.join("\n") !== expected.join("\n")) {
|
|
99
|
+
fail(firstHeading, `sections are out of order or repeated; expected: ${expected.join(" > ")}`);
|
|
100
|
+
}
|
|
101
|
+
const sections = new Map(h2s.map((heading, index) => [heading.name, {
|
|
102
|
+
start: heading.index + 1,
|
|
103
|
+
end: h2s[index + 1]?.index ?? lines.length,
|
|
104
|
+
line: heading.index + 1,
|
|
105
|
+
}]));
|
|
106
|
+
|
|
107
|
+
const metadata = ["Project", "Reviewed", "Stack", "Scope", "Files analyzed", "Total issues"];
|
|
108
|
+
for (const name of metadata) {
|
|
109
|
+
const matches = lines.flatMap((line, index) => line.startsWith(`**${name}:**`) ? [index] : []);
|
|
110
|
+
if (matches.length !== 1) fail(matches[0] + 1 || 1, `metadata ${name} must appear exactly once`);
|
|
111
|
+
}
|
|
112
|
+
const totalLine = lines.findIndex((line) => line.startsWith("**Total issues:**"));
|
|
113
|
+
const totalMatch = lines[totalLine]?.match(/^\*\*Total issues:\*\* (\d+) \((\d+) highest, (\d+) high, (\d+) medium, (\d+) low\)$/);
|
|
114
|
+
if (!totalMatch) fail(totalLine + 1, "Total issues has an invalid shape");
|
|
115
|
+
|
|
116
|
+
// ── findings ────────────────────────────────────────────────────────────────
|
|
117
|
+
// A table in an issue section is read by its header: the summary table of workflow step 39 carries
|
|
118
|
+
// issues, and the Recurring Patterns table carries patterns whose members are counted where they sit.
|
|
119
|
+
const cells = (line) => line.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
120
|
+
const isSummaryTable = (header) => header.includes("Category") && header.includes("Location");
|
|
121
|
+
const isPatternTable = (header) => header.includes("Pattern") && header.includes("Occurrences");
|
|
122
|
+
|
|
123
|
+
const counts = new Map(SEVERITIES.map((severity) => [severity, 0]));
|
|
124
|
+
let issueCount = 0;
|
|
125
|
+
const duplicates = new Map();
|
|
126
|
+
const headingPattern = /^### .+ — (Highest|High|Medium|Low)(?: \[.+\])?$/;
|
|
127
|
+
|
|
128
|
+
for (const sectionName of ISSUE_SECTIONS) {
|
|
129
|
+
const section = sections.get(sectionName);
|
|
130
|
+
if (!section) continue;
|
|
131
|
+
const headings = [];
|
|
132
|
+
for (let index = section.start; index < section.end; index++) {
|
|
133
|
+
if (lines[index].startsWith("### ")) headings.push(index);
|
|
134
|
+
}
|
|
135
|
+
for (let at = 0; at < headings.length; at++) {
|
|
136
|
+
const index = headings[at];
|
|
137
|
+
const end = headings[at + 1] ?? section.end;
|
|
138
|
+
const heading = lines[index].match(headingPattern);
|
|
139
|
+
if (!heading) {
|
|
140
|
+
fail(index + 1, "finding heading must end with an exact severity");
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const severity = heading[1];
|
|
144
|
+
if (sectionName === "Highest + High Issues" && !["Highest", "High"].includes(severity)) fail(index + 1, "severity does not match its section");
|
|
145
|
+
if (sectionName === "Medium Issues" && severity !== "Medium") fail(index + 1, "severity does not match its section");
|
|
146
|
+
if (sectionName === "Low Issues" && severity !== "Low") fail(index + 1, "severity does not match its section");
|
|
147
|
+
counts.set(severity, counts.get(severity) + 1);
|
|
148
|
+
issueCount++;
|
|
149
|
+
|
|
150
|
+
const block = lines.slice(index + 1, end);
|
|
151
|
+
const metaAt = block.findIndex((line) => line.startsWith("**Category:**"));
|
|
152
|
+
const meta = block[metaAt]?.match(/^\*\*Category:\*\* (.+?) \| \*\*File:\*\* `([^`]+)` \| \*\*Line:\*\* (\d+) \| \*\*Auto-fixable:\*\* (Yes|No) \| \*\*New code:\*\* (Yes|No)$/);
|
|
153
|
+
if (!meta) {
|
|
154
|
+
fail(index + 1, "finding metadata is missing or has the wrong field names");
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const categories = meta[1].split(/\s*(?:,|&)\s*/);
|
|
158
|
+
for (const category of categories) {
|
|
159
|
+
if (!DOMAINS.has(category)) fail(index + metaAt + 2, `unknown category: ${category}`);
|
|
160
|
+
}
|
|
161
|
+
const source = checkRepoPath(meta[2], index + metaAt + 2);
|
|
162
|
+
const sourceLine = Number(meta[3]);
|
|
163
|
+
// Workflow step 31 merges what two domains raise on one file and line into one entry naming
|
|
164
|
+
// both categories, so a second entry on that line is the merge that did not happen.
|
|
165
|
+
const key = `${meta[2]}:${sourceLine}`.toLowerCase();
|
|
166
|
+
if (duplicates.has(key)) fail(index + 1, `duplicate finding; first entry is at line ${duplicates.get(key)}`);
|
|
167
|
+
else duplicates.set(key, index + 1);
|
|
168
|
+
if (!block.some((line) => line.startsWith("**Problem:** "))) fail(index + 1, "finding has no Problem field");
|
|
169
|
+
if (!block.some((line) => line.startsWith("**Fix:** "))) fail(index + 1, "finding has no Fix field");
|
|
170
|
+
|
|
171
|
+
const fence = block.findIndex((line) => /^```[^`]*$/.test(line));
|
|
172
|
+
const fenceEnd = fence >= 0 ? block.findIndex((line, at) => at > fence && line === "```") : -1;
|
|
173
|
+
if (fence < 0 || fenceEnd < 0) {
|
|
174
|
+
fail(index + 1, "finding has no fenced snippet");
|
|
175
|
+
} else if (source && statSync(source).isFile()) {
|
|
176
|
+
const sourceLines = readFileSync(source, "utf8").split(/\r?\n/);
|
|
177
|
+
if (sourceLine < 1 || sourceLine > sourceLines.length) fail(index + metaAt + 2, `line is outside the file: ${sourceLine}`);
|
|
178
|
+
else {
|
|
179
|
+
// A snippet runs 3-7 lines and the stated line sits anywhere inside it, so the window is the
|
|
180
|
+
// snippet's own length on either side, and any one of its lines anchors it to the file.
|
|
181
|
+
const snippet = block.slice(fence + 1, fenceEnd).map((line) => line.trim()).filter(Boolean);
|
|
182
|
+
const reach = snippet.length;
|
|
183
|
+
const window = new Set(sourceLines.slice(Math.max(0, sourceLine - 1 - reach), sourceLine + reach).map((line) => line.trim()));
|
|
184
|
+
if (reach && !snippet.some((line) => window.has(line))) {
|
|
185
|
+
fail(index + fence + 2, `snippet matches no line within ${reach} line(s) of line ${sourceLine}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let counting = false;
|
|
192
|
+
let fenced = false;
|
|
193
|
+
for (let index = section.start; index < section.end; index++) {
|
|
194
|
+
if (lines[index].startsWith("```")) { fenced = !fenced; continue; }
|
|
195
|
+
if (fenced) continue;
|
|
196
|
+
if (/^\|[-:| ]+\|$/.test(lines[index])) {
|
|
197
|
+
const header = cells(lines[index - 1] ?? "");
|
|
198
|
+
counting = isSummaryTable(header);
|
|
199
|
+
if (!counting && !isPatternTable(header)) {
|
|
200
|
+
fail(index, "table header must carry Category and Location, or Pattern and Occurrences");
|
|
201
|
+
} else if (counting && !SECTION_SEVERITY[sectionName]) {
|
|
202
|
+
fail(index, "a summary table belongs in Medium Issues or Low Issues");
|
|
203
|
+
counting = false;
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (lines[index].startsWith("|") && lines[index].endsWith("|")) {
|
|
208
|
+
if (!counting) continue;
|
|
209
|
+
const severity = SECTION_SEVERITY[sectionName];
|
|
210
|
+
counts.set(severity, counts.get(severity) + 1);
|
|
211
|
+
issueCount++;
|
|
212
|
+
} else if (lines[index].trim()) counting = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── architecture opportunities ──────────────────────────────────────────────
|
|
217
|
+
const architecture = sections.get("Architecture Opportunities");
|
|
218
|
+
let architectureEntries = 0;
|
|
219
|
+
let topRecommendations = 0;
|
|
220
|
+
if (architecture) {
|
|
221
|
+
const headings = [];
|
|
222
|
+
for (let index = architecture.start; index < architecture.end; index++) if (lines[index].startsWith("### ")) headings.push(index);
|
|
223
|
+
if (!headings.length) fail(architecture.line, "Architecture Opportunities has no entries");
|
|
224
|
+
for (let at = 0; at < headings.length; at++) {
|
|
225
|
+
const index = headings[at];
|
|
226
|
+
const end = headings[at + 1] ?? architecture.end;
|
|
227
|
+
const heading = lines[index].match(headingPattern);
|
|
228
|
+
if (!heading) { fail(index + 1, "architecture heading must end with an exact severity"); continue; }
|
|
229
|
+
counts.set(heading[1], counts.get(heading[1]) + 1);
|
|
230
|
+
issueCount++;
|
|
231
|
+
architectureEntries++;
|
|
232
|
+
const fields = new Map();
|
|
233
|
+
for (let line = index + 1; line < end; line++) {
|
|
234
|
+
const match = lines[line].match(/^- \*\*([^*]+):\*\* (.+)$/);
|
|
235
|
+
if (!match) continue;
|
|
236
|
+
if (fields.has(match[1])) fail(line + 1, `duplicate architecture field: ${match[1]}`);
|
|
237
|
+
fields.set(match[1], { value: match[2], line: line + 1 });
|
|
238
|
+
}
|
|
239
|
+
for (const field of COMMON_ARCH_FIELDS) if (!fields.has(field)) fail(index + 1, `missing architecture field: ${field}`);
|
|
240
|
+
const confidence = fields.get("Confidence");
|
|
241
|
+
if (confidence && !["strong", "worth-exploring"].includes(confidence.value)) fail(confidence.line, `invalid Confidence: ${confidence.value}`);
|
|
242
|
+
const change = fields.get("Change type");
|
|
243
|
+
const conditional = change ? CONDITIONAL_ARCH_FIELDS[change.value] : null;
|
|
244
|
+
if (change && !conditional) fail(change.line, `invalid Change type: ${change.value}`);
|
|
245
|
+
for (const field of conditional ?? []) if (!fields.has(field)) fail(index + 1, `missing architecture field: ${field}`);
|
|
246
|
+
const allowed = new Set([...COMMON_ARCH_FIELDS, ...(conditional ?? []), "Top recommendation"]);
|
|
247
|
+
for (const [field, data] of fields) if (!allowed.has(field)) fail(data.line, `field does not apply to ${change?.value ?? "this change"}: ${field}`);
|
|
248
|
+
const fixability = fields.get("Fixability");
|
|
249
|
+
if (fixability && !["auto", "needs-confirm", "report-only"].includes(fixability.value)) fail(fixability.line, `invalid Fixability: ${fixability.value}`);
|
|
250
|
+
if (change?.value === "enforce" && fixability?.value !== "report-only") fail(fixability?.line ?? index + 1, "enforce changes must be report-only");
|
|
251
|
+
if (fields.has("Top recommendation")) topRecommendations++;
|
|
252
|
+
const files = fields.get("Files");
|
|
253
|
+
if (files) for (const file of files.value.split(",").map((item) => item.trim().replaceAll("`", ""))) checkRepoPath(file, files.line);
|
|
254
|
+
}
|
|
255
|
+
if (architectureEntries && topRecommendations !== 1) fail(architecture.line, "architecture findings require exactly one Top recommendation");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (totalMatch) {
|
|
259
|
+
const stated = totalMatch.slice(2).map(Number);
|
|
260
|
+
if (Number(totalMatch[1]) !== issueCount) fail(totalLine + 1, `Total issues says ${totalMatch[1]}, found ${issueCount}`);
|
|
261
|
+
SEVERITIES.forEach((severity, index) => {
|
|
262
|
+
if (stated[index] !== counts.get(severity)) fail(totalLine + 1, `${severity} count says ${stated[index]}, found ${counts.get(severity)}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── linked artifacts ────────────────────────────────────────────────────────
|
|
267
|
+
const reportDir = path.dirname(report);
|
|
268
|
+
for (let index = 0; index < lines.length; index++) {
|
|
269
|
+
for (const match of lines[index].matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
|
|
270
|
+
let link;
|
|
271
|
+
try { link = decodeURI(match[1].replace(/^<|>$/g, "")).split("#")[0]; }
|
|
272
|
+
catch { fail(index + 1, `artifact link has invalid escaping: ${match[1]}`); continue; }
|
|
273
|
+
if (/^(?:https?:|mailto:|#)/.test(link)) continue;
|
|
274
|
+
if (!/\.(?:json|mmd|md|svg)$/i.test(link)) continue;
|
|
275
|
+
const targets = [path.resolve(reportDir, link), path.resolve(repo, link)];
|
|
276
|
+
const target = targets.find((candidate) => insideRepo(candidate) && existsSync(candidate));
|
|
277
|
+
if (!target) fail(index + 1, `linked artifact does not exist: ${link}`);
|
|
278
|
+
else if (path.extname(target) === ".json") {
|
|
279
|
+
try { JSON.parse(readFileSync(target, "utf8")); } catch { fail(index + 1, `linked JSON is invalid: ${link}`); }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── architecture coverage ───────────────────────────────────────────────────
|
|
285
|
+
// The claim the report makes about how much of the tree was analysed is the one number a reader
|
|
286
|
+
// trusts when it says there are no cycles, so an overclaim is an error and everything else is not.
|
|
287
|
+
const coverageLine = lines.findIndex((line) => line.startsWith("**Architecture coverage:**"));
|
|
288
|
+
const coverage = lines[coverageLine]?.match(/^\*\*Architecture coverage:\*\* (\d+)\/(\d+)$/);
|
|
289
|
+
const architectureActive = coverageLine >= 0 || Boolean(architecture);
|
|
290
|
+
if (architectureActive) {
|
|
291
|
+
if (!coverage) fail(coverageLine + 1 || 1, "Architecture coverage must have the shape successful/selected");
|
|
292
|
+
const at = coverageLine + 1 || 1;
|
|
293
|
+
const core = ["projects.json", "co-change.md", "cruise-summary.md", "metrics.md"];
|
|
294
|
+
for (const artifact of core) if (!existsSync(path.join(reportDir, artifact))) warn(at, `missing architecture artifact: ${artifact}`);
|
|
295
|
+
const graphDir = path.join(reportDir, "graphs");
|
|
296
|
+
const graphFiles = existsSync(graphDir) && statSync(graphDir).isDirectory()
|
|
297
|
+
? readdirSync(graphDir).filter((file) => file.endsWith(".json")) : [];
|
|
298
|
+
for (const file of graphFiles) {
|
|
299
|
+
try { JSON.parse(readFileSync(path.join(graphDir, file), "utf8")); } catch { warn(at, `invalid graph JSON: graphs/${file}`); }
|
|
300
|
+
const diagram = path.join(reportDir, "diagrams", `${path.basename(file, ".json")}.mmd`);
|
|
301
|
+
if (!existsSync(diagram)) warn(at, `successful graph has no overview diagram: ${path.basename(diagram)}`);
|
|
302
|
+
}
|
|
303
|
+
const projectsPath = path.join(reportDir, "projects.json");
|
|
304
|
+
if (coverage && existsSync(projectsPath)) {
|
|
305
|
+
try {
|
|
306
|
+
const discovery = JSON.parse(readFileSync(projectsPath, "utf8"));
|
|
307
|
+
const projects = Array.isArray(discovery) ? discovery : discovery.projects;
|
|
308
|
+
const [successful, selected] = coverage.slice(1).map(Number);
|
|
309
|
+
if (!Array.isArray(projects)) fail(at, "projects.json must contain a projects array");
|
|
310
|
+
else if (selected !== projects.length) fail(at, `Architecture coverage selects ${selected} of the ${projects.length} discovered projects`);
|
|
311
|
+
if (successful > graphFiles.length) fail(at, `Architecture coverage claims ${successful} analysed project(s) over ${graphFiles.length} graph(s)`);
|
|
312
|
+
else if (successful < graphFiles.length) warn(at, `Architecture coverage claims ${successful} analysed project(s) under ${graphFiles.length} graph(s)`);
|
|
313
|
+
} catch { fail(at, "projects.json is invalid JSON"); }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const display = path.relative(repo, report) || path.basename(report);
|
|
318
|
+
for (const warning of warnings) console.error(`${display}:${warning.line}: warning: ${warning.message}`);
|
|
319
|
+
if (errors.length) {
|
|
320
|
+
for (const error of errors) console.error(`${display}:${error.line}: ${error.message}`);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
} else {
|
|
323
|
+
console.log(`Validated ${display}: ${issueCount} issue(s), ${warnings.length} warning(s).`);
|
|
324
|
+
}
|