co-maintainer 0.4.13 → 0.5.0-beta.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/LICENSE +21 -21
- package/README.md +57 -38
- package/dist/main.js +2 -3
- package/dist/package.json +21 -15
- package/dist/src/ai/batch.d.ts +24 -1
- package/dist/src/ai/batch.js +75 -23
- package/dist/src/ai/estimate.d.ts +40 -0
- package/dist/src/ai/estimate.js +113 -0
- package/dist/src/ai/fake.d.ts +1 -1
- package/dist/src/ai/fake.js +1 -1
- package/dist/src/ai/hetzner.js +1 -1
- package/dist/src/ai/openrouter.d.ts +13 -0
- package/dist/src/ai/openrouter.js +66 -12
- package/dist/src/ai/pricing.d.ts +14 -0
- package/dist/src/ai/pricing.js +77 -0
- package/dist/src/ai/provider.js +8 -0
- package/dist/src/ai/verify.d.ts +25 -0
- package/dist/src/ai/verify.js +78 -0
- package/dist/src/cli/args.js +25 -29
- package/dist/src/cli/commands/config.d.ts +44 -0
- package/dist/src/cli/commands/config.js +220 -0
- package/dist/src/cli/commands/probe.js +70 -103
- package/dist/src/cli/commands/registry.d.ts +57 -0
- package/dist/src/cli/commands/registry.js +713 -0
- package/dist/src/cli/commands/review.js +66 -43
- package/dist/src/cli/commands/serve.d.ts +4 -2
- package/dist/src/cli/commands/serve.js +14 -10
- package/dist/src/cli/commands/set.d.ts +2 -1
- package/dist/src/cli/commands/set.js +63 -17
- package/dist/src/cli/commands/view.d.ts +1 -0
- package/dist/src/cli/commands/view.js +180 -0
- package/dist/src/cli/error.d.ts +39 -0
- package/dist/src/cli/error.js +64 -0
- package/dist/src/cli/main.d.ts +7 -0
- package/dist/src/cli/main.js +82 -1
- package/dist/src/cli/prompt.js +21 -7
- package/dist/src/cli/review_args.d.ts +4 -0
- package/dist/src/cli/review_args.js +22 -9
- package/dist/src/cli/review_output.d.ts +2 -2
- package/dist/src/cli/review_output.js +7 -6
- package/dist/src/cli/review_result.d.ts +59 -8
- package/dist/src/cli/review_result.js +145 -103
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +31 -0
- package/dist/src/github/app.d.ts +13 -0
- package/dist/src/github/app.js +23 -0
- package/dist/src/github/app_manifest.d.ts +50 -0
- package/dist/src/github/app_manifest.js +138 -0
- package/dist/src/github/client.js +7 -1
- package/dist/src/github/collect.js +1 -1
- package/dist/src/github/gh.js +75 -18
- package/dist/src/knowledge/facts.js +8 -3
- package/dist/src/knowledge/guide.d.ts +8 -0
- package/dist/src/knowledge/guide.js +32 -8
- package/dist/src/knowledge/probe.js +13 -3
- package/dist/src/knowledge/sections.d.ts +9 -0
- package/dist/src/knowledge/sections.js +16 -0
- package/dist/src/knowledge/skill.d.ts +5 -0
- package/dist/src/knowledge/skill.js +68 -21
- package/dist/src/knowledge/synthesis.d.ts +17 -1
- package/dist/src/knowledge/synthesis.js +70 -15
- package/dist/src/knowledge/types.d.ts +5 -0
- package/dist/src/local/codegraph_prepare.d.ts +3 -2
- package/dist/src/local/codegraph_prepare.js +4 -2
- package/dist/src/local/git_ops.d.ts +5 -4
- package/dist/src/local/git_ops.js +8 -9
- package/dist/src/local/review_local.js +36 -14
- package/dist/src/pr/checkout.js +14 -3
- package/dist/src/pr/codegraph_tools.js +3 -3
- package/dist/src/pr/diff_summary.js +3 -3
- package/dist/src/pr/findings.js +16 -5
- package/dist/src/pr/findings_json.d.ts +26 -0
- package/dist/src/pr/findings_json.js +187 -0
- package/dist/src/pr/review_copy.d.ts +20 -0
- package/dist/src/pr/review_copy.js +40 -0
- package/dist/src/pr/reviewer.d.ts +2 -1
- package/dist/src/pr/reviewer.js +84 -91
- package/dist/src/remote/client.js +42 -42
- package/dist/src/remote/http.d.ts +5 -0
- package/dist/src/remote/http.js +49 -0
- package/dist/src/remote/server/guides.d.ts +22 -0
- package/dist/src/remote/server/guides.js +81 -0
- package/dist/src/remote/server/routes.js +24 -0
- package/dist/src/review/blocking.d.ts +35 -0
- package/dist/src/review/blocking.js +45 -0
- package/dist/src/review/carry_over.d.ts +12 -1
- package/dist/src/review/carry_over.js +35 -9
- package/dist/src/review/engine.d.ts +1 -0
- package/dist/src/review/guides.js +28 -1
- package/dist/src/server/api/installations.js +1 -1
- package/dist/src/server/api/repos.js +86 -3
- package/dist/src/server/api/settings.js +3 -3
- package/dist/src/server/app.d.ts +3 -0
- package/dist/src/server/app.js +12 -5
- package/dist/src/server/pages/activity.d.ts +1 -0
- package/dist/src/server/pages/activity.js +2 -2
- package/dist/src/server/pages/add_repo.js +73 -5
- package/dist/src/server/pages/client.d.ts +1 -1
- package/dist/src/server/pages/client.js +1 -1
- package/dist/src/server/pages/home.d.ts +6 -1
- package/dist/src/server/pages/home.js +25 -2
- package/dist/src/server/pages/knowledge.js +1 -1
- package/dist/src/server/pages/layout.js +2 -2
- package/dist/src/server/pages/repo.d.ts +1 -1
- package/dist/src/server/pages/repo.js +21 -2
- package/dist/src/server/pages/repo_prs.d.ts +5 -1
- package/dist/src/server/pages/repo_prs.js +36 -1
- package/dist/src/server/pages/repo_remote.js +2 -2
- package/dist/src/server/pages/repo_settings.js +2 -2
- package/dist/src/server/pages/router.d.ts +6 -0
- package/dist/src/server/pages/router.js +129 -9
- package/dist/src/server/pages/settings.d.ts +1 -1
- package/dist/src/server/pages/settings.js +87 -9
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/probe.d.ts +28 -0
- package/dist/src/services/probe.js +133 -0
- package/dist/src/services/remake_cron.d.ts +1 -1
- package/dist/src/services/remake_cron.js +3 -3
- package/dist/src/services/remote_review.js +14 -4
- package/dist/src/services/review.d.ts +11 -0
- package/dist/src/services/review.js +39 -11
- package/dist/src/services/setup.js +37 -19
- package/dist/src/services/setup_checklist.d.ts +10 -0
- package/dist/src/services/setup_checklist.js +87 -0
- package/dist/src/store/app_db.js +5 -2
- package/dist/src/store/cache_db.d.ts +4 -0
- package/dist/src/store/cache_db.js +27 -4
- package/dist/src/store/deliveries.d.ts +7 -0
- package/dist/src/store/deliveries.js +15 -0
- package/dist/src/tools/codegraph.d.ts +30 -0
- package/dist/src/tools/codegraph.js +49 -12
- package/dist/src/types.d.ts +10 -0
- package/dist/src/util/log.d.ts +3 -0
- package/dist/src/util/log.js +12 -0
- package/dist/src/util/run_summary.d.ts +33 -0
- package/dist/src/util/run_summary.js +47 -0
- package/dist/src/util/webhook_reachability.d.ts +11 -0
- package/dist/src/util/webhook_reachability.js +64 -0
- package/package.json +21 -15
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AiBatch } from "../ai/batch.js";
|
|
2
|
-
import {
|
|
2
|
+
import { isPullRequestFact } from "./guide.js";
|
|
3
|
+
import { sectionAllowsPrFacts, sectionTitles } from "./sections.js";
|
|
3
4
|
const allowedSections = new Set([
|
|
4
5
|
"identity",
|
|
5
6
|
"devloop",
|
|
@@ -40,7 +41,7 @@ function parseJson(text) {
|
|
|
40
41
|
return JSON.parse(`${cleaned.slice(start, lastCompleteObject + 1)}]`);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
function factsFromResponse(text, evidence, defaultScope) {
|
|
44
|
+
function factsFromResponse(text, evidence, defaultScope, origin) {
|
|
44
45
|
const parsed = parseJson(text);
|
|
45
46
|
if (!Array.isArray(parsed)) {
|
|
46
47
|
throw new Error("AI fact output must be an array");
|
|
@@ -73,6 +74,7 @@ function factsFromResponse(text, evidence, defaultScope) {
|
|
|
73
74
|
scope,
|
|
74
75
|
confidence,
|
|
75
76
|
status: "active",
|
|
77
|
+
origin,
|
|
76
78
|
},
|
|
77
79
|
];
|
|
78
80
|
});
|
|
@@ -124,8 +126,31 @@ function pullRequestUnit(pr) {
|
|
|
124
126
|
diff: promptText(pr.diff, 8_000),
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
|
-
function queueFor(provider, repo, ai, model, concurrency) {
|
|
128
|
-
return new AiBatch(provider, repo, Math.max(1, concurrency), `${ai}:v2:${model}
|
|
129
|
+
function queueFor(provider, repo, ai, model, concurrency, validate) {
|
|
130
|
+
return new AiBatch(provider, repo, Math.max(1, concurrency), `${ai}:v2:${model}`, validate);
|
|
131
|
+
}
|
|
132
|
+
/** `factsFromResponse` as a validator: `null` when the text parses into facts,
|
|
133
|
+
* otherwise the parse error's message. Used so an unparseable response is never
|
|
134
|
+
* cached (CORE-30). */
|
|
135
|
+
function validateFacts(response, evidence, defaultScope) {
|
|
136
|
+
try {
|
|
137
|
+
factsFromResponse(response.text, evidence, defaultScope, "repository");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
142
|
+
return `${evidence}: ${message}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** `validSection` as a validator: `null` when the section body is usable,
|
|
146
|
+
* otherwise a short reason. A section that is empty or malformed is retried
|
|
147
|
+
* once and then left uncached, so a later `sync` does not replay it. */
|
|
148
|
+
function validateSection(response, key) {
|
|
149
|
+
const section = cleanSection(response.text, key);
|
|
150
|
+
if (!validSection(section, key)) {
|
|
151
|
+
return "synthesis output is not a valid section";
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
129
154
|
}
|
|
130
155
|
function extractRequest(prompt, maxTokens) {
|
|
131
156
|
return {
|
|
@@ -151,17 +176,20 @@ function validSection(text, key) {
|
|
|
151
176
|
}
|
|
152
177
|
function sectionGoal(key) {
|
|
153
178
|
const goals = {
|
|
154
|
-
layout: "Describe module responsibilities and important dependency or change-impact paths
|
|
179
|
+
layout: "Describe module responsibilities and important dependency or change-impact paths. Do not list routine public symbols.",
|
|
155
180
|
tests: "State which verification is required after a behavior change and the dependency/setup that makes it meaningful.",
|
|
156
181
|
devloop: "Give local build, debugging, or change-impact guidance that is not already stated in Shipping.",
|
|
157
|
-
ship: "Give one concise release/CI checklist
|
|
182
|
+
ship: "Give one concise release/CI checklist. Merge overlapping workflow, artifact, and release-gate facts.",
|
|
158
183
|
style: "Keep only repository-specific, evidenced conventions. Return only the heading when none are actionable.",
|
|
159
184
|
};
|
|
160
185
|
return goals[key] ?? "Keep only actionable repository-specific guidance.";
|
|
161
186
|
}
|
|
162
187
|
function synthesisFacts(facts, key) {
|
|
163
|
-
return facts
|
|
164
|
-
|
|
188
|
+
return (facts
|
|
189
|
+
// A section that states repository policy never learns from a single pull
|
|
190
|
+
// request's narrative; only `review-bar` may (CORE-32 / F26b).
|
|
191
|
+
.filter((item) => item.sectionKey === key &&
|
|
192
|
+
(sectionAllowsPrFacts(key) || !isPullRequestFact(item)))
|
|
165
193
|
.sort((a, b) => (b.status === "active" ? 1 : 0) - (a.status === "active" ? 1 : 0) ||
|
|
166
194
|
({
|
|
167
195
|
current: 3,
|
|
@@ -176,9 +204,12 @@ function synthesisFacts(facts, key) {
|
|
|
176
204
|
b.weight - a.weight ||
|
|
177
205
|
b.evidence.length - a.evidence.length ||
|
|
178
206
|
a.claim.localeCompare(b.claim))
|
|
179
|
-
.slice(0, 20);
|
|
207
|
+
.slice(0, 20));
|
|
180
208
|
}
|
|
181
209
|
export async function extractAiFacts(provider, repo, source, options, usage) {
|
|
210
|
+
return (await extractAiFactsWithReport(provider, repo, source, options, usage)).facts;
|
|
211
|
+
}
|
|
212
|
+
export async function extractAiFactsWithReport(provider, repo, source, options, usage) {
|
|
182
213
|
const requests = [];
|
|
183
214
|
const evidence = [];
|
|
184
215
|
const scopes = [];
|
|
@@ -220,7 +251,13 @@ ${files}`, 2_200));
|
|
|
220
251
|
scopes.push("current");
|
|
221
252
|
}
|
|
222
253
|
}
|
|
223
|
-
const queue = queueFor(provider, repo, options.ai, options.lowModel ?? "", options.aiConcurrent
|
|
254
|
+
const queue = queueFor(provider, repo, options.ai, options.lowModel ?? "", options.aiConcurrent,
|
|
255
|
+
// Each request carries different evidence, so the validator looks the
|
|
256
|
+
// request up by identity to find which evidence it belongs to.
|
|
257
|
+
(request, response) => {
|
|
258
|
+
const at = requests.indexOf(request);
|
|
259
|
+
return validateFacts(response, evidence[at] ?? "repository files", scopes[at] ?? "historical-example");
|
|
260
|
+
});
|
|
224
261
|
const responses = await queue.run(requests, usage);
|
|
225
262
|
const facts = [];
|
|
226
263
|
for (let index = 0; index < responses.length; index++) {
|
|
@@ -228,7 +265,9 @@ ${files}`, 2_200));
|
|
|
228
265
|
if (!response)
|
|
229
266
|
continue;
|
|
230
267
|
try {
|
|
231
|
-
facts.push(...factsFromResponse(response.text, evidence[index], scopes[index] ?? "historical-example"
|
|
268
|
+
facts.push(...factsFromResponse(response.text, evidence[index], scopes[index] ?? "historical-example", evidence[index] === "repository files"
|
|
269
|
+
? "repository"
|
|
270
|
+
: "pull-request").filter((item) => !hasUnsupportedIdentifier(item.claim, source) &&
|
|
232
271
|
!hasUnsupportedPath(item.claim, source) &&
|
|
233
272
|
(evidence[index] !== "repository files" ||
|
|
234
273
|
allowsContributionSections(source) ||
|
|
@@ -241,7 +280,13 @@ ${files}`, 2_200));
|
|
|
241
280
|
console.log(`[ai] extract units ${index + 1}/${responses.length}`);
|
|
242
281
|
}
|
|
243
282
|
}
|
|
244
|
-
return facts;
|
|
283
|
+
return { facts, skipped: queue.skippedUnits() };
|
|
284
|
+
}
|
|
285
|
+
/** Same as {@link extractAiFacts}, but also reports what the batch skipped so
|
|
286
|
+
* the caller can put it in the final `[done]` line (CORE-30). */
|
|
287
|
+
export async function enrichFactsWithReport(provider, repo, base, source, options, usage) {
|
|
288
|
+
const { facts, skipped } = await extractAiFactsWithReport(provider, repo, source, options, usage);
|
|
289
|
+
return { facts: mergeFacts(base, facts), skipped };
|
|
245
290
|
}
|
|
246
291
|
function mergeFacts(base, extra) {
|
|
247
292
|
const merged = new Map(base.map((item) => [item.id, { ...item, evidence: [...item.evidence] }]));
|
|
@@ -268,6 +313,11 @@ export async function synthesizeSections(provider, repo, facts, _previousMarkdow
|
|
|
268
313
|
if (onlySections && !onlySections.has(key))
|
|
269
314
|
continue;
|
|
270
315
|
const relevant = synthesisFacts(facts, key);
|
|
316
|
+
// A section whose only facts came from a single pull request has no
|
|
317
|
+
// repository policy to synthesize, so it is left to the deterministic
|
|
318
|
+
// renderer instead of asking the model for an empty answer (CORE-32).
|
|
319
|
+
if (!relevant.length)
|
|
320
|
+
continue;
|
|
271
321
|
keys.push(key);
|
|
272
322
|
requests.push({
|
|
273
323
|
job: "synth_section",
|
|
@@ -304,12 +354,17 @@ FACTS:
|
|
|
304
354
|
${JSON.stringify(relevant)}`,
|
|
305
355
|
});
|
|
306
356
|
}
|
|
307
|
-
const queue = queueFor(provider, repo, ai, model, ai === "hetzner" ? 1 : concurrency);
|
|
357
|
+
const queue = queueFor(provider, repo, ai, model, ai === "hetzner" ? 1 : concurrency, (request, response) => validateSection(response, keys[requests.indexOf(request)] ?? ""));
|
|
308
358
|
const responses = await queue.run(requests, usage);
|
|
359
|
+
// A section that the batch skipped has no response, and a unit that failed
|
|
360
|
+
// validation never produces one: omit those keys entirely rather than
|
|
361
|
+
// passing an empty string, so `assembleSkill` keeps its own content for them
|
|
362
|
+
// (an empty string is a *present* override and would blank the section).
|
|
309
363
|
const overrides = {};
|
|
310
364
|
responses.forEach((response, index) => {
|
|
311
365
|
const section = response ? cleanSection(response.text, keys[index]) : "";
|
|
312
|
-
|
|
366
|
+
if (validSection(section, keys[index]))
|
|
367
|
+
overrides[keys[index]] = section;
|
|
313
368
|
});
|
|
314
|
-
return overrides;
|
|
369
|
+
return { overrides, skipped: queue.skippedUnits() };
|
|
315
370
|
}
|
|
@@ -39,6 +39,11 @@ export type Fact = {
|
|
|
39
39
|
scope: "current" | "repeated-history" | "historical-example";
|
|
40
40
|
confidence: "high" | "medium" | "low";
|
|
41
41
|
status: "active" | "contradicted" | "stale";
|
|
42
|
+
/** Where the fact was mined from. A fact read out of a single pull request
|
|
43
|
+
* describes that request, not the repository, so it may not become a
|
|
44
|
+
* repository-wide rule (CORE-32 / F26b). Missing means `repository`, which
|
|
45
|
+
* keeps facts written before this field existed. */
|
|
46
|
+
origin?: "repository" | "pull-request";
|
|
42
47
|
};
|
|
43
48
|
export type State = {
|
|
44
49
|
version: 1;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ToolHandler } from "../ai/mermaid_loop.ts";
|
|
2
1
|
import { type Presence } from "../tools/codegraph.ts";
|
|
2
|
+
import type { ToolHandler } from "../ai/mermaid_loop.ts";
|
|
3
3
|
import { type Run } from "../pr/checkout.ts";
|
|
4
4
|
export type LocalCodegraphState = "used" | "disabled" | "unavailable";
|
|
5
5
|
export type LocalCodegraphPrepare = {
|
|
@@ -11,7 +11,8 @@ export declare function prepareLocalCodegraph(input: {
|
|
|
11
11
|
gitRoot: string;
|
|
12
12
|
enabled: boolean;
|
|
13
13
|
allowInstall: boolean;
|
|
14
|
-
|
|
14
|
+
/** Overrides the TTY/CI detection, for tests. */
|
|
15
|
+
interactive?: boolean;
|
|
15
16
|
run?: Run;
|
|
16
17
|
detect?: () => Promise<Presence>;
|
|
17
18
|
}): Promise<LocalCodegraphPrepare>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { canPrompt, ensureCodegraphForReview, } from "../tools/codegraph.js";
|
|
1
2
|
import { codegraphTools, ensureCodegraphIndex } from "../pr/codegraph_tools.js";
|
|
2
|
-
import { ensureCodegraphForReview } from "../tools/codegraph.js";
|
|
3
3
|
import { createCodegraphRunner, LOCAL_CODEGRAPH_DIR, } from "../tools/codegraph_exec.js";
|
|
4
4
|
import { log, startHeartbeat } from "../util/log.js";
|
|
5
5
|
import { ensureCodegraphGitExclude } from "./codegraph_exclude.js";
|
|
@@ -12,7 +12,9 @@ export async function prepareLocalCodegraph(input) {
|
|
|
12
12
|
await ensureCodegraphGitExclude(input.gitRoot, run);
|
|
13
13
|
const resolved = await ensureCodegraphForReview({
|
|
14
14
|
allowInstall: input.allowInstall,
|
|
15
|
-
|
|
15
|
+
// The prompt is safe only when a human can answer it; `canPrompt` checks
|
|
16
|
+
// stdin, stdout and `CI`, so a `</dev/null` or piped run never asks (F01).
|
|
17
|
+
interactive: input.interactive ?? canPrompt(),
|
|
16
18
|
log: (message) => log("codegraph", message.replace(/^\[codegraph\]\s*/, "")),
|
|
17
19
|
});
|
|
18
20
|
if (!("path" in resolved)) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type Run } from "../pr/checkout.ts";
|
|
2
|
+
import { CliError } from "../cli/error.ts";
|
|
2
3
|
export declare function gitRoot(cwd: string, run?: Run): Promise<string>;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/** Backward-compatible name for the shared CLI error. Local and remote review
|
|
5
|
+
* threw this before CORE-10 introduced `CliError`, so it stays as a thin
|
|
6
|
+
* subclass and every existing call site keeps working. */
|
|
7
|
+
export declare class ReviewCliError extends CliError {
|
|
7
8
|
constructor(code: string, message: string, hint?: string, exitCode?: number);
|
|
8
9
|
}
|
|
9
10
|
export declare function assertGitQuiet(cwd: string, run?: Run): Promise<void>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runCommand } from "../pr/checkout.js";
|
|
2
2
|
import { normalizeGithubRemote } from "./git_parse.js";
|
|
3
3
|
import { stat } from "../util/runtime.js";
|
|
4
|
+
import { CliError, EXIT_USAGE } from "../cli/error.js";
|
|
4
5
|
export async function gitRoot(cwd, run = runCommand) {
|
|
5
6
|
const result = await run("git", ["rev-parse", "--show-toplevel"], cwd);
|
|
6
7
|
if (result.code !== 0) {
|
|
@@ -8,15 +9,13 @@ export async function gitRoot(cwd, run = runCommand) {
|
|
|
8
9
|
}
|
|
9
10
|
return result.stdout.trim();
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
constructor(code, message, hint, exitCode =
|
|
16
|
-
super(message);
|
|
17
|
-
this.
|
|
18
|
-
this.hint = hint;
|
|
19
|
-
this.exitCode = exitCode;
|
|
12
|
+
/** Backward-compatible name for the shared CLI error. Local and remote review
|
|
13
|
+
* threw this before CORE-10 introduced `CliError`, so it stays as a thin
|
|
14
|
+
* subclass and every existing call site keeps working. */
|
|
15
|
+
export class ReviewCliError extends CliError {
|
|
16
|
+
constructor(code, message, hint, exitCode = EXIT_USAGE) {
|
|
17
|
+
super(code, message, hint, exitCode);
|
|
18
|
+
this.name = "ReviewCliError";
|
|
20
19
|
}
|
|
21
20
|
}
|
|
22
21
|
export async function assertGitQuiet(cwd, run = runCommand) {
|
|
@@ -4,16 +4,19 @@ import { buildLocalReviewJson, formatHumanLocalReview, resolvedFromFirstReview,
|
|
|
4
4
|
import { emptyAiMetrics, recordAiCost, runInitOrRemake, } from "../services/setup.js";
|
|
5
5
|
import { aiFor } from "../services/review.js";
|
|
6
6
|
import { loadGuides } from "../review/guides.js";
|
|
7
|
-
import { buildCarryPromptSection, classifyCarryItems, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
|
|
7
|
+
import { buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
|
|
8
8
|
import { revisionHash } from "../review/revision.js";
|
|
9
9
|
import { parseFindings } from "../pr/findings.js";
|
|
10
10
|
import { reviewWorkspaceRevision } from "../pr/reviewer.js";
|
|
11
11
|
import { runCommand } from "../pr/checkout.js";
|
|
12
12
|
import { log, startHeartbeat, timed, withCliLogsToStderr, } from "../util/log.js";
|
|
13
|
+
import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
|
|
13
14
|
import { setCliInteractive } from "../cli/args.js";
|
|
14
15
|
import { prepareLocalCodegraph } from "./codegraph_prepare.js";
|
|
16
|
+
import { canPrompt } from "../tools/codegraph.js";
|
|
15
17
|
import { acquireLocalReviewLock } from "./review_lock.js";
|
|
16
18
|
import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, headSha, ReviewCliError, } from "./git_ops.js";
|
|
19
|
+
import { EXIT_RUNTIME, exitWith } from "../cli/error.js";
|
|
17
20
|
import { buildLocalRevision, mergeBase, resolveBaseRef, } from "./git_revision.js";
|
|
18
21
|
import { clearLocalCarry, localSubjectId, saveLocalCarry, tryLoadLocalCarry, } from "./carry_over_store.js";
|
|
19
22
|
function storedFindings(resolved) {
|
|
@@ -45,7 +48,9 @@ function fail(error, json) {
|
|
|
45
48
|
if (error.hint)
|
|
46
49
|
console.error(`Hint: ${error.hint}`);
|
|
47
50
|
}
|
|
48
|
-
process.exit
|
|
51
|
+
// Set the exit code and return; `process.exit` here would assert in libuv on
|
|
52
|
+
// Windows whenever a fetch pool is open (CORE-11).
|
|
53
|
+
exitWith(error.exitCode);
|
|
49
54
|
}
|
|
50
55
|
function installInterruptCleanup(releaseSync, json) {
|
|
51
56
|
const onSignal = () => {
|
|
@@ -58,10 +63,10 @@ function installInterruptCleanup(releaseSync, json) {
|
|
|
58
63
|
code: "aborted",
|
|
59
64
|
message: "Review canceled.",
|
|
60
65
|
},
|
|
61
|
-
exitCode:
|
|
66
|
+
exitCode: EXIT_RUNTIME,
|
|
62
67
|
}));
|
|
63
68
|
}
|
|
64
|
-
|
|
69
|
+
exitWith(EXIT_RUNTIME);
|
|
65
70
|
};
|
|
66
71
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
67
72
|
try {
|
|
@@ -106,7 +111,7 @@ export async function runLocalReview(cli) {
|
|
|
106
111
|
}
|
|
107
112
|
if (cli.remakeBeforeReview) {
|
|
108
113
|
if (options.auth !== "gh") {
|
|
109
|
-
throw new ReviewCliError("usage", "
|
|
114
|
+
throw new ReviewCliError("usage", "Sync before review requires GitHub CLI authentication (`--auth=gh`).");
|
|
110
115
|
}
|
|
111
116
|
await runInitOrRemake({ ...options, command: "remake" });
|
|
112
117
|
}
|
|
@@ -139,7 +144,7 @@ export async function runLocalReview(cli) {
|
|
|
139
144
|
else {
|
|
140
145
|
console.log("No changes to review.");
|
|
141
146
|
}
|
|
142
|
-
|
|
147
|
+
exitWith(0);
|
|
143
148
|
}
|
|
144
149
|
const subjectId = localSubjectId(repo, root, branch);
|
|
145
150
|
if (cli.fresh)
|
|
@@ -148,10 +153,19 @@ export async function runLocalReview(cli) {
|
|
|
148
153
|
if (carryLoad.unavailable) {
|
|
149
154
|
warnings.push({
|
|
150
155
|
code: "carry_over_unavailable",
|
|
151
|
-
message: "Could not read the local carry-over cache
|
|
156
|
+
message: "Could not read the local carry-over cache. Continuing without prior findings.",
|
|
152
157
|
});
|
|
153
158
|
}
|
|
154
|
-
|
|
159
|
+
let previous = carryLoad.data;
|
|
160
|
+
// A guide rebuilt after the last review judged its findings under rules
|
|
161
|
+
// that no longer exist. Rather than let those stale findings mask new
|
|
162
|
+
// ones, start fresh automatically (CORE-42 / F03) — the same effect as
|
|
163
|
+
// `--fresh`, without making the user remember the flag.
|
|
164
|
+
if (previous &&
|
|
165
|
+
guideRebuiltSince(previous.guideBuiltAt, guides.guideBuiltAt)) {
|
|
166
|
+
await clearLocalCarry(repo, root, branch).catch(() => { });
|
|
167
|
+
previous = null;
|
|
168
|
+
}
|
|
155
169
|
let carryPrevious = null;
|
|
156
170
|
let carryItems = [];
|
|
157
171
|
const extras = {};
|
|
@@ -174,7 +188,9 @@ export async function runLocalReview(cli) {
|
|
|
174
188
|
gitRoot: root,
|
|
175
189
|
enabled: options.useCodegraph === true,
|
|
176
190
|
allowInstall: cli.allowToolInstall,
|
|
177
|
-
|
|
191
|
+
// `--json` output must stay machine-readable, so never prompt then;
|
|
192
|
+
// `canPrompt` adds the TTY and CI checks (F01).
|
|
193
|
+
interactive: !json && canPrompt(),
|
|
178
194
|
});
|
|
179
195
|
extras.prepareCodegraphTools = () => Promise.resolve(codegraphPrep.tools);
|
|
180
196
|
const stopHeartbeat = startHeartbeat("reviewing local changes");
|
|
@@ -194,10 +210,12 @@ export async function runLocalReview(cli) {
|
|
|
194
210
|
}, aiFor(options), (message) => log("review", message), extras));
|
|
195
211
|
}
|
|
196
212
|
finally {
|
|
213
|
+
// Stopping the heartbeat on the error path too, otherwise the interval
|
|
214
|
+
// keeps the event loop alive and a post-fetch exit never lands (CORE-11).
|
|
215
|
+
stopHeartbeat();
|
|
197
216
|
await lock?.release();
|
|
198
217
|
lock = null;
|
|
199
218
|
}
|
|
200
|
-
stopHeartbeat();
|
|
201
219
|
const codegraphState = codegraphPrep.state;
|
|
202
220
|
const visiblePaths = new Set(response.visiblePaths);
|
|
203
221
|
if (carryPrevious) {
|
|
@@ -206,7 +224,7 @@ export async function runLocalReview(cli) {
|
|
|
206
224
|
const parsed = parseFindings(response.text);
|
|
207
225
|
const filesByPath = new Map(revision.files.map((f) => [f.path, f]));
|
|
208
226
|
const allResolved = carryPrevious
|
|
209
|
-
? resolveCarryOutcomes(carryItems, revision, visiblePaths, parsePreviousVerdicts(response.text), parsed
|
|
227
|
+
? resolveCarryOutcomes(carryItems, revision, visiblePaths, parsePreviousVerdicts(response.text), parsed)
|
|
210
228
|
: resolvedFromFirstReview(parsed, filesByPath);
|
|
211
229
|
const findingsToStore = storedFindings(allResolved);
|
|
212
230
|
const afterBuilt = await buildLocalRevision(root, baseSha, base.label, runCommand);
|
|
@@ -252,18 +270,22 @@ export async function runLocalReview(cli) {
|
|
|
252
270
|
warnings,
|
|
253
271
|
usage,
|
|
254
272
|
durationMs,
|
|
273
|
+
reviewBlocking: options.reviewBlocking,
|
|
255
274
|
}));
|
|
256
275
|
}
|
|
257
276
|
else {
|
|
258
277
|
console.log("\n" +
|
|
259
|
-
formatHumanLocalReview(header, revision, response.guideBuiltAt, codegraphState, allResolved, warnings) +
|
|
278
|
+
formatHumanLocalReview(header, revision, response.guideBuiltAt, codegraphState, allResolved, warnings, options.reviewBlocking) +
|
|
260
279
|
"\n");
|
|
280
|
+
printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - started));
|
|
261
281
|
}
|
|
262
|
-
|
|
282
|
+
exitWith(reviewExitCodeFromResolved(allResolved, options.reviewBlocking));
|
|
263
283
|
}
|
|
264
284
|
catch (error) {
|
|
265
|
-
if (error instanceof ReviewCliError)
|
|
285
|
+
if (error instanceof ReviewCliError) {
|
|
266
286
|
fail(error, json);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
267
289
|
throw error;
|
|
268
290
|
}
|
|
269
291
|
finally {
|
package/dist/src/pr/checkout.js
CHANGED
|
@@ -32,17 +32,28 @@ export async function pathExists(path) {
|
|
|
32
32
|
async function cloneInto(repo, dir, run) {
|
|
33
33
|
log("checkout", `cloning ${repo} into ${dir}`);
|
|
34
34
|
const started = performance.now();
|
|
35
|
+
// `-c core.longpaths=true` has to be in effect for the clone itself: a deep
|
|
36
|
+
// source tree can overrun the Windows path limit while the objects are being
|
|
37
|
+
// written, and setting the config afterwards is too late to help. CORE-11.
|
|
35
38
|
const result = await run("git", [
|
|
39
|
+
"-c",
|
|
40
|
+
"core.longpaths=true",
|
|
36
41
|
"clone",
|
|
37
42
|
"--filter=blob:none",
|
|
38
43
|
`https://github.com/${repo}`,
|
|
39
44
|
dir,
|
|
40
45
|
]);
|
|
41
46
|
if (result.code !== 0) {
|
|
42
|
-
|
|
47
|
+
// One actionable line, not the raw multi-line git transcript: the caller
|
|
48
|
+
// only needs to know the clone failed and what it costs them. CORE-11.
|
|
49
|
+
const firstLine = result.stderr
|
|
50
|
+
.split(/\r?\n/)
|
|
51
|
+
.map((line) => line.trim())
|
|
52
|
+
.find((line) => line.length > 0) ?? "";
|
|
53
|
+
throw new Error(`git clone failed (exit ${result.code}) for ${repo}, so this review has no ` +
|
|
54
|
+
`repository scope and falls back to the diff only` +
|
|
55
|
+
(firstLine ? `: ${firstLine}` : ""));
|
|
43
56
|
}
|
|
44
|
-
// Deep source trees overrun the Windows path limit even under a short root.
|
|
45
|
-
await run("git", ["config", "core.longpaths", "true"], dir);
|
|
46
57
|
log("checkout", `cloned in ${((performance.now() - started) / 1000).toFixed(1)}s`);
|
|
47
58
|
return dir;
|
|
48
59
|
}
|
|
@@ -143,7 +143,7 @@ export function codegraphTools(binary, worktree, runner = createCodegraphRunner(
|
|
|
143
143
|
},
|
|
144
144
|
file: {
|
|
145
145
|
type: "string",
|
|
146
|
-
description: "A file path
|
|
146
|
+
description: "A file path. Reads the file's symbol map instead of a single symbol.",
|
|
147
147
|
},
|
|
148
148
|
symbolsOnly: {
|
|
149
149
|
type: "boolean",
|
|
@@ -324,7 +324,7 @@ export function codegraphTools(binary, worktree, runner = createCodegraphRunner(
|
|
|
324
324
|
type: "function",
|
|
325
325
|
function: {
|
|
326
326
|
name: "codegraph-impact",
|
|
327
|
-
description: "Analyze what else in the codebase is affected by changing a specific symbol
|
|
327
|
+
description: "Analyze what else in the codebase is affected by changing a specific symbol, meaning its blast radius.",
|
|
328
328
|
parameters: {
|
|
329
329
|
type: "object",
|
|
330
330
|
properties: {
|
|
@@ -362,7 +362,7 @@ export function codegraphTools(binary, worktree, runner = createCodegraphRunner(
|
|
|
362
362
|
type: "function",
|
|
363
363
|
function: {
|
|
364
364
|
name: "codegraph-affected",
|
|
365
|
-
description: "Find test files affected by one or more changed source files
|
|
365
|
+
description: "Find test files affected by one or more changed source files. This is the direct way to check whether a change is covered by any test, instead of guessing from the diff.",
|
|
366
366
|
parameters: {
|
|
367
367
|
type: "object",
|
|
368
368
|
properties: {
|
|
@@ -10,7 +10,7 @@ export async function summarizeDiff(path, patch, provider, usage) {
|
|
|
10
10
|
prompt: `Summarize this diff for \`${path}\` in 3-5 sentences: what changed, the
|
|
11
11
|
mechanism, and precisely which branches, patterns, or call paths are affected.
|
|
12
12
|
Do not speculate beyond what the diff shows. Do not suggest fixes or judge
|
|
13
|
-
whether the change is correct
|
|
13
|
+
whether the change is correct. Only describe it.
|
|
14
14
|
|
|
15
15
|
${patch}`,
|
|
16
16
|
maxTokens: 500,
|
|
@@ -23,7 +23,7 @@ export const READ_FULL_DIFF_TOOL = {
|
|
|
23
23
|
type: "function",
|
|
24
24
|
function: {
|
|
25
25
|
name: "read-full-diff",
|
|
26
|
-
description: "Read the complete, untruncated diff for one file in this pull request's own changes. Only files whose diff was summarized (over 500 changed lines) are available this way
|
|
26
|
+
description: "Read the complete, untruncated diff for one file in this pull request's own changes. Only files whose diff was summarized (over 500 changed lines) are available this way. Everything else is already shown in full.",
|
|
27
27
|
parameters: {
|
|
28
28
|
type: "object",
|
|
29
29
|
properties: {
|
|
@@ -45,7 +45,7 @@ export function readFullDiff(patchByPath, args) {
|
|
|
45
45
|
if (patch)
|
|
46
46
|
return numberPatch(patch);
|
|
47
47
|
return patchByPath.size === 0
|
|
48
|
-
? "No large files were summarized in this review
|
|
48
|
+
? "No large files were summarized in this review. Every file's diff is already shown in full above."
|
|
49
49
|
: `No summarized diff found for \`${path}\`. Summarized files: ${[
|
|
50
50
|
...patchByPath.keys(),
|
|
51
51
|
].join(", ")}`;
|
package/dist/src/pr/findings.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const FILE_LINE = /(?<path>(?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]+):(?<from>\d+)(?:-(?<to>\d+))?/g;
|
|
2
|
-
|
|
2
|
+
/** The finding heading: `[P2 · non-blocking] \`path\` — \`symbol\``. The model
|
|
3
|
+
* sometimes writes `:` where the prompt asked for the em dash, and sometimes
|
|
4
|
+
* leaves the symbol off. Both are accepted: an unrecognised heading used to
|
|
5
|
+
* send every finding down the fallback path, which is what produced the
|
|
6
|
+
* truncated and shifted output in F02. The separator may also sit directly
|
|
7
|
+
* against the path (`\`src/a.ts\`: \`helper()\``), which is how the model
|
|
8
|
+
* usually writes the colon form. */
|
|
9
|
+
const NEW_HEADING = /^\[(?<severity>P[0-3])\s*·\s*(?<impact>blocking|non-blocking)\]\s+(?<path>.+?)(?:\s*(?:—|:)\s*(?<symbol>.+))?$/;
|
|
3
10
|
function uncode(value) {
|
|
4
11
|
return value.trim().replace(/^`|`$/g, "").trim();
|
|
5
12
|
}
|
|
@@ -27,19 +34,23 @@ function newFinding(header, block) {
|
|
|
27
34
|
if (!at)
|
|
28
35
|
return undefined;
|
|
29
36
|
const path = uncode(meta.groups.path);
|
|
30
|
-
const symbol = uncode(meta.groups.symbol);
|
|
37
|
+
const symbol = uncode(meta.groups.symbol ?? "");
|
|
31
38
|
const severity = meta.groups.severity;
|
|
32
39
|
const impact = meta.groups.impact;
|
|
33
40
|
const excerpt = bodyWithoutLocation(block);
|
|
34
41
|
const summary = excerpt.split(/\r?\n/).find((line) => line.trim()) ?? "";
|
|
35
42
|
return {
|
|
36
43
|
...at,
|
|
37
|
-
path
|
|
38
|
-
|
|
44
|
+
// The Location line is authoritative for the path; the heading only names
|
|
45
|
+
// it, and may omit the symbol entirely.
|
|
46
|
+
path: at.path || path,
|
|
47
|
+
heading: symbol
|
|
48
|
+
? `[${severity} · ${impact}] \`${path}\`: \`${symbol}\``
|
|
49
|
+
: `[${severity} · ${impact}] \`${path}\``,
|
|
39
50
|
excerpt,
|
|
40
51
|
severity,
|
|
41
52
|
blocking: impact === "blocking",
|
|
42
|
-
symbol,
|
|
53
|
+
symbol: symbol || undefined,
|
|
43
54
|
summary,
|
|
44
55
|
};
|
|
45
56
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Structured review output (CORE-40 / F02).
|
|
2
|
+
*
|
|
3
|
+
* F02: findings were truncated mid-sentence (`... while \`u`) and split at the
|
|
4
|
+
* wrong offsets, because the model's free-form Markdown was sliced by the
|
|
5
|
+
* fallback parser. The fix is to have the model return JSON and generate the
|
|
6
|
+
* Markdown ourselves, so a finding's boundaries can no longer be guessed from
|
|
7
|
+
* prose. Markdown stays the internal contract — every downstream consumer
|
|
8
|
+
* (`parseFindings`, carry-over, GitHub posting) keeps working unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import type { Json } from "../types.ts";
|
|
11
|
+
import type { ParsedFinding } from "./findings.ts";
|
|
12
|
+
/** OpenRouter `response_format` for a review. Providers that ignore it still
|
|
13
|
+
* get the prompt's instruction to return a fenced JSON block. */
|
|
14
|
+
export declare const FINDINGS_JSON_SCHEMA: Json;
|
|
15
|
+
/** The prompt's contract, kept next to the schema so the two cannot drift. */
|
|
16
|
+
export declare const FINDINGS_JSON_INSTRUCTIONS = "Return a single JSON object, and nothing else, with this shape:\n{\"findings\":[{\"severity\":\"P1\",\"blocking\":true,\"path\":\"src/a.ts\",\"lineFrom\":42,\"lineTo\":42,\"symbol\":\"helper()\",\"title\":\"The helper ignores its argument\",\"body\":\"One sentence on what is wrong and its impact, then a short evidence paragraph.\",\"suggestion\":\"the exact replacement lines, or omit this field\"}],\"previousFindings\":[{\"id\":\"F1\",\"state\":\"open\"}]}\n\nRules for the JSON:\n- \"severity\" is exactly one of P0, P1, P2, P3. \"blocking\" is true or false.\n- \"lineFrom\" and \"lineTo\" are numbers in the new file, copied from the DIFF\n column, never counted from the @@ header. \"lineTo\" may equal \"lineFrom\".\n- \"title\" is one sentence naming the defect, without the severity or the path.\n- \"body\" is the explanation. Do not repeat the title, the path, or the location\n line inside it; our code renders those. Do not add a closing sentence asking\n whether to explain more, and do not use the words Mechanism, Symptom,\n Scenario, Verified, Repro, Options, or Scope as labels.\n- \"suggestion\" holds only the replacement source lines, with their original\n indentation, when the fix replaces the exact lines lineFrom-lineTo in one\n hunk of the same file. Omit it otherwise. Never wrap it in a code fence.\n- When a fix needs removed lines, another file, or more than one hunk, omit\n \"suggestion\".\n- Return every independently actionable finding, including none: use\n {\"findings\":[]} when the change is clean. Never invent a finding to fill the\n array.";
|
|
17
|
+
/** Reads the structured output and renders it as Markdown. `undefined` means
|
|
18
|
+
* the text was not usable JSON, so the caller falls back to the legacy
|
|
19
|
+
* Markdown parser rather than dropping the review. */
|
|
20
|
+
export declare function findingsMarkdownFromJson(text: string): string | undefined;
|
|
21
|
+
/** The findings a JSON review produced, for callers that want the objects
|
|
22
|
+
* rather than the Markdown. */
|
|
23
|
+
export type JsonFindingResult = {
|
|
24
|
+
markdown: string;
|
|
25
|
+
findings: ParsedFinding[];
|
|
26
|
+
};
|