iterate-plugin 3.5.7 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +61 -14
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +106 -28
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -13,7 +13,7 @@
13
13
  * the actual subagent-driven meta-review critique; all deterministic math
14
14
  * lives here.
15
15
  */
16
- import { sortFindings } from "./review.js";
16
+ import { isValidRoundNumber, sortFindings } from "./review.js";
17
17
  /**
18
18
  * Number of distinct consistency checks performed by `metaReviewReport`.
19
19
  * The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
@@ -26,6 +26,50 @@ export const META_REVIEW_CHECKS = 10;
26
26
  * remainder is folded into a "+N more" suffix.
27
27
  */
28
28
  export const COVERAGE_LIST_TRUNCATE = 10;
29
+ /**
30
+ * Hard sanity ceiling on a reported round number.
31
+ *
32
+ * `rounds[].round` is model/JSON-authored and only needs to be a number to
33
+ * reach this audit. Round-gap detection enumerates `min..max` of the PRESENT
34
+ * round numbers, so an absurd value (rounds `{1, 1e15}` or a round of
35
+ * `Infinity`) used to turn a linear check into an effectively-infinite loop —
36
+ * a trivial CPU/memory denial of service (and one ROUND_GAP issue per missing
37
+ * round). Anything above this ceiling is rejected as an invalid round
38
+ * (ROUND_NUMBER) and excluded from gap enumeration instead of being walked.
39
+ * Generously above any real run: `config.max_rounds` is capped at 100.
40
+ */
41
+ export const MAX_REVIEW_ROUND_NUMBER = 1000;
42
+ /**
43
+ * Max individual ROUND_GAP issues emitted per audit. The remainder is folded
44
+ * into a single "+N more" issue so a wide-but-legal gap can never flood the
45
+ * issue list (each issue carries a detail string).
46
+ */
47
+ export const MAX_ROUND_GAP_REPORTS = 50;
48
+ /**
49
+ * Hard bound on the round-gap enumeration span. Below
50
+ * MAX_REVIEW_ROUND_NUMBER on purpose: even a legal range (e.g. round 1 plus
51
+ * round 1000) is too wide for per-number gap reporting to mean anything, so
52
+ * it collapses to ONE "range too wide" issue instead of ~1000 iterations and
53
+ * ~1000 issues. Together with the round-number ceiling this makes the
54
+ * enumeration O(MAX_ROUND_GAP_SPAN) no matter what reaches it.
55
+ */
56
+ export const MAX_ROUND_GAP_SPAN = 500;
57
+ /** Bounded, non-throwing list preview for issue detail strings. */
58
+ function previewList(values, max = 20) {
59
+ const shown = values
60
+ .slice(0, max)
61
+ .map((v) => (typeof v === 'number' ? String(v) : typeof v === 'string' ? v : '[object]'))
62
+ .join(', ');
63
+ return values.length > max ? `${shown}, … (+${values.length - max} more)` : shown;
64
+ }
65
+ /** Bounded, non-throwing object preview (a hostile round may be circular). */
66
+ function previewRound(r) {
67
+ if (r === null || typeof r !== 'object')
68
+ return String(r);
69
+ const roundNo = r.round;
70
+ const findings = r.findings;
71
+ return `{round: ${String(roundNo)}, findings: ${Array.isArray(findings) ? `array(${findings.length})` : typeof findings}}`;
72
+ }
29
73
  /**
30
74
  * Audit a ReviewReport for internal consistency.
31
75
  *
@@ -79,7 +123,9 @@ export function metaReviewReport(report) {
79
123
  const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
80
124
  for (const f of findings) {
81
125
  const s = f?.severity;
82
- if (s && s in sevCounts)
126
+ // hasOwn, not `in`: `'__proto__' in sevCounts` is true via inheritance,
127
+ // and an out-of-spec severity must simply not be bucketed.
128
+ if (s && Object.hasOwn(sevCounts, s))
83
129
  sevCounts[s]++;
84
130
  }
85
131
  const bucketSum = sevCounts.critical + sevCounts.high + sevCounts.medium + sevCounts.low;
@@ -116,18 +162,23 @@ export function metaReviewReport(report) {
116
162
  : [];
117
163
  const convSum = findingsByRound.reduce((a, b) => a + Number(b) || 0, 0);
118
164
  if (convSum !== total) {
119
- add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound ${JSON.stringify(findingsByRound)} sums to ${convSum}, ` +
165
+ add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound [${previewList(findingsByRound)}] sums to ${convSum}, ` +
120
166
  `but totalFindings is ${total}.`);
121
167
  }
122
168
  // `findingsByRound` is indexed by the actual round number (round r → index
123
- // r-1), so the "last round" is the LAST RECORDED round's reported number, not
124
- // the array's last index (the array is sized to the highest round, which only
125
- // equals the record count for contiguous 1..N round numbers). Read the flag
126
- // consistency the same way buildReviewReport/computeConvergence set it.
169
+ // r-1), so the "last round" is the HIGHEST VALID round number, not the
170
+ // array's last element (buildReviewReport sorts its round list, but this
171
+ // audit runs on arbitrary JSON, and a malformed trailing round — `1.5`,
172
+ // `Infinity` — must not be used as an index: Math.min(1.5, len) - 1 is
173
+ // fractional and reads `undefined` → 0 new → a false CONVERGENCE_FLAG).
127
174
  const reportRounds = Array.isArray(report.rounds) ? report.rounds : [];
128
- const lastRecordedRound = reportRounds.length > 0 && typeof reportRounds[reportRounds.length - 1]?.round === 'number'
129
- ? reportRounds[reportRounds.length - 1].round
130
- : null;
175
+ let lastRecordedRound = null;
176
+ for (const r of reportRounds) {
177
+ if (!r || typeof r !== 'object' || !isValidRoundNumber(r.round))
178
+ continue;
179
+ if (lastRecordedRound === null || r.round > lastRecordedRound)
180
+ lastRecordedRound = r.round;
181
+ }
131
182
  const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
132
183
  // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
133
184
  // reported round number may exceed the array length — read the SAME
@@ -144,8 +195,15 @@ export function metaReviewReport(report) {
144
195
  const rounds = Array.isArray(report.rounds) ? report.rounds : [];
145
196
  const seenRounds = new Set();
146
197
  for (const [index, r] of rounds.entries()) {
147
- if (!r || typeof r.round !== 'number' || r.round < 1) {
148
- add('ROUND_NUMBER', 'medium', 'A round has a missing or non-positive round number', `round: ${JSON.stringify(r)}`);
198
+ // Reject everything the deterministic core would refuse: a missing,
199
+ // non-integer, non-finite or absurdly large round number. This is also
200
+ // the DoS guard for ROUND_GAP below — a round of `Infinity`/1e15 would
201
+ // otherwise be treated as a legitimate endpoint of the enumeration range.
202
+ if (!r ||
203
+ typeof r !== 'object' ||
204
+ !isValidRoundNumber(r.round) ||
205
+ r.round > MAX_REVIEW_ROUND_NUMBER) {
206
+ add('ROUND_NUMBER', 'medium', 'A round has a missing, non-integer or out-of-range round number', `round: ${previewRound(r)} (round numbers must be integers in 1..${MAX_REVIEW_ROUND_NUMBER})`);
149
207
  continue;
150
208
  }
151
209
  seenRounds.add(r.round);
@@ -163,9 +221,30 @@ export function metaReviewReport(report) {
163
221
  if (present.length > 0) {
164
222
  const min = present[0];
165
223
  const max = present[present.length - 1];
166
- for (let i = min; i <= max; i++) {
167
- if (!seenRounds.has(i)) {
168
- add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${present.join(', ')}.`);
224
+ const span = max - min + 1;
225
+ const presentPreview = previewList(present, 20);
226
+ // Hard bound on the enumeration: `min`/`max` come from untrusted round
227
+ // numbers, so an unguarded `for (i = min; i <= max; i++)` over
228
+ // {1, 1e15} is an effectively-infinite loop (CPU DoS) that would also
229
+ // push ~1e15 issues. Rounds above MAX_REVIEW_ROUND_NUMBER are rejected
230
+ // above, which keeps `span` bounded on its own; this guard stays as the
231
+ // belt-and-braces backstop so the walk is O(MAX_ROUND_GAP_SPAN) no
232
+ // matter what reaches it.
233
+ if (span > MAX_ROUND_GAP_SPAN) {
234
+ add('ROUND_GAP', 'medium', `Round sequence ${min}..${max} is too wide to audit for gaps`, `${span} round numbers spanned; only rounds present were accepted: ${presentPreview}.`);
235
+ }
236
+ else {
237
+ const missing = [];
238
+ for (let i = min; i <= max; i++) {
239
+ if (!seenRounds.has(i))
240
+ missing.push(i);
241
+ }
242
+ for (const m of missing.slice(0, MAX_ROUND_GAP_REPORTS)) {
243
+ add('ROUND_GAP', 'medium', `Round ${m} is missing from the round sequence`, `rounds present: ${presentPreview}.`);
244
+ }
245
+ if (missing.length > MAX_ROUND_GAP_REPORTS) {
246
+ add('ROUND_GAP', 'medium', `${missing.length - MAX_ROUND_GAP_REPORTS} more rounds are missing (list capped)`, `${missing.length} of the ${span} numbers in ${min}..${max} are absent; only the first ` +
247
+ `${MAX_ROUND_GAP_REPORTS} are listed individually. rounds present: ${presentPreview}.`);
169
248
  }
170
249
  }
171
250
  }
@@ -177,6 +256,35 @@ export function metaReviewReport(report) {
177
256
  issues,
178
257
  };
179
258
  }
259
+ /**
260
+ * Human-readable reason for a failed evidence attestation.
261
+ *
262
+ * `file_too_large` / `binary_file` are DISTINCT from `line_out_of_range` (see
263
+ * evidence.ts): the file EXISTS but cannot be line-addressed, so saying
264
+ * "does not exist" would send the reader looking for a missing file.
265
+ */
266
+ function describeEvidenceViolation(v) {
267
+ switch (v.error) {
268
+ case 'file_too_large':
269
+ return `${v.file} is too large to line-address (over the evidence size cap)`;
270
+ case 'binary_file':
271
+ return `${v.file} is a binary (NUL-containing) file that cannot be line-addressed`;
272
+ case 'line_out_of_range':
273
+ return v.lineTotal !== undefined && v.lineTotal !== null
274
+ ? `${v.line} is beyond this file's ${v.lineTotal} lines`
275
+ : `${v.file} is not a regular line-addressable file (directory/device/FIFO)`;
276
+ default:
277
+ return `${v.file} does not exist at all (verifiable read required)`;
278
+ }
279
+ }
280
+ /** Headline for a failed evidence attestation (matches the detail above). */
281
+ function evidenceViolationSummary(v) {
282
+ const unaddressable = v.error === 'file_too_large' || v.error === 'binary_file';
283
+ const head = unaddressable
284
+ ? 'Finding cannot be anchored to line-addressable code'
285
+ : 'Finding references non-existent code';
286
+ return `${head}: ${v.file}${v.line ? `:${v.line}` : ''}`;
287
+ }
180
288
  /**
181
289
  * Build the final review report: pair the source report with its meta-review
182
290
  * verdict and a rolled-up summary. Pure and deterministic.
@@ -221,30 +329,26 @@ export function buildFinalReviewReport(report, opts = {}) {
221
329
  for (const violation of evidence.results) {
222
330
  if (violation.error === undefined)
223
331
  continue;
224
- const detail = violation.error === 'line_out_of_range'
225
- ? violation.lineTotal !== undefined && violation.lineTotal !== null
226
- ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
227
- : `${violation.file} is a binary/unreadable file not line-addressable`
228
- : `${violation.file} does not exist at all (verifiable read required)`;
332
+ const detail = describeEvidenceViolation(violation);
333
+ const summary = evidenceViolationSummary(violation);
229
334
  let roundHint = '';
230
335
  if (report && violation.file) {
231
336
  // Try to attribute the poisoned finding to the round that first
232
- // surfaced it (best-effort; report rounds carry it).
233
- for (const r of report.rounds ?? []) {
234
- const matched = (r.findings ?? []).some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
337
+ // surfaced it (best-effort; report rounds carry it). `rounds` is
338
+ // untrusted JSON, so a null entry must not throw here.
339
+ for (const r of (Array.isArray(report.rounds) ? report.rounds : []) ?? []) {
340
+ const findings = Array.isArray(r?.findings) ? r.findings : [];
341
+ const matched = findings.some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
235
342
  if (matched) {
236
- roundHint = ` (round ${r.round})`;
343
+ roundHint = ` (round ${String(r.round)})`;
237
344
  break;
238
345
  }
239
346
  }
240
347
  }
241
- const summary = `Finding references non-existent code: ${violation.file}` +
242
- (violation.line ? `:${violation.line}` : '') +
243
- roundHint;
244
348
  meta.issues.push({
245
349
  code: 'EVIDENCE_VIOLATION',
246
350
  severity: 'critical',
247
- summary,
351
+ summary: summary + roundHint,
248
352
  detail: detail + '. Review results must anchor to real, read code.',
249
353
  });
250
354
  }
@@ -68,7 +68,11 @@ const SIGNATURE_PATTERNS = [
68
68
  * Returns a sorted array of `{ name, line }` (1-based line numbers).
69
69
  */
