iterate-plugin 2.9.4 → 2.11.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.
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Checkpoint layout: `.iterate/checkpoint.json`.
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { resolveProjectRootForExec } from "../config-loader.js";
14
14
  import { checkpointPath, iterateDir } from "../paths.js";
@@ -89,7 +89,9 @@ export function computeStatus(input) {
89
89
  totalRounds,
90
90
  fixedCount,
91
91
  architecturalCount,
92
- findingsCount: checkpoint?.findings.length ?? 0,
92
+ // A checkpoint may predate the `findings` field (or be hand-edited) — a
93
+ // missing findings must degrade to 0, never throw.
94
+ findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
93
95
  totalDecisionLogEntries: entries.length,
94
96
  hasCheckpoint: checkpoint !== null,
95
97
  // A checkpoint left on disk means the previous run was interrupted before
@@ -187,7 +189,12 @@ export function registerCheckpointTool(ctx) {
187
189
  };
188
190
  try {
189
191
  mkdirSync(iterateDir(projectRoot), { recursive: true });
190
- writeFileSync(checkpointPath(projectRoot), JSON.stringify(checkpoint, null, 2), 'utf-8');
192
+ // Atomic write (temp + rename): a crash mid-write must not corrupt
193
+ // the checkpoint and silently lose the interruption state.
194
+ const cpPath = checkpointPath(projectRoot);
195
+ const tmpPath = `${cpPath}.tmp-${Date.now()}`;
196
+ writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
197
+ renameSync(tmpPath, cpPath);
191
198
  }
192
199
  catch (err) {
193
200
  return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` };
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync } from 'node:fs';
1
+ import { readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { join, dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -241,7 +241,8 @@ export function registerContextTool(ctx) {
241
241
  return { found: false, error: resolved.reason, searched: [] };
242
242
  }
243
243
  const projectRoot = resolved.root;
244
- const requested = (args.files ?? '')
244
+ // Guard: `files` must be a comma-separated string (model-controlled).
245
+ const requested = (typeof args.files === 'string' ? args.files : '')
245
246
  .split(',')
246
247
  .map((s) => s.trim().toLowerCase())
247
248
  .filter(Boolean);
@@ -262,8 +263,19 @@ export function registerContextTool(ctx) {
262
263
  // are all supported.
263
264
  const skillRoot = findSkillRoot(PLUGIN_SRC_DIR);
264
265
  const candidates = [];
265
- if (args.skillDir)
266
- candidates.push(args.skillDir);
266
+ // skillDir is a model-controlled path; only honor it when it is an
267
+ // existing directory (resolve it first) — otherwise fall through to
268
+ // the auto-detected root / project root.
269
+ if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
270
+ try {
271
+ const dir = resolve(args.skillDir);
272
+ if (existsSync(dir) && statSync(dir).isDirectory())
273
+ candidates.push(dir);
274
+ }
275
+ catch {
276
+ // unreadable/invalid skillDir — skip it
277
+ }
278
+ }
267
279
  if (skillRoot)
268
280
  candidates.push(skillRoot);
269
281
  candidates.push(projectRoot);
@@ -45,12 +45,20 @@ function logPath(projectRoot) {
45
45
  }
46
46
  /**
47
47
  * Append one entry to the decision log (JSONL format).
48
- * Returns the entry count after appending.
48
+ * Returns the entry count after appending. Never throws — a disk failure is
49
+ * surfaced through `error` so callers (fix/prune) can report the audit-trail
50
+ * miss without failing the mutation they already performed.
49
51
  */
50
52
  export function appendDecisionEntry(projectRoot, entry) {
51
- const filePath = logPath(projectRoot);
52
- const line = JSON.stringify(entry) + '\n';
53
- appendFileSync(filePath, line, 'utf-8');
53
+ let filePath;
54
+ try {
55
+ filePath = logPath(projectRoot);
56
+ const line = JSON.stringify(entry) + '\n';
57
+ appendFileSync(filePath, line, 'utf-8');
58
+ }
59
+ catch (err) {
60
+ return { count: -1, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
61
+ }
54
62
  // Count entries
55
63
  let count = 0;
56
64
  try {
@@ -64,21 +72,33 @@ export function appendDecisionEntry(projectRoot, entry) {
64
72
  }
65
73
  /**
66
74
  * Read all entries from the decision log.
75
+ * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
76
+ * one bad line must never empty the whole history for every reader.
67
77
  */
68
78
  export function readDecisionEntries(projectRoot) {
69
79
  const filePath = join(projectRoot, LOG_DIR, LOG_FILE);
70
80
  if (!existsSync(filePath))
71
81
  return [];
82
+ let content;
72
83
  try {
73
- const content = readFileSync(filePath, 'utf-8');
74
- return content
75
- .split('\n')
76
- .filter((l) => l.trim().length > 0)
77
- .map((l) => JSON.parse(l));
84
+ content = readFileSync(filePath, 'utf-8');
78
85
  }
79
86
  catch {
80
87
  return [];
81
88
  }
89
+ const out = [];
90
+ for (const line of content.split('\n')) {
91
+ const trimmed = line.trim();
92
+ if (trimmed.length === 0)
93
+ continue;
94
+ try {
95
+ out.push(JSON.parse(trimmed));
96
+ }
97
+ catch {
98
+ // skip the corrupt line, keep the rest
99
+ }
100
+ }
101
+ return out;
82
102
  }
83
103
  /**
84
104
  * Register the `iterate_decision_log` tool.
package/dist/tools/fix.js CHANGED
@@ -16,8 +16,8 @@
16
16
  * - Backups are written before any write, so a failure never destroys data.
17
17
  * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
18
  */
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
- import { join } from 'node:path';
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
20
+ import { join, sep } from 'node:path';
21
21
  import { defineTool } from '@deepseek-ai/dsh-tools';
22
22
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
23
23
  import { countTouchedMethods } from "../method-scope.js";
@@ -117,6 +117,9 @@ export function readRegistry(projectRoot) {
117
117
  const parsed = JSON.parse(readFileSync(file, 'utf-8'));
118
118
  if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
119
119
  return emptyRegistry();
120
+ // Defensive: a hand-edited or partially-written registry may contain a
121
+ // round without a `records` array — normalize instead of crashing readers.
122
+ parsed.rounds = parsed.rounds.filter((r) => r && typeof r === 'object' && Array.isArray(r.records));
120
123
  return parsed;
121
124
  }
122
125
  catch {
@@ -191,9 +194,64 @@ export function resolveProjectFile(projectRoot, file) {
191
194
  if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
192
195
  return { ok: false, reason: 'file resolves outside the project root' };
193
196
  }
197
+ // Symlink containment: the lexical prefix check above does not resolve
198
+ // symlinks. If the target exists, verify its REAL path stays inside the REAL
199
+ // project root so a symlinked directory/file inside the repo can never route
200
+ // a fix (write/rollback/diff) outside the project.
201
+ if (existsSync(resolved)) {
202
+ let rootReal;
203
+ let real;
204
+ try {
205
+ rootReal = realpathSync(projectRoot);
206
+ real = realpathSync(resolved);
207
+ }
208
+ catch {
209
+ return { ok: false, reason: 'failed to resolve real path for containment check' };
210
+ }
211
+ const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep;
212
+ if (real !== rootReal && !real.startsWith(rootPrefix)) {
213
+ return { ok: false, reason: 'file resolves outside the project root (symlink escape)' };
214
+ }
215
+ }
194
216
  return { ok: true, resolved };
195
217
  }
196
218
  // ─── Shared execute helpers ──────────────────────────────────────────────────
219
+ /**
220
+ * Minimal glob matcher for personalization.protected_paths.
221
+ * Supports `*` (any run of chars within one segment) and `**` (any chars,
222
+ * including separators). All other characters are literal. Pure, unit-testable.
223
+ */
224
+ export function globMatch(path, pattern) {
225
+ if (typeof path !== 'string' || typeof pattern !== 'string')
226
+ return false;
227
+ // Escape regex specials except our two wildcards.
228
+ let re = '';
229
+ for (let i = 0; i < pattern.length; i++) {
230
+ const ch = pattern[i];
231
+ if (ch === '*') {
232
+ const isDouble = pattern[i + 1] === '*';
233
+ if (isDouble) {
234
+ re += '[\\s\\S]*';
235
+ i++;
236
+ }
237
+ else {
238
+ re += '[^/\\\\]*';
239
+ }
240
+ }
241
+ else if ('.[]{}()+-^$|?'.includes(ch)) {
242
+ re += '\\' + ch;
243
+ }
244
+ else {
245
+ re += ch;
246
+ }
247
+ }
248
+ try {
249
+ return new RegExp('^' + re + '$').test(path);
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ }
197
255
  /** Read the current content of a file under the project root. */
198
256
  function readProjectFile(projectRoot, file) {
199
257
  const resolved = resolveProjectFile(projectRoot, file);
@@ -304,6 +362,28 @@ export function registerFixTool(ctx) {
304
362
  if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
305
363
  return { ok: false, error: 'finding.dimension must be a non-empty string' };
306
364
  }
365
+ // The finding must reference the file being fixed — the fix id and the
366
+ // rollback/diff target are derived from finding.file, so a mismatch
367
+ // would back up/restore the WRONG file.
368
+ if (finding.file !== file) {
369
+ return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` };
370
+ }
371
+ // Full finding validation, mirroring the review schema: malformed
372
+ // findings would produce lossy registry/log entries and a degraded id.
373
+ const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low']);
374
+ if (!SEVERITY_SET.has(finding.severity)) {
375
+ return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' };
376
+ }
377
+ if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
378
+ return { ok: false, error: 'finding.summary must be a non-empty string' };
379
+ }
380
+ if (typeof finding.is_atomic !== 'boolean') {
381
+ return { ok: false, error: 'finding.is_atomic must be a boolean' };
382
+ }
383
+ if (finding.line !== undefined && finding.line !== null &&
384
+ (typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
385
+ return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' };
386
+ }
307
387
  const current = readProjectFile(projectRoot, file);
308
388
  if (!current.ok)
309
389
  return { ok: false, error: current.reason };
@@ -332,6 +412,27 @@ export function registerFixTool(ctx) {
332
412
  const target = resolveProjectFile(projectRoot, file);
333
413
  if (!target.ok)
334
414
  return { ok: false, error: target.reason };
415
+ // Personalization guards (SKILL.md Phase 2): protected_paths veto the
416
+ // fix outright; forbidden_fixes veto fix approaches appearing in the
417
+ // new content. Both are security-relevant, so they are enforced here
418
+ // in the tool, not left to the model.
419
+ const pers = config.personalization;
420
+ const protectedPaths = Array.isArray(pers?.protected_paths)
421
+ ? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
422
+ : [];
423
+ for (const pattern of protectedPaths) {
424
+ if (globMatch(file, pattern)) {
425
+ return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
426
+ }
427
+ }
428
+ const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
429
+ ? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
430
+ : [];
431
+ for (const forbidden of forbiddenFixes) {
432
+ if (args.content.includes(forbidden)) {
433
+ return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
434
+ }
435
+ }
335
436
  const timestamp = new Date().toISOString();
336
437
  const backupPath = fixBackupPath(projectRoot, id, timestamp);
337
438
  try {
@@ -363,7 +464,20 @@ export function registerFixTool(ctx) {
363
464
  writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
364
465
  }
365
466
  catch (err) {
366
- return { ok: false, error: `failed to write fix registry: ${String(err)}` };
467
+ // Registry write failed the file was already modified but no record
468
+ // exists, so a later rollback/diff could never see it and a retry would
469
+ // back up the already-fixed content as "original". Restore the file
470
+ // from the backup to leave the tree exactly as it was.
471
+ try {
472
+ copyFileSync(backupPath, target.resolved);
473
+ }
474
+ catch (restoreErr) {
475
+ return {
476
+ ok: false,
477
+ error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
478
+ };
479
+ }
480
+ return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
367
481
  }
368
482
  appendDecisionEntry(projectRoot, {
369
483
  timestamp,
@@ -467,6 +581,9 @@ export function registerDiffTool(ctx) {
467
581
  if (existing) {
468
582
  existing.linesAdded += r.linesAdded;
469
583
  existing.linesRemoved += r.linesRemoved;
584
+ // Recompute the summary from the summed counts so a multi-fix
585
+ // file's text does not contradict its accumulated numbers.
586
+ existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`;
470
587
  }
471
588
  else {
472
589
  files.push({
@@ -17,12 +17,12 @@
17
17
  * - dryRun=true by default — the caller must explicitly opt into deletion.
18
18
  * - Each deletion is logged to the decision log (when not dry-run).
19
19
  */
20
- import { existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools';
23
23
  import { resolveProjectRootForExec } from "../config-loader.js";
24
24
  import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
25
- import { readRegistry, removeRecord, recomputeRoundCounts } from "./fix.js";
25
+ import { readRegistry, recomputeRoundCounts } from "./fix.js";
26
26
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
27
27
  /** Default retention for decision-log entries (in days). */
28
28
  const DEFAULT_RETAIN_DAYS = 30;
@@ -101,13 +101,17 @@ export function executePrune(projectRoot, retainDays, report) {
101
101
  trimmedEmptyRounds: 0,
102
102
  errors: [],
103
103
  };
104
- // 1. Rewrite the decision log, keeping only recent entries.
104
+ // 1. Rewrite the decision log, keeping only recent entries. Atomic
105
+ // (temp + rename) so a crash mid-write can never truncate the log.
105
106
  try {
106
107
  const entries = readDecisionEntries(projectRoot);
107
108
  const kept = entries.filter((e) => e.timestamp >= cutoff);
108
109
  result.deletedLogEntries = entries.length - kept.length;
109
110
  if (result.deletedLogEntries > 0) {
110
- writeFileSync(join(iterateDir(projectRoot), 'decision-log.jsonl'), kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
111
+ const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
112
+ const tmpPath = `${logPath}.tmp-${Date.now()}`;
113
+ writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
114
+ renameSync(tmpPath, logPath);
111
115
  }
112
116
  }
113
117
  catch (err) {
@@ -138,11 +142,14 @@ export function executePrune(projectRoot, retainDays, report) {
138
142
  if (report.emptyRounds.length > 0) {
139
143
  try {
140
144
  let registry = readRegistry(projectRoot);
141
- for (const round of report.emptyRounds) {
142
- for (const rec of [...registry.rounds.find((r) => r.round === round)?.records ?? []]) {
143
- registry = removeRecord(registry, rec.id);
144
- }
145
- }
145
+ const emptyRoundNos = new Set(report.emptyRounds);
146
+ // Drop whole empty rounds (records.length === 0) instead of only
147
+ // removing their records — an empty round has no records to remove, so
148
+ // the old loop was a no-op that still reported trimmedEmptyRounds.
149
+ registry = {
150
+ ...registry,
151
+ rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
152
+ };
146
153
  registry = recomputeRoundCounts(registry);
147
154
  writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8');
148
155
  result.trimmedEmptyRounds = report.emptyRounds.length;
@@ -145,7 +145,10 @@ export function registerReviewTool(ctx) {
145
145
  .map((r) => {
146
146
  const rr = r;
147
147
  const findings = Array.isArray(rr?.findings) ? rr.findings : [];
148
- return { round: typeof rr?.round === 'number' ? rr.round : 0, findings };
148
+ const readFiles = Array.isArray(rr?.readFiles)
149
+ ? rr.readFiles.filter((f) => typeof f === 'string')
150
+ : [];
151
+ return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
149
152
  })
150
153
  .filter((r) => r.round > 0);
151
154
  if (rounds.length === 0) {
@@ -1,4 +1,4 @@
1
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import yaml from 'js-yaml';
@@ -194,19 +194,22 @@ function applyEntries(projectRoot, incoming) {
194
194
  writeFileSync(configPath, yamlText, 'utf-8');
195
195
  }
196
196
  catch (err) {
197
- // Rollback: restore the backup (or delete the file we just created).
197
+ // Rollback: restore the backup, or REMOVE the file we just created when
198
+ // there was no prior config — an empty file left behind would poison all
199
+ // future config reads (empty YAML is not a valid mapping).
200
+ let rollbackError = '';
198
201
  try {
199
202
  if (backupPath)
200
203
  copyFileSync(backupPath, configPath);
201
204
  else if (existsSync(configPath))
202
- writeFileSync(configPath, '', 'utf-8');
205
+ rmSync(configPath, { force: true });
203
206
  }
204
- catch {
205
- // Rollback failure is reported, not swallowed silently.
207
+ catch (rbErr) {
208
+ rollbackError = `; rollback also failed: ${String(rbErr)}`;
206
209
  }
207
210
  return {
208
211
  ok: false,
209
- error: `Failed to write config: ${String(err)}`,
212
+ error: `Failed to write config: ${String(err)}${rollbackError}`,
210
213
  };
211
214
  }
212
215
  return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
@@ -30,10 +30,13 @@ async function runCommand(command, cwd, timeoutMs) {
30
30
  env: { ...process.env, PAGER: 'cat' },
31
31
  }, (error, stdout, stderr) => {
32
32
  const durationMs = Math.round(performance.now() - start);
33
- // error.code is the exit code when the command ran; error.killed means timeout
33
+ // error.code is the exit code when the command ran; when the binary
34
+ // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
35
+ // Coerce to a number so the integer output schema is never violated.
36
+ const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0);
34
37
  resolve({
35
38
  command,
36
- exitCode: error?.code ?? (error ? 1 : 0),
39
+ exitCode,
37
40
  stdout: stdout ?? '',
38
41
  stderr: stderr ?? '',
39
42
  timedOut: error?.killed === true,