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

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,
@@ -617,24 +819,21 @@ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.
617
819
  const v = u[k];
618
820
  return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
619
821
  });
620
- var MAX_CODES_PER_ROUND = 8;
621
- var hasCode = (codes, code) => codes !== void 0 && Object.prototype.hasOwnProperty.call(codes, code);
822
+ var MAX_IDS_PER_ROUND = 8;
823
+ var hasId = (ids, code) => ids !== void 0 && Object.prototype.hasOwnProperty.call(ids, code);
622
824
  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
- );
825
+ var normalizeIdCounts = (ids, priorCodes) => {
826
+ const entries = Object.entries(usableCountsMap(ids) ?? {});
628
827
  if (entries.length === 0) return void 0;
629
828
  const sorted = entries.sort((a, b) => {
630
829
  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;
830
+ const aPrior = hasId(priorCodes, a[0]) ? 1 : 0;
831
+ const bPrior = hasId(priorCodes, b[0]) ? 1 : 0;
633
832
  if (aPrior !== bPrior) return bPrior - aPrior;
634
833
  return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
635
834
  });
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);
835
+ const base = sorted.slice(0, MAX_IDS_PER_ROUND);
836
+ const priorKept = sorted.slice(MAX_IDS_PER_ROUND).filter(([code]) => hasId(priorCodes, code)).slice(0, MAX_IDS_PER_ROUND);
638
837
  return Object.fromEntries([...base, ...priorKept]);
639
838
  };
640
839
  var parseRounds = (body) => {
@@ -646,13 +845,13 @@ var parseRounds = (body) => {
646
845
  let priorCodes;
647
846
  for (const u of decoded.filter(isSeverityCounts)) {
648
847
  const rec = u;
649
- const codes = normalizeCodeCounts(rec["codes"], priorCodes);
848
+ const codes = normalizeIdCounts(rec["ids"], priorCodes) ?? normalizeIdCounts(rec["codes"], priorCodes);
650
849
  priorCodes = codes;
651
850
  const sha = rec["sha"];
652
851
  const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
653
852
  const round = rec["round"];
654
853
  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 };
854
+ 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
855
  const record3 = shaStr === void 0 ? base : { ...base, sha: shaStr };
657
856
  kept.push(roundNum === void 0 ? record3 : { ...record3, round: roundNum });
658
857
  }
@@ -666,29 +865,31 @@ var roundsSummary = (rounds, count = rounds.length) => {
666
865
  const trajectory = cells.length === 0 ? "" : rounds.length > TRAJECTORY_SCORES ? `\u2026 \u2192 ${cells.join(" \u2192 ")}` : cells.join(" \u2192 ");
667
866
  return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
668
867
  };
669
- var computeCodeCounts = (findings, systemic = []) => {
868
+ var computeIdCounts = (findings, systemic = []) => {
670
869
  const counts = /* @__PURE__ */ new Map();
671
870
  for (const code of [
672
- ...findings.map((f) => f.code),
673
- ...systemic.flatMap((s) => [s.code, ...s.finding_codes ?? []])
871
+ // resolveFindingId: an EMPTY id counts under the same synthesized key the answered match uses —
872
+ // one finding can never be simultaneously tracked (dropped-by-synthesis) and uncounted.
873
+ ...findings.map((f) => resolveFindingId(f)),
874
+ ...systemic.flatMap((s) => [s.id, ...s.finding_ids ?? []])
674
875
  ]) {
675
876
  if (code === void 0 || code === "") continue;
676
877
  counts.set(code, (counts.get(code) ?? 0) + 1);
677
878
  }
678
879
  return Object.fromEntries(counts);
679
880
  };
680
- var consecutiveCodeStreaks = (rounds) => {
881
+ var consecutiveIdStreaks = (rounds) => {
681
882
  const entries = [];
682
883
  if (rounds.length === 0) return {};
683
- const lastCodes = rounds[rounds.length - 1]?.codes;
884
+ const lastCodes = rounds[rounds.length - 1]?.ids;
684
885
  if (lastCodes === void 0) return {};
685
886
  for (const code of Object.keys(lastCodes)) {
686
887
  let streak = 0;
687
888
  let startIndex = rounds.length;
688
889
  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)) {
890
+ const codes = rounds[i]?.ids;
891
+ if (codes === void 0 || !hasId(codes, code)) break;
892
+ if (i > 0 && rounds[i]?.sha !== void 0 && rounds[i]?.sha === rounds[i - 1]?.sha && hasId(rounds[i - 1]?.ids, code)) {
692
893
  continue;
693
894
  }
694
895
  streak += 1;
@@ -702,12 +903,12 @@ var consecutiveCodeStreaks = (rounds) => {
702
903
  };
703
904
  var DEFAULT_METASTASIS_STREAK = 3;
704
905
  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 }));
906
+ 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
907
  var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
707
- const flagged = flaggedCodeStreaks(rounds, minStreak);
908
+ const flagged = flaggedIdStreaks(rounds, minStreak);
708
909
  if (flagged.length === 0) return "";
709
910
  const lines = flagged.map(
710
- ({ code, streak }) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(streak.streak)} consecutive rounds.`
911
+ ({ id, streak }) => `> **\`${escapeCodeBackticks(id)}\`** \u2014 findings in ${String(streak.streak)} consecutive rounds.`
711
912
  );