70
70
  export function collectMethodSignatures(text) {
71
- const lines = text.split('\n');
71
+ // Defensive: callers hand in file content read from disk / tool args, which
72
+ // may be missing or wrong-typed when a fix record is malformed. A non-string
73
+ // would throw on `.split` inside the atomic-fix gate.
74
+ const source = typeof text === 'string' ? text : '';
75
+ const lines = source.split('\n');
72
76
  const out = [];
73
77
  // Set lookup instead of scanning the growing array (O(S²) → O(S)).
74
78
  const seen = new Set();
@@ -95,7 +99,7 @@ export function collectMethodSignatures(text) {
95
99
  }
96
100
  /** Number of physical lines in `text` (a trailing newline does not add a line). */
97
101
  export function countTextLines(text) {
98
- if (text === '')
102
+ if (typeof text !== 'string' || text === '')
99
103
  return 0;
100
104
  const parts = text.split('\n');
101
105
  return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length;
@@ -107,11 +111,14 @@ export function countTextLines(text) {
107
111
  * span runs to the final non-blank line of the file.
108
112
  */
109
113
  export function collectMethodSpans(text) {
110
- const signatures = collectMethodSignatures(text);
114
+ // Same non-string guard as collectMethodSignatures: this runs inside the
115
+ // atomic-fix gate on content that may be missing.
116
+ const source = typeof text === 'string' ? text : '';
117
+ const signatures = collectMethodSignatures(source);
111
118
  if (signatures.length === 0)
112
119
  return [];
113
- const lines = text.split('\n');
114
- const lineCount = countTextLines(text);
120
+ const lines = source.split('\n');
121
+ const lineCount = countTextLines(source);
115
122
  /** Last non-blank line at or before `candidate`. */
116
123
  function trimBlank(endCandidate, floor) {
117
124
  let end = endCandidate;
@@ -137,6 +144,18 @@ export function collectMethodSpans(text) {
137
144
  function intersects(spanStart, spanEnd, regionStart, regionEnd) {
138
145
  return spanStart <= regionEnd && spanEnd >= regionStart;
139
146
  }
147
+ /**
148
+ * True when a hunk's coordinates are usable for span intersection.
149
+ *
150
+ * `hunks` is a `ChangedRegion[]` that reaches this gate from a diff computed
151
+ * on tool-arg content; a malformed entry (`NaN`, `Infinity`, a negative or
152
+ * fractional line number) would build a `NaN`/negative region that either
153
+ * matches every method or none, silently wrong-sizing the adjacent-method
154
+ * verdict. Non-finite and negative starts/counts are simply skipped.
155
+ */
156
+ function isValidRegion(start, count) {
157
+ return Number.isInteger(start) && start >= 0 && Number.isInteger(count) && count >= 1;
158
+ }
140
159
  /**
141
160
  * Count the distinct methods a set of diff hunks touches.
142
161
  *
@@ -150,13 +169,17 @@ function intersects(spanStart, spanEnd, regionStart, regionEnd) {
150
169
  * - Methods touched by both sides are counted once (keyed name@startLine).
151
170
  */
152
171
  export function countTouchedMethods(before, after, hunks) {
153
- if (hunks.length === 0)
172
+ // A non-array `hunks` (malformed fix record / tool arg) is "nothing
173
+ // changed", matching the empty-hunk case — never a throw in the fix gate.
174
+ if (!Array.isArray(hunks) || hunks.length === 0)
154
175
  return 0;
155
176
  const beforeSpans = collectMethodSpans(before);
156
177
  const afterSpans = collectMethodSpans(after);
157
178
  const touched = new Set();
158
179
  for (const h of hunks) {
159
- if (h.oldLines > 0) {
180
+ if (!h || typeof h !== 'object')
181
+ continue;
182
+ if (h.oldLines > 0 && isValidRegion(h.oldStart, h.oldLines)) {
160
183
  const oldEnd = h.oldStart + h.oldLines - 1;
161
184
  for (const s of beforeSpans) {
162
185
  if (intersects(s.startLine, s.endLine, h.oldStart, oldEnd)) {
@@ -164,7 +187,7 @@ export function countTouchedMethods(before, after, hunks) {
164
187
  }
165
188
  }
166
189
  }
167
- if (h.newLines > 0) {
190
+ if (h.newLines > 0 && isValidRegion(h.newStart, h.newLines)) {
168
191
  const newEnd = h.newStart + h.newLines - 1;
169
192
  for (const s of afterSpans) {
170
193
  if (intersects(s.startLine, s.endLine, h.newStart, newEnd)) {
@@ -22,8 +22,12 @@
22
22
  */
23
23
  import { readdirSync } from 'node:fs';
24
24
  import { join, relative } from 'node:path';
25
- /** Relative-scope sentinel for whole-module findings. */
26
- export const WHOLE_FILE_LINE = 0;
25
+ // Single source of truth for the whole-file line sentinel: evidence.ts's gate
26
+ // and this module's inventory filters must agree on what `0` means. Evidence
27
+ // imports neither this module nor review.ts, so the edge is acyclic (review.ts
28
+ // → review-scope.ts → evidence.ts → types.ts).
29
+ import { WHOLE_FILE_LINE } from "./evidence.js";
30
+ export { WHOLE_FILE_LINE };
27
31
  /** Source extensions a full-scope walk includes. */
28
32
  const SOURCE_EXTENSIONS = new Set([
29
33
  '.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
@@ -82,13 +86,19 @@ function isIgnoredDir(name) {
82
86
  }
83
87
  /** Collect the sorted relative-path inventory for a review scope. */
84
88
  export function collectScopeFiles(root, opts) {
89
+ // `opts` / `scope` come from tool args: a missing or malformed options
90
+ // object must degrade to a full walk, never throw inside the inventory step.
91
+ if (!opts || typeof opts !== 'object')
92
+ return collectFull(root);
85
93
  if (opts.scope === 'changed-only')
86
94
  return collectChanged(opts.changedFiles ?? []);
87
95
  return collectFull(root);
88
96
  }
89
97
  function collectChanged(changedFiles) {
98
+ // A non-array `changedFiles` (JSON arg) must not be iterated char-by-char.
99
+ const list = Array.isArray(changedFiles) ? changedFiles : [];
90
100
  const out = new Set();
91
- for (const rel of changedFiles) {
101
+ for (const rel of list) {
92
102
  if (typeof rel !== 'string' || !rel.trim())
93
103
  continue;
94
104
  if (rel === String(WHOLE_FILE_LINE))
@@ -145,9 +155,17 @@ function collectFull(root) {
145
155
  /** Split `files` into stable batches, keeping directory runs together. */
146
156
  export function chunkFiles(files, perChunk) {
147
157
  // Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
148
- // chunk (current.length >= NaN is never true).
149
- const size = Number.isFinite(perChunk) && perChunk >= 1 ? perChunk : DEFAULT_SCOPE_CHUNK_SIZE;
150
- const ordered = [...files].sort();
158
+ // chunk (current.length >= NaN is never true). Math.floor: a fractional
159
+ // size (2.5) made chunks alternate between 2 and 3 members — the caller's
160
+ // "files per batch" is a count, so it must be an integer.
161
+ const size = Number.isFinite(perChunk) && perChunk >= 1
162
+ ? Math.floor(perChunk)
163
+ : DEFAULT_SCOPE_CHUNK_SIZE;
164
+ // A non-array / sparse-free copy: callers pass tool-arg JSON, and sort()
165
+ // on a non-array (or `rel.includes` on a number) would throw.
166
+ const ordered = (Array.isArray(files) ? files : [])
167
+ .filter((rel) => typeof rel === 'string')
168
+ .sort();
151
169
  const chunks = [];
152
170
  let current = [];
153
171
  let lastDir;
@@ -172,19 +190,24 @@ export function chunkFiles(files, perChunk) {
172
190
  }
173
191
  /** Score self-reported reads against the assigned inventory. */
174
192
  export function computeCoverage(assigned, readFiles) {
193
+ // Both lists are JSON-arg-derived: `for...of` over a bare number throws and
194
+ // over a bare string iterates single characters into the read set. Anything
195
+ // non-array behaves like "nothing" (empty inventory / nothing read).
196
+ const assignedList = Array.isArray(assigned) ? assigned : [];
197
+ const readList = Array.isArray(readFiles) ? readFiles : [];
175
198
  const readNorm = new Set();
176
- for (const p of readFiles ?? []) {
199
+ for (const p of readList) {
177
200
  if (typeof p === 'string' && p)
178
201
  readNorm.add(normalizePath(p));
179
202
  }
180
- const assignedSorted = [...assigned].sort();
203
+ const assignedSorted = [...assignedList].filter((rel) => typeof rel === 'string').sort();
181
204
  const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)));
182
205
  const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)));
183
206
  const rawRatio = assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length;
184
207
  const ratio = Math.round(rawRatio * 1000) / 1000;
185
208
  return {
186
209
  assigned: assignedSorted,
187
- read: [...new Set((readFiles ?? []).filter((p) => typeof p === 'string'))].sort(),
210
+ read: [...new Set(readList.filter((p) => typeof p === 'string'))].sort(),
188
211
  covered,
189
212
  uncovered,
190
213
  ratio,
package/dist/review.js CHANGED
@@ -44,9 +44,44 @@ export function sortFindings(findings) {
44
44
  return (Number(a?.line) || 0) - (Number(b?.line) || 0);
45
45
  });
46
46
  }
47
+ /**
48
+ * Hard ceiling on the round cap the aggregation math will honor.
49
+ *
50
+ * Mirrors `MAX_MAX_ROUNDS` in `config-loader.ts` (the config bomb guard) —
51
+ * `test/review.test.ts` asserts the two constants stay in sync. The tool
52
+ * boundary only clamps the LOWER bound of `maxReviewRounds`, so an absurd
53
+ * caller value (1e15) would otherwise drive the `findingsByRound` allocation
54
+ * bound (`roundCap * 2`) straight into OOM territory.
55
+ */
56
+ export const MAX_REVIEW_ROUNDS_CAP = 100;
57
+ /**
58
+ * Clamp an untrusted round cap to `[1, MAX_REVIEW_ROUNDS_CAP]`.
59
+ *
60
+ * Non-finite / non-numeric input (`NaN`, `Infinity`, a string) collapses to
61
+ * 1 — the smallest sane cap. Without this, `Math.max(1, NaN)` stayed `NaN`
62
+ * and `new Array(NaN)` threw `RangeError` out of `aggregateRounds`.
63
+ */
64
+ export function clampMaxReviewRounds(value) {
65
+ if (typeof value !== 'number' || !Number.isFinite(value))
66
+ return 1;
67
+ return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(value)));
68
+ }
69
+ /**
70
+ * True when `n` is a round number the deterministic core accepts: a positive
71
+ * integer. Shared by `aggregateRounds` / `computeConvergence` /
72
+ * `buildReviewReport` (and re-read by the meta-review audit) so every reader
73
+ * of "the last round" agrees on which round numbers count — a fractional
74
+ * (`1.5`) or non-finite round is simply "not a round", never a convergence
75
+ * input that could be mis-indexed into `findingsByRound`.
76
+ */
77
+ export function isValidRoundNumber(n) {
78
+ return typeof n === 'number' && Number.isInteger(n) && n >= 1;
79
+ }
47
80
  /** Normalize a summary so near-identical duplicates collapse to one key. */
48
81
  export function normalizeSummary(summary) {
49
- return summary
82
+ // Coerce: `summary` is model-authored JSON when schema validation is off,
83
+ // so a numeric summary must not crash `.trim()` inside the dedupe key.
84
+ return String(summary ?? '')
50
85
  .trim()
51
86
  .toLowerCase()
52
87
  .replace(/[\s\n\t]+/g, ' ');
@@ -58,8 +93,12 @@ export function normalizeSummary(summary) {
58
93
  * neither side anchors one, i.e. whole-file findings).
59
94
  */
60
95
  export function findingKey(f) {
61
- const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0;
62
- return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`;
96
+ // Defensive reads: with `output_schema_validation: false` a finding may
97
+ // carry wrong-typed fields (and `f` itself may be a null list element), and
98
+ // this key feeds the dedupe Set on EVERY round — it must never throw.
99
+ const src = (f ?? {});
100
+ const line = typeof src.line === 'number' && src.line > 0 ? src.line : 0;
101
+ return `${String(src.file ?? '')}|${String(src.dimension ?? '')}|${line}|${normalizeSummary(src.summary)}`;
63
102
  }
64
103
  /**
65
104
  * Remove duplicate findings within a list.
@@ -69,6 +108,10 @@ export function dedupeFindings(findings) {
69
108
  const seen = new Set();
70
109
  const out = [];
71
110
  for (const f of findings) {
111
+ // A null / array entry (hostile or schema-validation-off JSON) must never
112
+ // reach `summarize`, which reads `f.severity` unguarded.
113
+ if (!f || typeof f !== 'object' || Array.isArray(f))
114
+ continue;
72
115
  const key = findingKey(f);
73
116
  if (seen.has(key))
74
117
  continue;
@@ -84,10 +127,19 @@ export function dedupeFindings(findings) {
84
127
  * - entry `line` is 0/undefined (whole file) OR equals the finding's line.
85
128
  */
86
129
  export function filterKnownIntentional(findings, known) {
87
- if (!known || known.length === 0)
130
+ // `known` is config/arg JSON: a non-array truthy value must behave like
131
+ // "no entries", never reach `known.some` and throw.
132
+ if (!Array.isArray(known) || known.length === 0)
88
133
  return findings;
89
134
  return findings.filter((f) => {
135
+ // A null / non-object finding has no file/dimension to match on; drop it
136
+ // here so it can never crash the comparator (aggregateRounds drops it too,
137
+ // so the report's counts stay consistent either way).
138
+ if (!f || typeof f !== 'object' || Array.isArray(f))
139
+ return false;
90
140
  const matched = known.some((k) => {
141
+ if (!k || typeof k !== 'object')
142
+ return false;
91
143
  const sameFile = k.file === f.file;
92
144
  const sameDim = k.dimension === f.dimension;
93
145
  if (!sameFile || !sameDim)
@@ -124,16 +176,25 @@ export function aggregateRounds(rounds, maxReviewRounds) {
124
176
  // 1e9) would otherwise allocate an array of that size below (OOM). Round
125
177
  // numbers above the configured cap are clamped to the cap.
126
178
  let maxRound = 0;
127
- const roundCap = Math.max(1, maxReviewRounds);
128
- for (const round of rounds) {
179
+ // Upper-bounded too: `Math.max(1, NaN)` used to stay NaN and blow up in
180
+ // `new Array(effectiveMax)`, and an unbounded cap scaled the allocation
181
+ // bound to whatever the caller asked for.
182
+ const roundCap = clampMaxReviewRounds(maxReviewRounds);
183
+ for (const round of Array.isArray(rounds) ? rounds : []) {
129
184
  if (!round || typeof round !== 'object')
130
185
  continue;
131
- if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
186
+ if (!isValidRoundNumber(round.round))
132
187
  continue;
133
188
  const findings = Array.isArray(round.findings) ? round.findings : [];
134
189
  if (round.round > maxRound)
135
190
  maxRound = round.round;
136
191
  for (const f of findings) {
192
+ // Null / non-object elements (schema validation off) would crash
193
+ // `findingKey`; dropping them here keeps `findingsByRound` summing to
194
+ // exactly the returned findings list (the meta-review's CONVERGENCE_SUM
195
+ // check reads that equality).
196
+ if (!f || typeof f !== 'object' || Array.isArray(f))
197
+ continue;
137
198
  const key = findingKey(f);
138
199
  if (seen.has(key))
139
200
  continue;
@@ -162,8 +223,9 @@ export function aggregateRounds(rounds, maxReviewRounds) {
162
223
  * Compute convergence statistics for a dry-run review.
163
224
  */
164
225
  export function computeConvergence(rounds, maxReviewRounds) {
165
- const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds);
166
- const totalRounds = rounds.length;
226
+ const list = Array.isArray(rounds) ? rounds : [];
227
+ const { findingsByRound } = aggregateRounds(list, maxReviewRounds);
228
+ const totalRounds = list.length;
167
229
  // `findingsByRound` is indexed by the actual round number (round r → index
168
230
  // r-1), sized to the highest present round (clamped). Convergence must read
169
231
  // the HIGHEST PRESENT round's count — not the last array element (rounds
@@ -171,15 +233,17 @@ export function computeConvergence(rounds, maxReviewRounds) {
171
233
  // 1..N). The count index is bounded by the array length aggregateRounds
172
234
  // actually allocated.
173
235
  let lastRound = 0;
174
- for (const round of rounds) {
175
- if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
236
+ for (const round of list) {
237
+ if (!isValidRoundNumber(round?.round))
176
238
  continue;
177
239
  if (round.round > lastRound)
178
240
  lastRound = round.round;
179
241
  }
180
242
  const idx = Math.min(lastRound, findingsByRound.length) - 1;
181
243
  const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
182
- const converged = totalRounds > 0 && lastRoundCount === 0;
244
+ // `lastRound === 0` means NO round carried an interpretable number: claiming
245
+ // `converged` there would bless a report the core could not actually read.
246
+ const converged = totalRounds > 0 && lastRound > 0 && lastRoundCount === 0;
183
247
  return {
184
248
  totalRounds,
185
249
  findingsByRound,
@@ -199,7 +263,15 @@ function summarize(findings) {
199
263
  high: 0,
200
264
  medium: 0,
201
265
  low: 0,
202
- byDimension: {},
266
+ // NULL-PROTOTYPE map: a model-authored `dimension` of `__proto__` or
267
+ // `constructor` would otherwise resolve against Object.prototype — the
268
+ // `__proto__` setter silently discards a primitive value and `constructor`
269
+ // string-concatenates the Object function — so the counter never
270
+ // accumulated and the meta-review's DIMENSION_SUM check false-positived.
271
+ // A null-prototype object has no inherited accessors, so every dimension
272
+ // (including those three names) is a plain own data property and
273
+ // JSON-serializes normally.
274
+ byDimension: Object.create(null),
203
275
  };
204
276
  for (const f of findings) {
205
277
  if (f.severity === 'critical')
@@ -223,7 +295,8 @@ function summarize(findings) {
223
295
  */
224
296
  export function buildReviewReport(input) {
225
297
  // 1. Filter known-intentional per round (before cross-round dedupe).
226
- const filteredRounds = input.rounds
298
+ const inputRounds = Array.isArray(input.rounds) ? input.rounds : [];
299
+ const filteredRounds = inputRounds
227
300
  .map((r) => ({
228
301
  round: typeof r?.round === 'number' ? r.round : 0,
229
302
  findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
@@ -246,7 +319,11 @@ export function buildReviewReport(input) {
246
319
  // non-contiguous round sets would otherwise read the wrong count).
247
320
  let lastRound = 0;
248
321
  for (const r of filteredRounds) {
249
- if (typeof r.round === 'number' && r.round > lastRound)
322
+ // Same validity rule as aggregateRounds: a fractional / NaN round is not
323
+ // a round, so it must never be used as an index into `findingsByRound`
324
+ // (Math.min(1.5, len) - 1 = 0.5 → reads `undefined` → counts as 0 new →
325
+ // a round that really did report findings would be read as "converged").
326
+ if (isValidRoundNumber(r.round) && r.round > lastRound)
250
327
  lastRound = r.round;
251
328
  }
252
329
  // `lastRound` can exceed the array length when over-cap rounds were FOLDED
@@ -255,7 +332,7 @@ export function buildReviewReport(input) {
255
332
  // new findings (which would falsely report "converged").
256
333
  const idx = Math.min(lastRound, findingsByRound.length) - 1;
257
334
  const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
258
- const converged = filteredRounds.length > 0 && lastRoundCount === 0;
335
+ const converged = filteredRounds.length > 0 && lastRound > 0 && lastRoundCount === 0;
259
336
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
260
337
  const computed = summarize(sorted);
261
338
  if (input.mode === 'normal' && typeof input.fixedCount === 'number' && Number.isInteger(input.fixedCount)) {
@@ -659,7 +736,9 @@ export function buildReviewPlan(input) {
659
736
  goal,
660
737
  scope,
661
738
  dimensions: dimensionTasks,
662
- maxReviewRounds: input.maxReviewRounds,
739
+ // Clamped with the SAME rule aggregateRounds honors, so the cap this plan
740
+ // advertises is never a number the aggregation math would silently clamp.
741
+ maxReviewRounds: clampMaxReviewRounds(input.maxReviewRounds),
663
742
  knownIntentional: input.knownIntentional ?? [],
664
743
  changedFiles: effectiveChangedOnly ? changedFiles : [],
665
744
  fallbackToFull,