@jphutchins/code-review 0.1.0-alpha.53 → 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,
@@ -568,6 +770,7 @@ var findingPayload = (finding, schemaVersion) => Buffer.from(
568
770
  JSON.stringify({ schema_version: schemaVersion, findings: [finding] }),
569
771
  "utf-8"
570
772
  ).toString("base64");
773
+ var lineRange = (startLine, endLine, separator) => startLine === endLine ? String(startLine) : `${String(startLine)}${separator}${String(endLine)}`;
571
774
  var findingPointer = (finding, schemaVersion, jsonUrl) => {
572
775
  const payload = findingPayload(finding, schemaVersion);
573
776
  if (payload.length > INLINE_EMBED_LIMIT_CHARS) {
@@ -616,24 +819,21 @@ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.
616
819
  const v = u[k];
617
820
  return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
618
821
  });
619
- var MAX_CODES_PER_ROUND = 8;
620
- 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);
621
824
  var escapeCodeBackticks = (code) => code.replace(/`/g, "-").replace(/\r?\n/g, " ");
622
- var normalizeCodeCounts = (codes, priorCodes) => {
623
- if (typeof codes !== "object" || codes === null || Array.isArray(codes)) return void 0;
624
- const entries = Object.entries(codes).filter(
625
- (e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
626
- );
825
+ var normalizeIdCounts = (ids, priorCodes) => {
826
+ const entries = Object.entries(usableCountsMap(ids) ?? {});
627
827
  if (entries.length === 0) return void 0;
628
828
  const sorted = entries.sort((a, b) => {
629
829
  if (b[1] !== a[1]) return b[1] - a[1];
630
- const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
631
- 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;
632
832
  if (aPrior !== bPrior) return bPrior - aPrior;
633
833
  return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
634
834
  });
635
- const base = sorted.slice(0, MAX_CODES_PER_ROUND);
636
- 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);
637
837
  return Object.fromEntries([...base, ...priorKept]);
638
838
  };
639
839
  var parseRounds = (body) => {
@@ -645,13 +845,13 @@ var parseRounds = (body) => {
645
845
  let priorCodes;
646
846
  for (const u of decoded.filter(isSeverityCounts)) {
647
847
  const rec = u;
648
- const codes = normalizeCodeCounts(rec["codes"], priorCodes);
848
+ const codes = normalizeIdCounts(rec["ids"], priorCodes) ?? normalizeIdCounts(rec["codes"], priorCodes);
649
849
  priorCodes = codes;
650
850
  const sha = rec["sha"];
651
851
  const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
652
852
  const round = rec["round"];
653
853
  const roundNum = typeof round === "number" && Number.isSafeInteger(round) && round >= 1 ? round : void 0;
654
- 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 };
655
855
  const record3 = shaStr === void 0 ? base : { ...base, sha: shaStr };
656
856
  kept.push(roundNum === void 0 ? record3 : { ...record3, round: roundNum });
657
857
  }
@@ -665,29 +865,31 @@ var roundsSummary = (rounds, count = rounds.length) => {
665
865
  const trajectory = cells.length === 0 ? "" : rounds.length > TRAJECTORY_SCORES ? `\u2026 \u2192 ${cells.join(" \u2192 ")}` : cells.join(" \u2192 ");
666
866
  return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
667
867
  };
668
- var computeCodeCounts = (findings, systemic = []) => {
868
+ var computeIdCounts = (findings, systemic = []) => {
669
869
  const counts = /* @__PURE__ */ new Map();
670
870
  for (const code of [
671
- ...findings.map((f) => f.code),
672
- ...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 ?? []])
673
875
  ]) {
674
876
  if (code === void 0 || code === "") continue;
675
877
  counts.set(code, (counts.get(code) ?? 0) + 1);
676
878
  }
677
879
  return Object.fromEntries(counts);
678
880
  };
679
- var consecutiveCodeStreaks = (rounds) => {
881
+ var consecutiveIdStreaks = (rounds) => {
680
882
  const entries = [];
681
883
  if (rounds.length === 0) return {};
682
- const lastCodes = rounds[rounds.length - 1]?.codes;
884
+ const lastCodes = rounds[rounds.length - 1]?.ids;
683
885
  if (lastCodes === void 0) return {};
684
886
  for (const code of Object.keys(lastCodes)) {
685
887
  let streak = 0;
686
888
  let startIndex = rounds.length;
687
889
  for (let i = rounds.length - 1; i >= 0; i--) {
688
- const codes = rounds[i]?.codes;
689
- if (codes === void 0 || !hasCode(codes, code)) break;
690
- 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)) {
691
893
  continue;
692
894
  }
693
895
  streak += 1;
@@ -701,12 +903,12 @@ var consecutiveCodeStreaks = (rounds) => {
701
903
  };
702
904
  var DEFAULT_METASTASIS_STREAK = 3;
703
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.";
704
- 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 }));
705
907
  var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
706
- const flagged = flaggedCodeStreaks(rounds, minStreak);
908
+ const flagged = flaggedIdStreaks(rounds, minStreak);
707
909
  if (flagged.length === 0) return "";
708
910
  const lines = flagged.map(
709
- ({ 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.`
710
912
  );
