iterate-plugin 3.5.4 → 3.5.5

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/src/transcript.ts CHANGED
@@ -71,6 +71,20 @@ function clampStringList(source: string[], cap: number): string[] {
71
71
  return out
72
72
  }
73
73
 
74
+ /**
75
+ * Deep-clone a decision-timeline payload so it is decoupled from the caller's
76
+ * live object. structuredClone is available in every supported Node release;
77
+ * on the off chance it is missing, fall back to a JSON round-trip (decision
78
+ * payloads are JSON-serializable by construction).
79
+ */
80
+ function cloneDecisionData(data: Record<string, unknown>): Record<string, unknown> {
81
+ try {
82
+ return structuredClone(data)
83
+ } catch {
84
+ return JSON.parse(JSON.stringify(data)) as Record<string, unknown>
85
+ }
86
+ }
87
+
74
88
  /** Normalize a single finding, dropping malformed entries. */
75
89
  function normalizeFinding(input: unknown): TranscriptFinding | null {
76
90
  if (!input || typeof input !== 'object') return null
@@ -113,6 +127,19 @@ interface LiveRound {
113
127
  threads: LiveThread[]
114
128
  }
115
129
 
130
+ /**
131
+ * Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
132
+ * with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
133
+ * A single oversized fixer agent could otherwise OOM the client with an
134
+ * unbounded finding dump inside one thread.
135
+ */
136
+ function pushThreadFinding(thread: LiveThread, raw: unknown): void {
137
+ thread.findings.push(raw)
138
+ if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
139
+ thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD)
140
+ }
141
+ }
142
+
116
143
  /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
117
144
  function mergeReportIntoThread(
118
145
  thread: LiveThread,
@@ -120,8 +147,7 @@ function mergeReportIntoThread(
120
147
  readFiles: readonly unknown[] | undefined,
121
148
  ): void {
122
149
  for (const raw of findings) {
123
- const f = normalizeFinding(raw)
124
- if (f) thread.findings.push(raw)
150
+ if (normalizeFinding(raw)) pushThreadFinding(thread, raw)
125
151
  }
126
152
  for (const r of readFiles ?? []) {
127
153
  if (typeof r === 'string') thread.readFiles.push(r)
@@ -146,6 +172,7 @@ export class ReviewTranscriptBuilder {
146
172
  private readonly rounds: LiveRound[] = []
147
173
  private readonly convergence: number[] = []
148
174
  private readonly globalFindings: TranscriptFinding[] = []
175
+ private readonly globalSeenKeys = new Set<string>()
149
176
  private readonly fixes: TranscriptFix[] = []
150
177
  private checkpoint: TranscriptCheckpoint | null = null
151
178
  private readonly timeline: TranscriptEntry[] = []
@@ -282,12 +309,11 @@ export class ReviewTranscriptBuilder {
282
309
  for (const raw of findings) {
283
310
  const f = normalizeFinding(raw)
284
311
  if (f) {
285
- thread.findings.push(raw)
286
- this.globalFindings.push(f)
312
+ pushThreadFinding(thread, raw)
313
+ this.addGlobal(f)
287
314
  }
288
315
  }
289
316
  }
290
- this.reevaluateGlobal()
291
317
  this.touch()
292
318
  }
293
319
 
@@ -299,9 +325,8 @@ export class ReviewTranscriptBuilder {
299
325
  mergeReportIntoThread(thread, findings, readFiles)
300
326
  for (const raw of findings) {
301
327
  const f = normalizeFinding(raw)
302
- if (f) this.globalFindings.push(f)
328
+ if (f) this.addGlobal(f)
303
329
  }
304
- this.reevaluateGlobal()
305
330
  this.touch()
306
331
  }
307
332
 
@@ -390,8 +415,12 @@ export class ReviewTranscriptBuilder {
390
415
  : this.round,
391
416
  type,
392
417
  data:
393
- entry.data && typeof entry.data === 'object'
394
- ? (entry.data as Record<string, unknown>)
418
+ entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
419
+ // Deep clone so a caller mutating its own object later can never
420
+ // alias into the timeline (serialize would then emit the mutated
421
+ // values instead of what was actually decided). Decision-log payloads
422
+ // are small JSON, so the clone cost is negligible.
423
+ ? cloneDecisionData(entry.data as Record<string, unknown>)
395
424
  : {},
396
425
  })
397
426
  if (this.timeline.length > MAX_TIMELINE) {
@@ -441,10 +470,9 @@ export class ReviewTranscriptBuilder {
441
470
  maxRounds: this.maxRounds,
442
471
  rounds,
443
472
  convergence: this.convergence,
444
- findings:
445
- this.globalFindings.length > MAX_FINDINGS_TOTAL
446
- ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
447
- : this.globalFindings,
473
+ // addGlobal maintains the newest-wins cap incrementally, so the array is
474
+ // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
475
+ findings: this.globalFindings,
448
476
  fixes: this.fixes,
449
477
  checkpoint: this.checkpoint,
450
478
  timeline: this.timeline,
@@ -477,22 +505,24 @@ export class ReviewTranscriptBuilder {
477
505
  return live.threads.length
478
506
  }
479
507
 
480
- /** Recompute the global finding list from per-thread findings (dedup). */
481
- private reevaluateGlobal(): void {
482
- // Rebuild from threads to derive a deterministic global list.
483
- this.globalFindings.length = 0
484
- const seen = new Set<string>()
485
- for (const r of this.rounds) {
486
- for (const t of r.threads) {
487
- for (const raw of t.findings) {
488
- const f = normalizeFinding(raw)
489
- if (!f) continue
490
- const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
491
- if (seen.has(key)) continue
492
- seen.add(key)
493
- this.globalFindings.push(f)
494
- if (this.globalFindings.length >= MAX_FINDINGS_TOTAL) return
495
- }
508
+ /**
509
+ * Insert one deduplicated finding into the compact global list.
510
+ * NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
511
+ * kept finding is evicted so fresh, still-relevant findings survive — this
512
+ * is the documented contract. Amortized O(1): dedupe via a seen-key set and
513
+ * eviction is one `shift` per overflow insert.
514
+ */
515
+ private addGlobal(f: TranscriptFinding): void {
516
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
517
+ if (this.globalSeenKeys.has(key)) return
518
+ this.globalSeenKeys.add(key)
519
+ this.globalFindings.push(f)
520
+ if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
521
+ const evicted = this.globalFindings.shift()
522
+ if (evicted) {
523
+ this.globalSeenKeys.delete(
524
+ `${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`,
525
+ )
496
526
  }
497
527
  }
498
528
  }