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/dist/tools/fix.js CHANGED
@@ -364,6 +364,7 @@ export function registerFixTool(ctx) {
364
364
  linesRemoved: { type: 'integer' },
365
365
  diffSummary: { type: 'string' },
366
366
  backupPath: { type: 'string' },
367
+ warning: { type: 'string' },
367
368
  error: { type: 'string' },
368
369
  },
369
370
  },
@@ -451,6 +452,16 @@ export function registerFixTool(ctx) {
451
452
  if (findFixRecord(registry, id)) {
452
453
  return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
453
454
  }
455
+ // No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
456
+ // file unchanged) must never burn a backup, a write, or a registry/success
457
+ // record. Placed after the registry check so a re-sent fix of an id that
458
+ // was ALREADY fixed is still reported as "already fixed this run".
459
+ if (added === 0 && removed === 0) {
460
+ return {
461
+ ok: false,
462
+ error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
463
+ };
464
+ }
454
465
  const target = resolveProjectFile(projectRoot, file);
455
466
  if (!target.ok)
456
467
  return { ok: false, error: target.reason };
@@ -521,12 +532,15 @@ export function registerFixTool(ctx) {
521
532
  }
522
533
  return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
523
534
  }
524
- appendDecisionEntry(projectRoot, {
535
+ const logRes = appendDecisionEntry(projectRoot, {
525
536
  timestamp,
526
537
  round: args.round,
527
538
  type: 'atomic_fix',
528
539
  data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
529
540
  });
541
+ // The fix itself succeeded, but a decision-log write failure would leave
542
+ // the audit trail incomplete — surface it (warning, not fatal) so the
543
+ // model/UI knows the record was not persisted.
530
544
  return {
531
545
  ok: true,
532
546
  id,
@@ -536,6 +550,7 @@ export function registerFixTool(ctx) {
536
550
  linesRemoved: removed,
537
551
  diffSummary: record.diffSummary,
538
552
  backupPath,
553
+ ...(logRes.error ? { warning: logRes.error } : {}),
539
554
  };
540
555
  });
541
556
  return result;
@@ -696,6 +711,7 @@ export function registerRollbackTool(ctx) {
696
711
  ok: { type: 'boolean', required: true },
697
712
  id: { type: 'string' },
698
713
  file: { type: 'string' },
714
+ warning: { type: 'string' },
699
715
  error: { type: 'string' },
700
716
  },
701
717
  },