711
913
  return [
712
914
  "> [!WARNING]",
@@ -715,27 +917,27 @@ var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
715
917
  ].join("\n");
716
918
  };
717
919
  var computeScopeMetastasis = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
718
- const flagged = flaggedCodeStreaks(rounds, minStreak);
920
+ const flagged = flaggedIdStreaks(rounds, minStreak);
719
921
  if (flagged.length === 0) return null;
720
922
  return {
721
923
  decision_prompt: SCOPE_METASTASIS_DECISION_PROMPT,
722
- recurring: flagged.map(({ code, streak }) => ({
723
- code,
924
+ recurring: flagged.map(({ id, streak }) => ({
925
+ id,
724
926
  consecutive_rounds: streak.streak,
725
927
  start_round: streak.startRound
726
928
  }))
727
929
  };
728
930
  };
729
931
  var computeSameRootNotes = (priorRounds, findings, currentSha) => {
730
- const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
932
+ const codes = findings.map((f) => resolveFindingId(f));
731
933
  const entries = [];
732
934
  for (const code of codes) {
733
935
  let lastRound = 0;
734
936
  for (let i = priorRounds.length - 1; i >= 0; i--) {
735
937
  if (currentSha !== void 0 && priorRounds[i]?.sha === currentSha) continue;
736
- 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))
737
939
  continue;
738
- const count = priorRounds[i]?.codes?.[code];
940
+ const count = priorRounds[i]?.ids?.[code];
739
941
  if (count !== void 0 && count > 0) {
740
942
  lastRound = priorRounds[i]?.round ?? i + 1;
741
943
  break;
@@ -769,13 +971,13 @@ var convergenceScore = (doc, threshold) => round2(
769
971
  0
770
972
  )
771
973
  );
772
- 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) => {
773
975
  const score = convergenceScore(doc, threshold);
774
- const normalized = normalizeCodeCounts(codes, priorRounds[priorRounds.length - 1]?.codes);
976
+ const normalized = normalizeIdCounts(ids, priorRounds[priorRounds.length - 1]?.ids);
775
977
  const current = {
776
978
  round,
777
979
  score,
778
- ...normalized !== void 0 ? { codes: { ...normalized } } : {},
980
+ ...normalized !== void 0 ? { ids: { ...normalized } } : {},
779
981
  ...sha !== void 0 ? { sha } : {}
780
982
  };
781
983
  const rounds = [...priorRounds, current].slice(-64);
@@ -794,11 +996,16 @@ var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
794
996
  if (!isBelowVisibilityFloor(rec, floor)) continue;
795
997
  const title = rec["title"];
796
998
  if (typeof title !== "string") continue;
797
- const code = typeof rec["code"] === "string" && rec["code"] !== "" ? rec["code"] : void 0;
798
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
+ });
799
1006
  nits.push({
800
1007
  title,
801
- ...code !== void 0 ? { code } : {},
1008
+ ...id !== void 0 ? { id } : {},
802
1009
  ...path !== void 0 ? { path } : {}
803
1010
  });
804
1011
  }
@@ -851,8 +1058,33 @@ var parseSurfaceSignal = (doc) => {
851
1058
  convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
852
1059
  };
