@izkac/forgekit 0.3.12 → 0.3.13

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.
Files changed (116) hide show
  1. package/bin/forge.mjs +107 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +1 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/init.mjs +680 -621
  17. package/src/install.mjs +815 -815
  18. package/src/install.test.mjs +180 -180
  19. package/src/integrity-check.mjs +60 -60
  20. package/src/integrity.mjs +682 -682
  21. package/src/integrity.test.mjs +566 -566
  22. package/src/lib.mjs +143 -143
  23. package/src/models.defaults.json +41 -41
  24. package/src/new-session.mjs +99 -99
  25. package/src/openspec-overlays/README.md +19 -19
  26. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  27. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  28. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  29. package/src/paths.mjs +92 -92
  30. package/src/plan-engine.mjs +321 -278
  31. package/src/plan-engine.test.mjs +447 -283
  32. package/src/preferences.defaults.json +78 -78
  33. package/src/preferences.mjs +438 -438
  34. package/src/preferences.test.mjs +174 -174
  35. package/src/record-evidence.mjs +204 -204
  36. package/src/resolve-model.mjs +312 -312
  37. package/src/resolve-model.test.mjs +194 -194
  38. package/src/review/cli.test.mjs +117 -117
  39. package/src/review/export.mjs +172 -172
  40. package/src/review/export.test.mjs +197 -197
  41. package/src/review/fixtures/valid-review.json +42 -42
  42. package/src/review/lib.mjs +894 -894
  43. package/src/review/lib.test.mjs +266 -266
  44. package/src/review/schema.json +196 -196
  45. package/src/review/signals.test.mjs +62 -62
  46. package/src/score-cli.mjs +68 -68
  47. package/src/score.mjs +568 -568
  48. package/src/score.test.mjs +366 -366
  49. package/src/session-reminder.mjs +207 -207
  50. package/src/session-status.mjs +70 -70
  51. package/src/set-models.mjs +186 -186
  52. package/src/set-phase.mjs +205 -205
  53. package/src/set-prefs.mjs +294 -294
  54. package/src/specs-sync.mjs +234 -0
  55. package/src/specs-sync.test.mjs +114 -0
  56. package/src/spine.mjs +93 -93
  57. package/src/triage-prompt.mjs +175 -175
  58. package/src/triage-prompt.test.mjs +50 -50
  59. package/src/vendor-openspec-overlays.mjs +176 -176
  60. package/src/vendor-openspec-overlays.test.mjs +62 -62
  61. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  62. package/vendor/skills/forge/SKILL.md +136 -136
  63. package/vendor/skills/forge/docs/forge.md +650 -647
  64. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  65. package/vendor/skills/forge/phases/finish.md +90 -87
  66. package/vendor/skills/forge/phases/implement.md +77 -77
  67. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  68. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  69. package/vendor/skills/forge/phases/review.md +25 -25
  70. package/vendor/skills/forge/phases/verify.md +124 -124
  71. package/vendor/skills/forge/references/forge-layout.md +85 -85
  72. package/vendor/skills/forge/references/pace.md +115 -115
  73. package/vendor/skills/forge/references/plan-routing.md +52 -51
  74. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  75. package/vendor/skills/forge/references/substantial-work.md +37 -37
  76. package/vendor/skills/forge/references/test-evidence.md +30 -30
  77. package/vendor/skills/forge/references/test-strategy.md +68 -68
  78. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  79. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  80. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  81. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  82. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  83. package/vendor/skills/thorough-code-review/examples.md +133 -133
  84. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  85. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  86. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  87. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  88. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  89. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  90. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  91. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  92. package/vendor/templates/adr/README.md +7 -7
  93. package/vendor/templates/adr/decisions.md +141 -141
  94. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  95. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  96. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  97. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  98. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  99. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  100. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  101. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  102. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  103. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  104. package/vendor/templates/project/claude/commands/forge.md +16 -16
  105. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  106. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  107. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  108. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  109. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  110. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  111. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  112. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  113. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  114. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  115. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  116. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