@@ -736,7 +752,7 @@ export function registerRollbackTool(ctx) {
736
752
  catch (err) {
737
753
  return { ok: false, error: `failed to update fix registry: ${String(err)}` };
738
754
  }
739
- appendDecisionEntry(projectRoot, {
755
+ const logRes = appendDecisionEntry(projectRoot, {
740
756
  timestamp: new Date().toISOString(),
741
757
  round: record.round,
742
758
  type: 'revert',
@@ -747,7 +763,12 @@ export function registerRollbackTool(ctx) {
747
763
  // a missing/corrupt transcript is left untouched and never breaks the
748
764
  // rollback flow.
749
765
  await markFixRolledBackInTranscript(projectRoot, id);
750
- return { ok: true, id, file: record.finding.file };
766
+ return {
767
+ ok: true,
768
+ id,
769
+ file: record.finding.file,
770
+ ...(logRes.error ? { warning: logRes.error } : {}),
771
+ };
751
772
  },
752
773
  }));
753
774
  }
@@ -28,7 +28,7 @@ import { join } from 'node:path';
28
28
  import { defineTool } from '@deepseek-ai/dsh-tools';
29
29
  import { resolveProjectRootForExec } from "../config-loader.js";
30
30
  import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
31
- import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
31
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
32
32
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
33
33
  import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
34
34
  import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
@@ -234,26 +234,36 @@ export function inspectPrune(projectRoot, retainDays) {
234
234
  */
235
235
  function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
236
236
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
237
- for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
238
- try {
239
- const entries = readDecisionEntries(projectRoot);
240
- const kept = entries.filter((e) => e.timestamp >= cutoff);
241
- const deleted = entries.length - kept.length;
242
- if (deleted === 0)
243
- return { deleted: 0 };
244
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
245
- // A concurrent appender may have landed new lines after our read. If the
246
- // log grew during the write window, re-read and prune again instead of
247
- // accepting a lost audit trail.
248
- const after = readDecisionEntries(projectRoot);
249
- if (after.length <= kept.length)
250
- return { deleted };
251
- }
252
- catch (err) {
253
- return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
237
+ // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
238
+ // no concurrent appender can slip a fresh line into the read-before-rewrite
239
+ // window, so the first attempt is nearly always the only one. (The retry
240
+ // loop stays as defense-in-depth for the lock-unavailable path.)
241
+ const release = acquireLogLock(projectRoot);
242
+ try {
243
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
244
+ try {
245
+ const entries = readDecisionEntries(projectRoot);
246
+ const kept = entries.filter((e) => e.timestamp >= cutoff);
247
+ const deleted = entries.length - kept.length;
248
+ if (deleted === 0)
249
+ return { deleted: 0 };
250
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
251
+ // A concurrent appender (cross-process, lock-unavailable path) may have
252
+ // landed new lines after our read. If the log grew during the write
253
+ // window, re-read and prune again instead of accepting a lost audit trail.
254
+ const after = readDecisionEntries(projectRoot);
255
+ if (after.length <= kept.length)
256
+ return { deleted };
257
+ }
258
+ catch (err) {
259
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
260
+ }
254
261
  }
262
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
263
+ }
264
+ finally {
265
+ release();
255
266
  }
256
- return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
257
267
  }
258
268
  /**
259
269
  * Execute the prune: apply every cleanable item reported by `inspectPrune`.
@@ -459,8 +469,10 @@ export function registerPruneTool(ctx) {
459
469
  };
460
470
  }
461
471
  const result = executePrune(projectRoot, retainDays, report);
462
- // Log the prune to the decision log.
463
- appendDecisionEntry(projectRoot, {
472
+ // Log the prune to the decision log. The mutations already happened, so
473
+ // a failed log append is reported via `errors` — a silent audit miss
474
+ // (F2) must never pretend every deletion was recorded.
475
+ const logRes = appendDecisionEntry(projectRoot, {
464
476
  timestamp: new Date().toISOString(),
465
477
  round: 0,
466
478
  type: 'decision',
@@ -475,6 +487,8 @@ export function registerPruneTool(ctx) {
475
487
  deletedDefenseEvents: result.deletedDefenseEvents,
476
488
  },
477
489
  });
490
+ if (logRes.error)
491
+ result.errors.push(logRes.error);
478
492
  return {
479
493
  ok: true,
480
494
  dryRun: false,
@@ -184,8 +184,9 @@ function readConfigFile(configPath) {
184
184
  return {};
185
185
  const content = readFileSync(configPath, 'utf-8');
186
186
  const parsed = yaml.load(content);
187
- if (!parsed || typeof parsed !== 'object') {
188
- // A config that exists but is not a YAML mapping must NOT be silently
187
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
188
+ // A config that exists but is NOT a YAML mapping (including a YAML list,
189
+ // which `typeof [] === 'object'` alone would accept) must NOT be silently
189
190
  // treated as empty: writing over it would destroy user data. Callers
190
191
  // surface this as an error and refuse to write.
191
192
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
@@ -54,6 +54,20 @@ function clampStringList(source, cap) {
54
54
  }
55
55
  return out;
56
56
  }
57
+ /**
58
+ * Deep-clone a decision-timeline payload so it is decoupled from the caller's
59
+ * live object. structuredClone is available in every supported Node release;
60
+ * on the off chance it is missing, fall back to a JSON round-trip (decision
61
+ * payloads are JSON-serializable by construction).
62
+ */
63
+ function cloneDecisionData(data) {
64
+ try {
65
+ return structuredClone(data);
66
+ }
67
+ catch {
68
+ return JSON.parse(JSON.stringify(data));
69
+ }
70
+ }
57
71
  /** Normalize a single finding, dropping malformed entries. */
58
72
  function normalizeFinding(input) {
59
73
  if (!input || typeof input !== 'object')
@@ -81,12 +95,23 @@ function normalizeFinding(input) {
81
95
  acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
82
96
  };
83
97
  }
98
+ /**
99
+ * Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
100
+ * with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
101
+ * A single oversized fixer agent could otherwise OOM the client with an
102
+ * unbounded finding dump inside one thread.
103
+ */
104
+ function pushThreadFinding(thread, raw) {
105
+ thread.findings.push(raw);
106
+ if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
107
+ thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD);
108
+ }
109
+ }
84
110
  /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
