@izkac/forgekit 0.3.11 → 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.
- package/bin/forge.mjs +107 -107
- package/bin/forgekit.mjs +83 -83
- package/bin/review.mjs +81 -81
- package/package.json +1 -1
- package/scripts/prepack.mjs +78 -78
- package/scripts/run-tests.mjs +50 -50
- package/src/adr.mjs +236 -236
- package/src/adr.test.mjs +170 -170
- package/src/change.mjs +327 -234
- package/src/change.test.mjs +145 -83
- package/src/cleanup-sessions.mjs +84 -84
- package/src/config.mjs +103 -103
- package/src/defer.mjs +75 -75
- package/src/doctor.mjs +350 -341
- package/src/doctor.test.mjs +114 -114
- package/src/init.mjs +680 -621
- package/src/install.mjs +815 -815
- package/src/install.test.mjs +180 -180
- package/src/integrity-check.mjs +60 -60
- package/src/integrity.mjs +682 -682
- package/src/integrity.test.mjs +566 -566
- package/src/lib.mjs +143 -143
- package/src/models.defaults.json +41 -41
- package/src/new-session.mjs +99 -99
- package/src/openspec-overlays/README.md +19 -19
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
- package/src/paths.mjs +92 -92
- package/src/plan-engine.mjs +321 -278
- package/src/plan-engine.test.mjs +447 -283
- package/src/preferences.defaults.json +78 -78
- package/src/preferences.mjs +438 -438
- package/src/preferences.test.mjs +174 -174
- package/src/record-evidence.mjs +204 -204
- package/src/resolve-model.mjs +312 -312
- package/src/resolve-model.test.mjs +194 -194
- package/src/review/cli.test.mjs +117 -117
- package/src/review/export.mjs +172 -172
- package/src/review/export.test.mjs +197 -197
- package/src/review/fixtures/valid-review.json +42 -42
- package/src/review/lib.mjs +894 -894
- package/src/review/lib.test.mjs +266 -266
- package/src/review/schema.json +196 -196
- package/src/review/signals.test.mjs +62 -62
- package/src/score-cli.mjs +68 -68
- package/src/score.mjs +568 -566
- package/src/score.test.mjs +366 -340
- package/src/session-reminder.mjs +207 -207
- package/src/session-status.mjs +70 -70
- package/src/set-models.mjs +186 -186
- package/src/set-phase.mjs +205 -205
- package/src/set-prefs.mjs +294 -294
- package/src/specs-sync.mjs +234 -0
- package/src/specs-sync.test.mjs +114 -0
- package/src/spine.mjs +93 -93
- package/src/triage-prompt.mjs +175 -175
- package/src/triage-prompt.test.mjs +50 -50
- package/src/vendor-openspec-overlays.mjs +176 -176
- package/src/vendor-openspec-overlays.test.mjs +62 -62
- package/vendor/skills/archive-to-adr/SKILL.md +149 -149
- package/vendor/skills/forge/SKILL.md +136 -136
- package/vendor/skills/forge/docs/forge.md +650 -647
- package/vendor/skills/forge/phases/brainstorm.md +23 -23
- package/vendor/skills/forge/phases/finish.md +90 -87
- package/vendor/skills/forge/phases/implement.md +77 -77
- package/vendor/skills/forge/phases/plan-openspec.md +60 -60
- package/vendor/skills/forge/phases/plan-specs.md +163 -117
- package/vendor/skills/forge/phases/review.md +25 -25
- package/vendor/skills/forge/phases/verify.md +124 -124
- package/vendor/skills/forge/references/forge-layout.md +85 -85
- package/vendor/skills/forge/references/pace.md +115 -115
- package/vendor/skills/forge/references/plan-routing.md +52 -51
- package/vendor/skills/forge/references/runtime-integrity.md +232 -225
- package/vendor/skills/forge/references/substantial-work.md +37 -37
- package/vendor/skills/forge/references/test-evidence.md +30 -30
- package/vendor/skills/forge/references/test-strategy.md +68 -68
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
- package/vendor/skills/thorough-code-review/SKILL.md +290 -290
- package/vendor/skills/thorough-code-review/examples.md +133 -133
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
- package/vendor/templates/adr/README.md +7 -7
- package/vendor/templates/adr/decisions.md +141 -141
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
- package/vendor/templates/project/claude/commands/forge-build.md +17 -17
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
- package/vendor/templates/project/claude/commands/forge-status.md +16 -16
- package/vendor/templates/project/claude/commands/forge.md +16 -16
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
- package/vendor/templates/project/cursor/commands/forge.md +16 -16
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/review/lib.mjs
CHANGED
|
@@ -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
|
+
}
|