@jphutchins/code-review 0.1.0-alpha.54 → 0.1.0-alpha.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
3
  import { readFileSync, writeFileSync, copyFileSync, statSync, readdirSync, appendFileSync } from 'fs';
4
- import { randomBytes } from 'crypto';
4
+ import { randomBytes, createHash } from 'crypto';
5
5
  import { resolve as resolve$1, join, dirname, basename, extname, sep } from 'path';
6
6
  import { Eta } from 'eta';
7
7
  import * as t from 'io-ts';
@@ -74,35 +74,43 @@ var UriString = t.refinement(
74
74
  (s) => !/\s/.test(s) && URL.canParse(s),
75
75
  "UriString"
76
76
  );
77
- var FindingRuleCodec = t.partial({
78
- code: t.string,
77
+ var RuleUrlCodec = t.partial({
79
78
  code_url: UriString
80
79
  });
81
- var FindingShape = t.intersection([
82
- t.type({
83
- path: t.string,
84
- start_line: LineNumber,
85
- end_line: LineNumber,
86
- severity: SeverityCodec,
87
- title: t.string,
88
- description: t.string,
89
- reasoning: t.string,
90
- confidence: Confidence,
91
- likelihood: Likelihood
92
- }),
93
- FindingRuleCodec,
94
- t.partial({
95
- side: SideCodec,
96
- recommendation: t.string,
97
- patch: t.string
98
- })
99
- ]);
80
+ var FindingRequired = t.type({
81
+ id: t.string,
82
+ path: t.string,
83
+ start_line: LineNumber,
84
+ end_line: LineNumber,
85
+ severity: SeverityCodec,
86
+ title: t.string,
87
+ description: t.string,
88
+ reasoning: t.string,
89
+ confidence: Confidence,
90
+ likelihood: Likelihood
91
+ });
92
+ var FindingOptional = t.partial({
93
+ side: SideCodec,
94
+ recommendation: t.string,
95
+ patch: t.string
96
+ });
97
+ var FindingShape = t.intersection([FindingRequired, RuleUrlCodec, FindingOptional]);
100
98
  var EndGeStart = t.refinement(
101
99
  FindingShape,
102
100
  (f) => f.end_line >= f.start_line,
103
101
  "EndGeStart"
104
102
  );
105
- var FindingCodec = t.exact(EndGeStart);
103
+ var FINDING_KEYS = /* @__PURE__ */ new Set([
104
+ ...Object.keys(FindingRequired.props),
105
+ ...Object.keys(RuleUrlCodec.props),
106
+ ...Object.keys(FindingOptional.props)
107
+ ]);
108
+ var FindingStrict = t.refinement(
109
+ EndGeStart,
110
+ (f) => Object.keys(f).every((k) => FINDING_KEYS.has(k)),
111
+ "FindingStrict"
112
+ );
113
+ var FindingCodec = t.exact(FindingStrict);
106
114
  var SystemicRequired = t.type({
107
115
  title: t.string,
108
116
  description: t.string,
@@ -112,13 +120,14 @@ var SystemicRequired = t.type({
112
120
  likelihood: Likelihood
113
121
  });
114
122
  var SystemicOptional = t.partial({
115
- finding_codes: t.array(t.string),
123
+ id: t.string,
124
+ finding_ids: t.array(t.string),
116
125
  paths: t.array(t.string)
117
126
  });
118
- var SystemicProblemShape = t.intersection([SystemicRequired, FindingRuleCodec, SystemicOptional]);
127
+ var SystemicProblemShape = t.intersection([SystemicRequired, RuleUrlCodec, SystemicOptional]);
119
128
  var SYSTEMIC_KEYS = /* @__PURE__ */ new Set([
120
129
  ...Object.keys(SystemicRequired.props),
121
- ...Object.keys(FindingRuleCodec.props),
130
+ ...Object.keys(RuleUrlCodec.props),
122
131
  ...Object.keys(SystemicOptional.props)
123
132
  ]);
124
133
  var SystemicProblemStrict = t.refinement(
@@ -128,7 +137,7 @@ var SystemicProblemStrict = t.refinement(
128
137
  );
129
138
  var SystemicProblemCodec = t.exact(SystemicProblemStrict);
130
139
  var RecurringShape = t.type({
131
- code: t.string,
140
+ id: t.string,
132
141
  consecutive_rounds: t.refinement(
133
142
  t.number,
134
143
  (n) => Number.isSafeInteger(n) && n >= 1,
@@ -162,14 +171,25 @@ var RoundNumber = t.refinement(
162
171
  (n) => Number.isSafeInteger(n) && n >= 1,
163
172
  "RoundNumber"
164
173
  );
165
- var CodeFrequency = t.record(
166
- t.string,
167
- t.refinement(t.number, (n) => Number.isSafeInteger(n) && n >= 0, "CodeFrequency")
174
+ var idFrequencyCodec = (name) => new t.Type(
175
+ name,
176
+ (u) => typeof u === "object" && u !== null && !Array.isArray(u),
177
+ (u, c) => {
178
+ if (typeof u !== "object" || u === null || Array.isArray(u)) return t.failure(u, c);
179
+ const entries = [];
180
+ for (const [k, v] of Object.entries(u)) {
181
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) return t.failure(v, c);
182
+ entries.push([k, v]);
183
+ }
184
+ return t.success(Object.fromEntries(entries));
185
+ },
186
+ (a) => a
168
187
  );
188
+ var IdFrequency = idFrequencyCodec("IdFrequency");
169
189
  var ConvergenceRoundRequired = t.type({ round: RoundNumber });
170
190
  var ConvergenceRoundOptional = t.partial({
171
191
  score: FiniteNumber,
172
- codes: CodeFrequency,
192
+ ids: IdFrequency,
173
193
  sha: t.string
174
194
  });
175
195
  var ConvergenceRoundShape = t.intersection([ConvergenceRoundRequired, ConvergenceRoundOptional]);
@@ -236,6 +256,188 @@ var RECOVERABLE_OPTIONAL_FIELDS = /* @__PURE__ */ new Set([
236
256
  ...PIPELINE_STAMPED_FIELDS,
237
257
  "change_size"
238
258
  ]);
259
+ var synthesizedId = (parts, prefix) => `${prefix}${createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 12)}`;
260
+ var synthesizedFindingId = (path, title) => synthesizedId([path, title], "f-");
261
+ var synthesizedSystemicId = (title) => synthesizedId([title], "s-");
262
+ var isSynthesizedFindingId = (id) => /^f-[A-Za-z0-9_-]{12}$/.test(id);
263
+ var resolveFindingId = (f) => f.id !== "" ? f.id : synthesizedFindingId(f.path, f.title);
264
+ var resolveRuleId = (rec) => rec.id !== void 0 && rec.id !== "" ? rec.id : rec.code !== void 0 && rec.code !== "" ? rec.code : rec.path !== void 0 ? synthesizedFindingId(rec.path, rec.title) : void 0;
265
+ var usableCountsMap = (v) => {
266
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return void 0;
267
+ const entries = Object.entries(v).filter(
268
+ (e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
269
+ );
270
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
271
+ };
272
+ var LegacyRuleCodec = t.partial({
273
+ code: t.string,
274
+ id: t.string,
275
+ code_url: UriString
276
+ });
277
+ var FindingShapeV09 = t.intersection([
278
+ t.type({
279
+ path: t.string,
280
+ start_line: LineNumber,
281
+ end_line: LineNumber,
282
+ severity: SeverityCodec,
283
+ title: t.string,
284
+ description: t.string,
285
+ reasoning: t.string,
286
+ confidence: Confidence,
287
+ likelihood: Likelihood
288
+ }),
289
+ LegacyRuleCodec,
290
+ t.partial({
291
+ side: SideCodec,
292
+ recommendation: t.string,
293
+ patch: t.string
294
+ })
295
+ ]);
296
+ var EndGeStartV09 = t.refinement(
297
+ FindingShapeV09,
298
+ (f) => f.end_line >= f.start_line,
299
+ "EndGeStartV09"
300
+ );
301
+ var FindingCodecV09 = t.exact(EndGeStartV09);
302
+ var SystemicV09Optional = t.partial({
303
+ finding_codes: t.array(t.string),
304
+ finding_ids: t.array(t.string),
305
+ paths: t.array(t.string)
306
+ });
307
+ var SystemicV09Shape = t.intersection([SystemicRequired, LegacyRuleCodec, SystemicV09Optional]);
308
+ var SYSTEMIC_V09_KEYS = /* @__PURE__ */ new Set([
309
+ ...Object.keys(SystemicRequired.props),
310
+ ...Object.keys(LegacyRuleCodec.props),
311
+ ...Object.keys(SystemicV09Optional.props)
312
+ ]);
313
+ var SystemicV09Strict = t.refinement(
314
+ SystemicV09Shape,
315
+ (s) => Object.keys(s).every((k) => SYSTEMIC_V09_KEYS.has(k)),
316
+ "SystemicV09Strict"
317
+ );
318
+ var SystemicProblemCodecV09 = t.exact(SystemicV09Strict);
319
+ var RecurringV09Shape = t.intersection([
320
+ t.partial({ code: t.string, id: t.string }),
321
+ t.type({
322
+ consecutive_rounds: t.refinement(
323
+ t.number,
324
+ (n) => Number.isSafeInteger(n) && n >= 1,
325
+ "ConsecutiveRoundsV09"
326
+ ),
327
+ start_round: t.refinement(
328
+ t.number,
329
+ (n) => Number.isSafeInteger(n) && n >= 1,
330
+ "StartRoundV09"
331
+ )
332
+ })
333
+ ]);
334
+ var ScopeMetastasisV09Shape = t.type({
335
+ decision_prompt: t.string,
336
+ recurring: t.array(RecurringV09Shape)
337
+ });
338
+ var SCOPE_METASTASIS_V09_KEYS = new Set(Object.keys(ScopeMetastasisV09Shape.props));
339
+ var ScopeMetastasisV09Strict = t.refinement(
340
+ ScopeMetastasisV09Shape,
341
+ (s) => Object.keys(s).every((k) => SCOPE_METASTASIS_V09_KEYS.has(k)),
342
+ "ScopeMetastasisV09Strict"
343
+ );
344
+ var ScopeMetastasisCodecV09 = t.exact(ScopeMetastasisV09Strict);
345
+ var CodeFrequencyV09 = idFrequencyCodec("CodeFrequencyV09");
346
+ var ConvergenceRoundV09Shape = t.intersection([
347
+ t.type({ round: RoundNumber }),
348
+ t.partial({ score: FiniteNumber, codes: CodeFrequencyV09, ids: CodeFrequencyV09, sha: t.string })
349
+ ]);
350
+ var CONVERGENCE_ROUND_V09_KEYS = /* @__PURE__ */ new Set(["round", "score", "codes", "ids", "sha"]);
351
+ var ConvergenceRoundV09Strict = t.refinement(
352
+ ConvergenceRoundV09Shape,
353
+ (r) => Object.keys(r).every((k) => CONVERGENCE_ROUND_V09_KEYS.has(k)),
354
+ "ConvergenceRoundV09Strict"
355
+ );
356
+ var ConvergenceRoundCodecV09 = t.exact(ConvergenceRoundV09Strict);
357
+ var ConvergenceV09Shape = t.intersection([
358
+ ConvergenceCoreShape,
359
+ t.partial({ rounds: t.array(ConvergenceRoundCodecV09) })
360
+ ]);
361
+ var CONVERGENCE_V09_KEYS = /* @__PURE__ */ new Set([...Object.keys(ConvergenceCoreShape.props), "rounds"]);
362
+ var ConvergenceV09Strict = t.refinement(
363
+ ConvergenceV09Shape,
364
+ (c) => Object.keys(c).every((k) => CONVERGENCE_V09_KEYS.has(k)),
365
+ "ConvergenceV09Strict"
366
+ );
367
+ var ConvergenceCodecV09 = t.exact(ConvergenceV09Strict);
368
+ var FindingsV09Shape = t.intersection([
369
+ t.type({
370
+ // Any pre-0.10 minor — the registry dispatches on major.minor before decoding, so the codec
371
+ // accepts every legacy patch version (0.4.x through 0.9.x) through the one tolerant shape.
372
+ // SchemaVersion keeps the F3 strictness: a patch-less "0.4" or over-long "0.4.0.0" still fails
373
+ // the codec gate exactly as the ajv gate rejects it.
374
+ schema_version: SchemaVersion,
375
+ summary: t.string,
376
+ verdict: VerdictCodec,
377
+ findings: t.array(FindingCodecV09)
378
+ }),
379
+ t.partial({
380
+ systemic_problems: t.array(SystemicProblemCodecV09),
381
+ scope_metastasis: ScopeMetastasisCodecV09,
382
+ convergence: ConvergenceCodecV09,
383
+ change_size: ChangeSizeCodec
384
+ })
385
+ ]);
386
+ var FindingsCodecV09 = t.exact(FindingsV09Shape);
387
+ var normalizeV09 = (doc) => {
388
+ const findings = doc.findings.map(({ code, id, ...f }) => ({
389
+ ...f,
390
+ id: resolveRuleId({ id, code, path: f.path, title: f.title }) ?? synthesizedFindingId(f.path, f.title)
391
+ }));
392
+ const systemic_problems = doc.systemic_problems?.map(
393
+ ({ code, id, finding_codes, finding_ids, ...s }) => ({
394
+ ...s,
395
+ id: resolveRuleId({ id, code, title: s.title }) ?? synthesizedSystemicId(s.title),
396
+ ...finding_ids !== void 0 && finding_ids.length > 0 ? { finding_ids } : finding_codes !== void 0 ? { finding_ids: finding_codes } : {}
397
+ })
398
+ );
399
+ const scope_metastasis = doc.scope_metastasis === void 0 ? void 0 : {
400
+ decision_prompt: doc.scope_metastasis.decision_prompt,
401
+ // A legacy recurring item carrying neither code nor id names nothing — drop it rather than
402
+ // synthesize an id with nothing to key it on.
403
+ recurring: doc.scope_metastasis.recurring.flatMap((r) => {
404
+ const carried = r.id !== void 0 && r.id !== "" ? r.id : r.code;
405
+ return carried === void 0 || carried === "" ? [] : [
406
+ {
407
+ id: carried,
408
+ consecutive_rounds: r.consecutive_rounds,
409
+ start_round: r.start_round
410
+ }
411
+ ];
412
+ })
413
+ };
414
+ const convergence = doc.convergence === void 0 ? void 0 : {
415
+ score: doc.convergence.score,
416
+ threshold: doc.convergence.threshold,
417
+ converged: doc.convergence.converged,
418
+ ...doc.convergence.rounds !== void 0 ? {
419
+ rounds: doc.convergence.rounds.map((r) => {
420
+ const ids = usableCountsMap(r.ids) ?? usableCountsMap(r.codes);
421
+ return {
422
+ round: r.round,
423
+ ...r.score !== void 0 ? { score: r.score } : {},
424
+ ...ids !== void 0 ? { ids } : {},
425
+ ...r.sha !== void 0 ? { sha: r.sha } : {}
426
+ };
427
+ })
428
+ } : {}
429
+ };
430
+ return {
431
+ schema_version: DEFAULT_SCHEMA_VERSION,
432
+ summary: doc.summary,
433
+ verdict: doc.verdict,
434
+ findings,
435
+ ...systemic_problems !== void 0 ? { systemic_problems } : {},
436
+ ...scope_metastasis !== void 0 ? { scope_metastasis } : {},
437
+ ...convergence !== void 0 ? { convergence } : {},
438
+ ...doc.change_size !== void 0 ? { change_size: doc.change_size } : {}
439
+ };
440
+ };
239
441
  var TriageCodec = t.type({
240
442
  safe: t.boolean,
241
443
  reasons: t.string
@@ -344,7 +546,7 @@ var TestSummaryCodec = t.intersection([
344
546
  failures: t.array(TestFailureCodec)
345
547
  })
346
548
  ]);
347
- var DEFAULT_SCHEMA_VERSION = "0.9.0";
549
+ var DEFAULT_SCHEMA_VERSION = "0.10.0";
348
550
  var incompleteFindings = (summary) => ({
349
551
  schema_version: DEFAULT_SCHEMA_VERSION,
350
552
  summary,
@@ -599,11 +801,17 @@ var parseReviewedRoute = (body) => ROUTE_RE.exec(body)?.[1] || null;
599
801
  var isFullReviewSticky = (body) => {
600
802
  const route = parseReviewedRoute(body);
601
803
  if (route === "full review") return true;
602
- if (route === "mechanic") return false;
804
+ if (route === "mechanic" || parseMechanicAncestor(body)) return false;
603
805
  return priorTrajectory(parseFindingsMarker(body), body).length > 0;
604
806
  };
807
+ var MECHANIC_ANCESTOR_MARKER = "<!-- review-mechanic-ancestor -->";
808
+ var parseMechanicAncestor = (body) => new RegExp(`(^|
809
+ )${MECHANIC_ANCESTOR_MARKER}(
810
+ |$)`).test(body);
605
811
  var COMPLETED_ANCESTOR_MARKER = "<!-- review-complete-ancestor -->";
606
- var parseCompletedAncestor = (body) => body.includes(COMPLETED_ANCESTOR_MARKER);
812
+ var parseCompletedAncestor = (body) => new RegExp(`(^|
813
+ )${COMPLETED_ANCESTOR_MARKER}(
814
+ |$)`).test(body);
607
815
  var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
608
816
  var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
609
817
  var parseFindingsMarker = (body) => {
@@ -617,24 +825,21 @@ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.
617
825
  const v = u[k];
618
826
  return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
619
827
  });
620
- var MAX_CODES_PER_ROUND = 8;
621
- var hasCode = (codes, code) => codes !== void 0 && Object.prototype.hasOwnProperty.call(codes, code);
828
+ var MAX_IDS_PER_ROUND = 8;
829
+ var hasId = (ids, code) => ids !== void 0 && Object.prototype.hasOwnProperty.call(ids, code);
622
830
  var escapeCodeBackticks = (code) => code.replace(/`/g, "-").replace(/\r?\n/g, " ");
623
- var normalizeCodeCounts = (codes, priorCodes) => {
624
- if (typeof codes !== "object" || codes === null || Array.isArray(codes)) return void 0;
625
- const entries = Object.entries(codes).filter(
626
- (e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
627
- );
831
+ var normalizeIdCounts = (ids, priorCodes) => {
832
+ const entries = Object.entries(usableCountsMap(ids) ?? {});
628
833
  if (entries.length === 0) return void 0;
629
834
  const sorted = entries.sort((a, b) => {
630
835
  if (b[1] !== a[1]) return b[1] - a[1];
631
- const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
632
- const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
836
+ const aPrior = hasId(priorCodes, a[0]) ? 1 : 0;
837
+ const bPrior = hasId(priorCodes, b[0]) ? 1 : 0;
633
838
  if (aPrior !== bPrior) return bPrior - aPrior;
634
839
  return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
635
840
  });
636
- const base = sorted.slice(0, MAX_CODES_PER_ROUND);
637
- const priorKept = sorted.slice(MAX_CODES_PER_ROUND).filter(([code]) => hasCode(priorCodes, code)).slice(0, MAX_CODES_PER_ROUND);
841
+ const base = sorted.slice(0, MAX_IDS_PER_ROUND);
842
+ const priorKept = sorted.slice(MAX_IDS_PER_ROUND).filter(([code]) => hasId(priorCodes, code)).slice(0, MAX_IDS_PER_ROUND);
638
843
  return Object.fromEntries([...base, ...priorKept]);
639
844
  };
640
845
  var parseRounds = (body) => {
@@ -646,13 +851,13 @@ var parseRounds = (body) => {
646
851
  let priorCodes;
647
852
  for (const u of decoded.filter(isSeverityCounts)) {
648
853
  const rec = u;
649
- const codes = normalizeCodeCounts(rec["codes"], priorCodes);
854
+ const codes = normalizeIdCounts(rec["ids"], priorCodes) ?? normalizeIdCounts(rec["codes"], priorCodes);
650
855
  priorCodes = codes;
651
856
  const sha = rec["sha"];
652
857
  const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
653
858
  const round = rec["round"];
654
859
  const roundNum = typeof round === "number" && Number.isSafeInteger(round) && round >= 1 ? round : void 0;
655
- const base = codes === void 0 ? { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit } : { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit, codes };
860
+ const base = codes === void 0 ? { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit } : { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit, ids: codes };
656
861
  const record3 = shaStr === void 0 ? base : { ...base, sha: shaStr };
657
862
  kept.push(roundNum === void 0 ? record3 : { ...record3, round: roundNum });
658
863
  }
@@ -666,29 +871,31 @@ var roundsSummary = (rounds, count = rounds.length) => {
666
871
  const trajectory = cells.length === 0 ? "" : rounds.length > TRAJECTORY_SCORES ? `\u2026 \u2192 ${cells.join(" \u2192 ")}` : cells.join(" \u2192 ");
667
872
  return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
668
873
  };
669
- var computeCodeCounts = (findings, systemic = []) => {
874
+ var computeIdCounts = (findings, systemic = []) => {
670
875
  const counts = /* @__PURE__ */ new Map();
671
876
  for (const code of [
672
- ...findings.map((f) => f.code),
673
- ...systemic.flatMap((s) => [s.code, ...s.finding_codes ?? []])
877
+ // resolveFindingId: an EMPTY id counts under the same synthesized key the answered match uses —
878
+ // one finding can never be simultaneously tracked (dropped-by-synthesis) and uncounted.
879
+ ...findings.map((f) => resolveFindingId(f)),
880
+ ...systemic.flatMap((s) => [s.id, ...s.finding_ids ?? []])
674
881
  ]) {
675
882
  if (code === void 0 || code === "") continue;
676
883
  counts.set(code, (counts.get(code) ?? 0) + 1);
677
884
  }
678
885
  return Object.fromEntries(counts);
679
886
  };
680
- var consecutiveCodeStreaks = (rounds) => {
887
+ var consecutiveIdStreaks = (rounds) => {
681
888
  const entries = [];
682
889
  if (rounds.length === 0) return {};
683
- const lastCodes = rounds[rounds.length - 1]?.codes;
890
+ const lastCodes = rounds[rounds.length - 1]?.ids;
684
891
  if (lastCodes === void 0) return {};
685
892
  for (const code of Object.keys(lastCodes)) {
686
893
  let streak = 0;
687
894
  let startIndex = rounds.length;
688
895
  for (let i = rounds.length - 1; i >= 0; i--) {
689
- const codes = rounds[i]?.codes;
690
- if (codes === void 0 || !hasCode(codes, code)) break;
691
- if (i > 0 && rounds[i]?.sha !== void 0 && rounds[i]?.sha === rounds[i - 1]?.sha && hasCode(rounds[i - 1]?.codes, code)) {
896
+ const codes = rounds[i]?.ids;
897
+ if (codes === void 0 || !hasId(codes, code)) break;
898
+ if (i > 0 && rounds[i]?.sha !== void 0 && rounds[i]?.sha === rounds[i - 1]?.sha && hasId(rounds[i - 1]?.ids, code)) {
692
899
  continue;
693
900
  }
694
901
  streak += 1;
@@ -702,12 +909,12 @@ var consecutiveCodeStreaks = (rounds) => {
702
909
  };
703
910
  var DEFAULT_METASTASIS_STREAK = 3;
704
911
  var SCOPE_METASTASIS_DECISION_PROMPT = "Findings keep recurring in the same mechanism across consecutive rounds \u2014 each fix keeps enabling the next finding in that machinery. This is a decision, not a directive: state in your summary whether you are committing to the expanding scope (plan the remaining facets of the recurring mechanism(s) above as one unit) or narrowing the scope so the recurrence stops.";
705
- var flaggedCodeStreaks = (rounds, minStreak) => Object.entries(consecutiveCodeStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak).map(([code, streak]) => ({ code, streak }));
912
+ var flaggedIdStreaks = (rounds, minStreak) => Object.entries(consecutiveIdStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak).map(([id, streak]) => ({ id, streak }));
706
913
  var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
707
- const flagged = flaggedCodeStreaks(rounds, minStreak);
914
+ const flagged = flaggedIdStreaks(rounds, minStreak);
708
915
  if (flagged.length === 0) return "";
709
916
  const lines = flagged.map(
710
- ({ code, streak }) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(streak.streak)} consecutive rounds.`
917
+ ({ id, streak }) => `> **\`${escapeCodeBackticks(id)}\`** \u2014 findings in ${String(streak.streak)} consecutive rounds.`
711
918
  );
712
919
  return [
713
920
  "> [!WARNING]",
@@ -716,27 +923,27 @@ var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
716
923
  ].join("\n");
717
924
  };
718
925
  var computeScopeMetastasis = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
719
- const flagged = flaggedCodeStreaks(rounds, minStreak);
926
+ const flagged = flaggedIdStreaks(rounds, minStreak);
720
927
  if (flagged.length === 0) return null;
721
928
  return {
722
929
  decision_prompt: SCOPE_METASTASIS_DECISION_PROMPT,
723
- recurring: flagged.map(({ code, streak }) => ({
724
- code,
930
+ recurring: flagged.map(({ id, streak }) => ({
931
+ id,
725
932
  consecutive_rounds: streak.streak,
726
933
  start_round: streak.startRound
727
934
  }))
728
935
  };
729
936
  };
730
937
  var computeSameRootNotes = (priorRounds, findings, currentSha) => {
731
- const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
938
+ const codes = findings.map((f) => resolveFindingId(f));
732
939
  const entries = [];
733
940
  for (const code of codes) {
734
941
  let lastRound = 0;
735
942
  for (let i = priorRounds.length - 1; i >= 0; i--) {
736
943
  if (currentSha !== void 0 && priorRounds[i]?.sha === currentSha) continue;
737
- if (i > 0 && priorRounds[i]?.sha !== void 0 && priorRounds[i]?.sha === priorRounds[i - 1]?.sha && hasCode(priorRounds[i - 1]?.codes, code))
944
+ if (i > 0 && priorRounds[i]?.sha !== void 0 && priorRounds[i]?.sha === priorRounds[i - 1]?.sha && hasId(priorRounds[i - 1]?.ids, code))
738
945
  continue;
739
- const count = priorRounds[i]?.codes?.[code];
946
+ const count = priorRounds[i]?.ids?.[code];
740
947
  if (count !== void 0 && count > 0) {
741
948
  lastRound = priorRounds[i]?.round ?? i + 1;
742
949
  break;
@@ -756,6 +963,12 @@ var CRITICAL_FLOOR_MARGIN = 0.01;
756
963
  var MINOR_FLOOR = 0.1;
757
964
  var DEFAULT_CONVERGENCE_THRESHOLD = 1;
758
965
  var convergenceFloor = (severity, threshold) => severity === "critical" ? threshold + CRITICAL_FLOOR_MARGIN : severity === "major" ? 0.5 : severity === "minor" ? MINOR_FLOOR : 0;
966
+ var mechanicConvergence = (prior, threshold) => prior === null ? null : {
967
+ score: convergenceFloor("critical", threshold),
968
+ threshold,
969
+ converged: false,
970
+ rounds: prior.rounds
971
+ };
759
972
  var round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
760
973
  var contribution = (severity, confidence, likelihood, threshold) => {
761
974
  const floor = convergenceFloor(severity, threshold);
@@ -770,13 +983,13 @@ var convergenceScore = (doc, threshold) => round2(
770
983
  0
771
984
  )
772
985
  );
773
- var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, codes = {}, sha) => {
986
+ var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, ids = {}, sha) => {
774
987
  const score = convergenceScore(doc, threshold);
775
- const normalized = normalizeCodeCounts(codes, priorRounds[priorRounds.length - 1]?.codes);
988
+ const normalized = normalizeIdCounts(ids, priorRounds[priorRounds.length - 1]?.ids);
776
989
  const current = {
777
990
  round,
778
991
  score,
779
- ...normalized !== void 0 ? { codes: { ...normalized } } : {},
992
+ ...normalized !== void 0 ? { ids: { ...normalized } } : {},
780
993
  ...sha !== void 0 ? { sha } : {}
781
994
  };
782
995
  const rounds = [...priorRounds, current].slice(-64);
@@ -795,11 +1008,16 @@ var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
795
1008
  if (!isBelowVisibilityFloor(rec, floor)) continue;
796
1009
  const title = rec["title"];
797
1010
  if (typeof title !== "string") continue;
798
- const code = typeof rec["code"] === "string" && rec["code"] !== "" ? rec["code"] : void 0;
799
1011
  const path = typeof rec["path"] === "string" ? rec["path"] : void 0;
1012
+ const id = resolveRuleId({
1013
+ id: typeof rec["id"] === "string" ? rec["id"] : void 0,
1014
+ code: typeof rec["code"] === "string" ? rec["code"] : void 0,
1015
+ path,
1016
+ title
1017
+ });
800
1018
  nits.push({
801
1019
  title,
802
- ...code !== void 0 ? { code } : {},
1020
+ ...id !== void 0 ? { id } : {},
803
1021
  ...path !== void 0 ? { path } : {}
804
1022
  });
805
1023
  }
@@ -852,8 +1070,33 @@ var parseSurfaceSignal = (doc) => {
852
1070
  convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
853
1071
  };
854
1072
  };
1073
+ var withLegacyConvergenceIds = (raw) => {
1074
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
1075
+ const rec = raw;
1076
+ const rounds = rec["rounds"];
1077
+ if (!Array.isArray(rounds)) return raw;
1078
+ const needsMigration = (r) => {
1079
+ if (typeof r !== "object" || r === null) return false;
1080
+ const round = r;
1081
+ return round["codes"] !== void 0 || usableCountsMap(round["ids"]) === void 0;
1082
+ };
1083
+ if (!rounds.some(needsMigration)) return raw;
1084
+ const mapped = rounds.map((r) => {
1085
+ if (typeof r !== "object" || r === null) return r;
1086
+ const round = r;
1087
+ const ids = usableCountsMap(round["ids"]) ?? usableCountsMap(round["codes"]);
1088
+ if (ids === void 0) {
1089
+ return round["ids"] !== void 0 || round["codes"] !== void 0 ? Object.fromEntries(Object.entries(round).filter(([k]) => k !== "ids" && k !== "codes")) : round;
1090
+ }
1091
+ const rest = Object.fromEntries(
1092
+ Object.entries(round).filter(([k]) => k !== "ids" && k !== "codes")
1093
+ );
1094
+ return { ...rest, ids };
1095
+ });
1096
+ return { ...rec, rounds: mapped };
1097
+ };
855
1098
  var validStampedConvergence = (raw) => {
856
- const decoded = ConvergenceCodec.decode(raw);
1099
+ const decoded = ConvergenceCodec.decode(withLegacyConvergenceIds(raw));
857
1100
  return decoded._tag === "Right" && decoded.right.rounds !== void 0 && decoded.right.rounds.length > 0 ? decoded.right : null;
858
1101
  };
859
1102
  var parseConvergence = (priorDoc) => {
@@ -878,7 +1121,7 @@ var parseConvergenceMarker = (body) => {
878
1121
  };
879
1122
  var roundRecordsToConvergenceRounds = (records) => records.map((r, i) => ({
880
1123
  round: r.round ?? i + 1,
881
- ...r.codes !== void 0 ? { codes: { ...r.codes } } : {},
1124
+ ...r.ids !== void 0 ? { ids: { ...r.ids } } : {},
882
1125
  ...r.sha !== void 0 ? { sha: r.sha } : {}
883
1126
  }));
884
1127
  var priorTrajectory = (priorDoc, priorBody) => parseConvergence(priorDoc)?.rounds ?? parseConvergenceMarker(priorBody)?.rounds ?? roundRecordsToConvergenceRounds(parseRounds(priorBody));
@@ -903,17 +1146,36 @@ var stripSurfaceFields = (doc) => {
903
1146
  );
904
1147
  return { ...rest, schema_version: DEFAULT_SCHEMA_VERSION };
905
1148
  };
1149
+ var carriedFindingsMarker = (body) => /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0] ?? null;
1150
+ var carriedMarkerPointer = (carried, convergence) => [
1151
+ `${AGENTS_STOP_DIRECTIVE}
1152
+ ${carried}`,
1153
+ ...convergence !== void 0 ? [convergenceMarker(convergence)] : []
1154
+ ].join("\n");
1155
+ var completedAncestryMarker = (body) => parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
1156
+ var carriedProvenanceMarkers = (body) => [
1157
+ // The placeholder keeps the REAL route marker: the seed chain requires it outright to stay
1158
+ // route-aware across the prose swap.
1159
+ ROUTE_RE.exec(body)?.[0],
1160
+ parseMechanicAncestor(body) ? MECHANIC_ANCESTOR_MARKER : void 0,
1161
+ completedAncestryMarker(body)
1162
+ ].filter((m) => m !== void 0).join("\n\n");
1163
+ var isFullReviewAncestry = (body) => parseReviewedRoute(body) === "full review" || parseReviewedRoute(body) === null && parseCompletedAncestor(body) && !parseMechanicAncestor(body);
1164
+ var carriedAncestryMarkers = (body) => [
1165
+ // A notice body carries no route marker, so the second hop reads the ancestor marker itself:
1166
+ // mechanic ancestry survives notice-over-notice, not just notice-over-sticky.
1167
+ parseReviewedRoute(body) === "mechanic" || parseMechanicAncestor(body) ? MECHANIC_ANCESTOR_MARKER : void 0,
1168
+ completedAncestryMarker(body)
1169
+ ].filter((m) => m !== void 0).join("\n\n");
906
1170
  var carryForwardMarkers = (body) => {
907
- const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
1171
+ const findings = carriedFindingsMarker(body);
908
1172
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
909
- const reviewedRoute = ROUTE_RE.exec(body)?.[0];
910
1173
  const convergence = CONVERGENCE_RE.exec(body)?.[0];
911
1174
  const rounds = ROUNDS_RE.exec(body)?.[0];
912
1175
  const signal = SIGNAL_RE.exec(body)?.[0];
913
- const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
914
1176
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
915
1177
  ${findings}` : void 0;
916
- return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, convergence, rounds, signal].filter((m) => m !== void 0).join("\n\n");
1178
+ return [findingsBlock, reviewedSha, carriedProvenanceMarkers(body), convergence, rounds, signal].filter((m) => m !== void 0 && m !== "").join("\n\n");
917
1179
  };
918
1180
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
919
1181
  var projectPatch = (patch, surface) => {
@@ -928,6 +1190,17 @@ var reviewBodyPointer = (headSha, stickyUrl, runUrl, runHasSummary) => {
928
1190
  const run2 = runUrl ? ` See the [workflow run](${runUrl}) for ${runHasSummary ? "this round's review in full, its job log," : "the job log"} and the findings artifact.` : "";
929
1191
  return `\u{1F916} Automated code review for \`${sha7}\` \u2014 see ${summary} for the verdict, walkthrough, and cost.${run2}`;
930
1192
  };
1193
+
1194
+ // src/comment-chain.ts
1195
+ function* ancestors(start, parentOf) {
1196
+ const seen = /* @__PURE__ */ new Set();
1197
+ let current = start;
1198
+ while (current !== null && !seen.has(current)) {
1199
+ yield current;
1200
+ seen.add(current);
1201
+ current = parentOf(current);
1202
+ }
1203
+ }
931
1204
  var numField = (rec, key2) => {
932
1205
  const v = rec[key2];
933
1206
  return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
@@ -1094,15 +1367,14 @@ var answeredRegistryFrom = (comments, botLogin) => {
1094
1367
  const byId = /* @__PURE__ */ new Map();
1095
1368
  for (const c of ordered) byId.set(c.id, c);
1096
1369
  const rootOf = (c) => {
1097
- let current = c;
1098
- const seen = /* @__PURE__ */ new Set();
1099
- while (current.in_reply_to_id !== null && !seen.has(current.id)) {
1100
- seen.add(current.id);
1101
- const parent = byId.get(current.in_reply_to_id);
1102
- if (parent === void 0) return null;
1103
- current = parent;
1104
- }
1105
- return current.in_reply_to_id === null ? current : null;
1370
+ const chain = [
1371
+ ...ancestors(
1372
+ c,
1373
+ (n) => n.in_reply_to_id === null ? null : byId.get(n.in_reply_to_id) ?? null
1374
+ )
1375
+ ];
1376
+ const root = chain[chain.length - 1];
1377
+ return root !== void 0 && root.in_reply_to_id === null ? root : null;
1106
1378
  };
1107
1379
  const findingOf = (root) => {
1108
1380
  const decoded = parseFindingsMarker(root.body ?? "");
@@ -1112,12 +1384,22 @@ var answeredRegistryFrom = (comments, botLogin) => {
1112
1384
  const title = first["title"];
1113
1385
  const description = first["description"];
1114
1386
  const reasoning = first["reasoning"];
1115
- const code = first["code"];
1387
+ const id = first["id"];
1388
+ const legacyCode = first["code"];
1116
1389
  const severity = first["severity"];
1117
1390
  const path = first["path"];
1118
1391
  const patch = first["patch"];
1119
1392
  return typeof title === "string" && typeof description === "string" && typeof reasoning === "string" && typeof path === "string" && (severity === "critical" || severity === "major" || severity === "minor" || severity === "nit") ? {
1120
- code: typeof code === "string" ? code : "",
1393
+ // resolveRuleId: the ONE legacy-spelling precedence the upcast, this reader, and the
1394
+ // below-floor nit reader share — a pre-id marker (or one written before the migration)
1395
+ // carries `code`; a codeless one resolves to the same synthesized id the registry's
1396
+ // legacy upcast derives, so the entry keys to the identical claim on the next round.
1397
+ code: resolveRuleId({
1398
+ id: typeof id === "string" ? id : void 0,
1399
+ code: typeof legacyCode === "string" ? legacyCode : void 0,
1400
+ path,
1401
+ title
1402
+ }) ?? synthesizedFindingId(path, title),
1121
1403
  title,
1122
1404
  description,
1123
1405
  reasoning,
@@ -1159,29 +1441,31 @@ var answeredRegistryFrom = (comments, botLogin) => {
1159
1441
  for (const entry of [...entries].sort(
1160
1442
  (a, b) => (b.repliedAt ?? "").localeCompare(a.repliedAt ?? "") || b.replyId - a.replyId
1161
1443
  )) {
1162
- const key2 = answeredNoteKey(entry);
1444
+ const key2 = answeredNoteKey({ id: entry.code, title: entry.title });
1163
1445
  if (!byKey.has(key2)) byKey.set(key2, entry);
1164
1446
  }
1165
1447
  return [...byKey.values()];
1166
1448
  };
1167
- var matches = (f, e) => f.code !== void 0 && f.code !== "" ? e.code === f.code : e.code === "" && e.title === f.title;
1449
+ var matches = (f, e) => e.code === resolveFindingId(f) || isSynthesizedFindingId(e.code) && e.title === f.title;
1168
1450
  var isVerbatimReRaise = (f, e) => f.title === e.title && f.description === e.description && f.reasoning === e.reasoning && f.severity === e.severity && f.path === e.path && (f.patch ?? null) === e.patch;
1169
1451
  var isAnsweredDrop = (f, e) => matches(f, e) && isVerbatimReRaise(f, e) && f.severity !== "critical";
1170
- var answeredNoteKey = (f) => f.code !== void 0 && f.code !== "" ? f.code : `title:${f.title}`;
1452
+ var answeredNoteKey = (f) => f.id !== "" ? f.id : `title:${f.title}`;
1171
1453
  var answeredNote = (e) => `Re-raised; prior answer at ${e.replyUrl} by ${e.replyAuthor} \u2014 cite the new evidence that invalidates it.`;
1172
1454
  var applyAnswered = (findings, registry) => {
1173
1455
  const kept = [];
1174
1456
  const noteEntries = [];
1175
- const droppedByKey = /* @__PURE__ */ new Map();
1457
+ const droppedByEntry = /* @__PURE__ */ new Map();
1458
+ const droppedFindingIds = [];
1176
1459
  let droppedCount = 0;
1177
1460
  for (const f of findings) {
1178
- const entry = registry.find((e) => matches(f, e));
1461
+ const entry = registry.find((e) => e.code === resolveFindingId(f)) ?? registry.find((e) => isSynthesizedFindingId(e.code) && e.title === f.title);
1179
1462
  if (entry === void 0) {
1180
1463
  kept.push(f);
1181
1464
  continue;
1182
1465
  }
1183
1466
  if (isAnsweredDrop(f, entry)) {
1184
- droppedByKey.set(answeredNoteKey(f), entry);
1467
+ droppedByEntry.set(entry.replyId, entry);
1468
+ droppedFindingIds.push(resolveFindingId(f));
1185
1469
  droppedCount += 1;
1186
1470
  } else {
1187
1471
  kept.push(f);
@@ -1191,7 +1475,8 @@ var applyAnswered = (findings, registry) => {
1191
1475
  return {
1192
1476
  findings: kept,
1193
1477
  reRaisedNotes: Object.fromEntries(noteEntries),
1194
- verbatimReRaised: [...droppedByKey.values()],
1478
+ verbatimReRaised: [...droppedByEntry.values()],
1479
+ droppedFindingIds,
1195
1480
  droppedCount
1196
1481
  };
1197
1482
  };
@@ -1210,9 +1495,9 @@ var fetchThreadComments = async (ghApi, repo, prNumber) => {
1210
1495
  try {
1211
1496
  const rows = parseJsonl(
1212
1497
  await ghApi([
1213
- `repos/${repo}/pulls/${String(prNumber)}/comments`,
1214
- "-f",
1215
- "per_page=100",
1498
+ // per_page rides the QUERY string — a `-f` field would flip `gh api` from GET to POST
1499
+ // and 422 every answered-thread fetch.
1500
+ `repos/${repo}/pulls/${String(prNumber)}/comments?per_page=100`,
1216
1501
  "--paginate",
1217
1502
  "--jq",
1218
1503
  THREAD_COMMENT_JQ
@@ -1254,7 +1539,44 @@ var permalinkFor = (base, f, anchor) => {
1254
1539
  };
1255
1540
  var CARRIED_TOTAL_CHARS = 4e4;
1256
1541
  var SUPPRESSED_NIT_BLOCK_OVERHEAD = 280;
1257
- var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1542
+ var DISCUSSION_TOTAL_CHARS = 8e3;
1543
+ var DISCUSSION_BLOCK_OVERHEAD = 120;
1544
+ var orphanedEntryHtml = (token, links, truncated) => `- **\`${token}\`**: ${links.map((d) => `[${escapeHtml(d.author)} \xB7 ${d.when}](${linkSafeUrl(d.url)})`).join(" ")}${truncated !== void 0 ? ` _(showing the ${String(PER_FINDING_LINKS)} newest of ${String(truncated)})_` : ""}`;
1545
+ var PER_FINDING_LINKS = 6;
1546
+ var DROPPED_IDS_SHOWN = 6;
1547
+ var budgetBySize = (items, cap, sizeOf, onDrop) => {
1548
+ const kept = [];
1549
+ const droppedItems = [];
1550
+ let used = 0;
1551
+ for (const item of items) {
1552
+ const size = sizeOf(item);
1553
+ if (used + size > cap) {
1554
+ droppedItems.push(item);
1555
+ const replaced = onDrop(item);
1556
+ if (replaced !== null) kept.push(replaced);
1557
+ continue;
1558
+ }
1559
+ kept.push(item);
1560
+ used += size;
1561
+ }
1562
+ return { kept, droppedItems, used };
1563
+ };
1564
+ var escapeHtml = (text) => text.replace(
1565
+ /[&<>"']/g,
1566
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c
1567
+ );
1568
+ var discussionAsideHtml = (links, truncated, prefix) => [
1569
+ ...links.map((d) => `${prefix}- [${escapeHtml(d.author)} \xB7 ${d.when}](${linkSafeUrl(d.url)})`),
1570
+ ...truncated !== void 0 ? [
1571
+ // A blank line closes the list: the note stands apart as its own paragraph instead of
1572
+ // reading as a continuation of the newest bullet.
1573
+ prefix,
1574
+ `${prefix}_(showing the ${String(PER_FINDING_LINKS)} newest of ${String(
1575
+ truncated
1576
+ )} replies \u2014 the rest live on the comment thread.)_`
1577
+ ] : []
1578
+ ].join("\n");
1579
+ var sanitizeFinding = (f, answeredNotes, discussionByFinding, permalinkBase, unanchored, discussionTruncated) => {
1258
1580
  const key2 = answeredNoteKey(f);
1259
1581
  const anchored = permalinkBase !== void 0 && !(unanchored?.has(f) ?? false);
1260
1582
  const permalink = permalinkBase === void 0 ? void 0 : permalinkFor(permalinkBase, f, anchored);
@@ -1262,18 +1584,23 @@ var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1262
1584
  ...f,
1263
1585
  title: escapePipes(f.title),
1264
1586
  path: escapeCodeBackticks(f.path),
1265
- ...f.code !== void 0 ? { code: escapeCodeBackticks(f.code), codeKey: f.code } : {},
1587
+ id: escapeCodeBackticks(f.id),
1588
+ // The RESOLVED id: the same-root notes are keyed on resolveFindingId (an empty id resolves to
1589
+ // its synthesized key), so the lookup must not miss the note on the raw id.
1590
+ idKey: resolveFindingId(f),
1266
1591
  ...f.code_url !== void 0 ? { code_url: linkSafeUrl(f.code_url) } : {},
1267
1592
  rangeLabel: lineRange(f.start_line, f.end_line, "\u2013"),
1268
1593
  ...permalink !== void 0 ? { permalink, permalinkAnchored: anchored } : {},
1269
1594
  patchProjection: projectPatch(f.patch, "comment-body"),
1270
- answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : ""
1595
+ answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : "",
1596
+ discussion: discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(discussionByFinding, f.id) ? discussionByFinding[f.id] ?? [] : [],
1597
+ ...discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(discussionTruncated, f.id) ? { discussionTruncated: discussionTruncated[f.id] } : {}
1271
1598
  };
1272
1599
  };
1273
1600
  var commentSafe = (text) => text.replace(/--+(?=>)/g, (dashes) => `${dashes}\u200B`);
1274
- var sanitizeSuppressedNit = (f) => ({
1601
+ var sanitizeSuppressedNit = (f, discussion, discussionTruncated) => ({
1275
1602
  title: escapeCodeBackticks(f.title),
1276
- ...f.code !== void 0 ? { code: escapeCodeBackticks(f.code) } : {},
1603
+ id: escapeCodeBackticks(f.id),
1277
1604
  ...f.code_url !== void 0 ? { codeUrl: linkSafeUrl(f.code_url) } : {},
1278
1605
  path: commentSafe(escapeCodeBackticks(f.path)),
1279
1606
  startLine: f.start_line,
@@ -1283,7 +1610,10 @@ var sanitizeSuppressedNit = (f) => ({
1283
1610
  confidence: formatConfidence(f.confidence),
1284
1611
  likelihood: formatConfidence(f.likelihood),
1285
1612
  m: formatConfidence(f.confidence * f.likelihood),
1286
- carried: carriedLines(f)
1613
+ carried: carriedLines(f),
1614
+ discussion,
1615
+ ...discussionTruncated !== void 0 ? { discussionTruncated } : {},
1616
+ discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncated, "> ") : ""
1287
1617
  });
1288
1618
  var carriedLines = (f) => [
1289
1619
  `description: ${clipText(f.description, BODY_CLIP_CHARS)}`,
@@ -1291,13 +1621,16 @@ var carriedLines = (f) => [
1291
1621
  `reasoning: ${clipText(f.reasoning, BODY_CLIP_CHARS)}`,
1292
1622
  ...f.patch !== void 0 ? ["patch:", clipText(f.patch, BODY_CLIP_CHARS)] : []
1293
1623
  ].flatMap((block) => commentSafe(block).split("\n")).map((line) => line.trimEnd());
1294
- var sanitizeSystemic = (s) => ({
1624
+ var sanitizeSystemic = (s, discussion, discussionTruncated) => ({
1295
1625
  ...s,
1296
1626
  title: escapePipes(s.title),
1297
- ...s.code !== void 0 ? { code: escapeCodeBackticks(s.code) } : {},
1627
+ discussion,
1628
+ ...discussionTruncated !== void 0 ? { discussionTruncated } : {},
1629
+ discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncated, "") : "",
1630
+ ...s.id !== void 0 ? { id: escapeCodeBackticks(s.id) } : {},
1298
1631
  ...s.code_url !== void 0 ? { code_url: linkSafeUrl(s.code_url) } : {},
1299
1632
  ...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
1300
- ...s.finding_codes !== void 0 ? { finding_codes: s.finding_codes.map(escapeCodeBackticks) } : {}
1633
+ ...s.finding_ids !== void 0 ? { finding_ids: s.finding_ids.map(escapeCodeBackticks) } : {}
1301
1634
  });
1302
1635
  var emptySeverityCounts = () => ({
1303
1636
  critical: 0,
@@ -1328,16 +1661,98 @@ var render = (input) => {
1328
1661
  const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
1329
1662
  const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
1330
1663
  const advisoryAllowed = isFullReviewRound;
1331
- const suppressedBudget = (input.suppressedNits ?? []).map(sanitizeSuppressedNit).reduce(
1332
- (acc, n) => {
1333
- const size = n.carried.reduce((sum, line) => sum + line.length + 3, 0) + SUPPRESSED_NIT_BLOCK_OVERHEAD + n.title.length + n.path.length * 2 + (n.code?.length ?? 0) + (n.codeUrl?.length ?? 0) + String(n.startLine).length * 2 + String(n.endLine).length + (n.side !== void 0 ? n.side.length + 2 : 0) + // The summary line's wrappers: backticks around the code and the [](...) around the link.
1334
- (n.code !== void 0 ? n.code.length + 2 : 0) + (n.codeUrl !== void 0 ? n.codeUrl.length + 4 : 0);
1335
- return acc.used + size > CARRIED_TOTAL_CHARS ? { list: acc.list, used: acc.used, dropped: acc.dropped + 1 } : { list: [...acc.list, n], used: acc.used + size, dropped: acc.dropped };
1336
- },
1337
- { list: [], used: 0, dropped: 0 }
1664
+ const discussionLinksFor = (id) => input.discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionByFinding, id) ? input.discussionByFinding[id] ?? [] : [];
1665
+ const discussionTruncatedFor = (id) => input.discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionTruncated, id) ? input.discussionTruncated[id] : void 0;
1666
+ const suppressedBudget = budgetBySize(
1667
+ (input.suppressedNits ?? []).map(
1668
+ (f) => sanitizeSuppressedNit(f, discussionLinksFor(f.id), discussionTruncatedFor(f.id))
1669
+ ),
1670
+ CARRIED_TOTAL_CHARS,
1671
+ (n) => (
1672
+ // The fixed overhead covers the summary line's structure; the reviewer-controlled variable
1673
+ // parts are budgeted at their true RENDERED size — the title, the path TWICE (summary +
1674
+ // location line), the code/code_url the summary line renders, each carried line's `> `
1675
+ // blockquote prefix (length + 3: prefix + newline), the location line's line numbers and
1676
+ // side label, and the discussion slot's link list.
1677
+ n.carried.reduce((sum, line) => sum + line.length + 3, 0) + SUPPRESSED_NIT_BLOCK_OVERHEAD + n.title.length + n.path.length * 2 + // The id renders with its two wrapper backticks; the code_url adds the [](...) link form.
1678
+ // The id renders ONCE: a backtick span inside parens, or the []() link form.
1679
+ n.id.length + (n.codeUrl !== void 0 ? n.codeUrl.length + 8 : 4) + String(n.startLine).length * 2 + String(n.endLine).length + (n.side !== void 0 ? n.side.length + 2 : 0) + // The composed slot's EXACT rendered size — its `> ` blockquote prefixes included.
1680
+ n.discussionHtml.length
1681
+ ),
1682
+ () => null
1338
1683
  );
1339
1684
  const permalinkBase = input.repo !== void 0 && input.repo !== "" && input.reviewedSha !== void 0 && input.reviewedSha !== "" ? `https://github.com/${input.repo}/blob/${input.reviewedSha}/` : void 0;
1340
1685
  const unanchored = new Set(input.unanchoredStrays ?? []);
1686
+ const strayViews = (input.strays ?? []).map((f) => {
1687
+ const view = sanitizeFinding(
1688
+ f,
1689
+ input.answeredNotes,
1690
+ input.discussionByFinding,
1691
+ permalinkBase,
1692
+ unanchored,
1693
+ input.discussionTruncated
1694
+ );
1695
+ return {
1696
+ ...view,
1697
+ discussionHtml: view.discussion.length > 0 ? discussionAsideHtml(view.discussion, view.discussionTruncated, "") : ""
1698
+ };
1699
+ });
1700
+ const orphanedPreBudget = /* @__PURE__ */ new Map();
1701
+ for (const [token, links] of Object.entries(input.orphanedDiscussion ?? {})) {
1702
+ const key2 = escapeCodeBackticks(token);
1703
+ const truncatedFor = input.orphanedTruncated !== void 0 && Object.prototype.hasOwnProperty.call(input.orphanedTruncated, token) ? input.orphanedTruncated[token] : void 0;
1704
+ const existing = orphanedPreBudget.get(key2);
1705
+ const htmlFor = (entryLinks, entryTruncated) => orphanedEntryHtml(key2, entryLinks, entryTruncated);
1706
+ if (existing === void 0) {
1707
+ orphanedPreBudget.set(key2, {
1708
+ links,
1709
+ ...truncatedFor !== void 0 ? { truncated: truncatedFor } : {},
1710
+ html: htmlFor(links, truncatedFor)
1711
+ });
1712
+ } else {
1713
+ const mergedTotal = (existing.truncated ?? existing.links.length) + (truncatedFor ?? links.length);
1714
+ const merged = [...existing.links, ...links].sort(
1715
+ (a, b) => (b.at ?? b.when).localeCompare(a.at ?? a.when)
1716
+ );
1717
+ const mergedLinks = merged.slice(0, PER_FINDING_LINKS);
1718
+ const mergedTruncated = mergedTotal > PER_FINDING_LINKS ? mergedTotal : void 0;
1719
+ orphanedPreBudget.set(key2, {
1720
+ links: mergedLinks,
1721
+ ...mergedTruncated !== void 0 ? { truncated: mergedTruncated } : {},
1722
+ html: htmlFor(mergedLinks, mergedTruncated)
1723
+ });
1724
+ }
1725
+ }
1726
+ const orphanedBudget = budgetBySize(
1727
+ [...orphanedPreBudget.entries()],
1728
+ DISCUSSION_TOTAL_CHARS,
1729
+ ([, entry]) => entry.html.length,
1730
+ () => null
1731
+ );
1732
+ const discussionBudget = budgetBySize(
1733
+ strayViews,
1734
+ DISCUSSION_TOTAL_CHARS - orphanedBudget.used,
1735
+ (view) => view.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + view.discussionHtml.length : 0,
1736
+ (view) => ({ ...view, discussion: [], discussionHtml: "" })
1737
+ );
1738
+ const systemicBudget = budgetBySize(
1739
+ (input.findings.systemic_problems ?? []).map(
1740
+ (s) => sanitizeSystemic(
1741
+ s,
1742
+ s.id !== void 0 ? discussionLinksFor(s.id) : [],
1743
+ s.id !== void 0 ? discussionTruncatedFor(s.id) : void 0
1744
+ )
1745
+ ),
1746
+ DISCUSSION_TOTAL_CHARS - orphanedBudget.used - discussionBudget.used,
1747
+ (s) => s.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + s.discussionHtml.length : 0,
1748
+ (s) => ({ ...s, discussion: [], discussionHtml: "" })
1749
+ );
1750
+ const orphanedRender = {
1751
+ lines: orphanedBudget.kept.map(([, entry]) => entry.html),
1752
+ // The "(showing N of M)" total counts DISTINCT DISPLAYED entries — the pre-budget fold above
1753
+ // already merged twins, and the budget's own drops are named in the dropped-threads marker.
1754
+ total: input.orphanedTotal !== void 0 ? input.orphanedTotal - (Object.keys(input.orphanedDiscussion ?? {}).length - orphanedPreBudget.size) : orphanedPreBudget.size - orphanedBudget.droppedItems.length
1755
+ };
1341
1756
  return eta.renderString(input.template, {
1342
1757
  findings: input.findings,
1343
1758
  envelope: input.envelope,
@@ -1358,13 +1773,20 @@ var render = (input) => {
1358
1773
  postedAt: input.postedAt ?? "",
1359
1774
  severityCounts,
1360
1775
  convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
1361
- strays: (input.strays ?? []).map(
1362
- (f) => sanitizeFinding(f, input.answeredNotes, permalinkBase, unanchored)
1363
- ),
1364
- suppressedNits: suppressedBudget.list,
1365
- carriedDroppedNits: suppressedBudget.dropped,
1776
+ strays: discussionBudget.kept,
1777
+ orphanedLines: orphanedRender.lines,
1778
+ orphanedUnresolvable: input.orphanedUnresolvable === true,
1779
+ discussionDropped: discussionBudget.droppedItems.length + systemicBudget.droppedItems.length + orphanedBudget.droppedItems.length,
1780
+ discussionDroppedIds: discussionBudget.droppedItems.map((v) => clipText(escapeCodeBackticks(v.idKey), 64)).slice(0, DROPPED_IDS_SHOWN),
1781
+ discussionDroppedSystemicIds: systemicBudget.droppedItems.flatMap((s) => s.id !== void 0 ? [clipText(s.id, 64)] : []).slice(0, DROPPED_IDS_SHOWN),
1782
+ discussionDroppedOrphanedIds: orphanedBudget.droppedItems.map(([token]) => clipText(token, 64)).slice(0, DROPPED_IDS_SHOWN),
1783
+ discussionDroppedExtra: Math.max(0, discussionBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, systemicBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, orphanedBudget.droppedItems.length - DROPPED_IDS_SHOWN),
1784
+ discussionCap: PER_FINDING_LINKS,
1785
+ orphanedTotal: orphanedRender.total,
1786
+ suppressedNits: suppressedBudget.kept,
1787
+ carriedDroppedNits: suppressedBudget.droppedItems.length,
1366
1788
  nitVisibilityFloor: input.nitVisibilityFloor ?? DEFAULT_NIT_VISIBILITY_FLOOR,
1367
- systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
1789
+ systemic: systemicBudget.kept,
1368
1790
  unanchoredCount: input.unanchoredCount ?? 0,
1369
1791
  inlineDisposition: input.inlineDisposition ?? null,
1370
1792
  unverifiedNoLogs: input.unverifiedNoLogs === true,
@@ -1479,15 +1901,10 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRoo
1479
1901
  );
1480
1902
  var buildInlineComments = (findings, diff, context) => {
1481
1903
  const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
1482
- const index = indexDiff(diff);
1483
- const { inDiff, strays } = partitionFindings(findings, index);
1904
+ const { inDiff, strays } = context.partition ?? partitionFindings(findings, indexDiff(diff));
1484
1905
  const eta = new Eta({ autoTrim: false });
1485
1906
  const modelsText = formatModels(models);
1486
- const noteFor = (f, notes) => {
1487
- if (notes === void 0) return "";
1488
- const key2 = answeredNoteKey(f);
1489
- return Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1490
- };
1907
+ const noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1491
1908
  const comments = inDiff.map((f) => {
1492
1909
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
1493
1910
  const clipProse = fullFindings !== void 0 && findingPayload(f, fullFindings.schema_version).length > INLINE_PROSE_CLIP_THRESHOLD_CHARS;
@@ -1499,8 +1916,8 @@ var buildInlineComments = (findings, diff, context) => {
1499
1916
  reasoning: clipText(f.reasoning, BODY_CLIP_CHARS),
1500
1917
  ...f.patch != null ? { patch: clipText(f.patch, BODY_CLIP_CHARS) } : {}
1501
1918
  } : f;
1502
- const sameRootNote = noteFor(f, context.sameRootNotes);
1503
- const answeredNote2 = noteFor(f, context.answeredNotes);
1919
+ const sameRootNote = noteFor(context.sameRootNotes, resolveFindingId(f));
1920
+ const answeredNote2 = noteFor(context.answeredNotes, answeredNoteKey(f));
1504
1921
  const comment = {
1505
1922
  path: f.path,
1506
1923
  line: f.end_line,
@@ -1944,33 +2361,47 @@ var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
1944
2361
  The workflow asked for an unrecognized notice kind (\`${kind}\`) \u2014 the pinned code-review CLI is older than the workflow calling it (check that its version matches). Failing closed. See the workflow logs.`
1945
2362
  );
1946
2363
  var identity = (decoded) => decoded;
2364
+ var legacyFindingsCodec = FindingsCodecV09;
2365
+ var legacyFindingsNormalize = (doc) => normalizeV09(doc);
1947
2366
  var findingsTable = [
1948
2367
  {
1949
2368
  minor: "0.4",
1950
2369
  defaultVersion: "0.4.0",
1951
- schemaFile: "findings.schema.json",
1952
- codec: FindingsCodec,
1953
- normalize: identity,
2370
+ // The frozen tolerant-in legacy schema, NOT the live 0.10 file: every ajv-gated channel
2371
+ // (validate --schema-version, the extraction ladder) dispatches the RAW doc's declared minor
2372
+ // through schemaPathFor, so the ajv gate must accept exactly what the tolerant legacy codec
2373
+ // accepts — the live file (id required) would reject the legacy docs the upcast promises to read.
2374
+ schemaFile: "v0.9/findings.schema.json",
2375
+ codec: legacyFindingsCodec,
2376
+ normalize: legacyFindingsNormalize,
1954
2377
  latest: false
1955
2378
  },
1956
2379
  {
1957
2380
  minor: "0.5",
1958
2381
  defaultVersion: "0.5.0",
1959
- schemaFile: "findings.schema.json",
1960
- codec: FindingsCodec,
1961
- normalize: identity,
2382
+ schemaFile: "v0.9/findings.schema.json",
2383
+ codec: legacyFindingsCodec,
2384
+ normalize: legacyFindingsNormalize,
1962
2385
  latest: false
1963
2386
  },
1964
2387
  {
1965
2388
  minor: "0.6",
1966
2389
  defaultVersion: "0.6.0",
1967
- schemaFile: "findings.schema.json",
1968
- codec: FindingsCodec,
1969
- normalize: identity,
2390
+ schemaFile: "v0.9/findings.schema.json",
2391
+ codec: legacyFindingsCodec,
2392
+ normalize: legacyFindingsNormalize,
1970
2393
  latest: false
1971
2394
  },
1972
2395
  {
1973
2396
  minor: "0.9",
2397
+ defaultVersion: "0.9.0",
2398
+ schemaFile: "v0.9/findings.schema.json",
2399
+ codec: legacyFindingsCodec,
2400
+ normalize: legacyFindingsNormalize,
2401
+ latest: false
2402
+ },
2403
+ {
2404
+ minor: "0.10",
1974
2405
  defaultVersion: DEFAULT_SCHEMA_VERSION,
1975
2406
  schemaFile: "findings.schema.json",
1976
2407
  codec: FindingsCodec,
@@ -2064,6 +2495,13 @@ var resolvers = {
2064
2495
  prices: (raw) => resolveSingleVersion("prices", raw)
2065
2496
  };
2066
2497
  var resolve = (kind, raw) => resolvers[kind](raw);
2498
+ var resolveTolerantFindings = (doc) => {
2499
+ const r = resolveFindings(doc);
2500
+ if (r.kind === "ok") return r.value;
2501
+ if (r.kind === "unsupported-version") return null;
2502
+ const legacy = FindingsCodecV09.decode(doc);
2503
+ return legacy._tag === "Right" ? normalizeV09(legacy.right) : null;
2504
+ };
2067
2505
  var MAX_BUFFER = 100 * 1024 * 1024;
2068
2506
  var MAX_TIMEOUT_MS = 2147483647;
2069
2507
  var parseTimeoutMs = (raw, fallback) => {
@@ -2539,24 +2977,58 @@ var postInlineReview = async (pr, comments, inDiff, ghApi) => {
2539
2977
  return { url, inlinePosted, unposted };
2540
2978
  }
2541
2979
  };
2542
- var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
2543
- const stdout = await ghApi(
2544
- [
2545
- `repos/${repo}/issues/${String(prNumber)}/comments`,
2546
- "--paginate",
2547
- "--jq",
2548
- ".[] | select(.user.login == env.CODE_REVIEW_BOT_LOGIN and (.body | startswith(env.CODE_REVIEW_MARKER))) | {id: .id, body: .body}"
2549
- ],
2550
- void 0,
2551
- { CODE_REVIEW_BOT_LOGIN: botLogin, CODE_REVIEW_MARKER: marker }
2980
+ var ISSUE_COMMENTS_JQ = '.[] | {id, user: (.user.login // "(deleted)"), created_at, html_url, body: (.body // "")}';
2981
+ var parseIssueCommentRows = (raw) => {
2982
+ let malformed = 0;
2983
+ const rows = [];
2984
+ for (const line of raw.split("\n")) {
2985
+ const trimmed = line.trim();
2986
+ if (trimmed === "") continue;
2987
+ const parsed = tryParseJson(trimmed);
2988
+ if (!parsed.ok) {
2989
+ malformed += 1;
2990
+ continue;
2991
+ }
2992
+ const rec = asRecord(parsed.value);
2993
+ const id = rec?.["id"];
2994
+ const author = rec?.["user"];
2995
+ const created = rec?.["created_at"];
2996
+ const url = rec?.["html_url"];
2997
+ const body = rec?.["body"];
2998
+ if (typeof id === "number" && typeof author === "string" && typeof created === "string" && typeof url === "string" && typeof body === "string") {
2999
+ rows.push({ id, author, created, url, body });
3000
+ } else {
3001
+ malformed += 1;
3002
+ }
3003
+ }
3004
+ rows.sort(
3005
+ (a, b) => a.created.localeCompare(b.created) || (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)
2552
3006
  );
2553
- const lines = stdout.trim().split("\n").filter(Boolean);
2554
- if (lines.length === 0) return null;
2555
- const last = lines[lines.length - 1] ?? null;
2556
- if (last === null) return null;
2557
- const parsed = JSON.parse(last);
2558
- return { id: parsed.id, body: parsed.body };
3007
+ return { rows, malformed };
3008
+ };
3009
+ var fetchIssueCommentRows = async (repo, prNumber, ghApi) => {
3010
+ const raw = await ghApi([
3011
+ // per_page rides the QUERY string — a `-f` field would flip `gh api` from GET to POST
3012
+ // (create-comment) and 422 every sticky lookup.
3013
+ `repos/${repo}/issues/${String(prNumber)}/comments?per_page=100`,
3014
+ "--paginate",
3015
+ "--jq",
3016
+ ISSUE_COMMENTS_JQ
3017
+ ]);
3018
+ const { rows, malformed } = parseIssueCommentRows(raw);
3019
+ if (malformed > 0) {
3020
+ throw new Error(
3021
+ `${String(malformed)} issue-comment row(s) failed to decode \u2014 refusing to treat a corrupted history as an absent sticky`
3022
+ );
3023
+ }
3024
+ return rows;
3025
+ };
3026
+ var selectBotComment = (rows, botLogin, marker) => {
3027
+ const bot = rows.filter((r) => r.author === botLogin && r.body.startsWith(marker));
3028
+ const last = bot[bot.length - 1];
3029
+ return last === void 0 ? null : { id: last.id, body: last.body };
2559
3030
  };
3031
+ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => selectBotComment(await fetchIssueCommentRows(repo, prNumber, ghApi), botLogin, marker);
2560
3032
  var parseCommentRef = (raw) => {
2561
3033
  const parsed = tryParseJson(raw);
2562
3034
  const rec = parsed.ok ? asRecord(parsed.value) : null;
@@ -2579,6 +3051,134 @@ var postComment = async (repo, prNumber, body, ghApi) => {
2579
3051
  );
2580
3052
  return parseCommentRef(stdout);
2581
3053
  };
3054
+ var ID_TOKEN_RE = /`([^`\n]+)`/g;
3055
+ var ORPHAN_TOKEN_CAP = 8;
3056
+ var escapedIdIndex = (ids) => {
3057
+ const owners = /* @__PURE__ */ new Map();
3058
+ for (const id of ids) {
3059
+ const escaped = escapeCodeBackticks(id);
3060
+ if (escaped === id) continue;
3061
+ const list = owners.get(escaped);
3062
+ if (list === void 0) owners.set(escaped, [id]);
3063
+ else if (!list.includes(id)) list.push(id);
3064
+ }
3065
+ return new Map(
3066
+ [...owners.entries()].flatMap(
3067
+ ([escaped, raws]) => raws.length === 1 && raws[0] !== void 0 ? [[escaped, raws[0]]] : []
3068
+ )
3069
+ );
3070
+ };
3071
+ var discussionRows = (rows, stickyId, botLogin) => {
3072
+ const seen = /* @__PURE__ */ new Set();
3073
+ return rows.filter((c) => {
3074
+ if (seen.has(c.id)) return false;
3075
+ seen.add(c.id);
3076
+ return c.id !== stickyId && c.author !== botLogin;
3077
+ });
3078
+ };
3079
+ var ID_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
3080
+ var mentionsOutsideKnown = (reachable, currentIds) => {
3081
+ const known = new Set(currentIds);
3082
+ const escapedToRaw = escapedIdIndex(currentIds);
3083
+ for (const c of reachable) {
3084
+ for (const m of c.body.matchAll(ID_TOKEN_RE)) {
3085
+ const token = m[1];
3086
+ if (token === void 0) continue;
3087
+ const raw = known.has(token) ? token : escapedToRaw.get(token);
3088
+ if ((raw === void 0 || !known.has(raw)) && ID_SHAPE_RE.test(token)) return true;
3089
+ }
3090
+ }
3091
+ return false;
3092
+ };
3093
+ var buildStickyDiscussion = (reachable, currentIds, priorIds) => {
3094
+ const known = new Set(currentIds);
3095
+ const departed = new Set(priorIds);
3096
+ const escapedToRaw = escapedIdIndex([...currentIds, ...priorIds]);
3097
+ const byFinding = Object.fromEntries(
3098
+ currentIds.map((id) => [id, []])
3099
+ );
3100
+ const orphaned = /* @__PURE__ */ new Map();
3101
+ const latestAt = /* @__PURE__ */ new Map();
3102
+ for (const c of reachable) {
3103
+ const link = { author: c.author, when: c.created.slice(0, 10), url: c.url, at: c.created };
3104
+ const pushed = /* @__PURE__ */ new Set();
3105
+ for (const m of c.body.matchAll(ID_TOKEN_RE)) {
3106
+ const token = m[1];
3107
+ if (token === void 0 || pushed.has(token)) continue;
3108
+ pushed.add(token);
3109
+ const rawKnown = known.has(token);
3110
+ const alias = rawKnown ? void 0 : escapedToRaw.get(token);
3111
+ const aliasIsKnown = alias !== void 0 && known.has(alias);
3112
+ const rawDeparted = departed.has(token);
3113
+ if (rawKnown) {
3114
+ byFinding[token]?.push(link);
3115
+ } else if (aliasIsKnown) {
3116
+ if (!rawDeparted) byFinding[alias]?.push(link);
3117
+ } else if (rawDeparted) {
3118
+ const key2 = token;
3119
+ const links = orphaned.get(key2);
3120
+ if (links === void 0) orphaned.set(key2, [link]);
3121
+ else links.push(link);
3122
+ const latest = latestAt.get(key2);
3123
+ if (latest === void 0 || c.created.localeCompare(latest) > 0)
3124
+ latestAt.set(key2, c.created);
3125
+ } else if (alias !== void 0 && departed.has(alias)) {
3126
+ const key2 = alias;
3127
+ const links = orphaned.get(key2);
3128
+ if (links === void 0) orphaned.set(key2, [link]);
3129
+ else links.push(link);
3130
+ const latest = latestAt.get(key2);
3131
+ if (latest === void 0 || c.created.localeCompare(latest) > 0)
3132
+ latestAt.set(key2, c.created);
3133
+ }
3134
+ }
3135
+ }
3136
+ const truncated = /* @__PURE__ */ new Map();
3137
+ for (const [id, links] of Object.entries(byFinding)) {
3138
+ links.reverse();
3139
+ if (links.length > PER_FINDING_LINKS) {
3140
+ truncated.set(id, links.length);
3141
+ links.splice(PER_FINDING_LINKS);
3142
+ }
3143
+ }
3144
+ const orphanedTruncated = /* @__PURE__ */ new Map();
3145
+ for (const [token, links] of orphaned) {
3146
+ links.reverse();
3147
+ if (links.length > PER_FINDING_LINKS) {
3148
+ orphanedTruncated.set(token, links.length);
3149
+ links.splice(PER_FINDING_LINKS);
3150
+ }
3151
+ }
3152
+ const orphanedTotal = orphaned.size;
3153
+ const byLatestReply = [...orphaned.entries()].sort(
3154
+ (a, b) => (latestAt.get(b[0]) ?? "").localeCompare(latestAt.get(a[0]) ?? "")
3155
+ );
3156
+ return {
3157
+ byFinding,
3158
+ orphaned: Object.fromEntries(byLatestReply.slice(0, ORPHAN_TOKEN_CAP)),
3159
+ orphanedTotal,
3160
+ truncated: Object.fromEntries(truncated),
3161
+ orphanedTruncated: Object.fromEntries(orphanedTruncated)
3162
+ };
3163
+ };
3164
+ var priorIdsFrom = (doc) => {
3165
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return [];
3166
+ const rec = doc;
3167
+ const idsOf = (field, systemic) => (Array.isArray(rec[field]) ? rec[field] : []).flatMap((raw) => {
3168
+ const item = typeof raw === "object" && raw !== null ? asRecord(raw) : null;
3169
+ if (item === null) return [];
3170
+ const title = typeof item["title"] === "string" ? item["title"] : "";
3171
+ const resolved = resolveRuleId({
3172
+ ...typeof item["id"] === "string" ? { id: item["id"] } : {},
3173
+ ...typeof item["code"] === "string" ? { code: item["code"] } : {},
3174
+ ...typeof item["path"] === "string" ? { path: item["path"] } : {},
3175
+ title
3176
+ });
3177
+ const id = resolved ?? (systemic && title !== "" ? synthesizedSystemicId(title) : void 0);
3178
+ return id !== void 0 ? [id] : [];
3179
+ });
3180
+ return [...idsOf("findings", false), ...idsOf("systemic_problems", true)];
3181
+ };
2582
3182
  var appendRunSummary = (summaryPath, body) => {
2583
3183
  if (summaryPath === void 0 || summaryPath === "") return;
2584
3184
  const rendered = body();
@@ -2735,13 +3335,14 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
2735
3335
  process.exit(0);
2736
3336
  }
2737
3337
  const prNumber = resolution.prNumber;
2738
- const [diff, existingSticky] = await Promise.all([
3338
+ const [diff, commentRows] = await Promise.all([
2739
3339
  fetchDiff(input.repo, prNumber, ghApi),
2740
- findBotComment(input.repo, prNumber, input.botLogin, DEFAULT_MARKER, ghApi)
3340
+ fetchIssueCommentRows(input.repo, prNumber, ghApi)
2741
3341
  ]);
3342
+ const existingSticky = selectBotComment(commentRows, input.botLogin, DEFAULT_MARKER);
2742
3343
  const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
2743
3344
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
2744
- const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
3345
+ const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body) && !parseMechanicAncestor(body));
2745
3346
  const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
2746
3347
  const priorDoc = existingSticky !== null ? parseFindingsMarker(existingSticky.body) : null;
2747
3348
  const priorBody = existingSticky?.body ?? "";
@@ -2752,12 +3353,12 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
2752
3353
  ...doc,
2753
3354
  convergence: conv ?? void 0
2754
3355
  });
2755
- const carriedFindingsLink = !input.jsonUrl && existingSticky !== null ? findingsArtifactUrl(existingSticky.body) : null;
2756
3356
  let warnedNoJsonUrl = false;
2757
3357
  const findingsBlob = (doc) => {
2758
3358
  if (input.jsonUrl) return findingsMarkerPair(input.jsonUrl, doc.convergence);
2759
- if (carriedFindingsLink !== null) {
2760
- return findingsMarkerPair(carriedFindingsLink, doc.convergence);
3359
+ const carriedLink = existingSticky !== null ? findingsArtifactUrl(existingSticky.body) : null;
3360
+ if (carriedLink !== null) {
3361
+ return findingsMarkerPair(carriedLink, doc.convergence);
2761
3362
  }
2762
3363
  if (!warnedNoJsonUrl) {
2763
3364
  warnedNoJsonUrl = true;
@@ -2808,8 +3409,41 @@ ${dropNote}` : ""}`,
2808
3409
  }
2809
3410
  const template = readFileSync(input.templatePath, "utf-8");
2810
3411
  const inlineRequested = input.inline === true;
2811
- const renderNotice = (message) => {
2812
- const findings2 = stampConvergence(incompleteFindings(`### \u26A0\uFE0F ${message}`), priorConv);
3412
+ const envelope = loadEnvelope(input.envelopePath);
3413
+ const reachable = existingSticky !== null ? discussionRows(commentRows, existingSticky.id, input.botLogin) : [];
3414
+ const priorIsMechanic = existingSticky !== null && (parseReviewedRoute(existingSticky.body) === "mechanic" || parseMechanicAncestor(existingSticky.body));
3415
+ const noticeDiscussion = async () => {
3416
+ if (existingSticky === null) {
3417
+ return {
3418
+ orphanedDiscussion: {},
3419
+ orphanedTotal: 0,
3420
+ orphanedTruncated: {},
3421
+ orphanedUnresolvable: false
3422
+ };
3423
+ }
3424
+ const wantsPrior = !priorIsMechanic && mentionsOutsideKnown(reachable, []);
3425
+ const resolved = wantsPrior ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3426
+ const built = buildStickyDiscussion(reachable, [], priorIdsFrom(resolved));
3427
+ const hadMarker = carriedFindingsMarker(existingSticky.body) !== null;
3428
+ return {
3429
+ orphanedDiscussion: built.orphaned,
3430
+ orphanedTotal: built.orphanedTotal,
3431
+ orphanedTruncated: built.orphanedTruncated,
3432
+ orphanedUnresolvable: wantsPrior && hadMarker && resolved === null
3433
+ };
3434
+ };
3435
+ const renderNotice = (message, discussion) => {
3436
+ const noticeConvergence = (input.route ?? envelope?.route) === "mechanic" ? mechanicConvergence(
3437
+ priorConv,
3438
+ input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD
3439
+ ) : priorConv;
3440
+ const findings2 = stampConvergence(incompleteFindings(`### \u26A0\uFE0F ${message}`), noticeConvergence);
3441
+ const noticeFindingsPointer = (doc) => {
3442
+ const carried = existingSticky !== null ? carriedFindingsMarker(existingSticky.body) : null;
3443
+ if (carried === null) return findingsBlob(doc);
3444
+ const provenance = existingSticky !== null ? carriedAncestryMarkers(existingSticky.body) : "";
3445
+ return [carriedMarkerPointer(carried, doc.convergence), provenance].filter((p) => p !== "").join("\n\n");
3446
+ };
2813
3447
  return formatMarkdown(
2814
3448
  render({
2815
3449
  findings: findings2,
@@ -2826,18 +3460,23 @@ ${dropNote}` : ""}`,
2826
3460
  convergenceRound: false,
2827
3461
  runUrl: input.runUrl,
2828
3462
  jsonUrl: input.jsonUrl,
2829
- findingsPointer: findingsBlob(findings2),
2830
- postedAt: input.postedAt
3463
+ findingsPointer: noticeFindingsPointer(findings2),
3464
+ postedAt: input.postedAt,
3465
+ orphanedDiscussion: discussion.orphanedDiscussion,
3466
+ orphanedTotal: discussion.orphanedTotal,
3467
+ orphanedTruncated: discussion.orphanedTruncated,
3468
+ orphanedUnresolvable: discussion.orphanedUnresolvable
2831
3469
  })
2832
3470
  );
2833
3471
  };
2834
3472
  if (isEmptyDiff(diff)) {
2835
3473
  if (wouldBuryCompleted(true)) leaveInPlace();
3474
+ const discussion = await noticeDiscussion();
2836
3475
  await upsertSticky(
2837
3476
  input.repo,
2838
3477
  prNumber,
2839
3478
  existingSticky,
2840
- renderNotice("The diff for this PR is empty \u2014 nothing to review."),
3479
+ renderNotice("The diff for this PR is empty \u2014 nothing to review.", discussion),
2841
3480
  ghApi
2842
3481
  );
2843
3482
  process.exit(0);
@@ -2845,11 +3484,12 @@ ${dropNote}` : ""}`,
2845
3484
  const findingsResult = loadFindings(input.findingsPath);
2846
3485
  if (findingsResult.kind !== "ok") {
2847
3486
  if (wouldBuryCompleted(true)) leaveInPlace();
3487
+ const discussion = await noticeDiscussion();
2848
3488
  await upsertSticky(
2849
3489
  input.repo,
2850
3490
  prNumber,
2851
3491
  existingSticky,
2852
- renderNotice(noticeMessageFor(findingsResult)),
3492
+ renderNotice(noticeMessageFor(findingsResult), discussion),
2853
3493
  ghApi
2854
3494
  );
2855
3495
  process.exit(0);
@@ -2861,33 +3501,59 @@ ${dropNote}` : ""}`,
2861
3501
  const reRaisedNotes = answeredFilter.reRaisedNotes;
2862
3502
  const verbatimReRaised = answeredFilter.verbatimReRaised;
2863
3503
  const droppedCount = answeredFilter.droppedCount;
2864
- const droppedCodes = new Set(verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []));
2865
- const keptCodes = new Set(
2866
- answeredFilter.findings.flatMap((f) => f.code !== void 0 && f.code !== "" ? [f.code] : [])
2867
- );
2868
- const trulyDropped = new Set([...droppedCodes].filter((c) => !keptCodes.has(c)));
3504
+ const droppedIds = /* @__PURE__ */ new Set([
3505
+ ...verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []),
3506
+ ...answeredFilter.droppedFindingIds.filter((id) => id !== "")
3507
+ ]);
3508
+ const keptCodes = new Set(answeredFilter.findings.map((f) => f.id));
3509
+ const trulyDropped = new Set([...droppedIds].filter((c) => !keptCodes.has(c)));
2869
3510
  const systemic = trulyDropped.size === 0 ? loadedFindings.systemic_problems ?? [] : (loadedFindings.systemic_problems ?? []).map((s) => {
2870
- if (s.finding_codes === void 0) return s;
2871
- const codes = s.finding_codes.filter((c) => !trulyDropped.has(c));
2872
- return codes.length === s.finding_codes.length ? s : { ...s, finding_codes: codes };
3511
+ if (s.finding_ids === void 0) return s;
3512
+ const codes = s.finding_ids.filter((c) => !trulyDropped.has(c));
3513
+ return codes.length === s.finding_ids.length ? s : { ...s, finding_ids: codes };
2873
3514
  });
2874
3515
  const findings = {
2875
3516
  ...loadedFindings,
2876
3517
  findings: [...answeredFilter.findings],
2877
3518
  ...systemic.length > 0 ? { systemic_problems: systemic } : {}
2878
3519
  };
3520
+ const broadCurrentIds = [
3521
+ ...findings.findings.map((f) => f.id).filter((id) => id !== ""),
3522
+ ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
3523
+ ];
2879
3524
  const roundHasNit = findings.findings.some((f) => f.severity === "nit");
2880
- const priorDocForNits = roundHasNit && existingSticky !== null && isFullReviewSticky(existingSticky.body) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3525
+ const nitWantsPrior = roundHasNit && existingSticky !== null && (isFullReviewSticky(existingSticky.body) || isFullReviewAncestry(existingSticky.body));
3526
+ const broadWantsPrior = existingSticky !== null && !priorIsMechanic && mentionsOutsideKnown(reachable, broadCurrentIds);
3527
+ const resolvedPrior = existingSticky !== null && (nitWantsPrior || broadWantsPrior) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3528
+ const priorDocForNits = nitWantsPrior ? resolvedPrior : null;
2881
3529
  const priorSuppressedKeys = new Set(
2882
3530
  priorBelowFloorNits(priorDocForNits, input.nitVisibilityFloor).map(
2883
- (n) => answeredNoteKey({ code: n.code, title: n.title })
3531
+ (n) => answeredNoteKey({ id: n.id ?? "", title: n.title })
2884
3532
  )
2885
3533
  );
2886
- const isSuppressedNit = (f) => f.severity === "nit" && (isBelowVisibilityFloor(f, input.nitVisibilityFloor) || priorSuppressedKeys.has(answeredNoteKey(f)));
3534
+ const isSuppressedNit = (f) => f.severity === "nit" && (isBelowVisibilityFloor(f, input.nitVisibilityFloor) || // ALL THREE key forms the prior side can emit: the resolved id (a coded or same-path
3535
+ // synthesized prior), the answeredNoteKey form (an empty-id prior), and the bare title: key
3536
+ // (a pathless codeless prior).
3537
+ priorSuppressedKeys.has(resolveFindingId(f)) || priorSuppressedKeys.has(answeredNoteKey(f)) || priorSuppressedKeys.has(`title:${f.title}`));
2887
3538
  const suppressedNits = findings.findings.filter(isSuppressedNit);
2888
3539
  const visibleFindings = findings.findings.filter((f) => !isSuppressedNit(f));
2889
3540
  const answeredDropNote = answeredReRaiseNote(verbatimReRaised, droppedCount) + (verbatimReRaised.length > 0 && findings.findings.length === 0 ? "\n> _The stop signal reflects the kept findings \u2014 this round carries none._" : "");
2890
- const envelope = loadEnvelope(input.envelopePath);
3541
+ const inlinePartition = input.inline === true && envelope !== null ? partitionFindings(visibleFindings, indexDiff(diff)) : null;
3542
+ const straysForDiscussion = inlinePartition?.strays ?? visibleFindings;
3543
+ const currentIds = [
3544
+ ...straysForDiscussion.map((f) => f.id).filter((id) => id !== ""),
3545
+ ...suppressedNits.map((f) => f.id).filter((id) => id !== ""),
3546
+ ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
3547
+ ];
3548
+ const orphanResolveFailed = existingSticky !== null && broadWantsPrior && carriedFindingsMarker(existingSticky.body) !== null && resolvedPrior === null;
3549
+ const broadLive = new Set(broadCurrentIds);
3550
+ const departedIds = priorIdsFrom(resolvedPrior).filter((id) => !broadLive.has(id));
3551
+ const stickyDiscussion = existingSticky !== null ? buildStickyDiscussion(reachable, currentIds, departedIds) : { byFinding: {}, orphaned: {}, orphanedTotal: 0, truncated: {}, orphanedTruncated: {} };
3552
+ const discussionByFinding = stickyDiscussion.byFinding;
3553
+ const orphanedDiscussion = stickyDiscussion.orphaned;
3554
+ const orphanedTotal = stickyDiscussion.orphanedTotal;
3555
+ const discussionTruncated = stickyDiscussion.truncated;
3556
+ const orphanedTruncated = stickyDiscussion.orphanedTruncated;
2891
3557
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
2892
3558
  const clocDiff = input.clocDiffPath ? loadClocDiff(input.clocDiffPath) : void 0;
2893
3559
  const effectiveRoute = input.route ?? envelope?.route;
@@ -2899,7 +3565,13 @@ ${dropNote}` : ""}`,
2899
3565
  }
2900
3566
  if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
2901
3567
  await emptyMechanicLeaveOrNote(existingSticky);
2902
- const stampedFindings2 = stampConvergence(findings, priorConv);
3568
+ const stampedFindings2 = stampConvergence(
3569
+ findings,
3570
+ effectiveRoute === "mechanic" ? mechanicConvergence(
3571
+ priorConv,
3572
+ input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD
3573
+ ) : priorConv
3574
+ );
2903
3575
  const body = formatMarkdown(
2904
3576
  render({
2905
3577
  findings: stampedFindings2,
@@ -2934,7 +3606,13 @@ ${dropNote}` : ""}`,
2934
3606
  unverifiedNoLogs: input.unverifiedNoLogs,
2935
3607
  jsonUrl: input.jsonUrl,
2936
3608
  findingsPointer: findingsBlob(stampedFindings2),
2937
- postedAt: input.postedAt
3609
+ postedAt: input.postedAt,
3610
+ discussionByFinding,
3611
+ orphanedDiscussion,
3612
+ orphanedTotal,
3613
+ discussionTruncated,
3614
+ orphanedTruncated,
3615
+ orphanedUnresolvable: orphanResolveFailed
2938
3616
  })
2939
3617
  );
2940
3618
  await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
@@ -2964,6 +3642,7 @@ ${dropNote}` : ""}`,
2964
3642
  strays,
2965
3643
  inDiff
2966
3644
  } = inlineRequested ? buildInlineComments(visibleFindings, diff, {
3645
+ ...inlinePartition !== null ? { partition: inlinePartition } : {},
2967
3646
  inlineTemplate,
2968
3647
  models: envelope.models.map((m) => m.model),
2969
3648
  findings,
@@ -2981,7 +3660,7 @@ ${dropNote}` : ""}`,
2981
3660
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
2982
3661
  const initialDisposition = !inlineRequested ? { kind: "disabled" } : comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2983
3662
  const currentCounts = computeSeverityCounts(findings.findings);
2984
- const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
3663
+ const currentCodes = computeIdCounts(findings.findings, findings.systemic_problems ?? []);
2985
3664
  const roundNumber = priorRoundCount + 1;
2986
3665
  const convergence = isRound ? buildConvergence(
2987
3666
  findings,
@@ -2990,6 +3669,11 @@ ${dropNote}` : ""}`,
2990
3669
  roundNumber,
2991
3670
  currentCodes,
2992
3671
  input.headSha.slice(0, 12)
3672
+ ) : effectiveRoute === "mechanic" ? (
3673
+ // A mechanic pass means CI failed — whatever the carried prior says, its own stamp must
3674
+ // never read "converged". The threshold-relative critical floor keeps the carried triple
3675
+ // consistent at every threshold; null (no marker) when no prior round exists (issue #224).
3676
+ mechanicConvergence(priorConv, input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD)
2993
3677
  ) : priorConv;
2994
3678
  const stampedFindings = stampConvergence(findings, convergence);
2995
3679
  const currentRoundCount = isRound ? roundNumber : priorRoundCount;
@@ -3011,6 +3695,12 @@ ${dropNote}` : ""}`,
3011
3695
  sameRootNotes,
3012
3696
  answeredNotes: reRaisedNotes,
3013
3697
  answeredReRaiseNote: answeredDropNote,
3698
+ discussionByFinding,
3699
+ orphanedDiscussion,
3700
+ orphanedTotal,
3701
+ discussionTruncated,
3702
+ orphanedTruncated,
3703
+ orphanedUnresolvable: orphanResolveFailed,
3014
3704
  roundCount: currentRoundCount,
3015
3705
  convergenceThreshold: input.convergenceThreshold,
3016
3706
  nitVisibilityFloor: input.nitVisibilityFloor,
@@ -3030,12 +3720,13 @@ ${dropNote}` : ""}`,
3030
3720
 
3031
3721
  > **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
3032
3722
  ` : "";
3033
- const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays) => formatMarkdown(
3723
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays, discussionOverride) => formatMarkdown(
3034
3724
  render({
3035
3725
  ...commonRenderInput,
3036
3726
  ...straysOverride ? { strays: straysOverride } : {},
3037
3727
  ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
3038
3728
  ...unanchoredStrays !== void 0 && unanchoredStrays.length > 0 ? { unanchoredStrays } : {},
3729
+ ...discussionOverride ?? {},
3039
3730
  inlineDisposition,
3040
3731
  reviewUrl: reviewUrl2
3041
3732
  }) + longFilesNote
@@ -3086,13 +3777,32 @@ ${dropNote}` : ""}`,
3086
3777
  await minimizeComments(prNumber, priorInlineComments, ghApi);
3087
3778
  const unanchoredCount = unposted.length;
3088
3779
  const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
3780
+ const finalDiscussion = unanchoredCount > 0 ? buildStickyDiscussion(
3781
+ reachable,
3782
+ [...currentIds, ...unposted.map((f) => f.id).filter((id) => id !== "")],
3783
+ departedIds
3784
+ ) : null;
3785
+ const finalDiscussionOverride = finalDiscussion !== null ? {
3786
+ discussionByFinding: finalDiscussion.byFinding,
3787
+ orphanedDiscussion: finalDiscussion.orphaned,
3788
+ orphanedTotal: finalDiscussion.orphanedTotal,
3789
+ discussionTruncated: finalDiscussion.truncated,
3790
+ orphanedTruncated: finalDiscussion.orphanedTruncated
3791
+ } : void 0;
3089
3792
  if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
3090
3793
  const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
3091
3794
  try {
3092
3795
  await patchComment(
3093
3796
  input.repo,
3094
3797
  stickyRef.id,
3095
- renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount, unposted),
3798
+ renderBody(
3799
+ finalDisposition,
3800
+ reviewUrl,
3801
+ finalStrays,
3802
+ unanchoredCount,
3803
+ unposted,
3804
+ finalDiscussionOverride
3805
+ ),
3096
3806
  ghApi
3097
3807
  );
3098
3808
  process.stderr.write(
@@ -3117,7 +3827,10 @@ ${dropNote}` : ""}`,
3117
3827
  // document deliberately carries every finding, so the GitHub-rejected ones must keep their
3118
3828
  // path-only links here too (issue #231 r2). Unconditional: renderBody's own gate treats an
3119
3829
  // empty array exactly like absence, so the ternary was a duplicated decision (issue #231 r3).
3120
- unposted
3830
+ unposted,
3831
+ // The summary is the sticky's long-lived twin — a rejected finding's rebuilt discussion
3832
+ // trail renders here exactly as on the final patch.
3833
+ finalDiscussionOverride
3121
3834
  )
3122
3835
  );
3123
3836
  };
@@ -3761,7 +4474,7 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
3761
4474
  join(input.outDir, "prior_review.json"),
3762
4475
  prior === null ? "null" : JSON.stringify(prior)
3763
4476
  );
3764
- const seedsFromPrior = input.conclusion === "success" && prior !== null && prior.body !== null && parseReviewedRoute(prior.body) === "full review";
4477
+ const seedsFromPrior = input.conclusion === "success" && prior !== null && prior.body !== null && isFullReviewAncestry(prior.body);
3765
4478
  const resolvedPrior = seedsFromPrior ? await resolvePriorFindings(prior.body, readArtifact) : null;
3766
4479
  const priorFindings = resolvedPrior !== null && typeof resolvedPrior === "object" ? resolvedPrior : null;
3767
4480
  writeFileSync(
@@ -4153,6 +4866,7 @@ var parseScope = (raw) => {
4153
4866
  };
4154
4867
 
4155
4868
  // src/index.ts
4869
+ var resolvedPriorValue = resolveTolerantFindings;
4156
4870
  var readJSON = (path) => {
4157
4871
  try {
4158
4872
  return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
@@ -4930,13 +5644,13 @@ var seedDraftCmd = defineCommand({
4930
5644
  const priorFindings = (() => {
4931
5645
  if (strippedPrior === null || typeof strippedPrior !== "object" || Array.isArray(strippedPrior))
4932
5646
  return strippedPrior;
4933
- const raw = strippedPrior;
4934
- const doc = answeredRegistry !== null && answeredRegistry.length > 0 && Array.isArray(raw["findings"]) ? {
4935
- ...raw,
4936
- findings: raw["findings"].filter(
5647
+ const resolved = resolvedPriorValue(strippedPrior);
5648
+ const doc = resolved === null ? strippedPrior : {
5649
+ ...resolved,
5650
+ findings: answeredRegistry !== null && answeredRegistry.length > 0 ? resolved.findings.filter(
4937
5651
  (f) => !answeredRegistry.some((e) => isAnsweredDrop(f, e))
4938
- )
4939
- } : raw;
5652
+ ) : resolved.findings
5653
+ };
4940
5654
  const carried = doc["scope_metastasis"];
4941
5655
  if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return doc;
4942
5656
  if (doc["verdict"] === "error") return doc;
@@ -4951,7 +5665,7 @@ var seedDraftCmd = defineCommand({
4951
5665
  `
4952
5666
  );
4953
5667
  }
4954
- if (parsedPrior !== null && parseReviewedRoute(priorBody ?? "") === "full review") {
5668
+ if (parsedPrior !== null && isFullReviewAncestry(priorBody ?? "")) {
4955
5669
  try {
4956
5670
  const belowFloor = priorBelowFloorNits(
4957
5671
  parsedPrior,
@@ -4980,22 +5694,27 @@ var seedDraftCmd = defineCommand({
4980
5694
  ([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2)
4981
5695
  )
4982
5696
  ) : priorFindings;
4983
- const accepts = (doc) => validateAgainstSchema(doc, schemaPath).valid && resolve("findings", doc).kind === "ok";
4984
- const seedDoc = accepts(priorFindings) ? priorFindings : accepts(barePrior) ? (process.stderr.write(
4985
- `Note: the in-force schema rejects a carried recoverable field (scope_metastasis/convergence/change_size) \u2014 seeding the prior without it (issue #150 review r2 / #182 review r2)
5697
+ const accepts = (doc) => {
5698
+ const resolved = resolvedPriorValue(doc);
5699
+ return resolved !== null && validateAgainstSchema(resolved, schemaPath).valid ? resolved : null;
5700
+ };
5701
+ const seedDoc = accepts(priorFindings) ?? (() => {
5702
+ const bare = accepts(barePrior);
5703
+ if (bare === null) return null;
5704
+ process.stderr.write(
5705
+ `Note: the in-force schema rejects a carried recoverable field (scope_metastasis/convergence/change_size) \u2014 seeding the prior without it (issue #150 review r2 / #182 review r2)
4986
5706
  `
4987
- ), barePrior) : null;
5707
+ );
5708
+ return bare;
5709
+ })();
4988
5710
  if (seedDoc === null) return false;
4989
- const resolution = resolve("findings", seedDoc);
4990
- if (resolution.kind !== "ok") return false;
4991
- if (isIncompleteFindings(resolution.value)) return false;
4992
- if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
5711
+ if (isIncompleteFindings(seedDoc)) return false;
5712
+ if (!isFullReviewAncestry(priorBody ?? "")) return false;
4993
5713
  writeFileSync(outPath, SEED_SENTINEL);
4994
5714
  writeFileSync(priorContextPath(outPath), `${JSON.stringify(seedDoc, null, 2)}
4995
5715
  `);
4996
- const count = resolution.value.findings.length;
4997
5716
  process.stderr.write(
4998
- `Seeded ${outPath} with the sentinel and wrote the prior review (${String(count)} finding(s)) to ${priorContextPath(outPath)} as context
5717
+ `Seeded ${outPath} with the sentinel and wrote the prior review (${String(seedDoc.findings.length)} finding(s)) to ${priorContextPath(outPath)} as context
4999
5718
  `
5000
5719
  );
5001
5720
  return true;