argus-reviewer-e2e 0.2.0 → 0.3.0

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.
@@ -1,8 +1,15 @@
1
- /* global github, context, core, require, process */
1
+ /* eslint-disable @typescript-eslint/no-require-imports */
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
 
6
+ // The composite action injects these objects at runtime. Keeping them in a
7
+ // module-level runtime binding lets the GitHub script load this file with a
8
+ // normal require() instead of evaluating its source dynamically.
9
+ let github
10
+ let context
11
+ let core
12
+
6
13
  const SENTINEL = '<!-- argus-reviewer -->'
7
14
 
8
15
  function formatUsd(n) {
@@ -64,12 +71,17 @@ function renderBody(report, codeReview, runUrl, ok, inlinePlan) {
64
71
  lines.push('')
65
72
  lines.push(`## argus-reviewer ${ok ? '✅ PASS' : '❌ FAIL'}`)
66
73
  lines.push('')
74
+ pushReviewTop(lines, codeReview)
67
75
  lines.push(
68
76
  `**Summary:** ${report.totals.passed}/${report.totals.tests} passed · ` +
69
77
  `${report.totals.visionCalls} vision calls · ` +
70
78
  `${formatUsd(report.totals.visionCostUsd)} spend · ` +
71
79
  `${report.totals.sandboxSeconds.toFixed(1)}s sandbox`,
72
80
  )
81
+ lines.push(
82
+ `**Fingerprint cache:** ${report.totals.cacheHits ?? 0} hit(s) · ` +
83
+ `${report.totals.cacheMisses ?? 0} miss(es) · ${report.totals.cacheHeals ?? 0} heal(s)`,
84
+ )
73
85
  lines.push('')
74
86
 
75
87
  lines.push('<details>')
@@ -232,6 +244,59 @@ function renderBody(report, codeReview, runUrl, ok, inlinePlan) {
232
244
  return lines.join('\n')
233
245
  }
234
246
 
247
+ // ---------------------------------------------------------------------------
248
+ // R6 — scannable top block under the sentinel: verdict icon + one-line
249
+ // summary + honest counts, so the first screen answers "verdict, what
250
+ // kinds, what needs me" before the <details> fold. ⛔ counts
251
+ // probe-reproduced findings only — a high p alone is never "proven" —
252
+ // while ◎ carries the Jev-confidence count; the two overlap when a
253
+ // finding is both (KTD2). The serialized blocker counts win when present
254
+ // (same numbers reviewBody prints); the recount is the fallback for
255
+ // reports predating them — P_FALLBACK_GATE must match
256
+ // P_TRUE_POSITIVE_THRESHOLD in src/cli.ts.
257
+
258
+ const REVIEW_VERDICT_ICON = { pass: '✅', approve: '👍', needs_changes: '🔴' }
259
+ const P_FALLBACK_GATE = 0.7
260
+
261
+ function pushReviewTop(lines, codeReview) {
262
+ if (!codeReview || codeReview.skipped) return
263
+ const findings = Array.isArray(codeReview.findings) ? codeReview.findings : []
264
+ const icon = REVIEW_VERDICT_ICON[codeReview.verdict] ?? '❔'
265
+ lines.push(
266
+ `**Code review:** ${icon} **${cell(codeReview.verdict ?? 'unknown')}** — ${cell(codeReview.summary)}`,
267
+ )
268
+ if (findings.length === 0) {
269
+ lines.push('no findings')
270
+ lines.push('')
271
+ return
272
+ }
273
+ const sev = { bug: 0, risk: 0, nit: 0, q: 0 }
274
+ for (const f of findings) if (Object.hasOwn(sev, f.severity)) sev[f.severity] += 1
275
+ const tail = []
276
+ // 🔧 counts serialized comments carrying a committable block — what
277
+ // actually lands on the PR — not every finding the model offered a
278
+ // patch for (overflowed/ineligible suggestions aren't committable).
279
+ const suggestions = Array.isArray(codeReview.reviewComments)
280
+ ? codeReview.reviewComments.filter((c) => extractSuggestion(c.body ?? '') !== '').length
281
+ : findings.filter((f) => typeof f.suggestion === 'string' && f.suggestion !== '').length
282
+ if (suggestions > 0) tail.push(`🔧 ${suggestions} suggestion${suggestions === 1 ? '' : 's'}`)
283
+ const reproduced =
284
+ typeof codeReview.provenBlockers === 'number'
285
+ ? codeReview.provenBlockers
286
+ : findings.filter((f) => f.evidence?.status === 'reproduced').length
287
+ if (reproduced > 0) tail.push(`⛔ ${reproduced} reproduced`)
288
+ const confident =
289
+ typeof codeReview.highConfidenceBlockers === 'number'
290
+ ? codeReview.highConfidenceBlockers
291
+ : findings.filter((f) => typeof f.p === 'number' && f.p >= P_FALLBACK_GATE).length
292
+ if (confident > 0) tail.push(`◎ ${confident} high-confidence`)
293
+ lines.push(
294
+ `🐛 ${sev.bug} · ⚠️ ${sev.risk} · 💡 ${sev.nit} · ❓ ${sev.q}` +
295
+ (tail.length > 0 ? ` — ${tail.join(' · ')}` : ''),
296
+ )
297
+ lines.push('')
298
+ }
299
+
235
300
  // The 🧠 Code review details block — shared by the full body and the
236
301
  // review-only body (run lane disabled).
237
302
  function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
@@ -242,6 +307,11 @@ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
242
307
  lines.push(
243
308
  `**Verdict:** ${codeReview.verdict} · ${codeReview.model} · ${codeReview.tokens}tok ${formatUsd(codeReview.visionCostUsd)}`,
244
309
  )
310
+ if (codeReview.headBinding) {
311
+ lines.push(
312
+ `**Head binding:** ${cell(codeReview.headBinding.status)} · ${cell(codeReview.headBinding.detail)}`,
313
+ )
314
+ }
245
315
  // U7 triage record — Jev annotate/route signals, never the gate.
246
316
  if (codeReview.triage) {
247
317
  const t = codeReview.triage
@@ -298,14 +368,6 @@ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
298
368
  )
299
369
  }
300
370
  lines.push('')
301
- // Inline-comment cap note — dedup'd fresh findings the
302
- // review.maxComments budget didn't post (TCA max_comments).
303
- if (inlinePlan !== undefined && inlinePlan.dropped > 0) {
304
- lines.push(
305
- `*+${inlinePlan.dropped} inline-eligible finding(s) not posted — \`review.maxComments\` cap ${inlinePlan.cap}.*`,
306
- )
307
- lines.push('')
308
- }
309
371
  // Secrets-lane audit line — adjudicated/suppressed counts, never literals.
310
372
  if (codeReview.secretsScan) {
311
373
  if (typeof codeReview.secretsScan.skipped === 'string') {
@@ -323,6 +385,20 @@ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
323
385
  lines.push('')
324
386
  }
325
387
  }
388
+ // Serialized comments that didn't post — the maxComments cap
389
+ // (serialized overflow) plus post-time drops (off-diff anchors,
390
+ // retry-ladder discards). Outside the findings guard: drops still
391
+ // disclose even when the findings table rendered nothing.
392
+ if (inlinePlan !== undefined && inlinePlan.dropped > 0) {
393
+ const overflow = inlinePlan.overflow ?? 0
394
+ const reasons = []
395
+ if (overflow > 0) reasons.push(`\`review.maxComments\` cap ${inlinePlan.cap}`)
396
+ if (inlinePlan.dropped - overflow > 0) {
397
+ reasons.push(`${inlinePlan.dropped - overflow} outside the PR diff`)
398
+ }
399
+ lines.push(`*+${inlinePlan.dropped} inline comment(s) not posted — ${reasons.join(', ')}.*`)
400
+ lines.push('')
401
+ }
326
402
  // U8 adjudication audit — outside the findings guard so suppressed-
327
403
  // only reviews still show what Jev removed. p values live on the
328
404
  // findings table and in code-review.json records.
@@ -354,6 +430,7 @@ function renderReviewOnlyBody(codeReview, runUrl, ok, inlinePlan) {
354
430
  lines.push('')
355
431
  lines.push(`## argus-reviewer ${ok ? '✅ PASS' : '❌ FAIL'}`)
356
432
  lines.push('')
433
+ pushReviewTop(lines, codeReview)
357
434
  if (!codeReview) {
358
435
  lines.push(
359
436
  '**Summary:** code-review only (run lane disabled) — no `code-review.json` found. The review step crashed or produced no report; the commit status fails closed — check the action logs before merging.',
@@ -406,80 +483,271 @@ function renderReviewOnlyBody(codeReview, runUrl, ok, inlinePlan) {
406
483
  return lines.join('\n')
407
484
  }
408
485
 
409
- async function planInlineComments(pr, codeReview) {
410
- if (!pr || !codeReview || codeReview.skipped || !codeReview.findings) return undefined
411
- // Must match the severity vocabulary emitted by the code-review schema
412
- // (src/cli.ts): bug/risk are inline-worthy; nit/q stay in the sticky body.
413
- const inlineSeverities = ['bug', 'risk']
414
- const comments = codeReview.findings
415
- .filter((f) => f.file && typeof f.line === 'number' && inlineSeverities.includes(f.severity))
416
- .map((f) => ({
417
- path: f.file,
418
- line: f.line,
419
- side: 'RIGHT',
420
- body: `**argus-reviewer ${f.severity}:** ${f.message}${
421
- f.category ? ` \`${f.category}\`` : ''
422
- }${
423
- f.evidence && f.evidence.status === 'reproduced'
424
- ? '\n\n*🧪 Reproduced by an Argus probe — fails on this PR head, clean on base. See workflow artifacts.*'
425
- : f.evidence && f.evidence.status !== 'exercised'
426
- ? `\n\n*CI evidence: ${f.evidence.detail}*`
427
- : ''
428
- }`,
429
- }))
430
- if (comments.length === 0) return { capped: [], dropped: 0, cap: 0 }
431
-
432
- // Re-runs on the same SHA must not duplicate inline comments — the sticky
433
- // body is upserted but review comments are not. Paginate fully (100/page)
434
- // and scope dedup to the current head: comments on older commits must not
435
- // suppress findings that still apply to this head. The key is the body's
436
- // first line (the finding itself) — trailing evidence notes like the
437
- // `reproduced` upgrade change the body but must not re-post a duplicate.
438
- const dedupKey = (path, line, body) => `${path}:${line}:${body.split('\n')[0]}`
439
- const posted = new Set()
486
+ // ---------------------------------------------------------------------------
487
+ // U3 — the review poster is deliberately dumb (KTD3): the CLI serializes the
488
+ // whole surface into code-review.json (`reviewComments[]` arrives
489
+ // eligibility-filtered, sanitized, severity-sorted, capped, keyed). This file
490
+ // only: freshness-gates the report (R9), dedups against posted comments (R10),
491
+ // validates anchors against the live diff (R8), dismisses stale self-reviews
492
+ // (KTD5), and POSTs one batched review with the serialized event plus a
493
+ // bounded retry ladder (R4/KTD4).
494
+
495
+ /** djb2 → 8 hex chars. Must match shortHash() in src/cli.ts — the CLI's
496
+ * dedupKey suffix is this hash over the raw suggestion text. */
497
+ function shortHash(s) {
498
+ let h = 5381
499
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
500
+ return (h >>> 0).toString(16).padStart(8, '0')
501
+ }
502
+
503
+ /** Pull the fenced ```` ```suggestion ```` block out of a posted comment body.
504
+ * The CLI's fence is longest-backtick-run+1 (min 4), so a run of exactly the
505
+ * fence's length can only appear as the closing fence — the backreference is
506
+ * safe against interior ``` runs. */
507
+ function extractSuggestion(body) {
508
+ const m = /\r?\n(`{4,})suggestion\r?\n([\s\S]*?)\r?\n\1/.exec(body)
509
+ return m === null ? '' : m[2]
510
+ }
511
+
512
+ /** Reconstruct the R10 dedupKey for an already-posted review comment:
513
+ * `path:line:bodyFirstLine:hash8(suggestion|'')` — identical to the key the
514
+ * CLI serialized, so a corrected suggestion re-posts instead of colliding. */
515
+ function postedDedupKey(c) {
516
+ const body = c.body ?? ''
517
+ return `${c.path}:${c.line}:${body.split('\n')[0]}:${shortHash(extractSuggestion(body))}`
518
+ }
519
+
520
+ /** Fetch every page of a list endpoint (100/page, octokit shape). */
521
+ async function listAll(fn, params) {
522
+ const out = []
440
523
  let page = 1
441
524
  for (;;) {
442
- const { data: existing } = await github.rest.pulls.listReviewComments({
443
- owner: context.repo.owner,
444
- repo: context.repo.repo,
445
- pull_number: pr.number,
446
- per_page: 100,
447
- page,
448
- })
525
+ const { data } = await fn({ ...params, per_page: 100, page })
526
+ if (!Array.isArray(data) || data.length === 0) break
527
+ out.push(...data)
528
+ if (data.length < 100) break
529
+ page += 1
530
+ }
531
+ return out
532
+ }
533
+
534
+ /** The GITHUB_TOKEN posts as `github-actions[bot]`; a custom token may post
535
+ * as its own login or `<actor>[bot]`. Match that set so stale *self* reviews
536
+ * are dismissed without ever touching a human reviewer's verdict. */
537
+ function isSelfLogin(login) {
538
+ const actor = context.actor
539
+ return (
540
+ login === 'github-actions[bot]' ||
541
+ login === actor ||
542
+ (typeof actor === 'string' && actor !== '' && login === `${actor}[bot]`)
543
+ )
544
+ }
545
+
546
+ /** RIGHT-side line numbers covered by a unified-diff patch. Every line in a
547
+ * hunk's `+c,d` range is a valid RIGHT-side anchor (context or added); `-`
548
+ * lines aren't counted in `d`, so the range is contiguous. */
549
+ function rightSideLines(patch) {
550
+ const lines = new Set()
551
+ for (const m of patch.matchAll(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/g)) {
552
+ const start = Number.parseInt(m[1], 10)
553
+ const count = m[2] === undefined ? 1 : Number.parseInt(m[2], 10)
554
+ for (let l = start; l < start + count; l++) lines.add(l)
555
+ }
556
+ return lines
557
+ }
558
+
559
+ /** True when a serialized comment anchors inside the live diff — path in the
560
+ * file list and `line` (plus `start_line` when present) on a RIGHT-side
561
+ * hunk line. Files without a `patch` (large/binary) accept no comments. */
562
+ function isOnDiff(c, diffLines) {
563
+ const valid = diffLines.get(c.path)
564
+ return (
565
+ valid !== undefined &&
566
+ valid.has(c.line) &&
567
+ (c.start_line === undefined || valid.has(c.start_line))
568
+ )
569
+ }
570
+
571
+ async function planInlineComments(pr, codeReview) {
572
+ if (!pr || !codeReview || codeReview.skipped) return undefined
573
+ // Old-format reports carry no serialized surface — degrade to sticky +
574
+ // status only, exactly as before U3. No review post, no API calls.
575
+ if (!Array.isArray(codeReview.reviewComments)) return undefined
576
+
577
+ // R9/KTD6 freshness — a planted or stale report must never produce
578
+ // committable suggestions or a blocking review. The sticky still posts;
579
+ // it renders status text, not code.
580
+ if (codeReview.headBinding?.intendedSha !== pr.head.sha) {
581
+ core.warning(
582
+ `code-review.json head binding ` +
583
+ `(${codeReview.headBinding?.intendedSha ?? 'missing'}) does not match ` +
584
+ `PR head ${pr.head.sha} — skipping inline review`,
585
+ )
586
+ return undefined
587
+ }
588
+
589
+ const cap = typeof codeReview.maxComments === 'number' ? codeReview.maxComments : 20
590
+ const overflow = typeof codeReview.commentsOverflow === 'number' ? codeReview.commentsOverflow : 0
591
+ const prRef = {
592
+ owner: context.repo.owner,
593
+ repo: context.repo.repo,
594
+ pull_number: pr.number,
595
+ }
596
+
597
+ // List failures degrade to sticky-only (warn), never crash main() before
598
+ // the sticky posts.
599
+ try {
600
+ // R10 dedup — paginate fully and scope to the current head so comments on
601
+ // older commits can't suppress still-valid findings. Keys are
602
+ // reconstructed from the posted body (first line + hash of the embedded
603
+ // suggestion), so a re-run with a corrected suggestion posts the fix
604
+ // instead of colliding.
605
+ const posted = new Set()
606
+ const existing = await listAll((p) => github.rest.pulls.listReviewComments(p), prRef)
449
607
  for (const c of existing) {
450
- if (c.body && c.body.startsWith('**argus-reviewer') && c.commit_id === pr.head.sha) {
451
- posted.add(dedupKey(c.path, c.line, c.body))
608
+ if (
609
+ c.commit_id === pr.head.sha &&
610
+ typeof c.body === 'string' &&
611
+ c.body.startsWith('**argus-reviewer')
612
+ ) {
613
+ posted.add(postedDedupKey(c))
452
614
  }
453
615
  }
454
- if (existing.length < 100) break
455
- page += 1
616
+ const fresh = codeReview.reviewComments.filter((c) => !posted.has(c.dedupKey))
617
+
618
+ // R8 live-diff validation — the diff is authoritative only at post time;
619
+ // drop anchors that aren't RIGHT-side lines in the current PR diff.
620
+ const files = await listAll((p) => github.rest.pulls.listFiles(p), prRef)
621
+ const diffLines = new Map()
622
+ for (const f of files) {
623
+ if (typeof f.patch === 'string') diffLines.set(f.filename, rightSideLines(f.patch))
624
+ }
625
+ const comments = fresh.filter((c) => isOnDiff(c, diffLines))
626
+ const offDiff = fresh.length - comments.length
627
+
628
+ return {
629
+ comments,
630
+ diffLines,
631
+ dropped: overflow + offDiff,
632
+ cap,
633
+ overflow,
634
+ offDiff,
635
+ event: codeReview.reviewEvent === 'request_changes' ? 'REQUEST_CHANGES' : 'COMMENT',
636
+ verdict: codeReview.verdict,
637
+ provenBlockers: codeReview.provenBlockers ?? 0,
638
+ highConfidenceBlockers: codeReview.highConfidenceBlockers ?? 0,
639
+ }
640
+ } catch (e) {
641
+ core.warning(`review planning failed: ${e.message} — sticky still posts`)
642
+ return undefined
456
643
  }
457
- const fresh = comments.filter((c) => !posted.has(dedupKey(c.path, c.line, c.body)))
644
+ }
458
645
 
459
- // review.maxComments caps inline noise (TCA max_comments) — applied
460
- // after dedup so already-posted comments don't eat the budget. A cap of
461
- // 0 disables inline posting entirely (no empty review).
462
- const cap = typeof codeReview.maxComments === 'number' ? codeReview.maxComments : 20
463
- const capped = fresh.slice(0, cap)
464
- return { capped, dropped: fresh.length - capped.length, cap }
646
+ /** Review body: verdict line + honest blocker counts (R6 — reproduced and
647
+ * p-gated are never lumped). Always present — REQUEST_CHANGES requires a
648
+ * body. Carries the sentinel so KTD5 dismissal can self-identify. */
649
+ function reviewBody(plan, note) {
650
+ const parts = [`verdict **${plan.verdict ?? 'unknown'}**`]
651
+ if (plan.provenBlockers > 0) {
652
+ parts.push(`⛔ ${plan.provenBlockers} reproduced blocker(s)`)
653
+ }
654
+ if (plan.highConfidenceBlockers > 0) {
655
+ parts.push(`◎ ${plan.highConfidenceBlockers} high-confidence blocker(s)`)
656
+ }
657
+ let body = `${SENTINEL}\n**argus-reviewer** — ${parts.join(' · ')}`
658
+ if (note !== undefined) body += `\n\n*${note}*`
659
+ return body
465
660
  }
466
661
 
467
662
  async function postInlineComments(pr, plan) {
468
- if (plan === undefined || plan.capped.length === 0) return
469
- // One batched review instead of N createReviewComment calls — avoids
470
- // secondary rate limits on large findings sets.
663
+ if (plan === undefined) return
664
+ const owner = context.repo.owner
665
+ const repo = context.repo.repo
666
+
667
+ // KTD5 — dismiss stale self reviews before posting so a fixed PR is never
668
+ // left gated by an obsolete REQUEST_CHANGES. Prior argus reviews carry the
669
+ // sentinel in their body; an empty-bodied PENDING draft is ours by
670
+ // authorship. Dismissal failure warns but never blocks the post.
471
671
  try {
472
- await github.rest.pulls.createReview({
473
- owner: context.repo.owner,
474
- repo: context.repo.repo,
672
+ const reviews = await listAll((p) => github.rest.pulls.listReviews(p), {
673
+ owner,
674
+ repo,
475
675
  pull_number: pr.number,
476
- commit_id: pr.head.sha,
477
- event: 'COMMENT',
478
- comments: plan.capped,
479
676
  })
677
+ for (const r of reviews) {
678
+ const stale =
679
+ (r.state === 'CHANGES_REQUESTED' || r.state === 'PENDING') &&
680
+ isSelfLogin(r.user?.login) &&
681
+ (typeof r.body !== 'string' || r.body === '' || r.body.includes(SENTINEL))
682
+ if (!stale) continue
683
+ try {
684
+ await github.rest.pulls.dismissReview({
685
+ owner,
686
+ repo,
687
+ pull_number: pr.number,
688
+ review_id: r.id,
689
+ message: 'Superseded by a newer argus-reviewer review.',
690
+ })
691
+ } catch (e) {
692
+ core.warning(`failed to dismiss stale review ${r.id}: ${e.message}`)
693
+ }
694
+ }
480
695
  } catch (e) {
481
- core.warning(`inline review failed: ${e.message}`)
696
+ core.warning(`failed to list prior reviews for dismissal: ${e.message}`)
697
+ }
698
+
699
+ // A COMMENT review with nothing to say posts nothing — the sticky already
700
+ // carries the verdict. REQUEST_CHANGES posts even with zero comments: the
701
+ // gate intent must land.
702
+ if (plan.event !== 'REQUEST_CHANGES' && plan.comments.length === 0) return
703
+
704
+ // dedupKey is poster-local — the API gets path/line/side/body
705
+ // (+start_line/start_side) verbatim.
706
+ const toGh = (c) => {
707
+ const { dedupKey: _dedupKey, ...rest } = c
708
+ return rest
709
+ }
710
+
711
+ // KTD4 bounded retry ladder — at most three createReview calls:
712
+ // (1) the serialized event; (2) on 403/422 (own-PR, permissions), COMMENT
713
+ // with a downgrade note in the body; (3) on a comment-caused 422, drop
714
+ // anchors failing diff membership and retry. Then warn and stop — never a
715
+ // per-comment fallback loop.
716
+ let event = plan.event
717
+ let note
718
+ let comments = plan.comments
719
+ let lastErr
720
+ for (let attempt = 0; attempt < 3; attempt++) {
721
+ try {
722
+ await github.rest.pulls.createReview({
723
+ owner,
724
+ repo,
725
+ pull_number: pr.number,
726
+ commit_id: pr.head.sha,
727
+ event,
728
+ body: reviewBody(plan, note),
729
+ comments: comments.map(toGh),
730
+ })
731
+ return
732
+ } catch (e) {
733
+ lastErr = e
734
+ if (attempt === 0 && event === 'REQUEST_CHANGES' && (e.status === 403 || e.status === 422)) {
735
+ event = 'COMMENT'
736
+ note = `REQUEST_CHANGES downgraded to COMMENT — ${e.status} ${e.message}`
737
+ continue
738
+ }
739
+ if (e.status === 422 && comments.length > 0) {
740
+ const kept = comments.filter((c) => isOnDiff(c, plan.diffLines))
741
+ if (kept.length < comments.length) {
742
+ plan.dropped += comments.length - kept.length
743
+ comments = kept
744
+ continue
745
+ }
746
+ }
747
+ break
748
+ }
482
749
  }
750
+ core.warning(`review post failed: ${lastErr?.message ?? 'unknown error'} — sticky still posts`)
483
751
  }
484
752
 
485
753
  async function main() {
@@ -521,7 +789,13 @@ async function main() {
521
789
  const runDisabled = process.env.ARGUS_RUN_DISABLED === '1'
522
790
  const ok = (runDisabled || report?.ok === true) && codeReviewOk
523
791
  const conclusion = !hasKey ? 'neutral' : ok ? 'success' : 'failure'
792
+ // Freshness + dedup + diff validation for the serialized review surface,
793
+ // computed before the sticky body renders so the "+N not posted" note is
794
+ // truthful. The review posts BEFORE the sticky so retry-ladder drops land
795
+ // in that note too; a plan of undefined (old-format or stale report) makes
796
+ // postInlineComments a no-op with no API calls.
524
797
  const inlinePlan = hasKey ? await planInlineComments(pr, codeReview) : undefined
798
+ if (pr) await postInlineComments(pr, inlinePlan)
525
799
  const body = !hasKey
526
800
  ? renderMissingKeyBody()
527
801
  : runDisabled
@@ -530,10 +804,6 @@ async function main() {
530
804
  ? renderNoReportBody(reportDir, runUrl)
531
805
  : renderBody(report, codeReview, runUrl, ok, inlinePlan)
532
806
 
533
- // Eligibility + dedup for inline comments, computed before the sticky
534
- // body renders so the "+N not posted" note counts the *fresh* set — the
535
- // cap is applied to fresh, not to raw findings (already-posted comments
536
- // must not inflate the dropped count).
537
807
  if (pr) {
538
808
  const { data: comments } = await github.rest.issues.listComments({
539
809
  owner,
@@ -557,7 +827,6 @@ async function main() {
557
827
  body,
558
828
  })
559
829
  }
560
- await postInlineComments(pr, inlinePlan)
561
830
  }
562
831
 
563
832
  const sha = pr ? pr.head.sha : context.sha
@@ -581,4 +850,21 @@ async function main() {
581
850
  core.setOutput('conclusion', conclusion)
582
851
  }
583
852
 
584
- return await main()
853
+ async function run(runtime) {
854
+ github = runtime.github
855
+ context = runtime.context
856
+ core = runtime.core
857
+ return main()
858
+ }
859
+
860
+ module.exports = {
861
+ run,
862
+ renderBody,
863
+ renderReviewOnlyBody,
864
+ renderMissingKeyBody,
865
+ renderNoReportBody,
866
+ planInlineComments,
867
+ postInlineComments,
868
+ shortHash,
869
+ postedDedupKey,
870
+ }
package/dist/api.d.ts CHANGED
@@ -92,6 +92,7 @@ export declare class TdSession {
92
92
  private constructor();
93
93
  static create(opts: TdSessionOptions): Promise<TdSession>;
94
94
  get visionCalls(): number;
95
+ get cacheStats(): import("./engine/loop.js").CacheStats;
95
96
  /** Non-fatal anomalies observed by the engine — journaled as evidence. */
96
97
  get errorRecords(): ErrorRecord[];
97
98
  get failed(): boolean;
package/dist/api.js CHANGED
@@ -90,6 +90,9 @@ export class TdSession {
90
90
  get visionCalls() {
91
91
  return this.engine.visionCalls;
92
92
  }
93
+ get cacheStats() {
94
+ return this.engine.cacheStats;
95
+ }
93
96
  /** Non-fatal anomalies observed by the engine — journaled as evidence. */
94
97
  get errorRecords() {
95
98
  return this.engine.errorRecords;
@@ -1,4 +1,5 @@
1
1
  import { FingerprintRecord } from './fingerprint.js';
2
+ export declare const FLOW_CACHE_SCHEMA_VERSION: 1;
2
3
  /** A cached assertion verdict, keyed on (question, page-state hash). */
3
4
  export interface CachedAssert {
4
5
  question: string;
@@ -9,6 +10,7 @@ export interface CachedAssert {
9
10
  model: string | undefined;
10
11
  }
11
12
  export interface FlowCache {
13
+ schemaVersion?: typeof FLOW_CACHE_SCHEMA_VERSION;
12
14
  steps: FingerprintRecord[];
13
15
  asserts?: CachedAssert[];
14
16
  }
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { writeAtomicJson } from '../fsutil.js';
4
+ export const FLOW_CACHE_SCHEMA_VERSION = 1;
4
5
  function sortKeys(_, value) {
5
6
  if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
6
7
  const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
@@ -19,11 +20,17 @@ export async function loadFlow(cacheDir, flowName) {
19
20
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
20
21
  return undefined;
21
22
  }
22
- const { steps, asserts } = parsed;
23
+ const { schemaVersion, steps, asserts } = parsed;
23
24
  if (!Array.isArray(steps)) {
24
25
  return undefined;
25
26
  }
26
- const out = { steps: steps };
27
+ if (schemaVersion !== undefined && schemaVersion !== FLOW_CACHE_SCHEMA_VERSION) {
28
+ return undefined;
29
+ }
30
+ const out = {
31
+ ...(schemaVersion === FLOW_CACHE_SCHEMA_VERSION ? { schemaVersion } : {}),
32
+ steps: steps,
33
+ };
27
34
  if (Array.isArray(asserts))
28
35
  out.asserts = asserts;
29
36
  return out;
@@ -37,5 +44,5 @@ export async function loadFlow(cacheDir, flowName) {
37
44
  }
38
45
  }
39
46
  export async function saveFlow(cacheDir, flowName, steps, asserts) {
40
- await writeAtomicJson(flowPath(cacheDir, flowName), { steps, asserts: asserts ?? [] }, sortKeys);
47
+ await writeAtomicJson(flowPath(cacheDir, flowName), { schemaVersion: FLOW_CACHE_SCHEMA_VERSION, steps, asserts: asserts ?? [] }, sortKeys);
41
48
  }