@fyeeme/pi-review 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -13
- package/index.ts +2 -2
- package/package.json +58 -57
- package/prompts/{review.md → review.parallel.md} +4 -4
- package/prompts/review.single.md +16 -0
- package/prompts/simplify.parallel.md +1 -1
- package/prompts/simplify.single.md +1 -1
- package/skills/{review → code-review}/SKILL.md +164 -70
- package/skills/{simplify → code-simplify}/SKILL.md +15 -15
- package/src/config.ts +16 -10
- package/src/diff.ts +2 -2
- package/src/dispatch.ts +94 -31
- package/src/loop.ts +266 -0
- package/src/tools/review_report.ts +81 -33
package/src/loop.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/loop.ts — /code-review --loop: extension-driven fix→re-review cycles.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the standalone Codex-style review extension's loop fixing
|
|
5
|
+
* (review → blocking-check → fix → re-review, bounded), with one deliberate
|
|
6
|
+
* deviation: the blocking decision reads the structured `review_report` JSON
|
|
7
|
+
* the skill must have written under the project's pi config dir
|
|
8
|
+
* (<cwd>/.pi/review/ by default — CONFIG_DIR_NAME) instead of scraping the
|
|
9
|
+
* assistant's markdown. The tool call is the report, so the JSON is the
|
|
10
|
+
* reliable artifact; markdown scraping was only ever a fallback.
|
|
11
|
+
*
|
|
12
|
+
* Loop shape (single-pass levels only — low/medium/high):
|
|
13
|
+
* review turn → read newest report → OPEN P0/P1 findings (P0/P1 without a
|
|
14
|
+
* decided outcome — a fix turn's re-report marks its findings
|
|
15
|
+
* fixed/skipped/no_change_needed)?
|
|
16
|
+
* none → done
|
|
17
|
+
* some, rounds left → fix prompt (followUp) → idle → re-review prompt → next round
|
|
18
|
+
* some, rounds spent → stop with a safety-limit note
|
|
19
|
+
* Esc/abort or a missing report stops the loop.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { OUTCOME_VALUES } from "./tools/review_report.ts";
|
|
25
|
+
|
|
26
|
+
// --- pure helpers (unit-tested) ---------------------------------------------
|
|
27
|
+
|
|
28
|
+
export const BLOCKING_PRIORITIES = ["P0", "P1"] as const;
|
|
29
|
+
|
|
30
|
+
/** Strip a --loop flag out of the trailing args; report whether it was there.
|
|
31
|
+
* Pure — unit-testable. */
|
|
32
|
+
export function extractLoopFlag(rest: string): { wantLoop: boolean; rest: string } {
|
|
33
|
+
const tokens = (rest ?? "").split(/\s+/).filter(Boolean);
|
|
34
|
+
const kept = tokens.filter((t) => t !== "--loop");
|
|
35
|
+
return { wantLoop: kept.length !== tokens.length, rest: kept.join(" ") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A blocking finding as surfaced back to the fix prompt. */
|
|
39
|
+
export interface BlockingFinding {
|
|
40
|
+
file: string;
|
|
41
|
+
line?: number;
|
|
42
|
+
priority: string;
|
|
43
|
+
summary: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract P0/P1 findings from a parsed review_report JSON (lenient: any
|
|
47
|
+
* shape mismatch → no findings rather than a throw). Pure — unit-testable. */
|
|
48
|
+
export function blockingFindings(report: unknown): BlockingFinding[] {
|
|
49
|
+
if (!report || typeof report !== "object" || Array.isArray(report)) return [];
|
|
50
|
+
const raw = (report as { findings?: unknown }).findings;
|
|
51
|
+
if (!Array.isArray(raw)) return [];
|
|
52
|
+
const out: BlockingFinding[] = [];
|
|
53
|
+
for (const f of raw) {
|
|
54
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) continue;
|
|
55
|
+
const rec = f as Record<string, unknown>;
|
|
56
|
+
if (rec.priority !== "P0" && rec.priority !== "P1") continue;
|
|
57
|
+
if (typeof rec.file !== "string" || rec.file.length === 0) continue;
|
|
58
|
+
// A decided outcome (a fix turn re-reports its findings with one, per
|
|
59
|
+
// the skill's fixed-later obligation) un-blocks the finding —
|
|
60
|
+
// re-prompting a fixed/skipped/declined finding just burns rounds.
|
|
61
|
+
if (typeof rec.outcome === "string" && (OUTCOME_VALUES as readonly string[]).includes(rec.outcome)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
out.push({
|
|
65
|
+
file: rec.file,
|
|
66
|
+
line: typeof rec.line === "number" ? rec.line : undefined,
|
|
67
|
+
priority: rec.priority,
|
|
68
|
+
summary: typeof rec.summary === "string" ? rec.summary : "",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Newest `*.json` report under `dir` modified after `sinceMs`, or null.
|
|
75
|
+
* Missing/unreadable dir → null. Pure — unit-testable. */
|
|
76
|
+
export function latestReportFile(dir: string, sinceMs: number): string | null {
|
|
77
|
+
let entries: fs.Dirent[];
|
|
78
|
+
try {
|
|
79
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
let newest: { file: string; mtime: number } | null = null;
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (!e.isFile() || !e.name.endsWith(".json")) continue;
|
|
86
|
+
const file = path.join(dir, e.name);
|
|
87
|
+
try {
|
|
88
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
89
|
+
if (mtime <= sinceMs) continue;
|
|
90
|
+
if (!newest || mtime > newest.mtime) newest = { file, mtime };
|
|
91
|
+
} catch {
|
|
92
|
+
/* stat failed — skip this entry */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return newest?.file ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Parse a report JSON file; garbage → null (the loop must not crash on a
|
|
99
|
+
* half-written or hand-edited file). */
|
|
100
|
+
function readReport(file: string): unknown {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- loop driver -------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Minimal session view for the quiescence wait — structural, so
|
|
111
|
+
* ExtensionCommandContext satisfies it and tests can drive the logic
|
|
112
|
+
* without a live session. */
|
|
113
|
+
export interface QuiescenceView {
|
|
114
|
+
isIdle(): boolean;
|
|
115
|
+
hasPendingMessages(): boolean;
|
|
116
|
+
waitForIdle(): Promise<void>;
|
|
117
|
+
signal?: { aborted: boolean };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Wait until the session is fully quiescent: nothing running AND nothing
|
|
121
|
+
* queued. A single `waitForIdle()` is NOT enough — it resolves at the first
|
|
122
|
+
* idle point even while follow-ups are still queued (a queued message does
|
|
123
|
+
* not flip `isIdle` until its run actually starts), which is exactly the
|
|
124
|
+
* window between `sendUserMessage(…, followUp)` and that turn's first
|
|
125
|
+
* token. Aborts return false; there is no timeout — Esc is the escape
|
|
126
|
+
* hatch, same as for the bare waitForIdle call. */
|
|
127
|
+
export async function waitForQuiescent(session: QuiescenceView): Promise<boolean> {
|
|
128
|
+
for (;;) {
|
|
129
|
+
if (session.signal?.aborted) return false;
|
|
130
|
+
if (!session.isIdle() || session.hasPendingMessages()) {
|
|
131
|
+
await session.waitForIdle();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Poll until the review turn has started (idle → busy or a new assistant
|
|
139
|
+
* message appears), then wait for it to finish. Returns false on timeout
|
|
140
|
+
* or abort. Mirrors the reference extension's waitForLoopTurnToStart. */
|
|
141
|
+
async function waitForTurnSettled(ctx: ExtensionCommandContext, baselineAssistantId: string): Promise<boolean> {
|
|
142
|
+
const START_TIMEOUT_MS = 15_000;
|
|
143
|
+
const POLL_MS = 50;
|
|
144
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
145
|
+
|
|
146
|
+
const lastAssistantId = (): string | undefined => {
|
|
147
|
+
const branch = ctx.sessionManager.getBranch();
|
|
148
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
149
|
+
const entry = branch[i]!;
|
|
150
|
+
if (entry.type === "message" && entry.message.role === "assistant") return entry.id;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
if (ctx.signal?.aborted) return false;
|
|
157
|
+
const current = lastAssistantId();
|
|
158
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages() || (current && current !== baselineAssistantId)) {
|
|
159
|
+
// Wait past every queued message, not merely to the next idle
|
|
160
|
+
// point — waitForIdle() alone returns inside the gap between
|
|
161
|
+
// queueing a followUp and its run actually starting.
|
|
162
|
+
return waitForQuiescent(ctx);
|
|
163
|
+
}
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function baselineAssistantId(ctx: ExtensionCommandContext): string {
|
|
170
|
+
const branch = ctx.sessionManager.getBranch();
|
|
171
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
172
|
+
const entry = branch[i]!;
|
|
173
|
+
if (entry.type === "message" && entry.message.role === "assistant") return entry.id;
|
|
174
|
+
}
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function fixPrompt(findings: BlockingFinding[]): string {
|
|
179
|
+
const list = findings
|
|
180
|
+
.map((f) => `- \`${f.file}${f.line != null ? `:${f.line}` : ""}\` [${f.priority}] ${f.summary}`)
|
|
181
|
+
.join("\n");
|
|
182
|
+
return [
|
|
183
|
+
"Fix the following blocking findings from the code review you just reported",
|
|
184
|
+
"(full failure scenarios are in the latest report JSON under .pi/review/):",
|
|
185
|
+
"",
|
|
186
|
+
list,
|
|
187
|
+
"",
|
|
188
|
+
"Apply minimal, surgical fixes — no drive-by refactors. Then re-report these",
|
|
189
|
+
"findings via the `review_report` tool with `outcome` set per finding",
|
|
190
|
+
"(fixed / skipped / no_change_needed) and run the verification guidance from",
|
|
191
|
+
"the review trigger message. Never leave the working tree verified-broken.",
|
|
192
|
+
].join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function reReviewPrompt(level: string): string {
|
|
196
|
+
return [
|
|
197
|
+
`Fixes applied. Re-run the code-review SINGLE-PASS flow for effort ${level} now`,
|
|
198
|
+
"— the diff has changed: re-resolve it, re-check the fixed locations and",
|
|
199
|
+
"sweep for regressions or newly exposed issues, then report via the",
|
|
200
|
+
"`review_report` tool again (fresh findings list, empty array if clean).",
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface LoopOptions {
|
|
205
|
+
/** The effort level the review runs at (echoed in re-review prompts). */
|
|
206
|
+
level: string;
|
|
207
|
+
/** Max fix→re-review rounds (config maxTurns.loop). */
|
|
208
|
+
passes: number;
|
|
209
|
+
/** Directory the review_report JSON files land in. */
|
|
210
|
+
reviewDir: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Drive the fix→re-review rounds after the FIRST review prompt has already
|
|
215
|
+
* been sent by the dispatcher. Each iteration reads the newest report (the
|
|
216
|
+
* first iteration sees the initial review's report, later ones the previous
|
|
217
|
+
* round's re-review) and sends at most one fix prompt. Runs at most `passes`
|
|
218
|
+
* fix→re-review rounds; the final iteration only reads the last re-review's
|
|
219
|
+
* verdict for the safety-limit message. Returns the number of fix rounds run.
|
|
220
|
+
*/
|
|
221
|
+
export async function runLoopFixing(
|
|
222
|
+
pi: ExtensionAPI,
|
|
223
|
+
ctx: ExtensionCommandContext,
|
|
224
|
+
options: LoopOptions,
|
|
225
|
+
): Promise<number> {
|
|
226
|
+
const { level, passes, reviewDir } = options;
|
|
227
|
+
const baseline = baselineAssistantId(ctx);
|
|
228
|
+
const loopStart = Date.now();
|
|
229
|
+
|
|
230
|
+
let fixes = 0;
|
|
231
|
+
for (;;) {
|
|
232
|
+
if (!(await waitForTurnSettled(ctx, baseline))) return fixes;
|
|
233
|
+
|
|
234
|
+
const reportFile = latestReportFile(reviewDir, loopStart);
|
|
235
|
+
if (reportFile === null) {
|
|
236
|
+
ctx.ui.notify("/code-review --loop: no review_report JSON found — stopping the loop.", "warning");
|
|
237
|
+
return fixes;
|
|
238
|
+
}
|
|
239
|
+
const findings = blockingFindings(readReport(reportFile));
|
|
240
|
+
if (findings.length === 0) {
|
|
241
|
+
ctx.ui.notify(
|
|
242
|
+
fixes === 0
|
|
243
|
+
? "/code-review --loop: no P0/P1 findings — nothing to fix, loop done."
|
|
244
|
+
: `/code-review --loop: clean after ${fixes} fix round(s) — no open P0/P1 findings remain.`,
|
|
245
|
+
"info",
|
|
246
|
+
);
|
|
247
|
+
return fixes;
|
|
248
|
+
}
|
|
249
|
+
if (fixes === passes) {
|
|
250
|
+
ctx.ui.notify(
|
|
251
|
+
`/code-review --loop: ${findings.length} P0/P1 finding(s) still open after ${passes} fix round(s) — safety limit reached, stopping.`,
|
|
252
|
+
"warning",
|
|
253
|
+
);
|
|
254
|
+
return fixes;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fixes++;
|
|
258
|
+
ctx.ui.notify(
|
|
259
|
+
`/code-review --loop: ${findings.length} blocking finding(s) — fixing (round ${fixes}/${passes})…`,
|
|
260
|
+
"info",
|
|
261
|
+
);
|
|
262
|
+
await pi.sendUserMessage(fixPrompt(findings), { deliverAs: "followUp" });
|
|
263
|
+
if (!(await waitForTurnSettled(ctx, baseline))) return fixes;
|
|
264
|
+
pi.sendUserMessage(reReviewPrompt(level), { deliverAs: "followUp" });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -31,12 +31,19 @@ import * as path from "node:path";
|
|
|
31
31
|
const VERDICT_VALUES = ["CONFIRMED", "PLAUSIBLE"] as const;
|
|
32
32
|
const Verdict = StringEnum(VERDICT_VALUES);
|
|
33
33
|
|
|
34
|
-
const OUTCOME_VALUES = ["fixed", "skipped", "no_change_needed"] as const;
|
|
35
34
|
/** CC ReportFindings `outcome` 三档(2.1.227 二进制实证)。fixed-later 再上报时更新。 */
|
|
35
|
+
export const OUTCOME_VALUES = ["fixed", "skipped", "no_change_needed"] as const;
|
|
36
36
|
const Outcome = StringEnum(OUTCOME_VALUES);
|
|
37
37
|
|
|
38
|
+
/** --loop 的 blocking 阈值:P0/P1 触发修复→再评审一轮(P2/P3 只入报告)。 */
|
|
39
|
+
const PRIORITY_VALUES = ["P0", "P1", "P2", "P3"] as const;
|
|
40
|
+
const Priority = StringEnum(PRIORITY_VALUES, {
|
|
41
|
+
description:
|
|
42
|
+
"优先级 P0(阻断,立刻修)/ P1(高)/ P2(中)/ P3(低)。--loop 循环修复以 P0/P1 为 blocking 阈值;省略视为 P2。",
|
|
43
|
+
});
|
|
44
|
+
|
|
38
45
|
// 供 SKILL-schema 同步测试引用(防漂移:SKILL 流程契约不得与常量脱节)。
|
|
39
|
-
export {
|
|
46
|
+
export { PRIORITY_VALUES, VERDICT_VALUES };
|
|
40
47
|
|
|
41
48
|
const Level = StringEnum([
|
|
42
49
|
"low",
|
|
@@ -59,6 +66,7 @@ const FindingParams = Type.Object({
|
|
|
59
66
|
"产生该发现的角度 slug:correctness / reuse / simplification / efficiency / altitude / conventions(或更具体如 test-coverage)。",
|
|
60
67
|
}),
|
|
61
68
|
verdict: Type.Optional(Verdict),
|
|
69
|
+
priority: Type.Optional(Priority),
|
|
62
70
|
short_summary: Type.Optional(
|
|
63
71
|
Type.String({
|
|
64
72
|
description:
|
|
@@ -91,6 +99,31 @@ const ReviewReportParams = Type.Object({
|
|
|
91
99
|
),
|
|
92
100
|
});
|
|
93
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 结果 schema——与落盘 `.pi/review/*.json` 对象同构(design D2 单一事实源):
|
|
104
|
+
* `structuredContent` 与磁盘 JSON 消费方(codemode 脚本 vs CI/--fix)观察同值。
|
|
105
|
+
* 字段单一来源:复用 FindingParams.properties(入参与出参同形,仅追加 note)。
|
|
106
|
+
*/
|
|
107
|
+
const FindingOutput = Type.Object({
|
|
108
|
+
...FindingParams.properties,
|
|
109
|
+
/** normalize 附注(非法 outcome 归一化说明),仅详情渲染用。 */
|
|
110
|
+
note: Type.Optional(Type.String()),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const ReviewReportOutput = Type.Object({
|
|
114
|
+
level: Level,
|
|
115
|
+
reportId: Type.Union([Type.String(), Type.Null()]),
|
|
116
|
+
target: Type.Union([Type.String(), Type.Null()]),
|
|
117
|
+
filesChanged: Type.Union([Type.Number(), Type.Null()]),
|
|
118
|
+
fannedOut: Type.Union([Type.Boolean(), Type.Null()]),
|
|
119
|
+
generatedAt: Type.String(),
|
|
120
|
+
findings: Type.Array(FindingOutput),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Static 派生类型:reportData 的类型即出参 schema,类型层单一事实源(消除断言)。 */
|
|
124
|
+
type FindingOut = Static<typeof FindingOutput>;
|
|
125
|
+
type ReviewReportOut = Static<typeof ReviewReportOutput>;
|
|
126
|
+
|
|
94
127
|
interface ReviewReportDetails {
|
|
95
128
|
level: string;
|
|
96
129
|
findingsCount: number;
|
|
@@ -108,6 +141,7 @@ type LooseFinding = {
|
|
|
108
141
|
line?: number;
|
|
109
142
|
category: string;
|
|
110
143
|
verdict?: string;
|
|
144
|
+
priority?: string;
|
|
111
145
|
short_summary?: string;
|
|
112
146
|
summary: string;
|
|
113
147
|
failure_scenario: string;
|
|
@@ -126,7 +160,10 @@ function sanitizeFinding(f: LooseFinding): { f: LooseFinding; note?: string } |
|
|
|
126
160
|
note = `(outcome "${outcome}" 非法,已归一化为 skipped)`;
|
|
127
161
|
outcome = "skipped";
|
|
128
162
|
}
|
|
129
|
-
|
|
163
|
+
// 非法 priority 静默丢弃(降至未标注),不影响该条 finding 存活。
|
|
164
|
+
const priority =
|
|
165
|
+
f.priority !== undefined && (PRIORITY_VALUES as readonly string[]).includes(f.priority) ? f.priority : undefined;
|
|
166
|
+
return { f: { ...f, outcome, priority }, note };
|
|
130
167
|
}
|
|
131
168
|
|
|
132
169
|
/**
|
|
@@ -150,25 +187,13 @@ function normalizeFindings(findings: LooseFinding[]): { findings: LooseFinding[]
|
|
|
150
187
|
|
|
151
188
|
// --- render -----------------------------------------------------------------
|
|
152
189
|
|
|
153
|
-
interface FindingInput {
|
|
154
|
-
file: string;
|
|
155
|
-
line?: number;
|
|
156
|
-
category: string;
|
|
157
|
-
verdict?: string;
|
|
158
|
-
short_summary?: string;
|
|
159
|
-
summary: string;
|
|
160
|
-
failure_scenario: string;
|
|
161
|
-
outcome?: string;
|
|
162
|
-
/** normalize 附注(如非法 outcome 归一化说明),仅渲染进详情块。 */
|
|
163
|
-
note?: string;
|
|
164
|
-
}
|
|
165
190
|
interface ReportInput {
|
|
166
191
|
level: string;
|
|
167
192
|
target?: string;
|
|
168
193
|
files_changed?: number;
|
|
169
194
|
fanned_out?: boolean;
|
|
170
195
|
reportId?: string;
|
|
171
|
-
findings:
|
|
196
|
+
findings: FindingOut[];
|
|
172
197
|
}
|
|
173
198
|
|
|
174
199
|
function fmtLoc(f: { file: string; line?: number }): string {
|
|
@@ -202,13 +227,14 @@ function renderReport(p: ReportInput): string {
|
|
|
202
227
|
lines.push("|---|------|------|------|------|");
|
|
203
228
|
for (let i = 0; i < p.findings.length; i++) {
|
|
204
229
|
const f = p.findings[i]!;
|
|
205
|
-
|
|
230
|
+
const verdictCell = [f.priority, f.verdict].filter(Boolean).join(" · ");
|
|
231
|
+
lines.push(`| ${i + 1} | ${escapeCell(verdictCell)} | ${escapeCell(f.category)} | ${escapeCell(fmtLoc(f))} | ${escapeCell(f.short_summary ?? f.summary)} |`);
|
|
206
232
|
}
|
|
207
233
|
lines.push("");
|
|
208
234
|
lines.push("**详情**");
|
|
209
235
|
lines.push("");
|
|
210
236
|
p.findings.forEach((f, i) => {
|
|
211
|
-
const v = f.verdict ? ` *(${f.verdict})*` : "";
|
|
237
|
+
const v = [f.priority, f.verdict].filter(Boolean).length > 0 ? ` *(${[f.priority, f.verdict].filter(Boolean).join(" · ")})*` : "";
|
|
212
238
|
const out = f.outcome ? `\n修复结果:\`${f.outcome}\`` : "";
|
|
213
239
|
const note = f.note ? `\n${f.note}` : "";
|
|
214
240
|
lines.push(`**${i + 1}. ${fmtLoc(f)} — ${f.category}**${v}`);
|
|
@@ -233,6 +259,9 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
|
|
|
233
259
|
"Use `review_report` only when the code-review skill instructs reporting findings; otherwise follow the active output format.",
|
|
234
260
|
],
|
|
235
261
|
parameters: ReviewReportParams,
|
|
262
|
+
// codemode 脚本/程序化调用方拿到 structuredContent(与磁盘 JSON 同构)而非文本;
|
|
263
|
+
// 模型侧行为(content/renderResult/落盘)不变。
|
|
264
|
+
outputSchema: ReviewReportOutput,
|
|
236
265
|
|
|
237
266
|
// 主防御:schema 校验之前清洗非法值(模型路径下校验失败即 throw、工具不执行,
|
|
238
267
|
// 因此 execute 内的防御对模型不可达)。返回符合 schema 的对象——非法 verdict
|
|
@@ -254,7 +283,38 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
|
|
|
254
283
|
const { findings: cleaned, notes } = normalizeFindings(
|
|
255
284
|
(params.findings ?? []) as unknown as LooseFinding[],
|
|
256
285
|
);
|
|
257
|
-
|
|
286
|
+
// 仅含已赋值字段的 finding:显式 undefined 键会让磁盘 JSON(serialize 丢弃)与
|
|
287
|
+
// structuredContent(原样返回)键集分叉;条件赋值保证两个视图逐键一致。
|
|
288
|
+
const findings: FindingOut[] = cleaned.map((f, i) => {
|
|
289
|
+
const note = notes.get(i);
|
|
290
|
+
const out: FindingOut = {
|
|
291
|
+
file: f.file,
|
|
292
|
+
category: f.category,
|
|
293
|
+
summary: f.summary,
|
|
294
|
+
failure_scenario: f.failure_scenario,
|
|
295
|
+
};
|
|
296
|
+
if (f.line !== undefined) out.line = f.line;
|
|
297
|
+
// 值域由 sanitizeFinding/normalizeFindings 保证(非法值已剔除/归一化),枚举收窄安全。
|
|
298
|
+
if (f.verdict !== undefined) out.verdict = f.verdict as FindingOut["verdict"];
|
|
299
|
+
if (f.priority !== undefined) out.priority = f.priority as FindingOut["priority"];
|
|
300
|
+
if (f.short_summary !== undefined) out.short_summary = f.short_summary;
|
|
301
|
+
if (f.outcome !== undefined) out.outcome = f.outcome as FindingOut["outcome"];
|
|
302
|
+
if (note !== undefined) out.note = note;
|
|
303
|
+
return out;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// 单一事实源(design D2):同一对象写盘 + 作 structuredContent;类型即出参
|
|
307
|
+
// schema 的 Static 派生,无需断言。
|
|
308
|
+
const now = new Date();
|
|
309
|
+
const reportData: ReviewReportOut = {
|
|
310
|
+
level: params.level,
|
|
311
|
+
reportId: params.report_id ?? null,
|
|
312
|
+
target: params.target ?? null,
|
|
313
|
+
filesChanged: params.files_changed ?? null,
|
|
314
|
+
fannedOut: params.fanned_out ?? null,
|
|
315
|
+
generatedAt: now.toISOString(),
|
|
316
|
+
findings,
|
|
317
|
+
};
|
|
258
318
|
|
|
259
319
|
const report = renderReport({
|
|
260
320
|
level: params.level,
|
|
@@ -267,7 +327,6 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
|
|
|
267
327
|
|
|
268
328
|
let outFile: string | null = null;
|
|
269
329
|
let writeError: string | null = null;
|
|
270
|
-
const now = new Date();
|
|
271
330
|
try {
|
|
272
331
|
const dir = path.join(ctx.cwd, CONFIG_DIR_NAME, "review");
|
|
273
332
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
@@ -276,19 +335,7 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
|
|
|
276
335
|
const fp = path.join(dir, `${ts}-${safeId}.json`);
|
|
277
336
|
await fs.promises.writeFile(
|
|
278
337
|
fp,
|
|
279
|
-
JSON.stringify(
|
|
280
|
-
{
|
|
281
|
-
level: params.level,
|
|
282
|
-
reportId: params.report_id ?? null,
|
|
283
|
-
target: params.target ?? null,
|
|
284
|
-
filesChanged: params.files_changed ?? null,
|
|
285
|
-
fannedOut: params.fanned_out ?? null,
|
|
286
|
-
generatedAt: now.toISOString(),
|
|
287
|
-
findings,
|
|
288
|
-
},
|
|
289
|
-
null,
|
|
290
|
-
2,
|
|
291
|
-
),
|
|
338
|
+
JSON.stringify(reportData, null, 2),
|
|
292
339
|
{ encoding: "utf-8", mode: 0o600 },
|
|
293
340
|
);
|
|
294
341
|
outFile = fp;
|
|
@@ -308,6 +355,7 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
|
|
|
308
355
|
};
|
|
309
356
|
return {
|
|
310
357
|
content: [{ type: "text" as const, text: report + tail }],
|
|
358
|
+
structuredContent: reportData,
|
|
311
359
|
details,
|
|
312
360
|
};
|
|
313
361
|
},
|