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