iterate-plugin 3.5.1 → 3.5.3
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/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/context.js +39 -5
- package/dist/tools/decision-log.js +8 -1
- package/dist/tools/defense-events.js +41 -6
- package/dist/tools/defense-store.js +53 -2
- package/dist/tools/experience-bank.js +8 -8
- package/dist/tools/experience-store.js +58 -5
- package/dist/tools/fix.js +52 -19
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +29 -15
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/transcript.js +46 -11
- package/dist/transcript.js +20 -5
- package/lib/client.js +52 -10
- package/lib/parse.js +3 -0
- package/package.json +7 -6
- package/src/client/index.ts +30 -5
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/context.ts +36 -4
- package/src/tools/decision-log.ts +10 -1
- package/src/tools/defense-events.ts +43 -6
- package/src/tools/defense-store.ts +54 -5
- package/src/tools/experience-bank.ts +8 -8
- package/src/tools/experience-store.ts +67 -14
- package/src/tools/fix.ts +53 -21
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +25 -14
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/transcript.ts +47 -11
- package/src/transcript.ts +23 -5
package/src/tools/fix.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
21
21
|
import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
|
|
22
|
-
import { join, sep } from 'node:path'
|
|
22
|
+
import { dirname, join, sep } from 'node:path'
|
|
23
23
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
24
24
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
25
25
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -126,11 +126,25 @@ export function readRegistry(projectRoot: string): FixRegistry {
|
|
|
126
126
|
try {
|
|
127
127
|
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
|
|
128
128
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
|
|
129
|
-
// Defensive: a hand-edited or partially-written registry may
|
|
130
|
-
// round without a `records` array
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Defensive normalization: a hand-edited or partially-written registry may
|
|
130
|
+
// contain a round without a `records` array, or records that are missing
|
|
131
|
+
// their id / finding object — all of which would make readers
|
|
132
|
+
// (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
|
|
133
|
+
parsed.rounds = parsed.rounds
|
|
134
|
+
.filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
|
|
135
|
+
.map((r) => ({
|
|
136
|
+
...r,
|
|
137
|
+
round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
|
|
138
|
+
records: r.records.filter(
|
|
139
|
+
(rec): rec is FixRecord =>
|
|
140
|
+
!!rec &&
|
|
141
|
+
typeof rec === 'object' &&
|
|
142
|
+
typeof rec.id === 'string' &&
|
|
143
|
+
!!rec.finding &&
|
|
144
|
+
typeof rec.finding === 'object',
|
|
145
|
+
),
|
|
146
|
+
}))
|
|
147
|
+
.filter((r) => typeof r.round === 'number' && Number.isFinite(r.round))
|
|
134
148
|
return parsed
|
|
135
149
|
} catch {
|
|
136
150
|
return emptyRegistry()
|
|
@@ -207,15 +221,25 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
|
|
|
207
221
|
return { ok: false, reason: 'file resolves outside the project root' }
|
|
208
222
|
}
|
|
209
223
|
// Symlink containment: the lexical prefix check above does not resolve
|
|
210
|
-
// symlinks.
|
|
211
|
-
//
|
|
212
|
-
// a
|
|
213
|
-
|
|
224
|
+
// symlinks. Verify the REAL path of the target (when it exists) AND of its
|
|
225
|
+
// nearest existing ancestor directory (when it does not — e.g. a fix that
|
|
226
|
+
// creates a new file) stays inside the REAL project root, so a symlinked
|
|
227
|
+
// directory inside the repo can never route a fix (write/rollback/diff)
|
|
228
|
+
// outside the project. The probe stops at the project root itself: the root
|
|
229
|
+
// is harness-provided (not model-controlled), and a path with no existing
|
|
230
|
+
// entry inside the repo cannot hide a symlink.
|
|
231
|
+
let probe = resolved
|
|
232
|
+
while (!existsSync(probe) && probe !== projectRoot) {
|
|
233
|
+
const parent = dirname(probe)
|
|
234
|
+
if (parent === probe) break
|
|
235
|
+
probe = parent
|
|
236
|
+
}
|
|
237
|
+
if (existsSync(probe)) {
|
|
214
238
|
let rootReal: string
|
|
215
239
|
let real: string
|
|
216
240
|
try {
|
|
217
241
|
rootReal = realpathSync(projectRoot)
|
|
218
|
-
real = realpathSync(
|
|
242
|
+
real = realpathSync(probe)
|
|
219
243
|
} catch {
|
|
220
244
|
return { ok: false, reason: 'failed to resolve real path for containment check' }
|
|
221
245
|
}
|
|
@@ -492,9 +516,9 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
492
516
|
// Registry write failed → the file was already modified but no record
|
|
493
517
|
// exists, so a later rollback/diff could never see it and a retry would
|
|
494
518
|
// back up the already-fixed content as "original". Restore the file
|
|
495
|
-
// from the backup to leave the tree exactly as it was.
|
|
519
|
+
// from the backup atomically to leave the tree exactly as it was.
|
|
496
520
|
try {
|
|
497
|
-
|
|
521
|
+
writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'))
|
|
498
522
|
} catch (restoreErr) {
|
|
499
523
|
return {
|
|
500
524
|
ok: false,
|
|
@@ -611,19 +635,25 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
|
|
|
611
635
|
for (const round of registry.rounds) {
|
|
612
636
|
for (const r of round.records) {
|
|
613
637
|
if (!r.success) continue
|
|
614
|
-
const
|
|
638
|
+
const file = typeof r.finding.file === 'string' ? r.finding.file : ''
|
|
639
|
+
if (!file) continue
|
|
640
|
+
// Coerce defensively: a hand-edited registry record missing the
|
|
641
|
+
// numeric fields must not produce NaN in the accumulated summary.
|
|
642
|
+
const added = Number(r.linesAdded) || 0
|
|
643
|
+
const removed = Number(r.linesRemoved) || 0
|
|
644
|
+
const existing = files.find((f) => f.file === file)
|
|
615
645
|
if (existing) {
|
|
616
|
-
existing.linesAdded +=
|
|
617
|
-
existing.linesRemoved +=
|
|
646
|
+
existing.linesAdded += added
|
|
647
|
+
existing.linesRemoved += removed
|
|
618
648
|
// Recompute the summary from the summed counts so a multi-fix
|
|
619
649
|
// file's text does not contradict its accumulated numbers.
|
|
620
650
|
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`
|
|
621
651
|
} else {
|
|
622
652
|
files.push({
|
|
623
|
-
file
|
|
624
|
-
diffSummary: r.diffSummary
|
|
625
|
-
linesAdded:
|
|
626
|
-
linesRemoved:
|
|
653
|
+
file,
|
|
654
|
+
diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
|
|
655
|
+
linesAdded: added,
|
|
656
|
+
linesRemoved: removed,
|
|
627
657
|
})
|
|
628
658
|
}
|
|
629
659
|
}
|
|
@@ -705,7 +735,9 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
705
735
|
const target = resolveProjectFile(projectRoot, record.finding.file)
|
|
706
736
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
707
737
|
try {
|
|
708
|
-
|
|
738
|
+
// Atomic restore: never leave a truncated source file if we crash
|
|
739
|
+
// mid-restore (matches the writeTextAtomic guarantee used by apply).
|
|
740
|
+
writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'))
|
|
709
741
|
} catch (err) {
|
|
710
742
|
return { ok: false, error: `failed to restore backup: ${String(err)}` }
|
|
711
743
|
}
|
package/src/tools/history.ts
CHANGED
|
@@ -60,8 +60,10 @@ export function summarizeFixRegistry(registry: FixRegistry): {
|
|
|
60
60
|
} {
|
|
61
61
|
const rounds = (registry.rounds ?? []).map((r) => ({
|
|
62
62
|
round: r.round,
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Coerce defensively so a hand-edited registry round missing either count
|
|
64
|
+
// can never propagate NaN into the integer output fields.
|
|
65
|
+
fixedCount: Number(r.fixedCount) || 0,
|
|
66
|
+
failedCount: Number(r.failedCount) || 0,
|
|
65
67
|
}))
|
|
66
68
|
return {
|
|
67
69
|
totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
|
|
@@ -92,7 +94,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
92
94
|
type: 'string',
|
|
93
95
|
description:
|
|
94
96
|
'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
|
|
95
|
-
'revert, validation, decision, report.',
|
|
97
|
+
'revert, round_failed, validation, decision, report, resume.',
|
|
96
98
|
},
|
|
97
99
|
since: {
|
|
98
100
|
type: 'string',
|
package/src/tools/prune.ts
CHANGED
|
@@ -106,16 +106,22 @@ export function inspectPrune(
|
|
|
106
106
|
}
|
|
107
107
|
const staleBackups: string[] = []
|
|
108
108
|
const fixDir = fixesDir(projectRoot)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
// existsSync does not prove readability — a permissions failure must degrade
|
|
110
|
+
// to "nothing listable" (report skipped) instead of throwing out of execute.
|
|
111
|
+
let fixEntries: string[] = []
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(fixDir)) fixEntries = readdirSync(fixDir)
|
|
114
|
+
} catch {
|
|
115
|
+
fixEntries = []
|
|
116
|
+
}
|
|
117
|
+
for (const entry of fixEntries) {
|
|
118
|
+
if (!entry.endsWith('.bak')) continue
|
|
119
|
+
// Extract the fix-id prefix (up to the first underscore after the id).
|
|
120
|
+
// e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
|
|
121
|
+
const match = entry.match(/^(fix-[a-z0-9]+)_/)
|
|
122
|
+
const id = match?.[1]
|
|
123
|
+
if (id && !activeIds.has(id)) {
|
|
124
|
+
staleBackups.push(entry)
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
@@ -126,10 +132,15 @@ export function inspectPrune(
|
|
|
126
132
|
|
|
127
133
|
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
128
134
|
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
129
|
-
// ever reads another writer's temp file.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
// ever reads another writer's temp file. A permissions failure listing the
|
|
136
|
+
// dir degrades to "none found" (report-only) rather than throwing.
|
|
137
|
+
let iterateEntries: string[] = []
|
|
138
|
+
try {
|
|
139
|
+
iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : []
|
|
140
|
+
} catch {
|
|
141
|
+
iterateEntries = []
|
|
142
|
+
}
|
|
143
|
+
const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
|
|
133
144
|
|
|
134
145
|
return {
|
|
135
146
|
oldLogEntries,
|
|
@@ -13,6 +13,9 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
13
13
|
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
|
|
14
14
|
import type { QualityGateSnapshot } from '../types.ts'
|
|
15
15
|
|
|
16
|
+
/** Valid severity values (kept in sync with QualityGateSnapshot counting). */
|
|
17
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
|
|
18
|
+
|
|
16
19
|
/** Validate a single finding object; returns true when well-formed. */
|
|
17
20
|
function isValidFinding(raw: unknown): raw is { dimension: string; severity: string; file: string; line?: number } {
|
|
18
21
|
if (!raw || typeof raw !== 'object') return false
|
|
@@ -20,6 +23,7 @@ function isValidFinding(raw: unknown): raw is { dimension: string; severity: str
|
|
|
20
23
|
return (
|
|
21
24
|
typeof f.dimension === 'string' &&
|
|
22
25
|
typeof f.severity === 'string' &&
|
|
26
|
+
VALID_SEVERITIES.has(f.severity) &&
|
|
23
27
|
typeof f.file === 'string' &&
|
|
24
28
|
(f.line === undefined || typeof f.line === 'number')
|
|
25
29
|
)
|
|
@@ -232,15 +232,20 @@ export function computeQualityGate(opts: {
|
|
|
232
232
|
? Math.round(dimensionGates.reduce((sum, d) => sum + d.score, 0) / dimensionGates.length)
|
|
233
233
|
: 0
|
|
234
234
|
|
|
235
|
-
// Determine overall status
|
|
235
|
+
// Determine overall status. An empty review (no dimensions, no findings, no
|
|
236
|
+
// validation results) is NOT a failure — there is simply nothing to gate —
|
|
237
|
+
// so it reports `pending` (not-applicable) instead of a fabricated FAIL.
|
|
236
238
|
const hasCritical = criticalCount > 0
|
|
237
239
|
const hasHighFail = dimensionGates.some((d) => d.status === 'fail')
|
|
238
240
|
const verificationFails = totalChecks > 0 && failedChecks > 0
|
|
241
|
+
const noData = dimensionGates.length === 0 && totalFindings === 0 && totalChecks === 0
|
|
239
242
|
|
|
240
243
|
let overallStatus: 'pass' | 'fail' | 'pending' = 'pass'
|
|
241
244
|
let failReason: string | undefined
|
|
242
245
|
|
|
243
|
-
if (
|
|
246
|
+
if (noData) {
|
|
247
|
+
overallStatus = 'pending'
|
|
248
|
+
} else if (hasCritical) {
|
|
244
249
|
overallStatus = 'fail'
|
|
245
250
|
failReason = `${criticalCount} critical findings present`
|
|
246
251
|
} else if (hasHighFail) {
|
package/src/tools/transcript.ts
CHANGED
|
@@ -44,7 +44,10 @@ function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
|
|
|
44
44
|
findings?: unknown
|
|
45
45
|
readFiles?: unknown
|
|
46
46
|
}
|
|
47
|
-
const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0
|
|
47
|
+
const roundNo = typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0
|
|
48
|
+
// NaN round values (`Math.floor(NaN) === NaN`, and `NaN <= 0` is false) would
|
|
49
|
+
// fall through the guard below; a finite check rejects them outright so a
|
|
50
|
+
// malformed round can never collapse into round 1 via the builder's coercion.
|
|
48
51
|
if (roundNo <= 0) return
|
|
49
52
|
builder.roundStart(roundNo)
|
|
50
53
|
const findings = Array.isArray(r.findings) ? r.findings : []
|
|
@@ -70,14 +73,16 @@ function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
|
|
|
70
73
|
function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
|
|
71
74
|
if (!input || typeof input !== 'object') return null
|
|
72
75
|
const c = input as Record<string, unknown>
|
|
73
|
-
|
|
76
|
+
// Number.isFinite: a NaN round must not pass `round <= 0` (which is false for
|
|
77
|
+
// NaN) and later coerce into a phantom round 1 row.
|
|
78
|
+
const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
|
|
74
79
|
if (round <= 0) return null
|
|
75
80
|
return {
|
|
76
81
|
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
|
|
77
82
|
round,
|
|
78
|
-
maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
|
|
79
|
-
fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
|
|
80
|
-
resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
|
|
83
|
+
maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
|
|
84
|
+
fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
|
|
85
|
+
resumeCount: typeof c.resumeCount === 'number' && Number.isFinite(c.resumeCount) ? c.resumeCount : 0,
|
|
81
86
|
updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
|
|
82
87
|
}
|
|
83
88
|
}
|
|
@@ -92,7 +97,7 @@ function normalizeFix(input: unknown): TranscriptFix | null {
|
|
|
92
97
|
return {
|
|
93
98
|
id,
|
|
94
99
|
timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
|
|
95
|
-
round: typeof f.round === 'number' ? f.round : 0,
|
|
100
|
+
round: typeof f.round === 'number' && Number.isFinite(f.round) ? f.round : 0,
|
|
96
101
|
file,
|
|
97
102
|
summary: typeof f.summary === 'string' ? f.summary : '',
|
|
98
103
|
linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
|
|
@@ -224,11 +229,24 @@ export function registerTranscriptTool(ctx: {
|
|
|
224
229
|
manifest = null
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
233
|
+
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
234
|
+
// builder so the steering text still lands.
|
|
235
|
+
let builder: ReviewTranscriptBuilder
|
|
236
|
+
if (manifest) {
|
|
237
|
+
try {
|
|
238
|
+
builder = rehydrateBuilder(manifest, approval)
|
|
239
|
+
} catch {
|
|
240
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
|
|
244
|
+
}
|
|
230
245
|
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
|
|
231
|
-
await
|
|
246
|
+
const persisted = await persistChecked(file, builder.serialize())
|
|
247
|
+
if (!persisted.ok) {
|
|
248
|
+
return { operation: 'nudge', updated: false, error: persisted.error }
|
|
249
|
+
}
|
|
232
250
|
return {
|
|
233
251
|
operation: 'nudge',
|
|
234
252
|
updated: true,
|
|
@@ -291,7 +309,10 @@ export function registerTranscriptTool(ctx: {
|
|
|
291
309
|
const last = convergence[convergence.length - 1]
|
|
292
310
|
if (convergence.length > 0 && last === 0) builder.finish()
|
|
293
311
|
|
|
294
|
-
await
|
|
312
|
+
const persisted = await persistChecked(file, builder.serialize())
|
|
313
|
+
if (!persisted.ok) {
|
|
314
|
+
return { operation: 'capture', found: true, updated: false, error: persisted.error }
|
|
315
|
+
}
|
|
295
316
|
const live = await readLive(projectRoot)
|
|
296
317
|
return {
|
|
297
318
|
operation: 'capture',
|
|
@@ -342,6 +363,21 @@ async function persist(file: string, manifest: TranscriptManifest): Promise<void
|
|
|
342
363
|
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
|
|
343
364
|
}
|
|
344
365
|
|
|
366
|
+
/** persist wrapped with a structured error channel so disk failures surface
|
|
367
|
+
* as a structured tool result instead of rejecting the whole execute (matches
|
|
368
|
+
* every other write path in this codebase). */
|
|
369
|
+
async function persistChecked(
|
|
370
|
+
file: string,
|
|
371
|
+
manifest: TranscriptManifest,
|
|
372
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
373
|
+
try {
|
|
374
|
+
await persist(file, manifest)
|
|
375
|
+
return { ok: true }
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
345
381
|
/**
|
|
346
382
|
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
347
383
|
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|
package/src/transcript.ts
CHANGED
|
@@ -53,6 +53,11 @@ const MAX_TIMELINE = 500
|
|
|
53
53
|
* grow the manifest payload without limit). */
|
|
54
54
|
const MAX_FIXES = 200
|
|
55
55
|
|
|
56
|
+
/** Max round number accepted by the builder. A model-authored/manifest-backed
|
|
57
|
+
* round value is attacker-influenced JSON: `roundStart(1e9)` / `snapshotConvergence`
|
|
58
|
+
* would otherwise preallocate arrays of that size and OOM the host. */
|
|
59
|
+
const MAX_ROUNDS = 1000
|
|
60
|
+
|
|
56
61
|
/** Thresholds applied when reducing a string list under a cap. */
|
|
57
62
|
function clampStringList(source: string[], cap: number): string[] {
|
|
58
63
|
const out: string[] = []
|
|
@@ -207,9 +212,13 @@ export class ReviewTranscriptBuilder {
|
|
|
207
212
|
/** Open a review round, capturing the current round index. */
|
|
208
213
|
roundStart(round: number, maxRounds?: number): void {
|
|
209
214
|
const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
|
|
210
|
-
|
|
215
|
+
// Clamp to MAX_ROUNDS: `this.rounds.length < this.round` below preallocates
|
|
216
|
+
// an array of size `round` — an unbounded model-controlled value (e.g. 1e9)
|
|
217
|
+
// would OOM the host. Values above the cap are folded into the cap so the
|
|
218
|
+
// real rounds are never silently dropped.
|
|
219
|
+
this.round = Math.min(r > 0 ? r : 1, MAX_ROUNDS)
|
|
211
220
|
if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
|
|
212
|
-
this.maxRounds = Math.floor(maxRounds)
|
|
221
|
+
this.maxRounds = Math.min(Math.floor(maxRounds), MAX_ROUNDS)
|
|
213
222
|
}
|
|
214
223
|
while (this.rounds.length < this.round) {
|
|
215
224
|
this.rounds.push({ round: this.rounds.length + 1, threads: [] })
|
|
@@ -294,7 +303,10 @@ export class ReviewTranscriptBuilder {
|
|
|
294
303
|
|
|
295
304
|
/** Record a round's new-finding count for the convergence series. */
|
|
296
305
|
snapshotConvergence(round: number, newCount: number): void {
|
|
297
|
-
const
|
|
306
|
+
const rawR = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
|
|
307
|
+
// Clamp (mirrors roundStart): the loop below preallocates `r` slots, so an
|
|
308
|
+
// unbounded model-controlled round number would OOM the host.
|
|
309
|
+
const r = Math.min(rawR > 0 ? rawR : 1, MAX_ROUNDS)
|
|
298
310
|
const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0
|
|
299
311
|
while (this.convergence.length < r) this.convergence.push(-1)
|
|
300
312
|
this.convergence[r - 1] = Math.floor(n)
|
|
@@ -312,7 +324,10 @@ export class ReviewTranscriptBuilder {
|
|
|
312
324
|
this.fixes.push({
|
|
313
325
|
id,
|
|
314
326
|
timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
|
|
315
|
-
round:
|
|
327
|
+
round:
|
|
328
|
+
typeof record.round === 'number' && Number.isFinite(record.round)
|
|
329
|
+
? Math.floor(record.round)
|
|
330
|
+
: this.round,
|
|
316
331
|
file,
|
|
317
332
|
summary: typeof record.summary === 'string' ? record.summary : '',
|
|
318
333
|
linesAdded:
|
|
@@ -363,7 +378,10 @@ export class ReviewTranscriptBuilder {
|
|
|
363
378
|
const type = typeof entry.type === 'string' ? entry.type : 'decision'
|
|
364
379
|
this.timeline.push({
|
|
365
380
|
timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
|
|
366
|
-
round:
|
|
381
|
+
round:
|
|
382
|
+
typeof entry.round === 'number' && Number.isFinite(entry.round)
|
|
383
|
+
? Math.floor(entry.round)
|
|
384
|
+
: this.round,
|
|
367
385
|
type,
|
|
368
386
|
data:
|
|
369
387
|
entry.data && typeof entry.data === 'object'
|