712
913
  return [
713
914
  "> [!WARNING]",
@@ -716,27 +917,27 @@ var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
716
917
  ].join("\n");
717
918
  };
718
919
  var computeScopeMetastasis = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
719
- const flagged = flaggedCodeStreaks(rounds, minStreak);
920
+ const flagged = flaggedIdStreaks(rounds, minStreak);
720
921
  if (flagged.length === 0) return null;
721
922
  return {
722
923
  decision_prompt: SCOPE_METASTASIS_DECISION_PROMPT,
723
- recurring: flagged.map(({ code, streak }) => ({
724
- code,
924
+ recurring: flagged.map(({ id, streak }) => ({
925
+ id,
725
926
  consecutive_rounds: streak.streak,
726
927
  start_round: streak.startRound
727
928
  }))
728
929
  };
729
930
  };
730
931
  var computeSameRootNotes = (priorRounds, findings, currentSha) => {
731
- const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
932
+ const codes = findings.map((f) => resolveFindingId(f));
732
933
  const entries = [];
733
934
  for (const code of codes) {
734
935
  let lastRound = 0;
735
936
  for (let i = priorRounds.length - 1; i >= 0; i--) {
736
937
  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))
938
+ if (i > 0 && priorRounds[i]?.sha !== void 0 && priorRounds[i]?.sha === priorRounds[i - 1]?.sha && hasId(priorRounds[i - 1]?.ids, code))
738
939
  continue;
739
- const count = priorRounds[i]?.codes?.[code];
940
+ const count = priorRounds[i]?.ids?.[code];
740
941
  if (count !== void 0 && count > 0) {
741
942
  lastRound = priorRounds[i]?.round ?? i + 1;
742
943
  break;
@@ -770,13 +971,13 @@ var convergenceScore = (doc, threshold) => round2(
770
971
  0
771
972
  )
772
973
  );
773
- var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, codes = {}, sha) => {
974
+ var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, ids = {}, sha) => {
774
975
  const score = convergenceScore(doc, threshold);
775
- const normalized = normalizeCodeCounts(codes, priorRounds[priorRounds.length - 1]?.codes);
976
+ const normalized = normalizeIdCounts(ids, priorRounds[priorRounds.length - 1]?.ids);
776
977
  const current = {
777
978
  round,
778
979
  score,
779
- ...normalized !== void 0 ? { codes: { ...normalized } } : {},
980
+ ...normalized !== void 0 ? { ids: { ...normalized } } : {},
780
981
  ...sha !== void 0 ? { sha } : {}
781
982
  };
782
983
  const rounds = [...priorRounds, current].slice(-64);
@@ -795,11 +996,16 @@ var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
795
996
  if (!isBelowVisibilityFloor(rec, floor)) continue;
796
997
  const title = rec["title"];
797
998
  if (typeof title !== "string") continue;
798
- const code = typeof rec["code"] === "string" && rec["code"] !== "" ? rec["code"] : void 0;
799
999
  const path = typeof rec["path"] === "string" ? rec["path"] : void 0;
1000
+ const id = resolveRuleId({
1001
+ id: typeof rec["id"] === "string" ? rec["id"] : void 0,
1002
+ code: typeof rec["code"] === "string" ? rec["code"] : void 0,
1003
+ path,
1004
+ title
1005
+ });
800
1006
  nits.push({
801
1007
  title,
802
- ...code !== void 0 ? { code } : {},
1008
+ ...id !== void 0 ? { id } : {},
803
1009
  ...path !== void 0 ? { path } : {}
804
1010
  });
805
1011
  }
@@ -852,8 +1058,33 @@ var parseSurfaceSignal = (doc) => {
852
1058
  convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
853
1059
  };
854
1060
  };
1061
+ var withLegacyConvergenceIds = (raw) => {
1062
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
1063
+ const rec = raw;
1064
+ const rounds = rec["rounds"];
1065
+ if (!Array.isArray(rounds)) return raw;
1066
+ const needsMigration = (r) => {
1067
+ if (typeof r !== "object" || r === null) return false;
1068
+ const round = r;
1069
+ return round["codes"] !== void 0 || usableCountsMap(round["ids"]) === void 0;
1070
+ };
1071
+ if (!rounds.some(needsMigration)) return raw;
1072
+ const mapped = rounds.map((r) => {
1073
+ if (typeof r !== "object" || r === null) return r;
1074
+ const round = r;
1075
+ const ids = usableCountsMap(round["ids"]) ?? usableCountsMap(round["codes"]);
1076
+ if (ids === void 0) {
1077
+ return round["ids"] !== void 0 || round["codes"] !== void 0 ? Object.fromEntries(Object.entries(round).filter(([k]) => k !== "ids" && k !== "codes")) : round;
1078
+ }
1079
+ const rest = Object.fromEntries(
1080
+ Object.entries(round).filter(([k]) => k !== "ids" && k !== "codes")
1081
+ );
1082
+ return { ...rest, ids };
1083
+ });
1084
+ return { ...rec, rounds: mapped };
1085
+ };
855
1086
  var validStampedConvergence = (raw) => {
856
- const decoded = ConvergenceCodec.decode(raw);
1087
+ const decoded = ConvergenceCodec.decode(withLegacyConvergenceIds(raw));
857
1088
  return decoded._tag === "Right" && decoded.right.rounds !== void 0 && decoded.right.rounds.length > 0 ? decoded.right : null;
858
1089
  };
859
1090
  var parseConvergence = (priorDoc) => {
@@ -878,7 +1109,7 @@ var parseConvergenceMarker = (body) => {
878
1109
  };
879
1110
  var roundRecordsToConvergenceRounds = (records) => records.map((r, i) => ({
880
1111
  round: r.round ?? i + 1,
881
- ...r.codes !== void 0 ? { codes: { ...r.codes } } : {},
1112
+ ...r.ids !== void 0 ? { ids: { ...r.ids } } : {},
882
1113
  ...r.sha !== void 0 ? { sha: r.sha } : {}
883
1114
  }));
884
1115
  var priorTrajectory = (priorDoc, priorBody) => parseConvergence(priorDoc)?.rounds ?? parseConvergenceMarker(priorBody)?.rounds ?? roundRecordsToConvergenceRounds(parseRounds(priorBody));
@@ -1112,12 +1343,22 @@ var answeredRegistryFrom = (comments, botLogin) => {
1112
1343
  const title = first["title"];
1113
1344
  const description = first["description"];
1114
1345
  const reasoning = first["reasoning"];
1115
- const code = first["code"];
1346
+ const id = first["id"];
1347
+ const legacyCode = first["code"];
1116
1348
  const severity = first["severity"];
1117
1349
  const path = first["path"];
1118
1350
  const patch = first["patch"];
1119
1351
  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 : "",
1352
+ // resolveRuleId: the ONE legacy-spelling precedence the upcast, this reader, and the
1353
+ // below-floor nit reader share — a pre-id marker (or one written before the migration)
1354
+ // carries `code`; a codeless one resolves to the same synthesized id the registry's
1355
+ // legacy upcast derives, so the entry keys to the identical claim on the next round.
1356
+ code: resolveRuleId({
1357
+ id: typeof id === "string" ? id : void 0,
1358
+ code: typeof legacyCode === "string" ? legacyCode : void 0,
1359
+ path,
1360
+ title
1361
+ }) ?? synthesizedFindingId(path, title),
1121
1362
  title,
1122
1363
  description,
1123
1364
  reasoning,
@@ -1159,29 +1400,31 @@ var answeredRegistryFrom = (comments, botLogin) => {
1159
1400
  for (const entry of [...entries].sort(
1160
1401
  (a, b) => (b.repliedAt ?? "").localeCompare(a.repliedAt ?? "") || b.replyId - a.replyId
1161
1402
  )) {
1162
- const key2 = answeredNoteKey(entry);
1403
+ const key2 = answeredNoteKey({ id: entry.code, title: entry.title });
1163
1404
  if (!byKey.has(key2)) byKey.set(key2, entry);
1164
1405
  }
1165
1406
  return [...byKey.values()];
1166
1407
  };
1167
- var matches = (f, e) => f.code !== void 0 && f.code !== "" ? e.code === f.code : e.code === "" && e.title === f.title;
1408
+ var matches = (f, e) => e.code === resolveFindingId(f) || isSynthesizedFindingId(e.code) && e.title === f.title;
1168
1409
  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
1410
  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}`;
1411
+ var answeredNoteKey = (f) => f.id !== "" ? f.id : `title:${f.title}`;
1171
1412
  var answeredNote = (e) => `Re-raised; prior answer at ${e.replyUrl} by ${e.replyAuthor} \u2014 cite the new evidence that invalidates it.`;
1172
1413
  var applyAnswered = (findings, registry) => {
1173
1414
  const kept = [];
1174
1415
  const noteEntries = [];
1175
- const droppedByKey = /* @__PURE__ */ new Map();
1416
+ const droppedByEntry = /* @__PURE__ */ new Map();
1417
+ const droppedFindingIds = [];
1176
1418
  let droppedCount = 0;
1177
1419
  for (const f of findings) {
1178
- const entry = registry.find((e) => matches(f, e));
1420
+ const entry = registry.find((e) => e.code === resolveFindingId(f)) ?? registry.find((e) => isSynthesizedFindingId(e.code) && e.title === f.title);
1179
1421
  if (entry === void 0) {
1180
1422
  kept.push(f);
1181
1423
  continue;
1182
1424
  }
1183
1425
  if (isAnsweredDrop(f, entry)) {
1184
- droppedByKey.set(answeredNoteKey(f), entry);
1426
+ droppedByEntry.set(entry.replyId, entry);
1427
+ droppedFindingIds.push(resolveFindingId(f));
1185
1428
  droppedCount += 1;
1186
1429
  } else {
1187
1430
  kept.push(f);
@@ -1191,7 +1434,8 @@ var applyAnswered = (findings, registry) => {
1191
1434
  return {
1192
1435
  findings: kept,
1193
1436
  reRaisedNotes: Object.fromEntries(noteEntries),
1194
- verbatimReRaised: [...droppedByKey.values()],
1437
+ verbatimReRaised: [...droppedByEntry.values()],
1438
+ droppedFindingIds,
1195
1439
  droppedCount
1196
1440
  };
1197
1441
  };
@@ -1262,7 +1506,10 @@ var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1262
1506
  ...f,
1263
1507
  title: escapePipes(f.title),
1264
1508
  path: escapeCodeBackticks(f.path),
1265
- ...f.code !== void 0 ? { code: escapeCodeBackticks(f.code), codeKey: f.code } : {},
1509
+ id: escapeCodeBackticks(f.id),
1510
+ // The RESOLVED id: the same-root notes are keyed on resolveFindingId (an empty id resolves to
1511
+ // its synthesized key), so the lookup must not miss the note on the raw id.
1512
+ idKey: resolveFindingId(f),
1266
1513
  ...f.code_url !== void 0 ? { code_url: linkSafeUrl(f.code_url) } : {},
1267
1514
  rangeLabel: lineRange(f.start_line, f.end_line, "\u2013"),
1268
1515
  ...permalink !== void 0 ? { permalink, permalinkAnchored: anchored } : {},
@@ -1273,7 +1520,7 @@ var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1273
1520
  var commentSafe = (text) => text.replace(/--+(?=>)/g, (dashes) => `${dashes}\u200B`);
1274
1521
  var sanitizeSuppressedNit = (f) => ({
1275
1522
  title: escapeCodeBackticks(f.title),
1276
- ...f.code !== void 0 ? { code: escapeCodeBackticks(f.code) } : {},
1523
+ id: escapeCodeBackticks(f.id),
1277
1524
  ...f.code_url !== void 0 ? { codeUrl: linkSafeUrl(f.code_url) } : {},
1278
1525
  path: commentSafe(escapeCodeBackticks(f.path)),
1279
1526
  startLine: f.start_line,
@@ -1294,10 +1541,10 @@ var carriedLines = (f) => [
1294
1541
  var sanitizeSystemic = (s) => ({
1295
1542
  ...s,
1296
1543
  title: escapePipes(s.title),
1297
- ...s.code !== void 0 ? { code: escapeCodeBackticks(s.code) } : {},
1544
+ ...s.id !== void 0 ? { id: escapeCodeBackticks(s.id) } : {},
1298
1545
  ...s.code_url !== void 0 ? { code_url: linkSafeUrl(s.code_url) } : {},
1299
1546
  ...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
1300
- ...s.finding_codes !== void 0 ? { finding_codes: s.finding_codes.map(escapeCodeBackticks) } : {}
1547
+ ...s.finding_ids !== void 0 ? { finding_ids: s.finding_ids.map(escapeCodeBackticks) } : {}
1301
1548
  });
1302
1549
  var emptySeverityCounts = () => ({
1303
1550
  critical: 0,
@@ -1330,8 +1577,8 @@ var render = (input) => {
1330
1577
  const advisoryAllowed = isFullReviewRound;
1331
1578
  const suppressedBudget = (input.suppressedNits ?? []).map(sanitizeSuppressedNit).reduce(
1332
1579
  (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);
1580
+ const size = 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.
1581
+ n.id.length * 2 + 2 + (n.codeUrl !== void 0 ? n.codeUrl.length + 4 : 0) + String(n.startLine).length * 2 + String(n.endLine).length + (n.side !== void 0 ? n.side.length + 2 : 0);
1335
1582
  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
1583
  },
1337
1584
  { list: [], used: 0, dropped: 0 }
@@ -1483,11 +1730,7 @@ var buildInlineComments = (findings, diff, context) => {
1483
1730
  const { inDiff, strays } = partitionFindings(findings, index);
1484
1731
  const eta = new Eta({ autoTrim: false });
1485
1732
  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
- };
1733
+ const noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1491
1734
  const comments = inDiff.map((f) => {
1492
1735
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
1493
1736
  const clipProse = fullFindings !== void 0 && findingPayload(f, fullFindings.schema_version).length > INLINE_PROSE_CLIP_THRESHOLD_CHARS;
@@ -1499,8 +1742,8 @@ var buildInlineComments = (findings, diff, context) => {
1499
1742
  reasoning: clipText(f.reasoning, BODY_CLIP_CHARS),
1500
1743
  ...f.patch != null ? { patch: clipText(f.patch, BODY_CLIP_CHARS) } : {}
1501
1744
  } : f;
1502
- const sameRootNote = noteFor(f, context.sameRootNotes);
1503
- const answeredNote2 = noteFor(f, context.answeredNotes);
1745
+ const sameRootNote = noteFor(context.sameRootNotes, resolveFindingId(f));
1746
+ const answeredNote2 = noteFor(context.answeredNotes, answeredNoteKey(f));
1504
1747
  const comment = {
1505
1748
  path: f.path,
1506
1749
  line: f.end_line,
@@ -1944,33 +2187,47 @@ var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
1944
2187
  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
2188
  );
1946
2189
  var identity = (decoded) => decoded;
2190
+ var legacyFindingsCodec = FindingsCodecV09;
2191
+ var legacyFindingsNormalize = (doc) => normalizeV09(doc);
1947
2192
  var findingsTable = [
1948
2193
  {
1949
2194
  minor: "0.4",
1950
2195
  defaultVersion: "0.4.0",
1951
- schemaFile: "findings.schema.json",
1952
- codec: FindingsCodec,
1953
- normalize: identity,
2196
+ // The frozen tolerant-in legacy schema, NOT the live 0.10 file: every ajv-gated channel
2197
+ // (validate --schema-version, the extraction ladder) dispatches the RAW doc's declared minor
2198
+ // through schemaPathFor, so the ajv gate must accept exactly what the tolerant legacy codec
2199
+ // accepts — the live file (id required) would reject the legacy docs the upcast promises to read.
2200
+ schemaFile: "v0.9/findings.schema.json",
2201
+ codec: legacyFindingsCodec,
2202
+ normalize: legacyFindingsNormalize,
1954
2203
  latest: false
1955
2204
  },
1956
2205
  {
1957
2206
  minor: "0.5",
1958
2207
  defaultVersion: "0.5.0",
1959
- schemaFile: "findings.schema.json",
1960
- codec: FindingsCodec,
1961
- normalize: identity,
2208
+ schemaFile: "v0.9/findings.schema.json",
2209
+ codec: legacyFindingsCodec,
2210
+ normalize: legacyFindingsNormalize,
1962
2211
  latest: false
1963
2212
  },
1964
2213
  {
1965
2214
  minor: "0.6",
1966
2215
  defaultVersion: "0.6.0",
1967
- schemaFile: "findings.schema.json",
1968
- codec: FindingsCodec,
1969
- normalize: identity,
2216
+ schemaFile: "v0.9/findings.schema.json",
2217
+ codec: legacyFindingsCodec,
2218
+ normalize: legacyFindingsNormalize,
1970
2219
  latest: false
1971
2220
  },
1972
2221
  {
1973
2222
  minor: "0.9",
2223
+ defaultVersion: "0.9.0",
2224
+ schemaFile: "v0.9/findings.schema.json",
2225
+ codec: legacyFindingsCodec,
2226
+ normalize: legacyFindingsNormalize,
2227
+ latest: false
2228
+ },
2229
+ {
2230
+ minor: "0.10",
1974
2231
  defaultVersion: DEFAULT_SCHEMA_VERSION,
1975
2232
  schemaFile: "findings.schema.json",
1976
2233
  codec: FindingsCodec,
@@ -2064,6 +2321,13 @@ var resolvers = {
2064
2321
  prices: (raw) => resolveSingleVersion("prices", raw)
2065
2322
  };
2066
2323
  var resolve = (kind, raw) => resolvers[kind](raw);
2324
+ var resolveTolerantFindings = (doc) => {
2325
+ const r = resolveFindings(doc);
2326
+ if (r.kind === "ok") return r.value;
2327
+ if (r.kind === "unsupported-version") return null;
2328
+ const legacy = FindingsCodecV09.decode(doc);
2329
+ return legacy._tag === "Right" ? normalizeV09(legacy.right) : null;
2330
+ };
2067
2331
  var MAX_BUFFER = 100 * 1024 * 1024;
2068
2332
  var MAX_TIMEOUT_MS = 2147483647;
2069
2333
  var parseTimeoutMs = (raw, fallback) => {
@@ -2861,15 +3125,16 @@ ${dropNote}` : ""}`,
2861
3125
  const reRaisedNotes = answeredFilter.reRaisedNotes;
2862
3126
  const verbatimReRaised = answeredFilter.verbatimReRaised;
2863
3127
  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)));
3128
+ const droppedIds = /* @__PURE__ */ new Set([
3129
+ ...verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []),
3130
+ ...answeredFilter.droppedFindingIds.filter((id) => id !== "")
3131
+ ]);
3132
+ const keptCodes = new Set(answeredFilter.findings.map((f) => f.id));
3133
+ const trulyDropped = new Set([...droppedIds].filter((c) => !keptCodes.has(c)));
2869
3134
  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 };
3135
+ if (s.finding_ids === void 0) return s;
3136
+ const codes = s.finding_ids.filter((c) => !trulyDropped.has(c));
3137
+ return codes.length === s.finding_ids.length ? s : { ...s, finding_ids: codes };
2873
3138
  });
2874
3139
  const findings = {
2875
3140
  ...loadedFindings,
@@ -2880,10 +3145,13 @@ ${dropNote}` : ""}`,
2880
3145
  const priorDocForNits = roundHasNit && existingSticky !== null && isFullReviewSticky(existingSticky.body) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
2881
3146
  const priorSuppressedKeys = new Set(
2882
3147
  priorBelowFloorNits(priorDocForNits, input.nitVisibilityFloor).map(
2883
- (n) => answeredNoteKey({ code: n.code, title: n.title })
3148
+ (n) => answeredNoteKey({ id: n.id ?? "", title: n.title })
2884
3149
  )
2885
3150
  );
2886
- const isSuppressedNit = (f) => f.severity === "nit" && (isBelowVisibilityFloor(f, input.nitVisibilityFloor) || priorSuppressedKeys.has(answeredNoteKey(f)));
3151
+ 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
3152
+ // synthesized prior), the answeredNoteKey form (an empty-id prior), and the bare title: key
3153
+ // (a pathless codeless prior).
3154
+ priorSuppressedKeys.has(resolveFindingId(f)) || priorSuppressedKeys.has(answeredNoteKey(f)) || priorSuppressedKeys.has(`title:${f.title}`));
2887
3155
  const suppressedNits = findings.findings.filter(isSuppressedNit);
2888
3156
  const visibleFindings = findings.findings.filter((f) => !isSuppressedNit(f));
2889
3157
  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._" : "");
@@ -2981,7 +3249,7 @@ ${dropNote}` : ""}`,
2981
3249
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
2982
3250
  const initialDisposition = !inlineRequested ? { kind: "disabled" } : comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2983
3251
  const currentCounts = computeSeverityCounts(findings.findings);
2984
- const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
3252
+ const currentCodes = computeIdCounts(findings.findings, findings.systemic_problems ?? []);
2985
3253
  const roundNumber = priorRoundCount + 1;
2986
3254
  const convergence = isRound ? buildConvergence(
2987
3255
  findings,
@@ -4153,6 +4421,7 @@ var parseScope = (raw) => {
4153
4421
  };
4154
4422
 
4155
4423
  // src/index.ts
4424
+ var resolvedPriorValue = resolveTolerantFindings;
4156
4425
  var readJSON = (path) => {
4157
4426
  try {
4158
4427
  return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
@@ -4930,13 +5199,13 @@ var seedDraftCmd = defineCommand({
4930
5199
  const priorFindings = (() => {
4931
5200
  if (strippedPrior === null || typeof strippedPrior !== "object" || Array.isArray(strippedPrior))
4932
5201
  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(
5202
+ const resolved = resolvedPriorValue(strippedPrior);
5203
+ const doc = resolved === null ? strippedPrior : {
5204
+ ...resolved,
5205
+ findings: answeredRegistry !== null && answeredRegistry.length > 0 ? resolved.findings.filter(
4937
5206
  (f) => !answeredRegistry.some((e) => isAnsweredDrop(f, e))
4938
- )
4939
- } : raw;
5207
+ ) : resolved.findings
5208
+ };
4940
5209
  const carried = doc["scope_metastasis"];
4941
5210
  if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return doc;
4942
5211
  if (doc["verdict"] === "error") return doc;
@@ -4980,22 +5249,27 @@ var seedDraftCmd = defineCommand({
4980
5249
  ([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2)
4981
5250
  )
4982
5251
  ) : 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)
5252
+ const accepts = (doc) => {
5253
+ const resolved = resolvedPriorValue(doc);
5254
+ return resolved !== null && validateAgainstSchema(resolved, schemaPath).valid ? resolved : null;
5255
+ };
5256
+ const seedDoc = accepts(priorFindings) ?? (() => {
5257
+ const bare = accepts(barePrior);
5258
+ if (bare === null) return null;
5259
+ process.stderr.write(
5260
+ `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
5261
  `
4987
- ), barePrior) : null;
5262
+ );
5263
+ return bare;
5264
+ })();
4988
5265
  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;
5266
+ if (isIncompleteFindings(seedDoc)) return false;
4992
5267
  if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
4993
5268
  writeFileSync(outPath, SEED_SENTINEL);
4994
5269
  writeFileSync(priorContextPath(outPath), `${JSON.stringify(seedDoc, null, 2)}
4995
5270
  `);
4996
- const count = resolution.value.findings.length;
4997
5271
  process.stderr.write(
4998
- `Seeded ${outPath} with the sentinel and wrote the prior review (${String(count)} finding(s)) to ${priorContextPath(outPath)} as context
5272
+ `Seeded ${outPath} with the sentinel and wrote the prior review (${String(seedDoc.findings.length)} finding(s)) to ${priorContextPath(outPath)} as context
4999
5273
  `
5000
5274
  );
5001
5275
  return true;