@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.
Files changed (62) hide show
  1. package/dist/{chunk-XD6CGYHO.js → chunk-2TF4DSRX.js} +65 -42
  2. package/dist/chunk-2TF4DSRX.js.map +1 -0
  3. package/dist/{chunk-ILZUCIZH.js → chunk-754NO4DH.js} +7 -7
  4. package/dist/chunk-754NO4DH.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-OIQRTDYR.js → chunk-I5S3NBFL.js} +20 -4
  8. package/dist/chunk-I5S3NBFL.js.map +1 -0
  9. package/dist/{chunk-U3VKVDZ7.js → chunk-IKVUW4WC.js} +92 -19
  10. package/dist/chunk-IKVUW4WC.js.map +1 -0
  11. package/dist/{chunk-R2HRWFRH.js → chunk-JAF7JIZQ.js} +1 -1
  12. package/dist/chunk-JAF7JIZQ.js.map +1 -0
  13. package/dist/{chunk-V3RF6GQW.js → chunk-KTDBDKL5.js} +1 -1
  14. package/dist/chunk-KTDBDKL5.js.map +1 -0
  15. package/dist/{chunk-MMVQPGK3.js → chunk-ONJZXUQT.js} +112 -24
  16. package/dist/chunk-ONJZXUQT.js.map +1 -0
  17. package/dist/{chunk-PVXFURDR.js → chunk-QPPLO2YV.js} +2 -2
  18. package/dist/chunk-QPPLO2YV.js.map +1 -0
  19. package/dist/{chunk-FGXA7JNR.js → chunk-W3RY755V.js} +14 -2
  20. package/dist/chunk-W3RY755V.js.map +1 -0
  21. package/dist/{chunk-NSTV4TRP.js → chunk-XPYVLHSX.js} +1 -1
  22. package/dist/chunk-XPYVLHSX.js.map +1 -0
  23. package/dist/{chunk-WSK45HW4.js → chunk-Z5B5X7QP.js} +1 -1
  24. package/dist/chunk-Z5B5X7QP.js.map +1 -0
  25. package/dist/claim-checks.d.ts +8 -13
  26. package/dist/claim-checks.js +2 -2
  27. package/dist/diff/index.d.ts +3 -3
  28. package/dist/diff/index.js +2 -1
  29. package/dist/fingerprint.js +1 -1
  30. package/dist/{index-BTDWXDck.d.ts → index-CrtHz4vN.d.ts} +3 -1
  31. package/dist/index.d.ts +11 -7
  32. package/dist/index.js +240 -61
  33. package/dist/index.js.map +1 -1
  34. package/dist/logger.js +1 -1
  35. package/dist/{model-gRDTk8yk.d.ts → model-BkqfyVh9.d.ts} +27 -6
  36. package/dist/model-output/index.d.ts +2 -2
  37. package/dist/model-output/index.js +9 -9
  38. package/dist/ports/index.d.ts +28 -6
  39. package/dist/{position-h_jdn6ZH.d.ts → position-zZl8KGVu.d.ts} +1 -1
  40. package/dist/prompts/file-review.d.ts +3 -3
  41. package/dist/prompts/file-review.js +11 -5
  42. package/dist/prompts/languages.d.ts +2 -1
  43. package/dist/prompts/languages.js +5 -3
  44. package/dist/prompts/verify.d.ts +1 -1
  45. package/dist/prompts/verify.js +1 -1
  46. package/dist/rules/detect.d.ts +1 -1
  47. package/dist/rules/detect.js +5 -5
  48. package/dist/rules/table.js +1 -1
  49. package/dist/token-tracker.js +2 -2
  50. package/package.json +2 -2
  51. package/dist/chunk-FGXA7JNR.js.map +0 -1
  52. package/dist/chunk-ILZUCIZH.js.map +0 -1
  53. package/dist/chunk-MMVQPGK3.js.map +0 -1
  54. package/dist/chunk-NSTV4TRP.js.map +0 -1
  55. package/dist/chunk-OIQRTDYR.js.map +0 -1
  56. package/dist/chunk-PVXFURDR.js.map +0 -1
  57. package/dist/chunk-R2HRWFRH.js.map +0 -1
  58. package/dist/chunk-RQ5S67XB.js.map +0 -1
  59. package/dist/chunk-U3VKVDZ7.js.map +0 -1
  60. package/dist/chunk-V3RF6GQW.js.map +0 -1
  61. package/dist/chunk-WSK45HW4.js.map +0 -1
  62. 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-XD6CGYHO.js";
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
- renderFileDiff
12
- } from "./chunk-MMVQPGK3.js";
13
- import "./chunk-RQ5S67XB.js";
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-V3RF6GQW.js";
27
+ } from "./chunk-KTDBDKL5.js";
28
+ import "./chunk-IKVUW4WC.js";
29
+ import "./chunk-XPYVLHSX.js";
30
+ import "./chunk-QPPLO2YV.js";
18
31
  import {
19
- ruleHitsToComments,
20
- scanFileForRuleHits
21
- } from "./chunk-ILZUCIZH.js";
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-FGXA7JNR.js";
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(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;
@@ -377,20 +390,49 @@ function scanRuleChannel(file, config) {
377
390
  return { comments: [], stats: null };
378
391
  }
379
392
  }
380
- async function reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, previousReview, rejectedExemplars = []) {
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 response = await model.reviewFile({
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
- parsedComments: [...response.parsed.comments, ...ruleScan.comments],
403
- inputTokens: response.inputTokens,
404
- outputTokens: response.outputTokens,
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(effectiveMaxComments) {
698
- return Math.min(40, Math.max(10, effectiveMaxComments * 3));
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 = () => ({ comments, dropped: [], reasons: /* @__PURE__ */ new Map() });
703
- if (comments.length === 0) return keepAll();
704
- const limit = verifyCandidateLimit(params.maxCandidates ?? config.review.max_comments);
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
- if ((severityRanks[c.severity] ?? 4) > minRank) {
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: effectiveMaxComments });
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
- let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.botUsername);
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
- 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.`;
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 = hasFailures ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? "" : "s"} could not be reviewed.` : null;
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,