853
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
+ };
854
1086
  var validStampedConvergence = (raw) => {
855
- const decoded = ConvergenceCodec.decode(raw);
1087
+ const decoded = ConvergenceCodec.decode(withLegacyConvergenceIds(raw));
856
1088
  return decoded._tag === "Right" && decoded.right.rounds !== void 0 && decoded.right.rounds.length > 0 ? decoded.right : null;
857
1089
  };
858
1090
  var parseConvergence = (priorDoc) => {
@@ -877,7 +1109,7 @@ var parseConvergenceMarker = (body) => {
877
1109
  };
878
1110
  var roundRecordsToConvergenceRounds = (records) => records.map((r, i) => ({
879
1111
  round: r.round ?? i + 1,
880
- ...r.codes !== void 0 ? { codes: { ...r.codes } } : {},
1112
+ ...r.ids !== void 0 ? { ids: { ...r.ids } } : {},
881
1113
  ...r.sha !== void 0 ? { sha: r.sha } : {}
882
1114
  }));
883
1115
  var priorTrajectory = (priorDoc, priorBody) => parseConvergence(priorDoc)?.rounds ?? parseConvergenceMarker(priorBody)?.rounds ?? roundRecordsToConvergenceRounds(parseRounds(priorBody));
@@ -1111,12 +1343,22 @@ var answeredRegistryFrom = (comments, botLogin) => {
1111
1343
  const title = first["title"];
1112
1344
  const description = first["description"];
1113
1345
  const reasoning = first["reasoning"];
1114
- const code = first["code"];
1346
+ const id = first["id"];
1347
+ const legacyCode = first["code"];
1115
1348
  const severity = first["severity"];
1116
1349
  const path = first["path"];
1117
1350
  const patch = first["patch"];
1118
1351
  return typeof title === "string" && typeof description === "string" && typeof reasoning === "string" && typeof path === "string" && (severity === "critical" || severity === "major" || severity === "minor" || severity === "nit") ? {
1119
- 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),
1120
1362
  title,
1121
1363
  description,
1122
1364
  reasoning,
@@ -1158,29 +1400,31 @@ var answeredRegistryFrom = (comments, botLogin) => {
1158
1400
  for (const entry of [...entries].sort(
1159
1401
  (a, b) => (b.repliedAt ?? "").localeCompare(a.repliedAt ?? "") || b.replyId - a.replyId
1160
1402
  )) {
1161
- const key2 = answeredNoteKey(entry);
1403
+ const key2 = answeredNoteKey({ id: entry.code, title: entry.title });
1162
1404
  if (!byKey.has(key2)) byKey.set(key2, entry);
1163
1405
  }
1164
1406
  return [...byKey.values()];
1165
1407
  };
1166
- 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;
1167
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;
1168
1410
  var isAnsweredDrop = (f, e) => matches(f, e) && isVerbatimReRaise(f, e) && f.severity !== "critical";
1169
- 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}`;
1170
1412
  var answeredNote = (e) => `Re-raised; prior answer at ${e.replyUrl} by ${e.replyAuthor} \u2014 cite the new evidence that invalidates it.`;
1171
1413
  var applyAnswered = (findings, registry) => {
1172
1414
  const kept = [];
1173
1415
  const noteEntries = [];
1174
- const droppedByKey = /* @__PURE__ */ new Map();
1416
+ const droppedByEntry = /* @__PURE__ */ new Map();
1417
+ const droppedFindingIds = [];
1175
1418
  let droppedCount = 0;
1176
1419
  for (const f of findings) {
1177
- 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);
1178
1421
  if (entry === void 0) {
1179
1422
  kept.push(f);
1180
1423
  continue;
1181
1424
  }
1182
1425
  if (isAnsweredDrop(f, entry)) {
1183
- droppedByKey.set(answeredNoteKey(f), entry);
1426
+ droppedByEntry.set(entry.replyId, entry);
1427
+ droppedFindingIds.push(resolveFindingId(f));
1184
1428
  droppedCount += 1;
1185
1429
  } else {
1186
1430
  kept.push(f);
@@ -1190,7 +1434,8 @@ var applyAnswered = (findings, registry) => {
1190
1434
  return {
1191
1435
  findings: kept,
1192
1436
  reRaisedNotes: Object.fromEntries(noteEntries),
1193
- verbatimReRaised: [...droppedByKey.values()],
1437
+ verbatimReRaised: [...droppedByEntry.values()],
1438
+ droppedFindingIds,
1194
1439
  droppedCount
1195
1440
  };
1196
1441
  };
@@ -1232,17 +1477,42 @@ var fetchThreadComments = async (ghApi, repo, prNumber) => {
1232
1477
 
1233
1478
  // src/render.ts
1234
1479
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
1235
- var linkSafeUrl = (url) => escapeCodeBackticks(url).replace(/\(/g, "%28").replace(/\)/g, "%29");
1480
+ var encodeAutolinkParens = (url) => url.replace(/\(/g, "%28").replace(/\)/g, "%29");
1481
+ var linkSafeUrl = (url) => encodeAutolinkParens(escapeCodeBackticks(url));
1482
+ var permalinkFor = (base, f, anchor) => {
1483
+ if (f.path === "" || f.side === "LEFT") return void 0;
1484
+ const path = f.path.split("/").map(
1485
+ (segment) => encodeAutolinkParens(
1486
+ encodeURIComponent(
1487
+ segment.replace(
1488
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
1489
+ "\uFFFD"
1490
+ )
1491
+ )
1492
+ // Markdown emphasis/syntax characters: `__` runs split the bare URL across CommonMark
1493
+ // nodes and truncate the autolink, and a path-only link ending in one of these loses its
1494
+ // tail to the autolinker's trailing-punctuation trim (issue #231 r4).
1495
+ ).replace(/_/g, "%5F").replace(/\*/g, "%2A").replace(/'/g, "%27").replace(/!/g, "%21")
1496
+ ).join("/");
1497
+ return anchor ? `${base}${path}#L${lineRange(f.start_line, f.end_line, "-L")}` : `${base}${path}`;
1498
+ };
1236
1499
  var CARRIED_TOTAL_CHARS = 4e4;