85
111
  function mergeReportIntoThread(thread, findings, readFiles) {
86
112
  for (const raw of findings) {
87
- const f = normalizeFinding(raw);
88
- if (f)
89
- thread.findings.push(raw);
113
+ if (normalizeFinding(raw))
114
+ pushThreadFinding(thread, raw);
90
115
  }
91
116
  for (const r of readFiles ?? []) {
92
117
  if (typeof r === 'string')
@@ -111,6 +136,7 @@ export class ReviewTranscriptBuilder {
111
136
  rounds = [];
112
137
  convergence = [];
113
138
  globalFindings = [];
139
+ globalSeenKeys = new Set();
114
140
  fixes = [];
115
141
  checkpoint = null;
116
142
  timeline = [];
@@ -237,12 +263,11 @@ export class ReviewTranscriptBuilder {
237
263
  for (const raw of findings) {
238
264
  const f = normalizeFinding(raw);
239
265
  if (f) {
240
- thread.findings.push(raw);
241
- this.globalFindings.push(f);
266
+ pushThreadFinding(thread, raw);
267
+ this.addGlobal(f);
242
268
  }
243
269
  }
244
270
  }
245
- this.reevaluateGlobal();
246
271
  this.touch();
247
272
  }
248
273
  /** Merge a round-level report snapshot (findings + readFiles) into a thread. */
@@ -255,9 +280,8 @@ export class ReviewTranscriptBuilder {
255
280
  for (const raw of findings) {
256
281
  const f = normalizeFinding(raw);
257
282
  if (f)
258
- this.globalFindings.push(f);
283
+ this.addGlobal(f);
259
284
  }
260
- this.reevaluateGlobal();
261
285
  this.touch();
262
286
  }
263
287
  // ─── Convergence (F2) ───────────────────────────────────────────────────
@@ -337,8 +361,12 @@ export class ReviewTranscriptBuilder {
337
361
  ? Math.floor(entry.round)
338
362
  : this.round,
339
363
  type,
340
- data: entry.data && typeof entry.data === 'object'
341
- ? entry.data
364
+ data: entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
365
+ // Deep clone so a caller mutating its own object later can never
366
+ // alias into the timeline (serialize would then emit the mutated
367
+ // values instead of what was actually decided). Decision-log payloads
368
+ // are small JSON, so the clone cost is negligible.
369
+ ? cloneDecisionData(entry.data)
342
370
  : {},
343
371
  });
344
372
  if (this.timeline.length > MAX_TIMELINE) {
@@ -385,9 +413,9 @@ export class ReviewTranscriptBuilder {
385
413
  maxRounds: this.maxRounds,
386
414
  rounds,
387
415
  convergence: this.convergence,
388
- findings: this.globalFindings.length > MAX_FINDINGS_TOTAL
389
- ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
390
- : this.globalFindings,
416
+ // addGlobal maintains the newest-wins cap incrementally, so the array is
417
+ // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
418
+ findings: this.globalFindings,
391
419
  fixes: this.fixes,
392
420
  checkpoint: this.checkpoint,
393
421
  timeline: this.timeline,
@@ -416,25 +444,23 @@ export class ReviewTranscriptBuilder {
416
444
  threadCount(live) {
417
445
  return live.threads.length;
418
446
  }
419
- /** Recompute the global finding list from per-thread findings (dedup). */
420
- reevaluateGlobal() {
421
- // Rebuild from threads to derive a deterministic global list.
422
- this.globalFindings.length = 0;
423
- const seen = new Set();
424
- for (const r of this.rounds) {
425
- for (const t of r.threads) {
426
- for (const raw of t.findings) {
427
- const f = normalizeFinding(raw);
428
- if (!f)
429
- continue;
430
- const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
431
- if (seen.has(key))
432
- continue;
433
- seen.add(key);
434
- this.globalFindings.push(f);
435
- if (this.globalFindings.length >= MAX_FINDINGS_TOTAL)
436
- return;
437
- }
447
+ /**
448
+ * Insert one deduplicated finding into the compact global list.
449
+ * NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
450
+ * kept finding is evicted so fresh, still-relevant findings survive — this
451
+ * is the documented contract. Amortized O(1): dedupe via a seen-key set and
452
+ * eviction is one `shift` per overflow insert.
453
+ */
454
+ addGlobal(f) {
455
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
456
+ if (this.globalSeenKeys.has(key))
457
+ return;
458
+ this.globalSeenKeys.add(key);
459
+ this.globalFindings.push(f);
460
+ if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
461
+ const evicted = this.globalFindings.shift();
462
+ if (evicted) {
463
+ this.globalSeenKeys.delete(`${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`);
438
464
  }
439
465
  }
440
466
  }
package/lib/client.js CHANGED
@@ -465,6 +465,10 @@ function normalizeTranscript(manifest) {
465
465
  phases: asArray(safeGet(src, "phases")).map((p) => typeof p === "string" ? p : ""),
466
466
  round: asNum(safeGet(src, "round")),
467
467
  maxRounds: asNum(safeGet(src, "maxRounds")),
468
+ // v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
469
+ // aborted_by_validation / aborted_by_config). Required for the
470
+ // observatory badge to render the stop reason instead of a bare "已结束".
471
+ stoppedReason: src.stoppedReason === null || src.stoppedReason === void 0 ? null : asStr(safeGet(src, "stoppedReason")) || null,
468
472
  rounds,
469
473
  convergence: asArray(safeGet(src, "convergence")).map((n) => asCount(n)),
470
474
  findings: asArray(safeGet(src, "findings")).map((f) => ({ ...f })),
package/lib/parse.js CHANGED
@@ -581,6 +581,12 @@ export function normalizeTranscript(manifest) {
581
581
  phases: asArray(safeGet(src, 'phases')).map((p) => (typeof p === 'string' ? p : '')),
582
582
  round: asNum(safeGet(src, 'round')),
583
583
  maxRounds: asNum(safeGet(src, 'maxRounds')),
584
+ // v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
585
+ // aborted_by_validation / aborted_by_config). Required for the
586
+ // observatory badge to render the stop reason instead of a bare "已结束".
587
+ stoppedReason: src.stoppedReason === null || src.stoppedReason === undefined
588
+ ? null
589
+ : asStr(safeGet(src, 'stoppedReason')) || null,
584
590
  rounds,
585
591
  convergence: asArray(safeGet(src, 'convergence')).map((n) => asCount(n)),
586
592
  findings: asArray(safeGet(src, 'findings')).map((f) => ({ ...f })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.4",
3
+ "version": "3.5.5",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,8 @@
38
38
  "0.1.5-alpha.2": "compatible",
39
39
  "0.1.5-rc.1": "compatible",
40
40
  "0.1.5-rc.2": "compatible",
41
- "0.1.6-alpha.1": "compatible"
41
+ "0.1.6-alpha.1": "compatible",
42
+ "0.1.6-alpha.2": "compatible"
42
43
  }
43
44
  },
44
45
  "client": {
@@ -76,14 +77,14 @@
76
77
  },
77
78
  "dependencies": {
78
79
  "@deepseek-ai/cordis": "4.0.2",
79
- "@deepseek-ai/dsh-tools": "0.1.6-alpha.1",
80
- "@deepseek-ai/dsh-util-values": "0.1.6-alpha.1",
80
+ "@deepseek-ai/dsh-tools": "0.1.6-alpha.2",
81
+ "@deepseek-ai/dsh-util-values": "0.1.6-alpha.2",
81
82
  "js-yaml": "4.3.2"
82
83
  },
83
84
  "devDependencies": {
84
- "@deepseek-ai/dsh-agent": "0.1.6-alpha.1",
85
- "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.1",
86
- "@deepseek-ai/dsh-session": "0.1.6-alpha.1",
85
+ "@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
86
+ "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
87
+ "@deepseek-ai/dsh-session": "0.1.6-alpha.2",
87
88
  "@types/js-yaml": "4.0.9",
88
89
  "@types/node": "22.15.0",
89
90
  "@types/react": "19.2.2",
package/src/atomic-fs.ts CHANGED
@@ -54,11 +54,19 @@ function tempPathFor(filePath: string): string {
54
54
  */
55
55
  export function writeTextAtomic(filePath: string, data: string): void {
56
56
  const tmp = tempPathFor(filePath)
57
- writeFileSync(tmp, data, 'utf-8')
57
+ try {
58
+ writeFileSync(tmp, data, 'utf-8')
59
+ } catch (err) {
60
+ // The write itself failed (disk full, permission, …): the temp file may
61
+ // already be partially written, so remove it before propagating — a
62
+ // failed write must never litter the directory either.
63
+ cleanupTemp(tmp)
64
+ throw err
65
+ }
58
66
  try {
59
67
  renameSync(tmp, filePath)
60
68
  } catch (err) {
61
- // Best-effort temp cleanup so a failed write never litters the directory.
69
+ // Best-effort temp cleanup so a failed rename never litters the directory.
62
70
  cleanupTemp(tmp)
63
71
  throw err
64
72
  }
@@ -67,11 +75,19 @@ export function writeTextAtomic(filePath: string, data: string): void {
67
75
  /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
68
76
  export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
69
77
  const tmp = tempPathFor(filePath)
70
- await writeFile(tmp, data, 'utf-8')
78
+ try {
79
+ await writeFile(tmp, data, 'utf-8')
80
+ } catch (err) {
81
+ // The write itself failed (disk full, permission, …): the temp file may
82
+ // already be partially written, so remove it before propagating — a
83
+ // failed write must never litter the directory either.
84
+ await cleanupTempAsync(tmp)
85
+ throw err
86
+ }
71
87
  try {
72
88
  await rename(tmp, filePath)
73
89
  } catch (err) {
74
- // Best-effort temp cleanup so a failed write never litters the directory.
90
+ // Best-effort temp cleanup so a failed rename never litters the directory.
75
91
  await cleanupTempAsync(tmp)
76
92
  throw err
77
93
  }
@@ -12,7 +12,10 @@ export function loadConfig(projectRoot: string): IterateConfig | null {
12
12
  try {
13
13
  const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8')
14
14
  const parsed = yaml.load(content) as Record<string, unknown>
15
- if (!parsed || typeof parsed !== 'object') return null
15
+ // A YAML sequence root (e.g. a list of findings or a `- foo` file) must
16
+ // not masquerade as a config object — treating it as one would merge its
17
+ // indices into the config (config bomb). Reject it, not coerce it.
18
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
16
19
  return parsed as unknown as IterateConfig
17
20
  } catch {
18
21
  return null
@@ -158,7 +161,7 @@ export function flattenCommands(
158
161
  */
159
162
  export function validateConfig(config: unknown): string[] {
160
163
  const errors: string[] = []
161
- if (!config || typeof config !== 'object') {
164
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
162
165
  errors.push('root')
163
166
  return errors
164
167
  }
@@ -206,7 +209,9 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
206
209
  * the DSH runtime injects per-session env into tool sub-processes).
207
210
  */
208
211
  export function resolveProjectRoot(input?: string, sessionCwd?: string): ProjectRootResult {
209
- const raw = (input ?? '').trim()
212
+ // A non-string input (number, array, …) from a hostile tool invocation must
213
+ // be treated as "no explicit path" instead of crashing on `.trim()`.
214
+ const raw = (typeof input === 'string' ? input : '').trim()
210
215
  // A NUL byte can never name a real path and makes `resolve()` (and every
211
216
  // downstream fs call) throw — treat it as unsafe input, not a throw path.
212
217
  if (raw.includes('\0')) {
@@ -10,8 +10,8 @@
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
12
 
13
- import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
14
- import { join } from 'node:path'
13
+ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
14
+ import { basename, dirname, join } from 'node:path'
15
15
  import yaml from 'js-yaml'
16
16
  import { writeTextAtomic } from './atomic-fs.ts'
17
17
 
@@ -23,6 +23,46 @@ export function configBackupSuffix(now = new Date()): string {
23
23
  return now.toISOString().replace(/[:.]/g, '-')
24
24
  }
25
25
 
26
+ /** Upper bound on configurable iteration rounds (config bomb guard). */
27
+ export const MAX_MAX_ROUNDS = 100
28
+
29
+ /** Upper bound on `atomic.max_lines` (a single fix never needs more). */
30
+ export const MAX_ATOMIC_MAX_LINES = 10_000
31
+
32
+ /** Upper bound on `atomic.max_adjacent_methods`. */
33
+ export const MAX_MAX_ADJACENT_METHODS = 200
34
+
35
+ /** Keep at most this many timestamped config backups (older ones are removed). */
36
+ export const MAX_CONFIG_BACKUPS = 5
37
+
38
+ /**
39
+ * Bound the timestamped config backups: after a fresh one is written, delete
40
+ * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
41
+ * filesystem failure here must never fail the write that just succeeded.
42
+ * @returns the absolute paths of the backups that were removed.
43
+ */
44
+ export function pruneOldConfigBackups(
45
+ configPath: string,
46
+ keep = MAX_CONFIG_BACKUPS,
47
+ ): string[] {
48
+ const removed: string[] = []
49
+ try {
50
+ const dir = dirname(configPath)
51
+ const prefix = `${basename(configPath)}.bak-`
52
+ const matches = existsSync(dir)
53
+ ? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
54
+ : []
55
+ const doomed = matches.slice(0, Math.max(0, matches.length - keep))
56
+ for (const f of doomed) {
57
+ rmSync(join(dir, f), { force: true })
58
+ removed.push(join(dir, f))
59
+ }
60
+ } catch {
61
+ // Best-effort cleanup — never surface a cleanup failure.
62
+ }
63
+ return removed
64
+ }
65
+
26
66
  /**
27
67
  * Validate a partial config update.
28
68
  * Returns an array of error strings (empty when the update is valid).
@@ -45,8 +85,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
45
85
  }
46
86
  }
47
87
  if ('max_rounds' in updates) {
48
- if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
49
- errors.push('updates.max_rounds must be a positive integer')
88
+ if (
89
+ typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
90
+ updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS
91
+ ) {
92
+ errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
50
93
  }
51
94
  }
52
95
  if ('review' in updates) {
@@ -62,11 +105,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
62
105
  if (!a || typeof a !== 'object') {
63
106
  errors.push('updates.atomic must be an object')
64
107
  } else {
65
- if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
66
- errors.push('updates.atomic.max_lines must be a positive integer')
108
+ if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
109
+ errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`)
67
110
  }
68
- if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
69
- errors.push('updates.atomic.max_adjacent_methods must be a non-negative number')
111
+ if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
112
+ errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`)
70
113
  }
71
114
  }
72
115
  }
@@ -168,7 +211,10 @@ export function readRawConfig(configPath: string): Record<string, unknown> {
168
211
  } catch {
169
212
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
170
213
  }
171
- if (!parsed || typeof parsed !== 'object') {
214
+ // A YAML sequence root (array) must not masquerade as a config object —
215
+ // `typeof [] === 'object'`, so the presence check alone would accept a
216
+ // config file that is actually a list. Writing over it would destroy data.
217
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
172
218
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
173
219
  }
174
220
  return parsed as Record<string, unknown>
@@ -207,5 +253,9 @@ export function writeConfigFile(
207
253
  return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
208
254
  }
209
255
 
256
+ // Success: bound the accumulation of timestamped backups so a long-lived
257
+ // project never collects an unbounded pile of config snapshots.
258
+ if (backupPath) pruneOldConfigBackups(configPath)
259
+
210
260
  return { ok: true, backupPath }
211
261
  }
package/src/evidence.ts CHANGED
@@ -125,8 +125,36 @@ export function verifyFinding(
125
125
  input: Locatable,
126
126
  opts: { readSet?: Set<string> } = {},
127
127
  ): FindingEvidence {
128
- const relFile = input.file ?? ''
128
+ // A null element / non-object finding (hostile or model-authored JSON) must
129
+ // be reported as poisoned evidence, never crash `path.resolve` downstream.
130
+ if (!input || typeof input !== 'object') {
131
+ return {
132
+ file: '',
133
+ line: null,
134
+ lineTotal: null,
135
+ resolvedPath: null,
136
+ verified: false,
137
+ error: 'file_not_found',
138
+ }
139
+ }
140
+ // A non-string `file` (number, object, …) would make `resolve` throw
141
+ // ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
142
+ const relFile = typeof input.file === 'string' ? input.file : ''
129
143
  const line = typeof input.line === 'number' ? input.line : null
144
+
145
+ // "" resolves to the project ROOT (resolve(root, '') === root), which then
146
+ // trips the not-a-regular-file branch as `line_out_of_range` — but there is
147
+ // no file reference at all. Report it as `file_not_found` upfront.
148
+ if (relFile === '') {
149
+ return {
150
+ file: '',
151
+ line,
152
+ lineTotal: null,
153
+ resolvedPath: null,
154
+ verified: false,
155
+ error: 'file_not_found',
156
+ }
157
+ }
130
158
  const resolved = resolveWithin(root, relFile)
131
159
 
132
160
  if (resolved === null || !existsSync(resolved)) {