@codraoss/core 0.9.4 → 0.9.5
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/dist/{chunk-XD6CGYHO.js → chunk-2TF4DSRX.js} +65 -42
- package/dist/chunk-2TF4DSRX.js.map +1 -0
- package/dist/{chunk-ILZUCIZH.js → chunk-754NO4DH.js} +7 -7
- package/dist/chunk-754NO4DH.js.map +1 -0
- package/dist/{chunk-RQ5S67XB.js → chunk-FKRCBUWZ.js} +8 -2
- package/dist/chunk-FKRCBUWZ.js.map +1 -0
- package/dist/{chunk-OIQRTDYR.js → chunk-I5S3NBFL.js} +20 -4
- package/dist/chunk-I5S3NBFL.js.map +1 -0
- package/dist/{chunk-U3VKVDZ7.js → chunk-IKVUW4WC.js} +92 -19
- package/dist/chunk-IKVUW4WC.js.map +1 -0
- package/dist/{chunk-R2HRWFRH.js → chunk-JAF7JIZQ.js} +1 -1
- package/dist/chunk-JAF7JIZQ.js.map +1 -0
- package/dist/{chunk-V3RF6GQW.js → chunk-KTDBDKL5.js} +1 -1
- package/dist/chunk-KTDBDKL5.js.map +1 -0
- package/dist/{chunk-MMVQPGK3.js → chunk-ONJZXUQT.js} +112 -24
- package/dist/chunk-ONJZXUQT.js.map +1 -0
- package/dist/{chunk-PVXFURDR.js → chunk-QPPLO2YV.js} +2 -2
- package/dist/chunk-QPPLO2YV.js.map +1 -0
- package/dist/{chunk-FGXA7JNR.js → chunk-W3RY755V.js} +14 -2
- package/dist/chunk-W3RY755V.js.map +1 -0
- package/dist/{chunk-NSTV4TRP.js → chunk-XPYVLHSX.js} +1 -1
- package/dist/chunk-XPYVLHSX.js.map +1 -0
- package/dist/{chunk-WSK45HW4.js → chunk-Z5B5X7QP.js} +1 -1
- package/dist/chunk-Z5B5X7QP.js.map +1 -0
- package/dist/claim-checks.d.ts +8 -13
- package/dist/claim-checks.js +2 -2
- package/dist/diff/index.d.ts +3 -3
- package/dist/diff/index.js +2 -1
- package/dist/fingerprint.js +1 -1
- package/dist/{index-BTDWXDck.d.ts → index-CrtHz4vN.d.ts} +3 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.js +240 -61
- package/dist/index.js.map +1 -1
- package/dist/logger.js +1 -1
- package/dist/{model-gRDTk8yk.d.ts → model-BkqfyVh9.d.ts} +27 -6
- package/dist/model-output/index.d.ts +2 -2
- package/dist/model-output/index.js +9 -9
- package/dist/ports/index.d.ts +28 -6
- package/dist/{position-h_jdn6ZH.d.ts → position-zZl8KGVu.d.ts} +1 -1
- package/dist/prompts/file-review.d.ts +3 -3
- package/dist/prompts/file-review.js +11 -5
- package/dist/prompts/languages.d.ts +2 -1
- package/dist/prompts/languages.js +5 -3
- package/dist/prompts/verify.d.ts +1 -1
- package/dist/prompts/verify.js +1 -1
- package/dist/rules/detect.d.ts +1 -1
- package/dist/rules/detect.js +5 -5
- package/dist/rules/table.js +1 -1
- package/dist/token-tracker.js +2 -2
- package/package.json +2 -2
- package/dist/chunk-FGXA7JNR.js.map +0 -1
- package/dist/chunk-ILZUCIZH.js.map +0 -1
- package/dist/chunk-MMVQPGK3.js.map +0 -1
- package/dist/chunk-NSTV4TRP.js.map +0 -1
- package/dist/chunk-OIQRTDYR.js.map +0 -1
- package/dist/chunk-PVXFURDR.js.map +0 -1
- package/dist/chunk-R2HRWFRH.js.map +0 -1
- package/dist/chunk-RQ5S67XB.js.map +0 -1
- package/dist/chunk-U3VKVDZ7.js.map +0 -1
- package/dist/chunk-V3RF6GQW.js.map +0 -1
- package/dist/chunk-WSK45HW4.js.map +0 -1
- package/dist/chunk-XD6CGYHO.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
dedupeFindings
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-2TF4DSRX.js";
|
|
4
4
|
import {
|
|
5
5
|
InMemoryKV,
|
|
6
6
|
InMemoryOrchestrator,
|
|
@@ -8,17 +8,33 @@ import {
|
|
|
8
8
|
InMemorySessionStore
|
|
9
9
|
} from "./chunk-JC74XH7Q.js";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
ruleHitsToComments,
|
|
12
|
+
scanFileForRuleHits
|
|
13
|
+
} from "./chunk-754NO4DH.js";
|
|
14
|
+
import "./chunk-JAF7JIZQ.js";
|
|
15
|
+
import {
|
|
16
|
+
changelogExcerptFromDiff,
|
|
17
|
+
renderFileDiff,
|
|
18
|
+
reviewBreadth,
|
|
19
|
+
wantsFileContext
|
|
20
|
+
} from "./chunk-ONJZXUQT.js";
|
|
21
|
+
import {
|
|
22
|
+
getLanguageForFile
|
|
23
|
+
} from "./chunk-FKRCBUWZ.js";
|
|
14
24
|
import {
|
|
15
25
|
parseVerifyResponse,
|
|
16
26
|
renderDiffSnippet
|
|
17
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-KTDBDKL5.js";
|
|
28
|
+
import "./chunk-IKVUW4WC.js";
|
|
29
|
+
import "./chunk-XPYVLHSX.js";
|
|
30
|
+
import "./chunk-QPPLO2YV.js";
|
|
18
31
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
logger
|
|
33
|
+
} from "./chunk-Z5B5X7QP.js";
|
|
34
|
+
import {
|
|
35
|
+
filterReviewableFiles,
|
|
36
|
+
parseUnifiedDiff
|
|
37
|
+
} from "./chunk-I5S3NBFL.js";
|
|
22
38
|
import {
|
|
23
39
|
ASYNC_BATCH_POLL_DELAY_SECONDS,
|
|
24
40
|
BIN_DIFF_CHAR_BUDGET,
|
|
@@ -27,6 +43,8 @@ import {
|
|
|
27
43
|
BUSY_RETRY_SECONDS,
|
|
28
44
|
DIFF_CACHE_TTL_SECONDS,
|
|
29
45
|
FILE_FIXED_SUBREQUESTS,
|
|
46
|
+
FRAGMENTED_HUNK_THRESHOLD,
|
|
47
|
+
FRAGMENTED_MIN_LINES,
|
|
30
48
|
FRESH_INVOCATION_YIELD_SECONDS,
|
|
31
49
|
JOB_LEASE_SECONDS,
|
|
32
50
|
MAX_FINALIZE_CONTINUATIONS,
|
|
@@ -38,18 +56,7 @@ import {
|
|
|
38
56
|
RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS,
|
|
39
57
|
REVIEW_CHUNK_WALL_CLOCK_MS,
|
|
40
58
|
VERIFY_MIN_ANSWER_RATIO
|
|
41
|
-
} from "./chunk-
|
|
42
|
-
import "./chunk-R2HRWFRH.js";
|
|
43
|
-
import "./chunk-U3VKVDZ7.js";
|
|
44
|
-
import "./chunk-NSTV4TRP.js";
|
|
45
|
-
import "./chunk-PVXFURDR.js";
|
|
46
|
-
import {
|
|
47
|
-
logger
|
|
48
|
-
} from "./chunk-WSK45HW4.js";
|
|
49
|
-
import {
|
|
50
|
-
filterReviewableFiles,
|
|
51
|
-
parseUnifiedDiff
|
|
52
|
-
} from "./chunk-OIQRTDYR.js";
|
|
59
|
+
} from "./chunk-W3RY755V.js";
|
|
53
60
|
|
|
54
61
|
// src/review/index.ts
|
|
55
62
|
import "@codraoss/schema/webhook";
|
|
@@ -140,13 +147,15 @@ async function getOrFetchRawDiffForCompletedJob(env, job, github) {
|
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
// src/review/budget.ts
|
|
143
|
-
function budgetAwareFileLimit(remainingSafeBudget, configuredChunkFileLimit, modelChainLength = 1) {
|
|
144
|
-
const budgetLimit = Math.floor(
|
|
145
|
-
|
|
150
|
+
function budgetAwareFileLimit(remainingSafeBudget, configuredChunkFileLimit, modelChainLength = 1, fetchesFileContent = false, runsSecondaryReviewer = false) {
|
|
151
|
+
const budgetLimit = Math.floor(
|
|
152
|
+
remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength, fetchesFileContent, runsSecondaryReviewer)
|
|
153
|
+
);
|
|
154
|
+
return Math.max(remainingSafeBudget > 0 ? 1 : 0, Math.min(configuredChunkFileLimit, budgetLimit));
|
|
146
155
|
}
|
|
147
|
-
function estimatedSubrequestsPerFile(modelChainLength) {
|
|
156
|
+
function estimatedSubrequestsPerFile(modelChainLength, fetchesFileContent = false, runsSecondaryReviewer = false) {
|
|
148
157
|
const modelAttempts = Math.max(1, Math.min(modelChainLength, MAX_MODEL_ATTEMPTS_ESTIMATE));
|
|
149
|
-
return FILE_FIXED_SUBREQUESTS + modelAttempts;
|
|
158
|
+
return FILE_FIXED_SUBREQUESTS + modelAttempts * (runsSecondaryReviewer ? 2 : 1) + (fetchesFileContent ? 1 : 0);
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
// src/review/pack.ts
|
|
@@ -154,6 +163,9 @@ function unitFiles(unit) {
|
|
|
154
163
|
return unit.kind === "single" ? [unit.file] : unit.files;
|
|
155
164
|
}
|
|
156
165
|
var measure = (file) => renderFileDiff(file).length;
|
|
166
|
+
function isFragmented(file) {
|
|
167
|
+
return file.hunks.length >= FRAGMENTED_HUNK_THRESHOLD && file.lineCount >= FRAGMENTED_MIN_LINES && !file.isNew;
|
|
168
|
+
}
|
|
157
169
|
var asBin = (files) => files.length === 1 ? { kind: "single", file: files[0] } : {
|
|
158
170
|
kind: "bin",
|
|
159
171
|
files,
|
|
@@ -174,7 +186,8 @@ function planReviewUnits(files, opts) {
|
|
|
174
186
|
};
|
|
175
187
|
for (const file of files) {
|
|
176
188
|
const fileChars = measure(file);
|
|
177
|
-
|
|
189
|
+
const promoteForContext = opts.fullFileContext === true && isFragmented(file);
|
|
190
|
+
if (promoteForContext || file.lineCount > PACKABLE_MAX_DIFF_LINES || fileChars > BIN_DIFF_CHAR_BUDGET) {
|
|
178
191
|
close();
|
|
179
192
|
units.push({ kind: "single", file });
|
|
180
193
|
continue;
|
|
@@ -377,20 +390,49 @@ function scanRuleChannel(file, config) {
|
|
|
377
390
|
return { comments: [], stats: null };
|
|
378
391
|
}
|
|
379
392
|
}
|
|
380
|
-
|
|
393
|
+
function tagReviewer(comments, reviewerModel) {
|
|
394
|
+
return comments.map((comment) => ({ ...comment, reviewerModel }));
|
|
395
|
+
}
|
|
396
|
+
async function runSecondaryReview(model, params, secondary, path) {
|
|
397
|
+
try {
|
|
398
|
+
return await model.reviewFile({
|
|
399
|
+
...params,
|
|
400
|
+
config: {
|
|
401
|
+
...params.config,
|
|
402
|
+
model: { ...params.config.model, main: secondary.model, fallbacks: secondary.fallbacks }
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
} catch (error) {
|
|
406
|
+
logger.warn(`Secondary reviewer failed for ${path}; keeping the primary review`, {
|
|
407
|
+
model: secondary.model,
|
|
408
|
+
error: error instanceof Error ? error.message : String(error)
|
|
409
|
+
});
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, previousReview, rejectedExemplars = [], changelogExcerpt = null, fileContext = null) {
|
|
381
414
|
const startedAt = env.clock.now();
|
|
382
415
|
const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0;
|
|
383
416
|
const ruleScan = scanRuleChannel(file, config);
|
|
384
417
|
try {
|
|
385
|
-
const
|
|
418
|
+
const reviewParams = {
|
|
386
419
|
file,
|
|
420
|
+
fileContext,
|
|
387
421
|
prTitle: pr.title ?? null,
|
|
388
422
|
prDescription: pr.body ?? null,
|
|
423
|
+
changelogExcerpt,
|
|
389
424
|
config,
|
|
390
425
|
totalLineCount,
|
|
391
426
|
compactPrompt,
|
|
392
427
|
rejectedExemplars
|
|
393
|
-
}
|
|
428
|
+
};
|
|
429
|
+
const response = await model.reviewFile(reviewParams);
|
|
430
|
+
const secondary = config.model?.secondary ?? null;
|
|
431
|
+
const secondaryReview = secondary && !compactPrompt ? await runSecondaryReview(model, reviewParams, secondary, file.path) : null;
|
|
432
|
+
const llmComments = [
|
|
433
|
+
...tagReviewer(response.parsed.comments, response.modelUsed),
|
|
434
|
+
...secondaryReview ? tagReviewer(secondaryReview.parsed.comments, secondaryReview.modelUsed) : []
|
|
435
|
+
];
|
|
394
436
|
await env.fileReviews.upsertFileReview(job.id, {
|
|
395
437
|
filePath: file.path,
|
|
396
438
|
fileStatus: "done",
|
|
@@ -399,9 +441,11 @@ async function reviewAndPersistFile(env, job, file, pr, config, totalLineCount,
|
|
|
399
441
|
diffLineCount: file.lineCount,
|
|
400
442
|
diffInput: null,
|
|
401
443
|
rawAiOutput: response.rawText,
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
444
|
+
// One row per file, always: `file_reviews` is unique on (job_id, file_path), and review
|
|
445
|
+
// inheritance, resume and finalize all assume that. The two reviewers merge into it.
|
|
446
|
+
parsedComments: [...llmComments, ...ruleScan.comments],
|
|
447
|
+
inputTokens: response.inputTokens + (secondaryReview?.inputTokens ?? 0),
|
|
448
|
+
outputTokens: response.outputTokens + (secondaryReview?.outputTokens ?? 0),
|
|
405
449
|
durationMs: env.clock.now() - startedAt,
|
|
406
450
|
verdict: response.parsed.verdict,
|
|
407
451
|
fileSummary: response.parsed.fileSummary,
|
|
@@ -410,8 +454,17 @@ async function reviewAndPersistFile(env, job, file, pr, config, totalLineCount,
|
|
|
410
454
|
errorMessage: null,
|
|
411
455
|
withheldCounts: {
|
|
412
456
|
evidence: (response.parsed.evidenceStats?.unmatched ?? 0) + (response.parsed.evidenceStats?.absent ?? 0) + (response.parsed.evidenceStats?.weak ?? 0),
|
|
413
|
-
claimDenied: Object.values(response.parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0)
|
|
414
|
-
|
|
457
|
+
claimDenied: Object.values(response.parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0),
|
|
458
|
+
// Findings about code the diff never touched. Counted apart from the evidence gate: those are
|
|
459
|
+
// findings whose quote could not be found at all, these are ones that were found in the wrong
|
|
460
|
+
// place, and only the second number says anything about how the reviewer is misreading a PR.
|
|
461
|
+
contextOnly: response.parsed.evidenceStats?.contextOnly ?? 0,
|
|
462
|
+
// "X is missing", answered by finding X. Counted so the gate's real hit rate is visible.
|
|
463
|
+
absenceRefuted: response.parsed.absenceCheckStats?.refuted ?? 0
|
|
464
|
+
},
|
|
465
|
+
// Only logged until now, which made "how often did a review run unconstrained or truncated?"
|
|
466
|
+
// unanswerable without reading the logs of every job one at a time.
|
|
467
|
+
degraded: response.degraded ?? null
|
|
415
468
|
});
|
|
416
469
|
logger.info(`File review parsed: ${file.path}`, {
|
|
417
470
|
jobId: job.id,
|
|
@@ -513,7 +566,7 @@ function proportionalSplit(total, weights) {
|
|
|
513
566
|
}
|
|
514
567
|
return parts;
|
|
515
568
|
}
|
|
516
|
-
async function reviewAndPersistBin(env, job, files, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars = []) {
|
|
569
|
+
async function reviewAndPersistBin(env, job, files, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars = [], changelogExcerpt = null) {
|
|
517
570
|
const startedAt = env.clock.now();
|
|
518
571
|
const ruleScans = new Map(files.map((file) => [file.path, scanRuleChannel(file, config)]));
|
|
519
572
|
const persisted = /* @__PURE__ */ new Set();
|
|
@@ -539,6 +592,7 @@ async function reviewAndPersistBin(env, job, files, pr, config, totalLineCount,
|
|
|
539
592
|
files,
|
|
540
593
|
prTitle: pr.title ?? null,
|
|
541
594
|
prDescription: pr.body ?? null,
|
|
595
|
+
changelogExcerpt,
|
|
542
596
|
config,
|
|
543
597
|
totalLineCount,
|
|
544
598
|
rejectedExemplars
|
|
@@ -569,8 +623,12 @@ async function reviewAndPersistBin(env, job, files, pr, config, totalLineCount,
|
|
|
569
623
|
errorMessage: null,
|
|
570
624
|
withheldCounts: {
|
|
571
625
|
evidence: (parsed.evidenceStats?.unmatched ?? 0) + (parsed.evidenceStats?.absent ?? 0) + (parsed.evidenceStats?.weak ?? 0),
|
|
572
|
-
claimDenied: Object.values(parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0)
|
|
626
|
+
claimDenied: Object.values(parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0),
|
|
627
|
+
contextOnly: parsed.evidenceStats?.contextOnly ?? 0,
|
|
628
|
+
absenceRefuted: parsed.absenceCheckStats?.refuted ?? 0
|
|
573
629
|
},
|
|
630
|
+
// The whole bin shared one call, so every member inherits its degradation.
|
|
631
|
+
degraded: response.degraded ?? null,
|
|
574
632
|
batchSize: files.length
|
|
575
633
|
};
|
|
576
634
|
});
|
|
@@ -694,14 +752,19 @@ function shadowEvaluate(candidates, posted) {
|
|
|
694
752
|
dropUnmatchedEvidence: count((c) => !c.evidence)
|
|
695
753
|
};
|
|
696
754
|
}
|
|
697
|
-
function verifyCandidateLimit(
|
|
698
|
-
return Math.min(40, Math.max(10,
|
|
755
|
+
function verifyCandidateLimit(breadth) {
|
|
756
|
+
return Math.min(40, Math.max(10, breadth * 3));
|
|
699
757
|
}
|
|
700
758
|
async function verifyFindings(params) {
|
|
701
759
|
const { comments, files, model, config, job } = params;
|
|
702
|
-
const keepAll = () => ({
|
|
703
|
-
|
|
704
|
-
|
|
760
|
+
const keepAll = (skipped) => ({
|
|
761
|
+
comments,
|
|
762
|
+
dropped: [],
|
|
763
|
+
reasons: /* @__PURE__ */ new Map(),
|
|
764
|
+
skipped
|
|
765
|
+
});
|
|
766
|
+
if (comments.length === 0) return keepAll(null);
|
|
767
|
+
const limit = verifyCandidateLimit(params.maxCandidates ?? reviewBreadth(config.review));
|
|
705
768
|
const toVerify = comments.slice(0, limit);
|
|
706
769
|
const fileByPath = new Map(files.map((file) => [file.path, file]));
|
|
707
770
|
const prepared = toVerify.map((comment) => ({
|
|
@@ -709,7 +772,7 @@ async function verifyFindings(params) {
|
|
|
709
772
|
snippet: renderDiffSnippet(fileByPath.get(comment.path), comment.line ?? void 0)
|
|
710
773
|
}));
|
|
711
774
|
const verifiable = prepared.filter((entry) => entry.snippet !== "" || entry.comment.evidence);
|
|
712
|
-
if (verifiable.length === 0) return keepAll();
|
|
775
|
+
if (verifiable.length === 0) return keepAll("no_verifiable_candidates");
|
|
713
776
|
const candidates = verifiable.map((entry, index) => ({
|
|
714
777
|
index,
|
|
715
778
|
path: entry.comment.path,
|
|
@@ -742,7 +805,7 @@ async function verifyFindings(params) {
|
|
|
742
805
|
candidates: candidates.length,
|
|
743
806
|
answered
|
|
744
807
|
});
|
|
745
|
-
return keepAll();
|
|
808
|
+
return keepAll("low_answer_ratio");
|
|
746
809
|
}
|
|
747
810
|
const dropped = [];
|
|
748
811
|
const reasons = /* @__PURE__ */ new Map();
|
|
@@ -769,13 +832,13 @@ async function verifyFindings(params) {
|
|
|
769
832
|
dropped: dropped.length,
|
|
770
833
|
topReasons: dropped.slice(0, 5).map((drop) => drop.reason)
|
|
771
834
|
});
|
|
772
|
-
return { comments: comments.filter((comment) => !droppedSet.has(comment)), dropped, reasons };
|
|
835
|
+
return { comments: comments.filter((comment) => !droppedSet.has(comment)), dropped, reasons, skipped: null };
|
|
773
836
|
} catch (error) {
|
|
774
837
|
logger.warn("Verification pass failed; posting pre-verification findings", {
|
|
775
838
|
jobId: job.id,
|
|
776
839
|
error: error instanceof Error ? error.message : String(error)
|
|
777
840
|
});
|
|
778
|
-
return keepAll();
|
|
841
|
+
return keepAll("verify_call_failed");
|
|
779
842
|
}
|
|
780
843
|
}
|
|
781
844
|
|
|
@@ -841,6 +904,50 @@ async function loadRejectedExemplars(env, job) {
|
|
|
841
904
|
|
|
842
905
|
// src/review/phase.ts
|
|
843
906
|
import { defaultRepoConfig as defaultRepoConfig2, REVIEW_CONCURRENCY_LIMITS } from "@codraoss/schema";
|
|
907
|
+
|
|
908
|
+
// src/review/file-context.ts
|
|
909
|
+
var VALIDATION_SAMPLES = 5;
|
|
910
|
+
function contentMatchesDiff(file, content) {
|
|
911
|
+
const lines = content.split("\n");
|
|
912
|
+
const samples = [];
|
|
913
|
+
for (const hunk of file.hunks) {
|
|
914
|
+
for (const line of hunk.lines) {
|
|
915
|
+
if (line.kind === "del" || typeof line.newLineNumber !== "number") continue;
|
|
916
|
+
if (!line.content.trim()) continue;
|
|
917
|
+
samples.push({ newLineNumber: line.newLineNumber, content: line.content });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (samples.length === 0) return false;
|
|
921
|
+
const step = Math.max(1, Math.floor(samples.length / VALIDATION_SAMPLES));
|
|
922
|
+
for (let i = 0; i < samples.length; i += step) {
|
|
923
|
+
const sample = samples[i];
|
|
924
|
+
if (lines[sample.newLineNumber - 1] !== sample.content) return false;
|
|
925
|
+
}
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
async function loadFileContext(github, job, file, onFetch) {
|
|
929
|
+
if (!github.getRepoFile) return null;
|
|
930
|
+
let content;
|
|
931
|
+
try {
|
|
932
|
+
onFetch?.();
|
|
933
|
+
content = await github.getRepoFile(job.owner, job.repo, file.path, job.commitSha);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
logger.warn(`Could not fetch file content for ${file.path}; reviewing the diff alone`, {
|
|
936
|
+
error: error instanceof Error ? error.message : String(error)
|
|
937
|
+
});
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
if (!content) return null;
|
|
941
|
+
if (!contentMatchesDiff(file, content)) {
|
|
942
|
+
logger.warn(`Fetched content for ${file.path} does not line up with the diff; reviewing the diff alone`, {
|
|
943
|
+
commitSha: job.commitSha
|
|
944
|
+
});
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
return content;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/review/phase.ts
|
|
844
951
|
async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
845
952
|
if (!hasCompletedStep(job, "Preparation")) {
|
|
846
953
|
await runPreparePhase(env, job, leaseOwner, github);
|
|
@@ -861,12 +968,16 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
861
968
|
const { concurrencyLevel, maxFiles } = await env.settings.getReviewSettings();
|
|
862
969
|
const { files } = await getDiffFiles(env, job, github, config, maxFiles);
|
|
863
970
|
const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0);
|
|
971
|
+
const changelogExcerpt = changelogExcerptFromDiff(files);
|
|
864
972
|
const configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel];
|
|
865
973
|
const modelChainLength = 1 + (config.model.fallbacks?.length ?? 0);
|
|
866
974
|
const reviewChunkFileLimit = budgetAwareFileLimit(
|
|
867
975
|
tracker.remainingSafeBudget(),
|
|
868
976
|
configuredChunkFileLimit,
|
|
869
|
-
modelChainLength
|
|
977
|
+
modelChainLength,
|
|
978
|
+
config.review.full_file_context,
|
|
979
|
+
// A second reviewer walks its own chain per file, so fewer files fit in one invocation.
|
|
980
|
+
Boolean(config.model?.secondary)
|
|
870
981
|
);
|
|
871
982
|
if (reviewChunkFileLimit <= 0) {
|
|
872
983
|
throw new Error("Subrequest budget for this invocation was exhausted before starting the next review chunk.");
|
|
@@ -917,7 +1028,7 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
917
1028
|
transientErrorCount: existing?.transient_error_count ?? 0
|
|
918
1029
|
}];
|
|
919
1030
|
}));
|
|
920
|
-
const units = planReviewUnits(files, { enabled: true }).flatMap((unit) => narrowUnit(unit, ledger));
|
|
1031
|
+
const units = planReviewUnits(files, { enabled: true, fullFileContext: config.review.full_file_context }).flatMap((unit) => narrowUnit(unit, ledger));
|
|
921
1032
|
const plannedBins = units.filter((unit) => unit.kind === "bin");
|
|
922
1033
|
let binsDispatched = 0;
|
|
923
1034
|
let filesDispatchedInBins = 0;
|
|
@@ -927,7 +1038,7 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
927
1038
|
const binFiles = unit.kind === "bin" ? unit.files : [];
|
|
928
1039
|
binFiles.forEach((file) => binnedPaths.add(file.path));
|
|
929
1040
|
reviewTasks.push((async () => {
|
|
930
|
-
const terminal = await reviewAndPersistBin(env, job, binFiles, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars);
|
|
1041
|
+
const terminal = await reviewAndPersistBin(env, job, binFiles, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars, changelogExcerpt);
|
|
931
1042
|
terminalProgress += terminal;
|
|
932
1043
|
})());
|
|
933
1044
|
processedThisChunk += 1;
|
|
@@ -955,6 +1066,16 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
955
1066
|
continue;
|
|
956
1067
|
}
|
|
957
1068
|
const inherited = parentReviews.get(file.path);
|
|
1069
|
+
let fileContextPromise = null;
|
|
1070
|
+
const fileContextFor = () => {
|
|
1071
|
+
if (!wantsFileContext(file, config.review.full_file_context, {
|
|
1072
|
+
compactPrompt: (existingReview?.transient_error_count ?? 0) > 0
|
|
1073
|
+
})) {
|
|
1074
|
+
return Promise.resolve(null);
|
|
1075
|
+
}
|
|
1076
|
+
fileContextPromise ??= loadFileContext(github, job, file, () => tracker.incrementSubrequests(1));
|
|
1077
|
+
return fileContextPromise;
|
|
1078
|
+
};
|
|
958
1079
|
const reviewTask = async () => {
|
|
959
1080
|
if (awaitingReview) {
|
|
960
1081
|
const poll = await model.pollReviewBatch({
|
|
@@ -971,7 +1092,7 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
971
1092
|
logger.warn(`Async batch poll failed for ${file.path}; falling back to synchronous review`, {
|
|
972
1093
|
error: poll.error instanceof Error ? poll.error.message : String(poll.error)
|
|
973
1094
|
});
|
|
974
|
-
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars);
|
|
1095
|
+
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor());
|
|
975
1096
|
terminalProgress += 1;
|
|
976
1097
|
return;
|
|
977
1098
|
}
|
|
@@ -982,8 +1103,10 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
982
1103
|
if (!inherited) {
|
|
983
1104
|
const submitted = await model.submitReviewBatch({
|
|
984
1105
|
file,
|
|
1106
|
+
fileContext: await fileContextFor(),
|
|
985
1107
|
prTitle: pr.title ?? null,
|
|
986
1108
|
prDescription: pr.body ?? null,
|
|
1109
|
+
changelogExcerpt,
|
|
987
1110
|
config,
|
|
988
1111
|
totalLineCount,
|
|
989
1112
|
compactPrompt: (existingReview?.transient_error_count ?? 0) > 0
|
|
@@ -1012,13 +1135,13 @@ async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
|
1012
1135
|
awaitingAsync += 1;
|
|
1013
1136
|
return;
|
|
1014
1137
|
}
|
|
1015
|
-
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars);
|
|
1138
|
+
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor());
|
|
1016
1139
|
terminalProgress += 1;
|
|
1017
1140
|
return;
|
|
1018
1141
|
}
|
|
1019
1142
|
if (!canInheritParentFileReview(config, inherited)) {
|
|
1020
1143
|
logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`);
|
|
1021
|
-
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars);
|
|
1144
|
+
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor());
|
|
1022
1145
|
terminalProgress += 1;
|
|
1023
1146
|
} else {
|
|
1024
1147
|
await env.fileReviews.upsertFileReview(job.id, {
|
|
@@ -1187,6 +1310,20 @@ async function applyFindingGates(params) {
|
|
|
1187
1310
|
const severityRanks = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 };
|
|
1188
1311
|
const minRank = severityRanks[config.review.min_severity] ?? 4;
|
|
1189
1312
|
const minConfidence = config.review.min_confidence ?? 0;
|
|
1313
|
+
const languageGates = config.review.language_gates ?? {};
|
|
1314
|
+
const gatesByLanguage = new Map(
|
|
1315
|
+
Object.entries(languageGates).map(([language, gate]) => [language.toLowerCase(), gate])
|
|
1316
|
+
);
|
|
1317
|
+
const thresholdsFor = (path) => {
|
|
1318
|
+
if (gatesByLanguage.size === 0) return { minRank, minConfidence };
|
|
1319
|
+
const language = getLanguageForFile(path)?.language;
|
|
1320
|
+
const override = language ? gatesByLanguage.get(language.toLowerCase()) : void 0;
|
|
1321
|
+
if (!override) return { minRank, minConfidence };
|
|
1322
|
+
return {
|
|
1323
|
+
minRank: override.min_severity ? severityRanks[override.min_severity] ?? 4 : minRank,
|
|
1324
|
+
minConfidence: override.min_confidence ?? minConfidence
|
|
1325
|
+
};
|
|
1326
|
+
};
|
|
1190
1327
|
const dispositions = /* @__PURE__ */ new Map();
|
|
1191
1328
|
const verifyReasons = /* @__PURE__ */ new Map();
|
|
1192
1329
|
const recordDisposition = (comments, stage) => {
|
|
@@ -1197,11 +1334,12 @@ async function applyFindingGates(params) {
|
|
|
1197
1334
|
}
|
|
1198
1335
|
};
|
|
1199
1336
|
let finalComments = reviewedComments.filter((c) => {
|
|
1200
|
-
|
|
1337
|
+
const thresholds = thresholdsFor(c.path);
|
|
1338
|
+
if ((severityRanks[c.severity] ?? 4) > thresholds.minRank) {
|
|
1201
1339
|
recordDisposition([c], "severity");
|
|
1202
1340
|
return false;
|
|
1203
1341
|
}
|
|
1204
|
-
if (typeof c.confidenceScore === "number" && c.confidenceScore < minConfidence) {
|
|
1342
|
+
if (typeof c.confidenceScore === "number" && c.confidenceScore < thresholds.minConfidence) {
|
|
1205
1343
|
recordDisposition([c], "confidence");
|
|
1206
1344
|
return false;
|
|
1207
1345
|
}
|
|
@@ -1234,7 +1372,7 @@ async function applyFindingGates(params) {
|
|
|
1234
1372
|
return (b.confidenceScore ?? 0) - (a.confidenceScore ?? 0);
|
|
1235
1373
|
});
|
|
1236
1374
|
const beforeVerifyList = finalComments;
|
|
1237
|
-
const verify = await verifyFindings({ job, config, files, comments: finalComments, model, maxCandidates:
|
|
1375
|
+
const verify = await verifyFindings({ job, config, files, comments: finalComments, model, maxCandidates: reviewBreadth(config.review) });
|
|
1238
1376
|
finalComments = verify.comments;
|
|
1239
1377
|
const droppedByVerification = verify.dropped.length;
|
|
1240
1378
|
for (const drop of verify.dropped) recordDisposition([drop.comment], drop.disposition);
|
|
@@ -1251,7 +1389,7 @@ async function applyFindingGates(params) {
|
|
|
1251
1389
|
const omittedCount = reviewedComments.length - finalComments.length;
|
|
1252
1390
|
const droppedByFilters = omittedCount - droppedBySuppression - droppedByVerification - droppedByCap;
|
|
1253
1391
|
const withheldByParser = reviews.reduce(
|
|
1254
|
-
(sum, review) => sum + (review.withheld_counts?.evidence ?? 0) + (review.withheld_counts?.claimDenied ?? 0),
|
|
1392
|
+
(sum, review) => sum + (review.withheld_counts?.evidence ?? 0) + (review.withheld_counts?.claimDenied ?? 0) + (review.withheld_counts?.contextOnly ?? 0) + (review.withheld_counts?.absenceRefuted ?? 0),
|
|
1255
1393
|
0
|
|
1256
1394
|
);
|
|
1257
1395
|
const byClaimType = {};
|
|
@@ -1269,6 +1407,9 @@ async function applyFindingGates(params) {
|
|
|
1269
1407
|
finalComments,
|
|
1270
1408
|
dispositions,
|
|
1271
1409
|
verifyReasons,
|
|
1410
|
+
// Non-null means these findings were never checked. The caller records it on the job, so a review
|
|
1411
|
+
// that skipped verification stops looking identical to one that passed it.
|
|
1412
|
+
verificationSkipped: verify.skipped,
|
|
1272
1413
|
suppressedComments,
|
|
1273
1414
|
droppedBySuppression,
|
|
1274
1415
|
beforeVerifyList,
|
|
@@ -1282,6 +1423,17 @@ async function applyFindingGates(params) {
|
|
|
1282
1423
|
}
|
|
1283
1424
|
|
|
1284
1425
|
// src/review/finalize.ts
|
|
1426
|
+
function partialReviewMessage(input) {
|
|
1427
|
+
const plural = (n) => n === 1 ? "" : "s";
|
|
1428
|
+
const reasons = [];
|
|
1429
|
+
if (input.failedFileCount > 0) {
|
|
1430
|
+
reasons.push(`${input.failedFileCount} of ${input.reviewedFileCount} file${plural(input.reviewedFileCount)} could not be reviewed`);
|
|
1431
|
+
}
|
|
1432
|
+
if (input.filesOverCap > 0) {
|
|
1433
|
+
reasons.push(`${input.filesOverCap} file${plural(input.filesOverCap)} left out by the file and diff-size limits`);
|
|
1434
|
+
}
|
|
1435
|
+
return reasons.length > 0 ? `Partial review: ${reasons.join("; ")}.` : null;
|
|
1436
|
+
}
|
|
1285
1437
|
async function runFinalizePhase(env, job, leaseOwner, github, formatter, model) {
|
|
1286
1438
|
await env.jobs.updateJobStep(job.id, "Generating Summary", { status: "running" });
|
|
1287
1439
|
const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber);
|
|
@@ -1333,10 +1485,12 @@ async function runFinalizePhase(env, job, leaseOwner, github, formatter, model)
|
|
|
1333
1485
|
}
|
|
1334
1486
|
const hasFailures = fileSummaries.some((file) => file.verdict === "failed");
|
|
1335
1487
|
const failedFileCount = fileSummaries.filter((file) => file.verdict === "failed").length;
|
|
1488
|
+
await env.jobs.updateJobStep(job.id, "Verifying Findings", { status: "running" });
|
|
1336
1489
|
const {
|
|
1337
1490
|
finalComments,
|
|
1338
1491
|
dispositions,
|
|
1339
1492
|
verifyReasons,
|
|
1493
|
+
verificationSkipped,
|
|
1340
1494
|
suppressedComments,
|
|
1341
1495
|
droppedBySuppression,
|
|
1342
1496
|
beforeVerifyList,
|
|
@@ -1359,6 +1513,7 @@ async function runFinalizePhase(env, job, leaseOwner, github, formatter, model)
|
|
|
1359
1513
|
logger.info("Finding pipeline outcome", {
|
|
1360
1514
|
jobId: job.id,
|
|
1361
1515
|
parsed: reviewedComments.length,
|
|
1516
|
+
verificationSkipped,
|
|
1362
1517
|
droppedByFilters,
|
|
1363
1518
|
droppedBySuppression,
|
|
1364
1519
|
droppedByVerification,
|
|
@@ -1381,17 +1536,27 @@ async function runFinalizePhase(env, job, leaseOwner, github, formatter, model)
|
|
|
1381
1536
|
jobId: job.id,
|
|
1382
1537
|
...shadowEvaluate(beforeVerifyList, finalComments)
|
|
1383
1538
|
});
|
|
1539
|
+
await env.jobs.updateJobStep(job.id, "Verifying Findings", verificationSkipped ? { status: "failed", error: `Verification did not run (${verificationSkipped}); findings were posted unverified.` } : { status: "done" });
|
|
1384
1540
|
const rawVerdict = formatter.summarizeVerdict([...finalComments, ...suppressedComments], hasFailures);
|
|
1385
1541
|
const everythingWithheld = finalComments.length === 0 && suppressedComments.length === 0 && (withheldByParser > 0 || omittedCount > 0);
|
|
1386
1542
|
const verdictSummary = everythingWithheld && rawVerdict.verdict === "approve" ? { ...rawVerdict, verdict: "comment" } : rawVerdict;
|
|
1387
1543
|
await env.jobs.updateJobStep(job.id, "Generating Summary", { status: "done" });
|
|
1388
1544
|
await heartbeatAndCheckSuperseded(env, job.id, leaseOwner);
|
|
1389
|
-
|
|
1545
|
+
const formattedSummary = formatter.formatReviewOverview({
|
|
1546
|
+
commitSha: pr.head.sha,
|
|
1547
|
+
postedFindings: finalComments.length,
|
|
1548
|
+
filesReviewed: files.length,
|
|
1549
|
+
linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0),
|
|
1550
|
+
withheldFindings: withheldByParser + droppedByFilters + droppedByVerification,
|
|
1551
|
+
filesFailed: failedFileCount
|
|
1552
|
+
});
|
|
1390
1553
|
if (filesOverCap > 0) {
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1554
|
+
logger.info("Some reviewable files were skipped by the file or diff-size limits", {
|
|
1555
|
+
jobId: job.id,
|
|
1556
|
+
filesOverCap,
|
|
1557
|
+
reviewed: files.length,
|
|
1558
|
+
maxFiles: reviewSettings.maxFiles
|
|
1559
|
+
});
|
|
1395
1560
|
}
|
|
1396
1561
|
const finalizeRetriedPastPost = job.steps.some(
|
|
1397
1562
|
(step) => step.name === "Completing" && (step.status === "running" || step.status === "done")
|
|
@@ -1414,6 +1579,16 @@ async function runFinalizePhase(env, job, leaseOwner, github, formatter, model)
|
|
|
1414
1579
|
const postedFingerprints = review.postedIndices.map((index) => finalComments[index]?.fingerprint).filter((fingerprint) => Boolean(fingerprint));
|
|
1415
1580
|
await env.fileReviews.markCommentsPosted(job.id, postedFingerprints);
|
|
1416
1581
|
}
|
|
1582
|
+
if (finalComments.length === 0 && github.addIssueReaction) {
|
|
1583
|
+
try {
|
|
1584
|
+
await github.addIssueReaction(job.owner, job.repo, job.prNumber, "+1");
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
logger.warn("Could not react to the pull request", {
|
|
1587
|
+
jobId: job.id,
|
|
1588
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1417
1592
|
try {
|
|
1418
1593
|
const withReasons = /* @__PURE__ */ new Map();
|
|
1419
1594
|
for (const fingerprint of /* @__PURE__ */ new Set([...dispositions.keys(), ...verifyReasons.keys()])) {
|
|
@@ -1436,7 +1611,11 @@ async function runFinalizePhase(env, job, leaseOwner, github, formatter, model)
|
|
|
1436
1611
|
const sev = comment.severity || "unknown";
|
|
1437
1612
|
severityDistribution[sev] = (severityDistribution[sev] || 0) + 1;
|
|
1438
1613
|
}
|
|
1439
|
-
const partialErrorMessage =
|
|
1614
|
+
const partialErrorMessage = partialReviewMessage({
|
|
1615
|
+
failedFileCount: hasFailures ? failedFileCount : 0,
|
|
1616
|
+
reviewedFileCount: files.length,
|
|
1617
|
+
filesOverCap
|
|
1618
|
+
});
|
|
1440
1619
|
await env.jobs.completeJob(job.id, {
|
|
1441
1620
|
verdict: verdictSummary.verdict,
|
|
1442
1621
|
fileCount: files.length,
|