1237
1500
  var SUPPRESSED_NIT_BLOCK_OVERHEAD = 280;
1238
- var sanitizeFinding = (f, answeredNotes) => {
1501
+ var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1239
1502
  const key2 = answeredNoteKey(f);
1503
+ const anchored = permalinkBase !== void 0 && !(unanchored?.has(f) ?? false);
1504
+ const permalink = permalinkBase === void 0 ? void 0 : permalinkFor(permalinkBase, f, anchored);
1240
1505
  return {
1241
1506
  ...f,
1242
1507
  title: escapePipes(f.title),
1243
1508
  path: escapeCodeBackticks(f.path),
1244
- ...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),
1245
1513
  ...f.code_url !== void 0 ? { code_url: linkSafeUrl(f.code_url) } : {},
1514
+ rangeLabel: lineRange(f.start_line, f.end_line, "\u2013"),
1515
+ ...permalink !== void 0 ? { permalink, permalinkAnchored: anchored } : {},
1246
1516
  patchProjection: projectPatch(f.patch, "comment-body"),
1247
1517
  answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : ""
1248
1518
  };
@@ -1250,7 +1520,7 @@ var sanitizeFinding = (f, answeredNotes) => {
1250
1520
  var commentSafe = (text) => text.replace(/--+(?=>)/g, (dashes) => `${dashes}\u200B`);
1251
1521
  var sanitizeSuppressedNit = (f) => ({
1252
1522
  title: escapeCodeBackticks(f.title),
1253
- ...f.code !== void 0 ? { code: escapeCodeBackticks(f.code) } : {},
1523
+ id: escapeCodeBackticks(f.id),
1254
1524
  ...f.code_url !== void 0 ? { codeUrl: linkSafeUrl(f.code_url) } : {},
1255
1525
  path: commentSafe(escapeCodeBackticks(f.path)),
1256
1526
  startLine: f.start_line,
@@ -1271,10 +1541,10 @@ var carriedLines = (f) => [
1271
1541
  var sanitizeSystemic = (s) => ({
1272
1542
  ...s,
1273
1543
  title: escapePipes(s.title),
1274
- ...s.code !== void 0 ? { code: escapeCodeBackticks(s.code) } : {},
1544
+ ...s.id !== void 0 ? { id: escapeCodeBackticks(s.id) } : {},
1275
1545
  ...s.code_url !== void 0 ? { code_url: linkSafeUrl(s.code_url) } : {},
1276
1546
  ...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
1277
- ...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) } : {}
1278
1548
  });
1279
1549
  var emptySeverityCounts = () => ({
1280
1550
  critical: 0,
@@ -1307,12 +1577,14 @@ var render = (input) => {
1307
1577
  const advisoryAllowed = isFullReviewRound;
1308
1578
  const suppressedBudget = (input.suppressedNits ?? []).map(sanitizeSuppressedNit).reduce(
1309
1579
  (acc, n) => {
1310
- 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.
1311
- (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);
1312
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 };
1313
1583
  },
1314
1584
  { list: [], used: 0, dropped: 0 }
1315
1585
  );
1586
+ const permalinkBase = input.repo !== void 0 && input.repo !== "" && input.reviewedSha !== void 0 && input.reviewedSha !== "" ? `https://github.com/${input.repo}/blob/${input.reviewedSha}/` : void 0;
1587
+ const unanchored = new Set(input.unanchoredStrays ?? []);
1316
1588
  return eta.renderString(input.template, {
1317
1589
  findings: input.findings,
1318
1590
  envelope: input.envelope,
@@ -1333,7 +1605,9 @@ var render = (input) => {
1333
1605
  postedAt: input.postedAt ?? "",
1334
1606
  severityCounts,
1335
1607
  convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
1336
- strays: (input.strays ?? []).map((f) => sanitizeFinding(f, input.answeredNotes)),
1608
+ strays: (input.strays ?? []).map(
1609
+ (f) => sanitizeFinding(f, input.answeredNotes, permalinkBase, unanchored)
1610
+ ),
1337
1611
  suppressedNits: suppressedBudget.list,
1338
1612
  carriedDroppedNits: suppressedBudget.dropped,
1339
1613
  nitVisibilityFloor: input.nitVisibilityFloor ?? DEFAULT_NIT_VISIBILITY_FLOOR,
@@ -1456,11 +1730,7 @@ var buildInlineComments = (findings, diff, context) => {
1456
1730
  const { inDiff, strays } = partitionFindings(findings, index);
1457
1731
  const eta = new Eta({ autoTrim: false });
1458
1732
  const modelsText = formatModels(models);
1459
- const noteFor = (f, notes) => {
1460
- if (notes === void 0) return "";
1461
- const key2 = answeredNoteKey(f);
1462
- return Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1463
- };
1733
+ const noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1464
1734
  const comments = inDiff.map((f) => {
1465
1735
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
1466
1736
  const clipProse = fullFindings !== void 0 && findingPayload(f, fullFindings.schema_version).length > INLINE_PROSE_CLIP_THRESHOLD_CHARS;
@@ -1472,8 +1742,8 @@ var buildInlineComments = (findings, diff, context) => {
1472
1742
  reasoning: clipText(f.reasoning, BODY_CLIP_CHARS),
1473
1743
  ...f.patch != null ? { patch: clipText(f.patch, BODY_CLIP_CHARS) } : {}
1474
1744
  } : f;
1475
- const sameRootNote = noteFor(f, context.sameRootNotes);
1476
- const answeredNote2 = noteFor(f, context.answeredNotes);
1745
+ const sameRootNote = noteFor(context.sameRootNotes, resolveFindingId(f));
1746
+ const answeredNote2 = noteFor(context.answeredNotes, answeredNoteKey(f));
1477
1747
  const comment = {
1478
1748
  path: f.path,
1479
1749
  line: f.end_line,
@@ -1917,33 +2187,47 @@ var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
1917
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.`
1918
2188
  );
1919
2189
  var identity = (decoded) => decoded;
2190
+ var legacyFindingsCodec = FindingsCodecV09;
2191
+ var legacyFindingsNormalize = (doc) => normalizeV09(doc);
1920
2192
  var findingsTable = [
1921
2193
  {
1922
2194
  minor: "0.4",
1923
2195
  defaultVersion: "0.4.0",
1924
- schemaFile: "findings.schema.json",
1925
- codec: FindingsCodec,
1926
- 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,
1927
2203
  latest: false
1928
2204
  },
1929
2205
  {
1930
2206
  minor: "0.5",
1931
2207
  defaultVersion: "0.5.0",
1932
- schemaFile: "findings.schema.json",
1933
- codec: FindingsCodec,
1934
- normalize: identity,
2208
+ schemaFile: "v0.9/findings.schema.json",
2209
+ codec: legacyFindingsCodec,
2210
+ normalize: legacyFindingsNormalize,
1935
2211
  latest: false
1936
2212
  },
1937
2213
  {
1938
2214
  minor: "0.6",
1939
2215
  defaultVersion: "0.6.0",
1940
- schemaFile: "findings.schema.json",
1941
- codec: FindingsCodec,
1942
- normalize: identity,
2216
+ schemaFile: "v0.9/findings.schema.json",
2217
+ codec: legacyFindingsCodec,
2218
+ normalize: legacyFindingsNormalize,
1943
2219
  latest: false
1944
2220
  },
1945
2221
  {
1946
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",
1947
2231
  defaultVersion: DEFAULT_SCHEMA_VERSION,
1948
2232
  schemaFile: "findings.schema.json",
1949
2233
  codec: FindingsCodec,
@@ -2037,6 +2321,13 @@ var resolvers = {
2037
2321
  prices: (raw) => resolveSingleVersion("prices", raw)
2038
2322
  };
2039
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
+ };
2040
2331
  var MAX_BUFFER = 100 * 1024 * 1024;
2041
2332
  var MAX_TIMEOUT_MS = 2147483647;
2042
2333
  var parseTimeoutMs = (raw, fallback) => {
@@ -2083,14 +2374,32 @@ var execFileWithTimeout = (spec) => new Promise((resolve4, reject) => {
2083
2374
 
2084
2375
  // src/gh.ts
2085
2376
  var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
2086
- var runGhApi = (args, stdin, env) => execFileWithTimeout({
2087
- command: "gh",
2088
- args: ["api", ...args],
2089
- label: `gh api ${describeEndpoint(args)}`,
2090
- timeoutMs: subprocessTimeoutMs(),
2091
- env,
2092
- stdin
2093
- });
2377
+ var flagFirst;
2378
+ var withEscapeRetry = async (run2, idempotent) => {
2379
+ if (flagFirst) return run2(true);
2380
+ try {
2381
+ return await run2(false);
2382
+ } catch (err) {
2383
+ if (errMsg(err).includes("--allow-escape-sequences")) {
2384
+ if (!idempotent) throw err;
2385
+ flagFirst = true;
2386
+ return run2(true);
2387
+ }
2388
+ throw err;
2389
+ }
2390
+ };
2391
+ var isIdempotentCall = (args) => !args.includes("--input") && !args.includes("--method") && !args.includes("graphql");
2392
+ var runGhApi = (args, stdin, env) => withEscapeRetry(
2393
+ (withFlag) => execFileWithTimeout({
2394
+ command: "gh",
2395
+ args: ["api", ...withFlag ? ["--allow-escape-sequences"] : [], ...args],
2396
+ label: `gh api ${describeEndpoint(args)}`,
2397
+ timeoutMs: subprocessTimeoutMs(),
2398
+ env,
2399
+ stdin
2400
+ }),
2401
+ isIdempotentCall(args)
2402
+ );
2094
2403
  var run = promisify(execFile);
2095
2404
  var STEP_TIMEOUT_MS = 6e4;
2096
2405
  var MARKER_URL = /<!-- code-review:findings-json (https?:\/\/[^\s>]+) -->/;
@@ -2143,11 +2452,14 @@ var readArtifactFindings = (ghApiPath) => {
2143
2452
  };
2144
2453
  };
2145
2454
  var ghArtifactReader = readArtifactFindings(async (url, outPath) => {
2146
- const { stdout } = await run("gh", ["api", url], {
2147
- encoding: "buffer",
2148
- maxBuffer: 256 * 1024 * 1024,
2149
- timeout: STEP_TIMEOUT_MS
2150
- });
2455
+ const { stdout } = await withEscapeRetry(
2456
+ (withFlag) => run("gh", ["api", ...withFlag ? ["--allow-escape-sequences"] : [], url], {
2457
+ encoding: "buffer",
2458
+ maxBuffer: 256 * 1024 * 1024,
2459
+ timeout: STEP_TIMEOUT_MS
2460
+ }),
2461
+ true
2462
+ );
2151
2463
  await writeFile(outPath, stdout);
2152
2464
  });
2153
2465
  var withoutKey = (doc, key2) => Object.fromEntries(Object.entries(doc).filter(([k]) => k !== key2));
@@ -2813,15 +3125,16 @@ ${dropNote}` : ""}`,
2813
3125
  const reRaisedNotes = answeredFilter.reRaisedNotes;
2814
3126
  const verbatimReRaised = answeredFilter.verbatimReRaised;
2815
3127
  const droppedCount = answeredFilter.droppedCount;
2816
- const droppedCodes = new Set(verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []));
2817
- const keptCodes = new Set(
2818
- answeredFilter.findings.flatMap((f) => f.code !== void 0 && f.code !== "" ? [f.code] : [])
2819
- );
2820
- 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)));
2821
3134
  const systemic = trulyDropped.size === 0 ? loadedFindings.systemic_problems ?? [] : (loadedFindings.systemic_problems ?? []).map((s) => {
2822
- if (s.finding_codes === void 0) return s;
2823
- const codes = s.finding_codes.filter((c) => !trulyDropped.has(c));
2824
- 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 };
2825
3138
  });
2826
3139
  const findings = {
2827
3140
  ...loadedFindings,
@@ -2832,10 +3145,13 @@ ${dropNote}` : ""}`,
2832
3145
  const priorDocForNits = roundHasNit && existingSticky !== null && isFullReviewSticky(existingSticky.body) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
2833
3146
  const priorSuppressedKeys = new Set(
2834
3147
  priorBelowFloorNits(priorDocForNits, input.nitVisibilityFloor).map(
2835
- (n) => answeredNoteKey({ code: n.code, title: n.title })
3148
+ (n) => answeredNoteKey({ id: n.id ?? "", title: n.title })
2836
3149
  )
2837
3150
  );
2838
- 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}`));
2839
3155
  const suppressedNits = findings.findings.filter(isSuppressedNit);
2840
3156
  const visibleFindings = findings.findings.filter((f) => !isSuppressedNit(f));
2841
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._" : "");
@@ -2862,6 +3178,7 @@ ${dropNote}` : ""}`,
2862
3178
  template,
2863
3179
  route: effectiveRoute,
2864
3180
  reviewedSha: input.headSha,
3181
+ repo: input.headRepo || input.repo,
2865
3182
  effort: input.effort,
2866
3183
  sameRootNotes: {},
2867
3184
  // The answered-state honesty rules apply on EVERY surface that renders the filtered
@@ -2932,7 +3249,7 @@ ${dropNote}` : ""}`,
2932
3249
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
2933
3250
  const initialDisposition = !inlineRequested ? { kind: "disabled" } : comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2934
3251
  const currentCounts = computeSeverityCounts(findings.findings);
2935
- const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
3252
+ const currentCodes = computeIdCounts(findings.findings, findings.systemic_problems ?? []);
2936
3253
  const roundNumber = priorRoundCount + 1;
2937
3254
  const convergence = isRound ? buildConvergence(
2938
3255
  findings,
@@ -2954,6 +3271,7 @@ ${dropNote}` : ""}`,
2954
3271
  template,
2955
3272
  route: effectiveRoute,
2956
3273
  reviewedSha: input.headSha,
3274
+ repo: input.headRepo || input.repo,
2957
3275
  effort: input.effort,
2958
3276
  testReport,
2959
3277
  clocDiff,
@@ -2980,11 +3298,12 @@ ${dropNote}` : ""}`,
2980
3298
 
2981
3299
  > **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.
2982
3300
  ` : "";
2983
- const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
3301
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays) => formatMarkdown(
2984
3302
  render({
2985
3303
  ...commonRenderInput,
2986
3304
  ...straysOverride ? { strays: straysOverride } : {},
2987
3305
  ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
3306
+ ...unanchoredStrays !== void 0 && unanchoredStrays.length > 0 ? { unanchoredStrays } : {},
2988
3307
  inlineDisposition,
2989
3308
  reviewUrl: reviewUrl2
2990
3309
  }) + longFilesNote
@@ -3041,7 +3360,7 @@ ${dropNote}` : ""}`,
3041
3360
  await patchComment(
3042
3361
  input.repo,
3043
3362
  stickyRef.id,
3044
- renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
3363
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount, unposted),
3045
3364
  ghApi
3046
3365
  );
3047
3366
  process.stderr.write(
@@ -3060,7 +3379,13 @@ ${dropNote}` : ""}`,
3060
3379
  () => renderBody(
3061
3380
  { kind: "whole-document", inlineCount: inlinePosted, rejectedCount: unanchoredCount },
3062
3381
  reviewUrl,
3063
- visibleFindings
3382
+ visibleFindings,
3383
+ void 0,
3384
+ // The rejected-anchor invariant holds on EVERY surface, the run summary included — this
3385
+ // document deliberately carries every finding, so the GitHub-rejected ones must keep their
3386
+ // path-only links here too (issue #231 r2). Unconditional: renderBody's own gate treats an
3387
+ // empty array exactly like absence, so the ternary was a duplicated decision (issue #231 r3).
3388
+ unposted
3064
3389
  )
3065
3390
  );
3066
3391
  };
@@ -4096,6 +4421,7 @@ var parseScope = (raw) => {
4096
4421
  };
4097
4422
 
4098
4423
  // src/index.ts
4424
+ var resolvedPriorValue = resolveTolerantFindings;
4099
4425
  var readJSON = (path) => {
4100
4426
  try {
4101
4427
  return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
@@ -4873,13 +5199,13 @@ var seedDraftCmd = defineCommand({
4873
5199
  const priorFindings = (() => {
4874
5200
  if (strippedPrior === null || typeof strippedPrior !== "object" || Array.isArray(strippedPrior))
4875
5201
  return strippedPrior;
4876
- const raw = strippedPrior;
4877
- const doc = answeredRegistry !== null && answeredRegistry.length > 0 && Array.isArray(raw["findings"]) ? {
4878
- ...raw,
4879
- 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(
4880
5206
  (f) => !answeredRegistry.some((e) => isAnsweredDrop(f, e))
4881
- )
4882
- } : raw;
5207
+ ) : resolved.findings
5208
+ };
4883
5209
  const carried = doc["scope_metastasis"];
4884
5210
  if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return doc;
4885
5211
  if (doc["verdict"] === "error") return doc;
@@ -4923,22 +5249,27 @@ var seedDraftCmd = defineCommand({
4923
5249
  ([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2)
4924
5250
  )
4925
5251
  ) : priorFindings;
4926
- const accepts = (doc) => validateAgainstSchema(doc, schemaPath).valid && resolve("findings", doc).kind === "ok";
4927
- const seedDoc = accepts(priorFindings) ? priorFindings : accepts(barePrior) ? (process.stderr.write(
4928
- `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)
4929
5261
  `
4930
- ), barePrior) : null;
5262
+ );
5263
+ return bare;
5264
+ })();
4931
5265
  if (seedDoc === null) return false;
4932
- const resolution = resolve("findings", seedDoc);
4933
- if (resolution.kind !== "ok") return false;
4934
- if (isIncompleteFindings(resolution.value)) return false;
5266
+ if (isIncompleteFindings(seedDoc)) return false;
4935
5267
  if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
4936
5268
  writeFileSync(outPath, SEED_SENTINEL);
4937
5269
  writeFileSync(priorContextPath(outPath), `${JSON.stringify(seedDoc, null, 2)}
4938
5270
  `);
4939
- const count = resolution.value.findings.length;
4940
5271
  process.stderr.write(
4941
- `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
4942
5273
  `
4943
5274
  );
4944
5275
  return true;
@@ -5447,6 +5778,10 @@ var postCmd = defineCommand({
5447
5778
  const priceResolution = resolvePrices(args.prices);
5448
5779
  await post({
5449
5780
  repo: args.repo,
5781
+ // The workflow's post step threads HEAD_REPO env (the fork's owner/name) — a finding
5782
+ // permalink targets the tree the reviewed SHA lives in (issue #231 r1). Absent/empty ⇒ the
5783
+ // base repo. Env rather than a flag: an older pinned CLI simply ignores it.
5784
+ headRepo: process.env["HEAD_REPO"] || void 0,
5450
5785
  headSha: args["head-sha"],
5451
5786
  botLogin: args["bot-login"] || "github-actions[bot]",
5452
5787
  findingsPath: args.findings,