@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.
Files changed (70) hide show
  1. package/dist/{chunk-XD6CGYHO.js → chunk-4XEMXRNG.js} +62 -39
  2. package/dist/chunk-4XEMXRNG.js.map +1 -0
  3. package/dist/{chunk-ILZUCIZH.js → chunk-EQL5OOEF.js} +7 -7
  4. package/dist/chunk-EQL5OOEF.js.map +1 -0
  5. package/dist/{chunk-RQ5S67XB.js → chunk-FKRCBUWZ.js} +8 -2
  6. package/dist/chunk-FKRCBUWZ.js.map +1 -0
  7. package/dist/{chunk-JC74XH7Q.js → chunk-GDX2ZEBA.js} +1 -1
  8. package/dist/chunk-GDX2ZEBA.js.map +1 -0
  9. package/dist/{chunk-OIQRTDYR.js → chunk-I5S3NBFL.js} +20 -4
  10. package/dist/chunk-I5S3NBFL.js.map +1 -0
  11. package/dist/{chunk-U3VKVDZ7.js → chunk-IKVUW4WC.js} +92 -19
  12. package/dist/chunk-IKVUW4WC.js.map +1 -0
  13. package/dist/{chunk-R2HRWFRH.js → chunk-JAF7JIZQ.js} +1 -1
  14. package/dist/chunk-JAF7JIZQ.js.map +1 -0
  15. package/dist/{chunk-V3RF6GQW.js → chunk-KTDBDKL5.js} +1 -1
  16. package/dist/chunk-KTDBDKL5.js.map +1 -0
  17. package/dist/{chunk-MMVQPGK3.js → chunk-ONJZXUQT.js} +112 -24
  18. package/dist/chunk-ONJZXUQT.js.map +1 -0
  19. package/dist/{chunk-PVXFURDR.js → chunk-QPPLO2YV.js} +2 -2
  20. package/dist/chunk-QPPLO2YV.js.map +1 -0
  21. package/dist/{chunk-FGXA7JNR.js → chunk-W3RY755V.js} +14 -2
  22. package/dist/chunk-W3RY755V.js.map +1 -0
  23. package/dist/{chunk-NSTV4TRP.js → chunk-XPYVLHSX.js} +1 -1
  24. package/dist/chunk-XPYVLHSX.js.map +1 -0
  25. package/dist/{chunk-WSK45HW4.js → chunk-Z5B5X7QP.js} +1 -1
  26. package/dist/chunk-Z5B5X7QP.js.map +1 -0
  27. package/dist/claim-checks.d.ts +8 -13
  28. package/dist/claim-checks.js +2 -2
  29. package/dist/diff/index.d.ts +3 -3
  30. package/dist/diff/index.js +2 -1
  31. package/dist/fingerprint.js +1 -1
  32. package/dist/formatter-CDFpqbDP.d.ts +24 -0
  33. package/dist/formatter.d.ts +29 -0
  34. package/dist/formatter.js +111 -0
  35. package/dist/formatter.js.map +1 -0
  36. package/dist/{index-BTDWXDck.d.ts → index-CrtHz4vN.d.ts} +3 -1
  37. package/dist/index.d.ts +12 -7
  38. package/dist/index.js +239 -62
  39. package/dist/index.js.map +1 -1
  40. package/dist/logger.js +1 -1
  41. package/dist/{model-gRDTk8yk.d.ts → model-BkqfyVh9.d.ts} +27 -6
  42. package/dist/model-output/index.d.ts +2 -2
  43. package/dist/model-output/index.js +9 -9
  44. package/dist/ports/index.d.ts +22 -18
  45. package/dist/ports/index.js +1 -1
  46. package/dist/{position-h_jdn6ZH.d.ts → position-zZl8KGVu.d.ts} +1 -1
  47. package/dist/prompts/file-review.d.ts +3 -3
  48. package/dist/prompts/file-review.js +11 -5
  49. package/dist/prompts/languages.d.ts +2 -1
  50. package/dist/prompts/languages.js +5 -3
  51. package/dist/prompts/verify.d.ts +1 -1
  52. package/dist/prompts/verify.js +1 -1
  53. package/dist/rules/detect.d.ts +1 -1
  54. package/dist/rules/detect.js +5 -5
  55. package/dist/rules/table.js +1 -1
  56. package/dist/token-tracker.js +2 -2
  57. package/package.json +10 -2
  58. package/dist/chunk-FGXA7JNR.js.map +0 -1
  59. package/dist/chunk-ILZUCIZH.js.map +0 -1
  60. package/dist/chunk-JC74XH7Q.js.map +0 -1
  61. package/dist/chunk-MMVQPGK3.js.map +0 -1
  62. package/dist/chunk-NSTV4TRP.js.map +0 -1
  63. package/dist/chunk-OIQRTDYR.js.map +0 -1
  64. package/dist/chunk-PVXFURDR.js.map +0 -1
  65. package/dist/chunk-R2HRWFRH.js.map +0 -1
  66. package/dist/chunk-RQ5S67XB.js.map +0 -1
  67. package/dist/chunk-U3VKVDZ7.js.map +0 -1
  68. package/dist/chunk-V3RF6GQW.js.map +0 -1
  69. package/dist/chunk-WSK45HW4.js.map +0 -1
  70. 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-JC74XH7Q.js";
6
+ } from "./chunk-GDX2ZEBA.js";
10
7
  import {
11
- renderFileDiff
12
- } from "./chunk-MMVQPGK3.js";
13
- import "./chunk-RQ5S67XB.js";
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-V3RF6GQW.js";
26
+ } from "./chunk-KTDBDKL5.js";
18
27
  import {
19
28
  ruleHitsToComments,
20
29
  scanFileForRuleHits
21
- } from "./chunk-ILZUCIZH.js";
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-FGXA7JNR.js";
42
- import "./chunk-R2HRWFRH.js";
43
- import "./chunk-U3VKVDZ7.js";
44
- import "./chunk-NSTV4TRP.js";
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-WSK45HW4.js";
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(remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength));
145
- return Math.min(configuredChunkFileLimit, budgetLimit);
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
- if (file.lineCount > PACKABLE_MAX_DIFF_LINES || fileChars > BIN_DIFF_CHAR_BUDGET) {
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
- async function reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, previousReview, rejectedExemplars = []) {
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 response = await model.reviewFile({
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
- parsedComments: [...response.parsed.comments, ...ruleScan.comments],
403
- inputTokens: response.inputTokens,
404
- outputTokens: response.outputTokens,
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(effectiveMaxComments) {
698
- return Math.min(40, Math.max(10, effectiveMaxComments * 3));
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 = () => ({ comments, dropped: [], reasons: /* @__PURE__ */ new Map() });
703
- if (comments.length === 0) return keepAll();
704
- const limit = verifyCandidateLimit(params.maxCandidates ?? config.review.max_comments);
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
- if ((severityRanks[c.severity] ?? 4) > minRank) {
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: effectiveMaxComments });
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
- let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.botUsername);
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
- formattedSummary += `
1392
-
1393
- > [!WARNING]
1394
- > **${filesOverCap} file${filesOverCap === 1 ? " was" : "s were"} not reviewed.** This pull request has ${files.length + filesOverCap} reviewable files and the limit is ${reviewSettings.maxFiles}. Raise it in Settings to cover the whole diff.`;
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 = hasFailures ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? "" : "s"} could not be reviewed.` : null;
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,