@@ -1,894 +1,894 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Shared validation, discovery, scaffolding, and rendering for the thorough
4
- * code review export pipeline.
5
- *
6
- * JSON is the single source of truth for a review. The paired Markdown is
7
- * *generated* from the JSON (see {@link renderMarkdown}) so the two can never
8
- * drift. The contract is mirrored in
9
- * `.cursor/skills/thorough-code-review/reference/report-schema.json` in product
10
- * repos, or forgekit `skills/thorough-code-review/reference/report-schema.json`;
11
- * the `schema-consistency.test.mjs` test asserts the enums below stay in sync with
12
- * that file.
13
- */
14
-
15
- import fs from 'node:fs';
16
- import path from 'node:path';
17
-
18
- export const LENSES = [
19
- 'security',
20
- 'correctness',
21
- 'smells',
22
- 'architecture',
23
- 'performance',
24
- 'tests',
25
- 'contracts',
26
- 'errors',
27
- 'maintainability',
28
- ];
29
-
30
- export const SCOPE_TYPES = ['uncommitted', 'branch', 'paths', 'commit_range', 'file'];
31
-
32
- export const SEVERITIES = ['critical', 'important', 'minor'];
33
-
34
- /** Severity ordering, high → low, for graded `--fail-on` gates. */
35
- export const SEVERITY_RANK = { critical: 3, important: 2, minor: 1 };
36
-
37
- export const INITIAL_VERDICTS = ['confirmed', 'false_positive', 'downgraded', 'needs_decision'];
38
-
39
- export const REVERIFY_VERDICTS = ['resolved', 'still_open', 'partially_fixed', 'regressed'];
40
-
41
- export const ALL_VERDICTS = [...INITIAL_VERDICTS, ...REVERIFY_VERDICTS];
42
-
43
- /** Verdicts that count as an unresolved, still-actionable issue. */
44
- export const OPEN_VERDICTS = ['confirmed', 'downgraded', 'still_open', 'partially_fixed', 'regressed'];
45
-
46
- export const CONFIDENCES = ['low', 'medium', 'high'];
47
-
48
- /** Allowed keys for the optional pipeline `stats` object (all non-negative integers). */
49
- export const STATS_KEYS = [
50
- 'scouts',
51
- 'skeptics_dedicated',
52
- 'skeptics_batched',
53
- 'inline_verdicts',
54
- 'grounded_skips',
55
- 'carried_forward',
56
- 'second_opinions',
57
- ];
58
-
59
- const LENS_SET = new Set(LENSES);
60
- const SCOPE_TYPE_SET = new Set(SCOPE_TYPES);
61
- const SEVERITY_SET = new Set(SEVERITIES);
62
- const INITIAL_VERDICT_SET = new Set(INITIAL_VERDICTS);
63
- const REVERIFY_VERDICT_SET = new Set(REVERIFY_VERDICTS);
64
- const ALL_VERDICT_SET = new Set(ALL_VERDICTS);
65
- const CONFIDENCE_SET = new Set(CONFIDENCES);
66
- const STATS_KEY_SET = new Set(STATS_KEYS);
67
-
68
- const FINDING_ID = /^(F|dup)-[0-9]{3}$/;
69
- const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
70
-
71
- export const REVIEWS_DIR = '.reviews';
72
-
73
- /**
74
- * @param {unknown} report
75
- * @returns {{ ok: true, report: Record<string, unknown> } | { ok: false, errors: string[] }}
76
- */
77
- export function validateReport(report) {
78
- const errors = [];
79
-
80
- if (!report || typeof report !== 'object' || Array.isArray(report)) {
81
- return { ok: false, errors: ['root must be an object'] };
82
- }
83
-
84
- const r = /** @type {Record<string, unknown>} */ (report);
85
-
86
- requireString(r, 'review_id', errors);
87
- requireEnum(r, 'kind', new Set(['review', 'reverify']), errors);
88
- requireIsoTimestamp(r, 'created_at', errors);
89
-
90
- if (!r.scope || typeof r.scope !== 'object' || Array.isArray(r.scope)) {
91
- errors.push('scope must be an object');
92
- } else {
93
- const scope = /** @type {Record<string, unknown>} */ (r.scope);
94
- requireEnum(scope, 'type', SCOPE_TYPE_SET, errors, 'scope');
95
- requireString(scope, 'description', errors, 'scope');
96
- if (scope.paths !== undefined) {
97
- if (!Array.isArray(scope.paths) || scope.paths.some((p) => typeof p !== 'string')) {
98
- errors.push('scope.paths must be an array of strings');
99
- }
100
- }
101
- }
102
-
103
- if (!Array.isArray(r.lenses) || r.lenses.length < 1) {
104
- errors.push('lenses must be a non-empty array');
105
- } else if (r.lenses.some((l) => typeof l !== 'string' || !LENS_SET.has(l))) {
106
- errors.push(`lenses entries must be one of: ${LENSES.join(', ')}`);
107
- }
108
-
109
- if (r.parent_report !== undefined && typeof r.parent_report !== 'string') {
110
- errors.push('parent_report must be a string when present');
111
- }
112
-
113
- if (r.kind === 'reverify' && typeof r.parent_report !== 'string') {
114
- errors.push('reverify reports require parent_report');
115
- }
116
-
117
- if (!r.summary || typeof r.summary !== 'object' || Array.isArray(r.summary)) {
118
- errors.push('summary must be an object');
119
- } else {
120
- validateSummary(/** @type {Record<string, unknown>} */ (r.summary), r.findings, errors);
121
- }
122
-
123
- if (!Array.isArray(r.findings)) {
124
- errors.push('findings must be an array');
125
- } else {
126
- r.findings.forEach((f, i) => validateFinding(f, i, errors, r.kind));
127
- }
128
-
129
- if (r.dedupe_preflight !== undefined) {
130
- validateDedupePreflight(r.dedupe_preflight, errors);
131
- }
132
-
133
- if (r.coverage !== undefined) {
134
- validateCoverage(r.coverage, errors);
135
- }
136
-
137
- if (r.signals !== undefined) {
138
- validateSignals(r.signals, errors);
139
- }
140
-
141
- if (r.stats !== undefined) {
142
- validateStats(r.stats, errors);
143
- }
144
-
145
- if (errors.length > 0) {
146
- return { ok: false, errors };
147
- }
148
-
149
- return { ok: true, report: r };
150
- }
151
-
152
- /**
153
- * Reconciles the summary's verdict tallies against the actual findings so a
154
- * hand-edited report cannot claim counts that disagree with its own body.
155
- *
156
- * @param {Record<string, unknown>} summary
157
- * @param {unknown} findings
158
- * @param {string[]} errors
159
- */
160
- function validateSummary(summary, findings, errors) {
161
- if (typeof summary.tentative_count !== 'number' || summary.tentative_count < 0) {
162
- errors.push('summary.tentative_count must be a non-negative number');
163
- }
164
-
165
- if (!Array.isArray(findings)) {
166
- return; // findings errors reported separately
167
- }
168
-
169
- if (
170
- typeof summary.tentative_count === 'number' &&
171
- summary.tentative_count < findings.length
172
- ) {
173
- errors.push(
174
- `summary.tentative_count (${summary.tentative_count}) must be >= findings count (${findings.length})`,
175
- );
176
- }
177
-
178
- const actual = countByVerdict({ findings });
179
- for (const verdict of ALL_VERDICTS) {
180
- const claimed = summary[verdict];
181
- if (claimed === undefined) {
182
- if (actual[verdict] > 0) {
183
- errors.push(
184
- `summary.${verdict} missing but ${actual[verdict]} finding(s) have that verdict`,
185
- );
186
- }
187
- continue;
188
- }
189
- if (typeof claimed !== 'number' || claimed !== actual[verdict]) {
190
- errors.push(
191
- `summary.${verdict} (${String(claimed)}) does not match findings tally (${actual[verdict]})`,
192
- );
193
- }
194
- }
195
- }
196
-
197
- /**
198
- * @param {unknown} finding
199
- * @param {number} index
200
- * @param {string[]} errors
201
- * @param {unknown} kind
202
- */
203
- function validateFinding(finding, index, errors, kind) {
204
- const prefix = `findings[${index}]`;
205
-
206
- if (!finding || typeof finding !== 'object' || Array.isArray(finding)) {
207
- errors.push(`${prefix} must be an object`);
208
- return;
209
- }
210
-
211
- const f = /** @type {Record<string, unknown>} */ (finding);
212
-
213
- if (typeof f.id !== 'string' || !FINDING_ID.test(f.id)) {
214
- errors.push(`${prefix}.id must match F-### or dup-###`);
215
- }
216
- if (typeof f.lens !== 'string' || !LENS_SET.has(f.lens)) {
217
- errors.push(`${prefix}.lens invalid`);
218
- }
219
- requireString(f, 'location', errors, prefix);
220
- requireString(f, 'claim', errors, prefix);
221
- if (typeof f.severity !== 'string' || !SEVERITY_SET.has(f.severity)) {
222
- errors.push(`${prefix}.severity must be critical|important|minor`);
223
- }
224
- if (typeof f.verdict !== 'string' || !ALL_VERDICT_SET.has(f.verdict)) {
225
- errors.push(`${prefix}.verdict invalid`);
226
- }
227
- requireString(f, 'verdict_reason', errors, prefix);
228
-
229
- // Optional fields — validated when present so the JSON schema and runtime
230
- // validator agree on more than just the required core.
231
- if (f.title !== undefined && (typeof f.title !== 'string' || f.title.length < 1)) {
232
- errors.push(`${prefix}.title must be a non-empty string when present`);
233
- }
234
- if (f.evidence !== undefined && typeof f.evidence !== 'string') {
235
- errors.push(`${prefix}.evidence must be a string when present`);
236
- }
237
- if (
238
- f.phase1_confidence !== undefined &&
239
- (typeof f.phase1_confidence !== 'string' || !CONFIDENCE_SET.has(f.phase1_confidence))
240
- ) {
241
- errors.push(`${prefix}.phase1_confidence must be low|medium|high when present`);
242
- }
243
- if (
244
- f.original_severity !== undefined &&
245
- (typeof f.original_severity !== 'string' || !SEVERITY_SET.has(f.original_severity))
246
- ) {
247
- errors.push(`${prefix}.original_severity must be critical|important|minor when present`);
248
- }
249
- if (f.verdict === 'downgraded' && typeof f.original_severity !== 'string') {
250
- errors.push(`${prefix}.original_severity is required when verdict=downgraded`);
251
- }
252
- if (f.second_opinion !== undefined) {
253
- validateSecondOpinion(f.second_opinion, `${prefix}.second_opinion`, errors);
254
- }
255
-
256
- if (kind === 'review' && typeof f.verdict === 'string' && REVERIFY_VERDICT_SET.has(f.verdict)) {
257
- errors.push(`${prefix}.verdict ${f.verdict} not valid for kind=review`);
258
- }
259
- if (kind === 'reverify' && typeof f.verdict === 'string' && INITIAL_VERDICT_SET.has(f.verdict)) {
260
- errors.push(`${prefix}.verdict ${f.verdict} not valid for kind=reverify`);
261
- }
262
- }
263
-
264
- /**
265
- * A risk-weighted second skeptic opinion recorded for dangerous-quadrant findings.
266
- *
267
- * @param {unknown} value
268
- * @param {string} prefix
269
- * @param {string[]} errors
270
- */
271
- function validateSecondOpinion(value, prefix, errors) {
272
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
273
- errors.push(`${prefix} must be an object`);
274
- return;
275
- }
276
- const s = /** @type {Record<string, unknown>} */ (value);
277
- if (typeof s.verdict !== 'string' || !ALL_VERDICT_SET.has(s.verdict)) {
278
- errors.push(`${prefix}.verdict invalid`);
279
- }
280
- requireString(s, 'verdict_reason', errors, prefix);
281
- if (s.agrees !== undefined && typeof s.agrees !== 'boolean') {
282
- errors.push(`${prefix}.agrees must be a boolean when present`);
283
- }
284
- }
285
-
286
- /**
287
- * @param {unknown} dedupe
288
- * @param {string[]} errors
289
- */
290
- function validateDedupePreflight(dedupe, errors) {
291
- if (!dedupe || typeof dedupe !== 'object' || Array.isArray(dedupe)) {
292
- errors.push('dedupe_preflight must be an object');
293
- return;
294
- }
295
- const d = /** @type {Record<string, unknown>} */ (dedupe);
296
- if (typeof d.count !== 'number' || d.count < 0) {
297
- errors.push('dedupe_preflight.count must be a non-negative number');
298
- }
299
- }
300
-
301
- /**
302
- * The coverage ledger from the recall/completeness pass: which files were
303
- * actually reviewed, which were skipped, and which active lenses produced no
304
- * findings (with a justification each).
305
- *
306
- * @param {unknown} coverage
307
- * @param {string[]} errors
308
- */
309
- function validateCoverage(coverage, errors) {
310
- if (!coverage || typeof coverage !== 'object' || Array.isArray(coverage)) {
311
- errors.push('coverage must be an object');
312
- return;
313
- }
314
- const c = /** @type {Record<string, unknown>} */ (coverage);
315
- for (const key of ['files_reviewed', 'files_skipped']) {
316
- if (c[key] !== undefined && (!Array.isArray(c[key]) || c[key].some((x) => typeof x !== 'string'))) {
317
- errors.push(`coverage.${key} must be an array of strings when present`);
318
- }
319
- }
320
- if (c.lenses_without_findings !== undefined) {
321
- if (!Array.isArray(c.lenses_without_findings)) {
322
- errors.push('coverage.lenses_without_findings must be an array when present');
323
- } else {
324
- c.lenses_without_findings.forEach((entry, i) => {
325
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
326
- errors.push(`coverage.lenses_without_findings[${i}] must be an object`);
327
- return;
328
- }
329
- const e = /** @type {Record<string, unknown>} */ (entry);
330
- if (typeof e.lens !== 'string' || !LENS_SET.has(e.lens)) {
331
- errors.push(`coverage.lenses_without_findings[${i}].lens invalid`);
332
- }
333
- if (typeof e.reason !== 'string' || e.reason.length < 1) {
334
- errors.push(`coverage.lenses_without_findings[${i}].reason must be a non-empty string`);
335
- }
336
- });
337
- }
338
- }
339
- }
340
-
341
- /**
342
- * The signals pre-flight summary: which deterministic tools were run and what
343
- * they reported, so the scout's grounded findings are auditable.
344
- *
345
- * @param {unknown} signals
346
- * @param {string[]} errors
347
- */
348
- function validateSignals(signals, errors) {
349
- if (!signals || typeof signals !== 'object' || Array.isArray(signals)) {
350
- errors.push('signals must be an object');
351
- return;
352
- }
353
- const s = /** @type {Record<string, unknown>} */ (signals);
354
- if (s.tools !== undefined) {
355
- if (!Array.isArray(s.tools)) {
356
- errors.push('signals.tools must be an array when present');
357
- } else {
358
- s.tools.forEach((tool, i) => {
359
- if (!tool || typeof tool !== 'object' || Array.isArray(tool)) {
360
- errors.push(`signals.tools[${i}] must be an object`);
361
- return;
362
- }
363
- const t = /** @type {Record<string, unknown>} */ (tool);
364
- if (typeof t.name !== 'string' || t.name.length < 1) {
365
- errors.push(`signals.tools[${i}].name must be a non-empty string`);
366
- }
367
- if (t.status !== undefined && !['pass', 'fail', 'skipped'].includes(/** @type {string} */ (t.status))) {
368
- errors.push(`signals.tools[${i}].status must be pass|fail|skipped when present`);
369
- }
370
- });
371
- }
372
- }
373
- }
374
-
375
- /**
376
- * Pipeline stats: how many subagents ran and which shortcuts fired, so the
377
- * cost/depth of a review run is auditable from the report itself.
378
- *
379
- * @param {unknown} stats
380
- * @param {string[]} errors
381
- */
382
- function validateStats(stats, errors) {
383
- if (!stats || typeof stats !== 'object' || Array.isArray(stats)) {
384
- errors.push('stats must be an object');
385
- return;
386
- }
387
- const s = /** @type {Record<string, unknown>} */ (stats);
388
- for (const [key, value] of Object.entries(s)) {
389
- if (!STATS_KEY_SET.has(key)) {
390
- errors.push(`stats.${key} is not a known stats key (expected ${STATS_KEYS.join(', ')})`);
391
- continue;
392
- }
393
- if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
394
- errors.push(`stats.${key} must be a non-negative integer`);
395
- }
396
- }
397
- }
398
-
399
- /**
400
- * @param {Record<string, unknown>} obj
401
- * @param {string} key
402
- * @param {string[]} errors
403
- * @param {string} [prefix]
404
- */
405
- function requireString(obj, key, errors, prefix = '') {
406
- const p = prefix ? `${prefix}.` : '';
407
- if (typeof obj[key] !== 'string' || obj[key].length < 1) {
408
- errors.push(`${p}${key} must be a non-empty string`);
409
- }
410
- }
411
-
412
- /**
413
- * @param {Record<string, unknown>} obj
414
- * @param {string} key
415
- * @param {string[]} errors
416
- */
417
- function requireIsoTimestamp(obj, key, errors) {
418
- const v = obj[key];
419
- if (typeof v !== 'string' || !ISO_UTC.test(v) || Number.isNaN(Date.parse(v))) {
420
- errors.push(`${key} must be an ISO-8601 UTC timestamp (e.g. 2026-06-05T16:00:00.000Z)`);
421
- }
422
- }
423
-
424
- /**
425
- * @param {Record<string, unknown>} obj
426
- * @param {string} key
427
- * @param {Set<string>} allowed
428
- * @param {string[]} errors
429
- * @param {string} [prefix]
430
- */
431
- function requireEnum(obj, key, allowed, errors, prefix = '') {
432
- const p = prefix ? `${prefix}.` : '';
433
- if (typeof obj[key] !== 'string' || !allowed.has(obj[key])) {
434
- errors.push(`${p}${key} must be one of: ${[...allowed].join(', ')}`);
435
- }
436
- }
437
-
438
- /**
439
- * @param {string} reviewsDir
440
- * @returns {string | null}
441
- */
442
- export function findLatestReviewJson(reviewsDir) {
443
- if (!fs.existsSync(reviewsDir)) {
444
- return null;
445
- }
446
-
447
- const candidates = fs
448
- .readdirSync(reviewsDir)
449
- .filter((name) => name.endsWith('-review.json') && !name.endsWith('-reverify.json'))
450
- .map((name) => {
451
- const full = path.join(reviewsDir, name);
452
- return { full, mtime: fs.statSync(full).mtimeMs };
453
- })
454
- .sort((a, b) => b.mtime - a.mtime);
455
-
456
- return candidates[0]?.full ?? null;
457
- }
458
-
459
- /**
460
- * @param {Record<string, unknown>} report
461
- * @returns {Record<string, number>}
462
- */
463
- export function countByVerdict(report) {
464
- /** @type {Record<string, number>} */
465
- const counts = {};
466
- for (const v of ALL_VERDICTS) counts[v] = 0;
467
-
468
- const findings = /** @type {Array<{ verdict?: string }>} */ (report.findings ?? []);
469
- for (const f of findings) {
470
- const v = f.verdict;
471
- if (v && v in counts) {
472
- counts[v] += 1;
473
- }
474
- }
475
-
476
- return counts;
477
- }
478
-
479
- /**
480
- * Count open findings at or above a severity level. `null` level counts all open.
481
- *
482
- * @param {Record<string, unknown>} report
483
- * @param {('critical'|'important'|'minor')} [level]
484
- * @returns {number}
485
- */
486
- export function countOpenAtOrAbove(report, level = 'critical') {
487
- const findings = /** @type {Array<{ severity?: string; verdict?: string }>} */ (
488
- report.findings ?? []
489
- );
490
- const openVerdicts = new Set(OPEN_VERDICTS);
491
- const threshold = SEVERITY_RANK[level] ?? SEVERITY_RANK.critical;
492
-
493
- return findings.filter(
494
- (f) =>
495
- f.verdict &&
496
- openVerdicts.has(f.verdict) &&
497
- f.severity != null &&
498
- (SEVERITY_RANK[/** @type {keyof typeof SEVERITY_RANK} */ (f.severity)] ?? 0) >= threshold,
499
- ).length;
500
- }
501
-
502
- /**
503
- * @param {Record<string, unknown>} report
504
- * @returns {number}
505
- */
506
- export function countOpenCritical(report) {
507
- return countOpenAtOrAbove(report, 'critical');
508
- }
509
-
510
- /**
511
- * @param {Record<string, unknown>} report
512
- * @returns {string}
513
- */
514
- export function formatSummary(report) {
515
- const verdicts = countByVerdict(report);
516
- const scope = /** @type {{ description?: string }} */ (report.scope ?? {});
517
- const lines = [
518
- `review_id: ${report.review_id}`,
519
- `kind: ${report.kind}`,
520
- `scope: ${scope.description ?? '(unknown)'}`,
521
- `lenses: ${(/** @type {string[]} */ (report.lenses)).join(', ')}`,
522
- `findings: ${(/** @type {unknown[]} */ (report.findings)).length}`,
523
- `confirmed: ${verdicts.confirmed}`,
524
- `false_positive: ${verdicts.false_positive}`,
525
- `needs_decision: ${verdicts.needs_decision}`,
526
- `open_critical: ${countOpenCritical(report)}`,
527
- ];
528
- return lines.join('\n');
529
- }
530
-
531
- // ---------------------------------------------------------------------------
532
- // Scaffolding
533
- // ---------------------------------------------------------------------------
534
-
535
- /**
536
- * Convert a Date to the canonical compact UTC stamp used in review ids,
537
- * e.g. 2026-06-05T16:00:00.000Z → 20260605T160000Z.
538
- *
539
- * @param {Date} date
540
- * @returns {string}
541
- */
542
- export function compactStamp(date) {
543
- return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
544
- }
545
-
546
- /**
547
- * Normalize a free-text scope into a filesystem-safe slug.
548
- *
549
- * @param {string} input
550
- * @returns {string}
551
- */
552
- export function slugify(input) {
553
- return String(input)
554
- .toLowerCase()
555
- .replace(/[^a-z0-9]+/g, '-')
556
- .replace(/^-+|-+$/g, '')
557
- .slice(0, 60) || 'review';
558
- }
559
-
560
- /**
561
- * Build a minimal but schema-valid review skeleton. CLI wrappers add git SHAs.
562
- *
563
- * @param {{
564
- * slug: string,
565
- * type?: ('uncommitted'|'branch'|'paths'|'commit_range'|'file'),
566
- * kind?: ('review'|'reverify'),
567
- * description?: string,
568
- * lenses?: string[],
569
- * paths?: string[],
570
- * parentReport?: string,
571
- * baseSha?: string,
572
- * headSha?: string,
573
- * now: Date,
574
- * }} opts
575
- * @returns {{ reviewId: string, fileBase: string, report: Record<string, unknown> }}
576
- */
577
- export function buildReviewSkeleton(opts) {
578
- const {
579
- slug,
580
- type = 'branch',
581
- kind = 'review',
582
- description = '',
583
- lenses = LENSES,
584
- paths,
585
- parentReport,
586
- baseSha,
587
- headSha,
588
- now,
589
- } = opts;
590
-
591
- if (!SCOPE_TYPE_SET.has(type)) {
592
- throw new Error(`unknown scope type: ${type} (expected ${SCOPE_TYPES.join('|')})`);
593
- }
594
- if (kind !== 'review' && kind !== 'reverify') {
595
- throw new Error(`unknown kind: ${kind} (expected review|reverify)`);
596
- }
597
- if (kind === 'reverify' && !parentReport) {
598
- throw new Error('reverify skeletons require parentReport');
599
- }
600
- const cleanSlug = slugify(slug);
601
- const stamp = compactStamp(now);
602
- const reviewId = `${stamp}-${cleanSlug}`;
603
- const fileBase = `${reviewId}-${kind === 'reverify' ? 'reverify' : 'review'}`;
604
-
605
- /** @type {Record<string, unknown>} */
606
- const scope = { type, description: description || `${type} — ${cleanSlug}` };
607
- if (baseSha) scope.base_sha = baseSha;
608
- if (headSha) scope.head_sha = headSha;
609
- if (paths && paths.length > 0) scope.paths = paths;
610
-
611
- /** @type {Record<string, unknown>} */
612
- const report = {
613
- review_id: reviewId,
614
- kind,
615
- created_at: now.toISOString(),
616
- scope,
617
- lenses: [...lenses],
618
- summary: { tentative_count: 0 },
619
- findings: [],
620
- };
621
- if (parentReport) report.parent_report = parentReport;
622
-
623
- return { reviewId, fileBase, report };
624
- }
625
-
626
- // ---------------------------------------------------------------------------
627
- // Rendering — Markdown is generated from the JSON, never hand-authored.
628
- // ---------------------------------------------------------------------------
629
-
630
- const SEVERITY_ORDER = ['critical', 'important', 'minor'];
631
-
632
- /** Verdicts shown in the main body, per report kind. */
633
- const MAIN_BODY_VERDICTS = {
634
- review: new Set(['confirmed', 'downgraded']),
635
- reverify: new Set(['still_open', 'partially_fixed', 'regressed']),
636
- };
637
-
638
- /**
639
- * @param {{ id?: string, title?: string, claim?: string }} f
640
- * @returns {string}
641
- */
642
- function findingTitle(f) {
643
- if (f.title) return f.title;
644
- const claim = (f.claim ?? '').trim();
645
- return claim.length > 80 ? `${claim.slice(0, 77)}…` : claim || '(untitled)';
646
- }
647
-
648
- /**
649
- * Render the human-readable Markdown report from a validated report object.
650
- *
651
- * @param {Record<string, unknown>} report
652
- * @returns {string}
653
- */
654
- export function renderMarkdown(report) {
655
- const kind = /** @type {'review'|'reverify'} */ (report.kind);
656
- const scope = /** @type {{ type?: string, description?: string, paths?: string[] }} */ (
657
- report.scope ?? {}
658
- );
659
- const summary = /** @type {Record<string, unknown>} */ (report.summary ?? {});
660
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? []);
661
- const lenses = /** @type {string[]} */ (report.lenses ?? []);
662
- const verdicts = countByVerdict(report);
663
-
664
- const out = [];
665
- out.push(`# Code review — ${scope.description ?? report.review_id}`);
666
- out.push('');
667
- out.push(`**Review ID:** ${report.review_id}`);
668
- out.push(`**Kind:** ${kind}`);
669
- out.push(`**Created:** ${report.created_at}`);
670
- out.push(`**Scope:** ${scope.type ?? '—'} — ${scope.description ?? '—'}`);
671
- out.push(`**Lenses:** ${lenses.join(', ')}`);
672
- out.push(`**Parent report:** ${report.parent_report ?? '—'}`);
673
- out.push('');
674
-
675
- out.push('## Executive summary');
676
- out.push('');
677
- out.push(/** @type {string} */ (summary.headline) || '_(summary pending)_');
678
- out.push('');
679
-
680
- out.push('### Verdict counts');
681
- out.push('');
682
- out.push('| Verdict | Count |');
683
- out.push('| ------- | ----- |');
684
- const verdictLabels = {
685
- confirmed: 'Confirmed',
686
- downgraded: 'Downgraded',
687
- false_positive: 'False positive',
688
- needs_decision: 'Needs decision',
689
- resolved: 'Resolved',
690
- still_open: 'Still open',
691
- partially_fixed: 'Partially fixed',
692
- regressed: 'Regressed',
693
- };
694
- for (const [v, label] of Object.entries(verdictLabels)) {
695
- if (verdicts[v] > 0) out.push(`| ${label} | ${verdicts[v]} |`);
696
- }
697
- out.push('');
698
-
699
- const topActions = /** @type {string[]} */ (summary.top_actions ?? []);
700
- if (topActions.length > 0) {
701
- out.push('### Top actions');
702
- out.push('');
703
- topActions.forEach((a, i) => out.push(`${i + 1}. ${a}`));
704
- out.push('');
705
- }
706
-
707
- const mainVerdicts = MAIN_BODY_VERDICTS[kind] ?? MAIN_BODY_VERDICTS.review;
708
- const mainFindings = findings.filter(
709
- (f) => mainVerdicts.has(/** @type {string} */ (f.verdict)) && f.verdict !== 'needs_decision',
710
- );
711
-
712
- for (const sev of SEVERITY_ORDER) {
713
- const group = mainFindings.filter((f) => f.severity === sev);
714
- if (group.length === 0) continue;
715
- out.push('---');
716
- out.push('');
717
- out.push(`## ${sev[0].toUpperCase()}${sev.slice(1)}`);
718
- out.push('');
719
- for (const f of group) out.push(...renderFinding(f));
720
- }
721
-
722
- const needsDecision = findings.filter((f) => f.verdict === 'needs_decision');
723
- if (needsDecision.length > 0) {
724
- out.push('---');
725
- out.push('');
726
- out.push('## Needs decision');
727
- out.push('');
728
- for (const f of needsDecision) out.push(...renderFinding(f));
729
- }
730
-
731
- const coverage = /** @type {Record<string, unknown> | undefined} */ (report.coverage);
732
- if (coverage) {
733
- out.push('---');
734
- out.push('');
735
- out.push('## Coverage ledger');
736
- out.push('');
737
- const reviewed = /** @type {string[]} */ (coverage.files_reviewed ?? []);
738
- const skipped = /** @type {string[]} */ (coverage.files_skipped ?? []);
739
- out.push(`- **Files reviewed:** ${reviewed.length}`);
740
- if (skipped.length > 0) out.push(`- **Files skipped:** ${skipped.join(', ')}`);
741
- const lensesZero = /** @type {Array<{lens:string,reason:string}>} */ (
742
- coverage.lenses_without_findings ?? []
743
- );
744
- for (const e of lensesZero) out.push(`- **${e.lens}** produced no findings — ${e.reason}`);
745
- out.push('');
746
- }
747
-
748
- const stats = /** @type {Record<string, number> | undefined} */ (report.stats);
749
- if (stats && Object.keys(stats).length > 0) {
750
- out.push('---');
751
- out.push('');
752
- out.push('## Pipeline stats');
753
- out.push('');
754
- const statLabels = {
755
- scouts: 'Scouts',
756
- skeptics_dedicated: 'Dedicated skeptics',
757
- skeptics_batched: 'Batched skeptics',
758
- inline_verdicts: 'Inline verdicts',
759
- grounded_skips: 'Grounded skips',
760
- carried_forward: 'Carried forward',
761
- second_opinions: 'Second opinions',
762
- };
763
- out.push(
764
- Object.entries(statLabels)
765
- .filter(([k]) => stats[k] !== undefined)
766
- .map(([k, label]) => `**${label}:** ${stats[k]}`)
767
- .join(' · '),
768
- );
769
- out.push('');
770
- }
771
-
772
- const falsePositives = findings.filter((f) => f.verdict === 'false_positive');
773
- if (falsePositives.length > 0) {
774
- out.push('---');
775
- out.push('');
776
- out.push('## Appendix A — Rejected findings (false positives)');
777
- out.push('');
778
- for (const f of falsePositives) {
779
- out.push(`### ${f.id}: ${findingTitle(f)}`);
780
- out.push('');
781
- out.push(`- **Claim:** ${f.claim}`);
782
- out.push(`- **Why rejected:** ${f.verdict_reason}`);
783
- out.push('');
784
- }
785
- }
786
-
787
- const dedupe = /** @type {{ items?: Array<{id:string,location:string,claim:string}> }} */ (
788
- report.dedupe_preflight ?? {}
789
- );
790
- if (dedupe.items && dedupe.items.length > 0) {
791
- out.push('---');
792
- out.push('');
793
- out.push('## Appendix B — Dedupe pre-flight');
794
- out.push('');
795
- out.push('| ID | Location | Claim |');
796
- out.push('| -- | -------- | ----- |');
797
- for (const d of dedupe.items) out.push(`| ${d.id} | ${d.location} | ${d.claim} |`);
798
- out.push('');
799
- }
800
-
801
- out.push('---');
802
- out.push('');
803
- out.push('## Appendix C — Method');
804
- out.push('');
805
- out.push(`- Phase 1: Scout pass (${summary.tentative_count ?? 0} tentative findings)`);
806
- out.push('- Phase 2: Adversarial skeptic verification (severity-routed, budgeted)');
807
- if (kind === 'reverify') {
808
- out.push(`- Re-verification against parent report ${report.parent_report}`);
809
- }
810
- out.push('');
811
-
812
- return out.join('\n');
813
- }
814
-
815
- // ---------------------------------------------------------------------------
816
- // Signals pre-flight — map scope → affected workspaces → grounding commands.
817
- // ---------------------------------------------------------------------------
818
-
819
- /** Scripts whose output grounds scout findings, in run order. */
820
- export const SIGNAL_SCRIPTS = ['typecheck', 'lint', 'test'];
821
-
822
- /**
823
- * Map scoped paths to the workspaces that own them, so the pre-flight runs
824
- * typecheck/test on exactly the affected packages instead of the whole repo.
825
- *
826
- * @param {string[]} paths Repo-relative paths (any separator).
827
- * @param {Array<{ name: string, dir: string, scripts?: Record<string, string> }>} packages
828
- * @returns {{ workspaces: Array<{ name: string, dir: string, scripts?: Record<string, string> }>, unmatched: string[] }}
829
- */
830
- export function mapPathsToWorkspaces(paths, packages) {
831
- const byDepth = [...packages].sort((a, b) => b.dir.length - a.dir.length);
832
- /** @type {Map<string, { name: string, dir: string, scripts?: Record<string, string> }>} */
833
- const matched = new Map();
834
- const unmatched = [];
835
-
836
- for (const raw of paths) {
837
- const p = String(raw).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
838
- const pkg = byDepth.find((k) => p === k.dir || p.startsWith(`${k.dir}/`));
839
- if (pkg) matched.set(pkg.name, pkg);
840
- else unmatched.push(p);
841
- }
842
-
843
- return {
844
- workspaces: [...matched.values()].sort((a, b) => a.name.localeCompare(b.name)),
845
- unmatched,
846
- };
847
- }
848
-
849
- /**
850
- * Suggest the grounding commands to run for a set of matched workspaces — only
851
- * the scripts each workspace actually defines.
852
- *
853
- * @param {Array<{ name: string, scripts?: Record<string, string> }>} workspaces
854
- * @returns {string[]}
855
- */
856
- export function suggestSignalCommands(workspaces) {
857
- const commands = [];
858
- for (const ws of workspaces) {
859
- for (const script of SIGNAL_SCRIPTS) {
860
- if (ws.scripts && ws.scripts[script]) {
861
- commands.push(`npm run ${script} -w ${ws.name}`);
862
- }
863
- }
864
- }
865
- return commands;
866
- }
867
-
868
- /**
869
- * @param {Record<string, unknown>} f
870
- * @returns {string[]}
871
- */
872
- function renderFinding(f) {
873
- const lines = [];
874
- lines.push(`### ${f.id}: ${findingTitle(f)}`);
875
- lines.push('');
876
- lines.push(`- **Lens:** ${f.lens}`);
877
- lines.push(`- **Location:** \`${f.location}\``);
878
- const sevLine =
879
- f.verdict === 'downgraded' && f.original_severity
880
- ? `${f.severity} (was ${f.original_severity})`
881
- : /** @type {string} */ (f.severity);
882
- lines.push(`- **Severity:** ${sevLine}`);
883
- lines.push(`- **Verdict:** ${f.verdict}`);
884
- lines.push(`- **Claim:** ${f.claim}`);
885
- lines.push(`- **Reason:** ${f.verdict_reason}`);
886
- const second = /** @type {{verdict?:string,verdict_reason?:string} | undefined} */ (
887
- f.second_opinion
888
- );
889
- if (second) {
890
- lines.push(`- **Second skeptic:** ${second.verdict} — ${second.verdict_reason}`);
891
- }
892
- lines.push('');
893
- return lines;
894
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared validation, discovery, scaffolding, and rendering for the thorough
4
+ * code review export pipeline.
5
+ *
6
+ * JSON is the single source of truth for a review. The paired Markdown is
7
+ * *generated* from the JSON (see {@link renderMarkdown}) so the two can never
8
+ * drift. The contract is mirrored in
9
+ * `.cursor/skills/thorough-code-review/reference/report-schema.json` in product
10
+ * repos, or forgekit `skills/thorough-code-review/reference/report-schema.json`;
11
+ * the `schema-consistency.test.mjs` test asserts the enums below stay in sync with
12
+ * that file.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ export const LENSES = [
19
+ 'security',
20
+ 'correctness',
21
+ 'smells',
22
+ 'architecture',
23
+ 'performance',
24
+ 'tests',
25
+ 'contracts',
26
+ 'errors',
27
+ 'maintainability',
28
+ ];
29
+
30
+ export const SCOPE_TYPES = ['uncommitted', 'branch', 'paths', 'commit_range', 'file'];
31
+
32
+ export const SEVERITIES = ['critical', 'important', 'minor'];
33
+
34
+ /** Severity ordering, high → low, for graded `--fail-on` gates. */
35
+ export const SEVERITY_RANK = { critical: 3, important: 2, minor: 1 };
36
+
37
+ export const INITIAL_VERDICTS = ['confirmed', 'false_positive', 'downgraded', 'needs_decision'];
38
+
39
+ export const REVERIFY_VERDICTS = ['resolved', 'still_open', 'partially_fixed', 'regressed'];
40
+
41
+ export const ALL_VERDICTS = [...INITIAL_VERDICTS, ...REVERIFY_VERDICTS];
42
+
43
+ /** Verdicts that count as an unresolved, still-actionable issue. */
44
+ export const OPEN_VERDICTS = ['confirmed', 'downgraded', 'still_open', 'partially_fixed', 'regressed'];
45
+
46
+ export const CONFIDENCES = ['low', 'medium', 'high'];
47
+
48
+ /** Allowed keys for the optional pipeline `stats` object (all non-negative integers). */
49
+ export const STATS_KEYS = [
50
+ 'scouts',
51
+ 'skeptics_dedicated',
52
+ 'skeptics_batched',
53
+ 'inline_verdicts',
54
+ 'grounded_skips',
55
+ 'carried_forward',
56
+ 'second_opinions',
57
+ ];
58
+
59
+ const LENS_SET = new Set(LENSES);
60
+ const SCOPE_TYPE_SET = new Set(SCOPE_TYPES);
61
+ const SEVERITY_SET = new Set(SEVERITIES);
62
+ const INITIAL_VERDICT_SET = new Set(INITIAL_VERDICTS);
63
+ const REVERIFY_VERDICT_SET = new Set(REVERIFY_VERDICTS);
64
+ const ALL_VERDICT_SET = new Set(ALL_VERDICTS);
65
+ const CONFIDENCE_SET = new Set(CONFIDENCES);
66
+ const STATS_KEY_SET = new Set(STATS_KEYS);
67
+
68
+ const FINDING_ID = /^(F|dup)-[0-9]{3}$/;
69
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
70
+
71
+ export const REVIEWS_DIR = '.reviews';
72
+
73
+ /**
74
+ * @param {unknown} report
75
+ * @returns {{ ok: true, report: Record<string, unknown> } | { ok: false, errors: string[] }}
76
+ */
77
+ export function validateReport(report) {
78
+ const errors = [];
79
+
80
+ if (!report || typeof report !== 'object' || Array.isArray(report)) {
81
+ return { ok: false, errors: ['root must be an object'] };
82
+ }
83
+
84
+ const r = /** @type {Record<string, unknown>} */ (report);
85
+
86
+ requireString(r, 'review_id', errors);
87
+ requireEnum(r, 'kind', new Set(['review', 'reverify']), errors);
88
+ requireIsoTimestamp(r, 'created_at', errors);
89
+
90
+ if (!r.scope || typeof r.scope !== 'object' || Array.isArray(r.scope)) {
91
+ errors.push('scope must be an object');
92
+ } else {
93
+ const scope = /** @type {Record<string, unknown>} */ (r.scope);
94
+ requireEnum(scope, 'type', SCOPE_TYPE_SET, errors, 'scope');
95
+ requireString(scope, 'description', errors, 'scope');
96
+ if (scope.paths !== undefined) {
97
+ if (!Array.isArray(scope.paths) || scope.paths.some((p) => typeof p !== 'string')) {
98
+ errors.push('scope.paths must be an array of strings');
99
+ }
100
+ }
101
+ }
102
+
103
+ if (!Array.isArray(r.lenses) || r.lenses.length < 1) {
104
+ errors.push('lenses must be a non-empty array');
105
+ } else if (r.lenses.some((l) => typeof l !== 'string' || !LENS_SET.has(l))) {
106
+ errors.push(`lenses entries must be one of: ${LENSES.join(', ')}`);
107
+ }
108
+
109
+ if (r.parent_report !== undefined && typeof r.parent_report !== 'string') {
110
+ errors.push('parent_report must be a string when present');
111
+ }
112
+
113
+ if (r.kind === 'reverify' && typeof r.parent_report !== 'string') {
114
+ errors.push('reverify reports require parent_report');
115
+ }
116
+
117
+ if (!r.summary || typeof r.summary !== 'object' || Array.isArray(r.summary)) {
118
+ errors.push('summary must be an object');
119
+ } else {
120
+ validateSummary(/** @type {Record<string, unknown>} */ (r.summary), r.findings, errors);
121
+ }
122
+
123
+ if (!Array.isArray(r.findings)) {
124
+ errors.push('findings must be an array');
125
+ } else {
126
+ r.findings.forEach((f, i) => validateFinding(f, i, errors, r.kind));
127
+ }
128
+
129
+ if (r.dedupe_preflight !== undefined) {
130
+ validateDedupePreflight(r.dedupe_preflight, errors);
131
+ }
132
+
133
+ if (r.coverage !== undefined) {
134
+ validateCoverage(r.coverage, errors);
135
+ }
136
+
137
+ if (r.signals !== undefined) {
138
+ validateSignals(r.signals, errors);
139
+ }
140
+
141
+ if (r.stats !== undefined) {
142
+ validateStats(r.stats, errors);
143
+ }
144
+
145
+ if (errors.length > 0) {
146
+ return { ok: false, errors };
147
+ }
148
+
149
+ return { ok: true, report: r };
150
+ }
151
+
152
+ /**
153
+ * Reconciles the summary's verdict tallies against the actual findings so a
154
+ * hand-edited report cannot claim counts that disagree with its own body.
155
+ *
156
+ * @param {Record<string, unknown>} summary
157
+ * @param {unknown} findings
158
+ * @param {string[]} errors
159
+ */
160
+ function validateSummary(summary, findings, errors) {
161
+ if (typeof summary.tentative_count !== 'number' || summary.tentative_count < 0) {
162
+ errors.push('summary.tentative_count must be a non-negative number');
163
+ }
164
+
165
+ if (!Array.isArray(findings)) {
166
+ return; // findings errors reported separately
167
+ }
168
+
169
+ if (
170
+ typeof summary.tentative_count === 'number' &&
171
+ summary.tentative_count < findings.length
172
+ ) {
173
+ errors.push(
174
+ `summary.tentative_count (${summary.tentative_count}) must be >= findings count (${findings.length})`,
175
+ );
176
+ }
177
+
178
+ const actual = countByVerdict({ findings });
179
+ for (const verdict of ALL_VERDICTS) {
180
+ const claimed = summary[verdict];
181
+ if (claimed === undefined) {
182
+ if (actual[verdict] > 0) {
183
+ errors.push(
184
+ `summary.${verdict} missing but ${actual[verdict]} finding(s) have that verdict`,
185
+ );
186
+ }
187
+ continue;
188
+ }
189
+ if (typeof claimed !== 'number' || claimed !== actual[verdict]) {
190
+ errors.push(
191
+ `summary.${verdict} (${String(claimed)}) does not match findings tally (${actual[verdict]})`,
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * @param {unknown} finding
199
+ * @param {number} index
200
+ * @param {string[]} errors
201
+ * @param {unknown} kind
202
+ */
203
+ function validateFinding(finding, index, errors, kind) {
204
+ const prefix = `findings[${index}]`;
205
+
206
+ if (!finding || typeof finding !== 'object' || Array.isArray(finding)) {
207
+ errors.push(`${prefix} must be an object`);
208
+ return;
209
+ }
210
+
211
+ const f = /** @type {Record<string, unknown>} */ (finding);
212
+
213
+ if (typeof f.id !== 'string' || !FINDING_ID.test(f.id)) {
214
+ errors.push(`${prefix}.id must match F-### or dup-###`);
215
+ }
216
+ if (typeof f.lens !== 'string' || !LENS_SET.has(f.lens)) {
217
+ errors.push(`${prefix}.lens invalid`);
218
+ }
219
+ requireString(f, 'location', errors, prefix);
220
+ requireString(f, 'claim', errors, prefix);
221
+ if (typeof f.severity !== 'string' || !SEVERITY_SET.has(f.severity)) {
222
+ errors.push(`${prefix}.severity must be critical|important|minor`);
223
+ }
224
+ if (typeof f.verdict !== 'string' || !ALL_VERDICT_SET.has(f.verdict)) {
225
+ errors.push(`${prefix}.verdict invalid`);
226
+ }
227
+ requireString(f, 'verdict_reason', errors, prefix);
228
+
229
+ // Optional fields — validated when present so the JSON schema and runtime
230
+ // validator agree on more than just the required core.
231
+ if (f.title !== undefined && (typeof f.title !== 'string' || f.title.length < 1)) {
232
+ errors.push(`${prefix}.title must be a non-empty string when present`);
233
+ }
234
+ if (f.evidence !== undefined && typeof f.evidence !== 'string') {
235
+ errors.push(`${prefix}.evidence must be a string when present`);
236
+ }
237
+ if (
238
+ f.phase1_confidence !== undefined &&
239
+ (typeof f.phase1_confidence !== 'string' || !CONFIDENCE_SET.has(f.phase1_confidence))
240
+ ) {
241
+ errors.push(`${prefix}.phase1_confidence must be low|medium|high when present`);
242
+ }
243
+ if (
244
+ f.original_severity !== undefined &&
245
+ (typeof f.original_severity !== 'string' || !SEVERITY_SET.has(f.original_severity))
246
+ ) {
247
+ errors.push(`${prefix}.original_severity must be critical|important|minor when present`);
248
+ }
249
+ if (f.verdict === 'downgraded' && typeof f.original_severity !== 'string') {
250
+ errors.push(`${prefix}.original_severity is required when verdict=downgraded`);
251
+ }
252
+ if (f.second_opinion !== undefined) {
253
+ validateSecondOpinion(f.second_opinion, `${prefix}.second_opinion`, errors);
254
+ }
255
+
256
+ if (kind === 'review' && typeof f.verdict === 'string' && REVERIFY_VERDICT_SET.has(f.verdict)) {
257
+ errors.push(`${prefix}.verdict ${f.verdict} not valid for kind=review`);
258
+ }
259
+ if (kind === 'reverify' && typeof f.verdict === 'string' && INITIAL_VERDICT_SET.has(f.verdict)) {
260
+ errors.push(`${prefix}.verdict ${f.verdict} not valid for kind=reverify`);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * A risk-weighted second skeptic opinion recorded for dangerous-quadrant findings.
266
+ *
267
+ * @param {unknown} value
268
+ * @param {string} prefix
269
+ * @param {string[]} errors
270
+ */
271
+ function validateSecondOpinion(value, prefix, errors) {
272
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
273
+ errors.push(`${prefix} must be an object`);
274
+ return;
275
+ }
276
+ const s = /** @type {Record<string, unknown>} */ (value);
277
+ if (typeof s.verdict !== 'string' || !ALL_VERDICT_SET.has(s.verdict)) {
278
+ errors.push(`${prefix}.verdict invalid`);
279
+ }
280
+ requireString(s, 'verdict_reason', errors, prefix);
281
+ if (s.agrees !== undefined && typeof s.agrees !== 'boolean') {
282
+ errors.push(`${prefix}.agrees must be a boolean when present`);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * @param {unknown} dedupe
288
+ * @param {string[]} errors
289
+ */
290
+ function validateDedupePreflight(dedupe, errors) {
291
+ if (!dedupe || typeof dedupe !== 'object' || Array.isArray(dedupe)) {
292
+ errors.push('dedupe_preflight must be an object');
293
+ return;
294
+ }
295
+ const d = /** @type {Record<string, unknown>} */ (dedupe);
296
+ if (typeof d.count !== 'number' || d.count < 0) {
297
+ errors.push('dedupe_preflight.count must be a non-negative number');
298
+ }
299
+ }
300
+
301
+ /**
302
+ * The coverage ledger from the recall/completeness pass: which files were
303
+ * actually reviewed, which were skipped, and which active lenses produced no
304
+ * findings (with a justification each).
305
+ *
306
+ * @param {unknown} coverage
307
+ * @param {string[]} errors
308
+ */
309
+ function validateCoverage(coverage, errors) {
310
+ if (!coverage || typeof coverage !== 'object' || Array.isArray(coverage)) {
311
+ errors.push('coverage must be an object');
312
+ return;
313
+ }
314
+ const c = /** @type {Record<string, unknown>} */ (coverage);
315
+ for (const key of ['files_reviewed', 'files_skipped']) {
316
+ if (c[key] !== undefined && (!Array.isArray(c[key]) || c[key].some((x) => typeof x !== 'string'))) {
317
+ errors.push(`coverage.${key} must be an array of strings when present`);
318
+ }
319
+ }
320
+ if (c.lenses_without_findings !== undefined) {
321
+ if (!Array.isArray(c.lenses_without_findings)) {
322
+ errors.push('coverage.lenses_without_findings must be an array when present');
323
+ } else {
324
+ c.lenses_without_findings.forEach((entry, i) => {
325
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
326
+ errors.push(`coverage.lenses_without_findings[${i}] must be an object`);
327
+ return;
328
+ }
329
+ const e = /** @type {Record<string, unknown>} */ (entry);
330
+ if (typeof e.lens !== 'string' || !LENS_SET.has(e.lens)) {
331
+ errors.push(`coverage.lenses_without_findings[${i}].lens invalid`);
332
+ }
333
+ if (typeof e.reason !== 'string' || e.reason.length < 1) {
334
+ errors.push(`coverage.lenses_without_findings[${i}].reason must be a non-empty string`);
335
+ }
336
+ });
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * The signals pre-flight summary: which deterministic tools were run and what
343
+ * they reported, so the scout's grounded findings are auditable.
344
+ *
345
+ * @param {unknown} signals
346
+ * @param {string[]} errors
347
+ */
348
+ function validateSignals(signals, errors) {
349
+ if (!signals || typeof signals !== 'object' || Array.isArray(signals)) {
350
+ errors.push('signals must be an object');
351
+ return;
352
+ }
353
+ const s = /** @type {Record<string, unknown>} */ (signals);
354
+ if (s.tools !== undefined) {
355
+ if (!Array.isArray(s.tools)) {
356
+ errors.push('signals.tools must be an array when present');
357
+ } else {
358
+ s.tools.forEach((tool, i) => {
359
+ if (!tool || typeof tool !== 'object' || Array.isArray(tool)) {
360
+ errors.push(`signals.tools[${i}] must be an object`);
361
+ return;
362
+ }
363
+ const t = /** @type {Record<string, unknown>} */ (tool);
364
+ if (typeof t.name !== 'string' || t.name.length < 1) {
365
+ errors.push(`signals.tools[${i}].name must be a non-empty string`);
366
+ }
367
+ if (t.status !== undefined && !['pass', 'fail', 'skipped'].includes(/** @type {string} */ (t.status))) {
368
+ errors.push(`signals.tools[${i}].status must be pass|fail|skipped when present`);
369
+ }
370
+ });
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Pipeline stats: how many subagents ran and which shortcuts fired, so the
377
+ * cost/depth of a review run is auditable from the report itself.
378
+ *
379
+ * @param {unknown} stats
380
+ * @param {string[]} errors
381
+ */
382
+ function validateStats(stats, errors) {
383
+ if (!stats || typeof stats !== 'object' || Array.isArray(stats)) {
384
+ errors.push('stats must be an object');
385
+ return;
386
+ }
387
+ const s = /** @type {Record<string, unknown>} */ (stats);
388
+ for (const [key, value] of Object.entries(s)) {
389
+ if (!STATS_KEY_SET.has(key)) {
390
+ errors.push(`stats.${key} is not a known stats key (expected ${STATS_KEYS.join(', ')})`);
391
+ continue;
392
+ }
393
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
394
+ errors.push(`stats.${key} must be a non-negative integer`);
395
+ }
396
+ }
397
+ }
398
+
399
+ /**
400
+ * @param {Record<string, unknown>} obj
401
+ * @param {string} key
402
+ * @param {string[]} errors
403
+ * @param {string} [prefix]
404
+ */
405
+ function requireString(obj, key, errors, prefix = '') {
406
+ const p = prefix ? `${prefix}.` : '';
407
+ if (typeof obj[key] !== 'string' || obj[key].length < 1) {
408
+ errors.push(`${p}${key} must be a non-empty string`);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * @param {Record<string, unknown>} obj
414
+ * @param {string} key
415
+ * @param {string[]} errors
416
+ */
417
+ function requireIsoTimestamp(obj, key, errors) {
418
+ const v = obj[key];
419
+ if (typeof v !== 'string' || !ISO_UTC.test(v) || Number.isNaN(Date.parse(v))) {
420
+ errors.push(`${key} must be an ISO-8601 UTC timestamp (e.g. 2026-06-05T16:00:00.000Z)`);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * @param {Record<string, unknown>} obj
426
+ * @param {string} key
427
+ * @param {Set<string>} allowed
428
+ * @param {string[]} errors
429
+ * @param {string} [prefix]
430
+ */
431
+ function requireEnum(obj, key, allowed, errors, prefix = '') {
432
+ const p = prefix ? `${prefix}.` : '';
433
+ if (typeof obj[key] !== 'string' || !allowed.has(obj[key])) {
434
+ errors.push(`${p}${key} must be one of: ${[...allowed].join(', ')}`);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * @param {string} reviewsDir
440
+ * @returns {string | null}
441
+ */
442
+ export function findLatestReviewJson(reviewsDir) {
443
+ if (!fs.existsSync(reviewsDir)) {
444
+ return null;
445
+ }
446
+
447
+ const candidates = fs
448
+ .readdirSync(reviewsDir)
449
+ .filter((name) => name.endsWith('-review.json') && !name.endsWith('-reverify.json'))
450
+ .map((name) => {
451
+ const full = path.join(reviewsDir, name);
452
+ return { full, mtime: fs.statSync(full).mtimeMs };
453
+ })
454
+ .sort((a, b) => b.mtime - a.mtime);
455
+
456
+ return candidates[0]?.full ?? null;
457
+ }
458
+
459
+ /**
460
+ * @param {Record<string, unknown>} report
461
+ * @returns {Record<string, number>}
462
+ */
463
+ export function countByVerdict(report) {
464
+ /** @type {Record<string, number>} */
465
+ const counts = {};
466
+ for (const v of ALL_VERDICTS) counts[v] = 0;
467
+
468
+ const findings = /** @type {Array<{ verdict?: string }>} */ (report.findings ?? []);
469
+ for (const f of findings) {
470
+ const v = f.verdict;
471
+ if (v && v in counts) {
472
+ counts[v] += 1;
473
+ }
474
+ }
475
+
476
+ return counts;
477
+ }
478
+
479
+ /**
480
+ * Count open findings at or above a severity level. `null` level counts all open.
481
+ *
482
+ * @param {Record<string, unknown>} report
483
+ * @param {('critical'|'important'|'minor')} [level]
484
+ * @returns {number}
485
+ */
486
+ export function countOpenAtOrAbove(report, level = 'critical') {
487
+ const findings = /** @type {Array<{ severity?: string; verdict?: string }>} */ (
488
+ report.findings ?? []
489
+ );
490
+ const openVerdicts = new Set(OPEN_VERDICTS);
491
+ const threshold = SEVERITY_RANK[level] ?? SEVERITY_RANK.critical;
492
+
493
+ return findings.filter(
494
+ (f) =>
495
+ f.verdict &&
496
+ openVerdicts.has(f.verdict) &&
497
+ f.severity != null &&
498
+ (SEVERITY_RANK[/** @type {keyof typeof SEVERITY_RANK} */ (f.severity)] ?? 0) >= threshold,
499
+ ).length;
500
+ }
501
+
502
+ /**
503
+ * @param {Record<string, unknown>} report
504
+ * @returns {number}
505
+ */
506
+ export function countOpenCritical(report) {
507
+ return countOpenAtOrAbove(report, 'critical');
508
+ }
509
+
510
+ /**
511
+ * @param {Record<string, unknown>} report
512
+ * @returns {string}
513
+ */
514
+ export function formatSummary(report) {
515
+ const verdicts = countByVerdict(report);
516
+ const scope = /** @type {{ description?: string }} */ (report.scope ?? {});
517
+ const lines = [
518
+ `review_id: ${report.review_id}`,
519
+ `kind: ${report.kind}`,
520
+ `scope: ${scope.description ?? '(unknown)'}`,
521
+ `lenses: ${(/** @type {string[]} */ (report.lenses)).join(', ')}`,
522
+ `findings: ${(/** @type {unknown[]} */ (report.findings)).length}`,
523
+ `confirmed: ${verdicts.confirmed}`,
524
+ `false_positive: ${verdicts.false_positive}`,
525
+ `needs_decision: ${verdicts.needs_decision}`,
526
+ `open_critical: ${countOpenCritical(report)}`,
527
+ ];
528
+ return lines.join('\n');
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // Scaffolding
533
+ // ---------------------------------------------------------------------------
534
+
535
+ /**
536
+ * Convert a Date to the canonical compact UTC stamp used in review ids,
537
+ * e.g. 2026-06-05T16:00:00.000Z → 20260605T160000Z.
538
+ *
539
+ * @param {Date} date
540
+ * @returns {string}
541
+ */
542
+ export function compactStamp(date) {
543
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
544
+ }
545
+
546
+ /**
547
+ * Normalize a free-text scope into a filesystem-safe slug.
548
+ *
549
+ * @param {string} input
550
+ * @returns {string}
551
+ */
552
+ export function slugify(input) {
553
+ return String(input)
554
+ .toLowerCase()
555
+ .replace(/[^a-z0-9]+/g, '-')
556
+ .replace(/^-+|-+$/g, '')
557
+ .slice(0, 60) || 'review';
558
+ }
559
+
560
+ /**
561
+ * Build a minimal but schema-valid review skeleton. CLI wrappers add git SHAs.
562
+ *
563
+ * @param {{
564
+ * slug: string,
565
+ * type?: ('uncommitted'|'branch'|'paths'|'commit_range'|'file'),
566
+ * kind?: ('review'|'reverify'),
567
+ * description?: string,
568
+ * lenses?: string[],
569
+ * paths?: string[],
570
+ * parentReport?: string,
571
+ * baseSha?: string,
572
+ * headSha?: string,
573
+ * now: Date,
574
+ * }} opts
575
+ * @returns {{ reviewId: string, fileBase: string, report: Record<string, unknown> }}
576
+ */
577
+ export function buildReviewSkeleton(opts) {
578
+ const {
579
+ slug,
580
+ type = 'branch',
581
+ kind = 'review',
582
+ description = '',
583
+ lenses = LENSES,
584
+ paths,
585
+ parentReport,
586
+ baseSha,
587
+ headSha,
588
+ now,
589
+ } = opts;
590
+
591
+ if (!SCOPE_TYPE_SET.has(type)) {
592
+ throw new Error(`unknown scope type: ${type} (expected ${SCOPE_TYPES.join('|')})`);
593
+ }
594
+ if (kind !== 'review' && kind !== 'reverify') {
595
+ throw new Error(`unknown kind: ${kind} (expected review|reverify)`);
596
+ }
597
+ if (kind === 'reverify' && !parentReport) {
598
+ throw new Error('reverify skeletons require parentReport');
599
+ }
600
+ const cleanSlug = slugify(slug);
601
+ const stamp = compactStamp(now);
602
+ const reviewId = `${stamp}-${cleanSlug}`;
603
+ const fileBase = `${reviewId}-${kind === 'reverify' ? 'reverify' : 'review'}`;
604
+
605
+ /** @type {Record<string, unknown>} */
606
+ const scope = { type, description: description || `${type} — ${cleanSlug}` };
607
+ if (baseSha) scope.base_sha = baseSha;
608
+ if (headSha) scope.head_sha = headSha;
609
+ if (paths && paths.length > 0) scope.paths = paths;
610
+
611
+ /** @type {Record<string, unknown>} */
612
+ const report = {
613
+ review_id: reviewId,
614
+ kind,
615
+ created_at: now.toISOString(),
616
+ scope,
617
+ lenses: [...lenses],
618
+ summary: { tentative_count: 0 },
619
+ findings: [],
620
+ };
621
+ if (parentReport) report.parent_report = parentReport;
622
+
623
+ return { reviewId, fileBase, report };
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Rendering — Markdown is generated from the JSON, never hand-authored.
628
+ // ---------------------------------------------------------------------------
629
+
630
+ const SEVERITY_ORDER = ['critical', 'important', 'minor'];
631
+
632
+ /** Verdicts shown in the main body, per report kind. */
633
+ const MAIN_BODY_VERDICTS = {
634
+ review: new Set(['confirmed', 'downgraded']),
635
+ reverify: new Set(['still_open', 'partially_fixed', 'regressed']),
636
+ };
637
+
638
+ /**
639
+ * @param {{ id?: string, title?: string, claim?: string }} f
640
+ * @returns {string}
641
+ */
642
+ function findingTitle(f) {
643
+ if (f.title) return f.title;
644
+ const claim = (f.claim ?? '').trim();
645
+ return claim.length > 80 ? `${claim.slice(0, 77)}…` : claim || '(untitled)';
646
+ }
647
+
648
+ /**
649
+ * Render the human-readable Markdown report from a validated report object.
650
+ *
651
+ * @param {Record<string, unknown>} report
652
+ * @returns {string}
653
+ */
654
+ export function renderMarkdown(report) {
655
+ const kind = /** @type {'review'|'reverify'} */ (report.kind);
656
+ const scope = /** @type {{ type?: string, description?: string, paths?: string[] }} */ (
657
+ report.scope ?? {}
658
+ );
659
+ const summary = /** @type {Record<string, unknown>} */ (report.summary ?? {});
660
+ const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? []);
661
+ const lenses = /** @type {string[]} */ (report.lenses ?? []);
662
+ const verdicts = countByVerdict(report);
663
+
664
+ const out = [];
665
+ out.push(`# Code review — ${scope.description ?? report.review_id}`);
666
+ out.push('');
667
+ out.push(`**Review ID:** ${report.review_id}`);
668
+ out.push(`**Kind:** ${kind}`);
669
+ out.push(`**Created:** ${report.created_at}`);
670
+ out.push(`**Scope:** ${scope.type ?? '—'} — ${scope.description ?? '—'}`);
671
+ out.push(`**Lenses:** ${lenses.join(', ')}`);
672
+ out.push(`**Parent report:** ${report.parent_report ?? '—'}`);
673
+ out.push('');
674
+
675
+ out.push('## Executive summary');
676
+ out.push('');
677
+ out.push(/** @type {string} */ (summary.headline) || '_(summary pending)_');
678
+ out.push('');
679
+
680
+ out.push('### Verdict counts');
681
+ out.push('');
682
+ out.push('| Verdict | Count |');
683
+ out.push('| ------- | ----- |');
684
+ const verdictLabels = {
685
+ confirmed: 'Confirmed',
686
+ downgraded: 'Downgraded',
687
+ false_positive: 'False positive',
688
+ needs_decision: 'Needs decision',
689
+ resolved: 'Resolved',
690
+ still_open: 'Still open',
691
+ partially_fixed: 'Partially fixed',
692
+ regressed: 'Regressed',
693
+ };
694
+ for (const [v, label] of Object.entries(verdictLabels)) {
695
+ if (verdicts[v] > 0) out.push(`| ${label} | ${verdicts[v]} |`);
696
+ }
697
+ out.push('');
698
+
699
+ const topActions = /** @type {string[]} */ (summary.top_actions ?? []);
700
+ if (topActions.length > 0) {
701
+ out.push('### Top actions');
702
+ out.push('');
703
+ topActions.forEach((a, i) => out.push(`${i + 1}. ${a}`));
704
+ out.push('');
705
+ }
706
+
707
+ const mainVerdicts = MAIN_BODY_VERDICTS[kind] ?? MAIN_BODY_VERDICTS.review;
708
+ const mainFindings = findings.filter(
709
+ (f) => mainVerdicts.has(/** @type {string} */ (f.verdict)) && f.verdict !== 'needs_decision',
710
+ );
711
+
712
+ for (const sev of SEVERITY_ORDER) {
713
+ const group = mainFindings.filter((f) => f.severity === sev);
714
+ if (group.length === 0) continue;
715
+ out.push('---');
716
+ out.push('');
717
+ out.push(`## ${sev[0].toUpperCase()}${sev.slice(1)}`);
718
+ out.push('');
719
+ for (const f of group) out.push(...renderFinding(f));
720
+ }
721
+
722
+ const needsDecision = findings.filter((f) => f.verdict === 'needs_decision');
723
+ if (needsDecision.length > 0) {
724
+ out.push('---');
725
+ out.push('');
726
+ out.push('## Needs decision');
727
+ out.push('');
728
+ for (const f of needsDecision) out.push(...renderFinding(f));
729
+ }
730
+
731
+ const coverage = /** @type {Record<string, unknown> | undefined} */ (report.coverage);
732
+ if (coverage) {
733
+ out.push('---');
734
+ out.push('');
735
+ out.push('## Coverage ledger');
736
+ out.push('');
737
+ const reviewed = /** @type {string[]} */ (coverage.files_reviewed ?? []);
738
+ const skipped = /** @type {string[]} */ (coverage.files_skipped ?? []);
739
+ out.push(`- **Files reviewed:** ${reviewed.length}`);
740
+ if (skipped.length > 0) out.push(`- **Files skipped:** ${skipped.join(', ')}`);
741
+ const lensesZero = /** @type {Array<{lens:string,reason:string}>} */ (
742
+ coverage.lenses_without_findings ?? []
743
+ );
744
+ for (const e of lensesZero) out.push(`- **${e.lens}** produced no findings — ${e.reason}`);
745
+ out.push('');
746
+ }
747
+
748
+ const stats = /** @type {Record<string, number> | undefined} */ (report.stats);
749
+ if (stats && Object.keys(stats).length > 0) {
750
+ out.push('---');
751
+ out.push('');
752
+ out.push('## Pipeline stats');
753
+ out.push('');
754
+ const statLabels = {
755
+ scouts: 'Scouts',
756
+ skeptics_dedicated: 'Dedicated skeptics',
757
+ skeptics_batched: 'Batched skeptics',
758
+ inline_verdicts: 'Inline verdicts',
759
+ grounded_skips: 'Grounded skips',
760
+ carried_forward: 'Carried forward',
761
+ second_opinions: 'Second opinions',
762
+ };
763
+ out.push(
764
+ Object.entries(statLabels)
765
+ .filter(([k]) => stats[k] !== undefined)
766
+ .map(([k, label]) => `**${label}:** ${stats[k]}`)
767
+ .join(' · '),
768
+ );
769
+ out.push('');
770
+ }
771
+
772
+ const falsePositives = findings.filter((f) => f.verdict === 'false_positive');
773
+ if (falsePositives.length > 0) {
774
+ out.push('---');
775
+ out.push('');
776
+ out.push('## Appendix A — Rejected findings (false positives)');
777
+ out.push('');
778
+ for (const f of falsePositives) {
779
+ out.push(`### ${f.id}: ${findingTitle(f)}`);
780
+ out.push('');
781
+ out.push(`- **Claim:** ${f.claim}`);
782
+ out.push(`- **Why rejected:** ${f.verdict_reason}`);
783
+ out.push('');
784
+ }
785
+ }
786
+
787
+ const dedupe = /** @type {{ items?: Array<{id:string,location:string,claim:string}> }} */ (
788
+ report.dedupe_preflight ?? {}
789
+ );
790
+ if (dedupe.items && dedupe.items.length > 0) {
791
+ out.push('---');
792
+ out.push('');
793
+ out.push('## Appendix B — Dedupe pre-flight');
794
+ out.push('');
795
+ out.push('| ID | Location | Claim |');
796
+ out.push('| -- | -------- | ----- |');
797
+ for (const d of dedupe.items) out.push(`| ${d.id} | ${d.location} | ${d.claim} |`);
798
+ out.push('');
799
+ }
800
+
801
+ out.push('---');
802
+ out.push('');
803
+ out.push('## Appendix C — Method');
804
+ out.push('');
805
+ out.push(`- Phase 1: Scout pass (${summary.tentative_count ?? 0} tentative findings)`);
806
+ out.push('- Phase 2: Adversarial skeptic verification (severity-routed, budgeted)');
807
+ if (kind === 'reverify') {
808
+ out.push(`- Re-verification against parent report ${report.parent_report}`);
809
+ }
810
+ out.push('');
811
+
812
+ return out.join('\n');
813
+ }
814
+
815
+ // ---------------------------------------------------------------------------
816
+ // Signals pre-flight — map scope → affected workspaces → grounding commands.
817
+ // ---------------------------------------------------------------------------
818
+
819
+ /** Scripts whose output grounds scout findings, in run order. */
820
+ export const SIGNAL_SCRIPTS = ['typecheck', 'lint', 'test'];
821
+
822
+ /**
823
+ * Map scoped paths to the workspaces that own them, so the pre-flight runs
824
+ * typecheck/test on exactly the affected packages instead of the whole repo.
825
+ *
826
+ * @param {string[]} paths Repo-relative paths (any separator).
827
+ * @param {Array<{ name: string, dir: string, scripts?: Record<string, string> }>} packages
828
+ * @returns {{ workspaces: Array<{ name: string, dir: string, scripts?: Record<string, string> }>, unmatched: string[] }}
829
+ */
830
+ export function mapPathsToWorkspaces(paths, packages) {
831
+ const byDepth = [...packages].sort((a, b) => b.dir.length - a.dir.length);
832
+ /** @type {Map<string, { name: string, dir: string, scripts?: Record<string, string> }>} */
833
+ const matched = new Map();
834
+ const unmatched = [];
835
+
836
+ for (const raw of paths) {
837
+ const p = String(raw).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
838
+ const pkg = byDepth.find((k) => p === k.dir || p.startsWith(`${k.dir}/`));
839
+ if (pkg) matched.set(pkg.name, pkg);
840
+ else unmatched.push(p);
841
+ }
842
+
843
+ return {
844
+ workspaces: [...matched.values()].sort((a, b) => a.name.localeCompare(b.name)),
845
+ unmatched,
846
+ };
847
+ }
848
+
849
+ /**
850
+ * Suggest the grounding commands to run for a set of matched workspaces — only
851
+ * the scripts each workspace actually defines.
852
+ *
853
+ * @param {Array<{ name: string, scripts?: Record<string, string> }>} workspaces
854
+ * @returns {string[]}
855
+ */
856
+ export function suggestSignalCommands(workspaces) {
857
+ const commands = [];
858
+ for (const ws of workspaces) {
859
+ for (const script of SIGNAL_SCRIPTS) {
860
+ if (ws.scripts && ws.scripts[script]) {
861
+ commands.push(`npm run ${script} -w ${ws.name}`);
862
+ }
863
+ }
864
+ }
865
+ return commands;
866
+ }
867
+
868
+ /**
869
+ * @param {Record<string, unknown>} f
870
+ * @returns {string[]}
871
+ */
872
+ function renderFinding(f) {
873
+ const lines = [];
874
+ lines.push(`### ${f.id}: ${findingTitle(f)}`);
875
+ lines.push('');
876
+ lines.push(`- **Lens:** ${f.lens}`);
877
+ lines.push(`- **Location:** \`${f.location}\``);
878
+ const sevLine =
879
+ f.verdict === 'downgraded' && f.original_severity
880
+ ? `${f.severity} (was ${f.original_severity})`
881
+ : /** @type {string} */ (f.severity);
882
+ lines.push(`- **Severity:** ${sevLine}`);
883
+ lines.push(`- **Verdict:** ${f.verdict}`);
884
+ lines.push(`- **Claim:** ${f.claim}`);
885
+ lines.push(`- **Reason:** ${f.verdict_reason}`);
886
+ const second = /** @type {{verdict?:string,verdict_reason?:string} | undefined} */ (
887
+ f.second_opinion
888
+ );
889
+ if (second) {
890
+ lines.push(`- **Second skeptic:** ${second.verdict} — ${second.verdict_reason}`);
891
+ }
892
+ lines.push('');
893
+ return lines;
894
+ }