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/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 contain a
130
- // round without a `records` array — normalize instead of crashing readers.
131
- parsed.rounds = parsed.rounds.filter(
132
- (r) => r && typeof r === 'object' && Array.isArray(r.records),
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. If the target exists, verify its REAL path stays inside the REAL
211
- // project root so a symlinked directory/file inside the repo can never route
212
- // a fix (write/rollback/diff) outside the project.
213
- if (existsSync(resolved)) {
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(resolved)
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
- copyFileSync(backupPath, target.resolved)
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 existing = files.find((f) => f.file === r.finding.file)
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 += r.linesAdded
617
- existing.linesRemoved += r.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: r.finding.file,
624
- diffSummary: r.diffSummary,
625
- linesAdded: r.linesAdded,
626
- linesRemoved: r.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
- copyFileSync(record.backupPath, target.resolved)
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
  }
@@ -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
- fixedCount: r.fixedCount,
64
- failedCount: r.failedCount,
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',
@@ -106,16 +106,22 @@ export function inspectPrune(
106
106
  }
107
107
  const staleBackups: string[] = []
108
108
  const fixDir = fixesDir(projectRoot)
109
- if (existsSync(fixDir)) {
110
- for (const entry of readdirSync(fixDir)) {
111
- if (!entry.endsWith('.bak')) continue
112
- // Extract the fix-id prefix (up to the first underscore after the id).
113
- // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
114
- const match = entry.match(/^(fix-[a-z0-9]+)_/)
115
- const id = match?.[1]
116
- if (id && !activeIds.has(id)) {
117
- staleBackups.push(entry)
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
- const staleTemps = existsSync(iterateDir(projectRoot))
131
- ? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
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 (hasCritical) {
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) {
@@ -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
- const round = typeof c.round === 'number' ? c.round : 0
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
- const builder = manifest
228
- ? rehydrateBuilder(manifest, approval)
229
- : new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
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 persist(file, builder.serialize())
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 persist(file, builder.serialize())
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
- this.round = r > 0 ? r : 1
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 r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
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: typeof record.round === 'number' ? record.round : this.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: typeof entry.round === 'number' ? entry.round : this.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'