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