iterate-plugin 2.10.0 → 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.
@@ -114,20 +114,23 @@ function collectChanged(changedFiles: string[]): string[] {
114
114
  }
115
115
 
116
116
  function collectFull(root: string): string[] {
117
- // Deterministic recursive walk built on Node's fs; a code reviewer never
118
- // anchors findings to lock files, images, or vendored builds.
117
+ // Deterministic iterative walk (explicit stack unbounded recursion could
118
+ // overflow on pathologically deep trees); a code reviewer never anchors
119
+ // findings to lock files, images, or vendored builds.
119
120
  const out: string[] = []
120
- const walk = (dir: string): void => {
121
+ const stack: string[] = [root]
122
+ while (stack.length > 0) {
123
+ const dir = stack.pop()!
121
124
  let entries: import('node:fs').Dirent[]
122
125
  try {
123
126
  entries = readdirSync(dir, { withFileTypes: true })
124
127
  } catch {
125
- return
128
+ continue
126
129
  }
127
130
  for (const entry of entries) {
128
131
  const abs = join(dir, entry.name)
129
132
  if (entry.isDirectory()) {
130
- if (!isIgnoredDir(entry.name)) walk(abs)
133
+ if (!isIgnoredDir(entry.name)) stack.push(abs)
131
134
  continue
132
135
  }
133
136
  if (!entry.isFile()) continue
@@ -136,13 +139,14 @@ function collectFull(root: string): string[] {
136
139
  out.push(rel.split(SEP).join(SEP))
137
140
  }
138
141
  }
139
- walk(root)
140
142
  return out.sort()
141
143
  }
142
144
 
143
145
  /** Split `files` into stable batches, keeping directory runs together. */
144
146
  export function chunkFiles(files: string[], perChunk?: number): string[][] {
145
- const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk
147
+ // Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
148
+ // chunk (current.length >= NaN is never true).
149
+ const size = Number.isFinite(perChunk) && (perChunk as number) >= 1 ? (perChunk as number) : DEFAULT_SCOPE_CHUNK_SIZE
146
150
  const ordered = [...files].sort()
147
151
  const chunks: string[][] = []
148
152
  let current: string[] = []
package/src/review.ts CHANGED
@@ -44,9 +44,11 @@ export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
44
44
  const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low
45
45
  const bySeverity = rankA - rankB
46
46
  if (bySeverity !== 0) return bySeverity
47
- const byFile = a.file.localeCompare(b.file)
47
+ // Defensive coercion: `file`/`line` can be wrong-typed when schema
48
+ // validation is disabled — String()/Number() keep the comparator total.
49
+ const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''))
48
50
  if (byFile !== 0) return byFile
49
- return (a.line ?? 0) - (b.line ?? 0)
51
+ return (Number(a.line) || 0) - (Number(b.line) || 0)
50
52
  })
51
53
  }
52
54
 
@@ -58,9 +60,15 @@ export function normalizeSummary(summary: string): string {
58
60
  .replace(/[\s\n\t]+/g, ' ')
59
61
  }
60
62
 
61
- /** Dedupe key: same file + same dimension + similar summary. */
63
+ /**
64
+ * Dedupe key: same file + same dimension + similar summary + explicit line.
65
+ * Including the line keeps two genuine issues with identical wording at
66
+ * different locations from collapsing into one (the line is omitted only when
67
+ * neither side anchors one, i.e. whole-file findings).
68
+ */
62
69
  export function findingKey(f: ReviewFinding): string {
63
- return `${f.file}|${f.dimension}|${normalizeSummary(f.summary)}`
70
+ const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0
71
+ return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`
64
72
  }
65
73
 
66
74
  /**
@@ -129,12 +137,19 @@ export function aggregateRounds(
129
137
  const merged: ReviewFinding[] = []
130
138
 
131
139
  // Guard: round numbers are expected to be positive integers. Skip malformed
132
- // entries defensively rather than letting `firstRoundByKey` key on NaN/0.
140
+ // entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
141
+ // crashing on null / non-array findings.
142
+ // Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
143
+ // 1e9) would otherwise allocate an array of that size below (OOM). Round
144
+ // numbers above the configured cap are clamped to the cap.
133
145
  let maxRound = 0
146
+ const roundCap = Math.max(1, maxReviewRounds)
134
147
  for (const round of rounds) {
148
+ if (!round || typeof round !== 'object') continue
135
149
  if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
150
+ const findings = Array.isArray(round.findings) ? round.findings : []
136
151
  if (round.round > maxRound) maxRound = round.round
137
- for (const f of round.findings) {
152
+ for (const f of findings) {
138
153
  const key = findingKey(f)
139
154
  if (seen.has(key)) continue
140
155
  seen.add(key)
@@ -142,9 +157,11 @@ export function aggregateRounds(
142
157
  merged.push(f)
143
158
  }
144
159
  }
160
+ // Clamp the allocation bound so a hostile round number cannot OOM the tool.
161
+ const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
145
162
 
146
163
  const findingsByRound: number[] = []
147
- for (let r = 1; r <= maxRound; r++) {
164
+ for (let r = 1; r <= effectiveMax; r++) {
148
165
  let count = 0
149
166
  for (const key of firstRoundByKey.keys()) {
150
167
  if (firstRoundByKey.get(key) === r) count++
@@ -165,12 +182,18 @@ export function computeConvergence(
165
182
  const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
166
183
  const totalRounds = rounds.length
167
184
  // `findingsByRound` is indexed by the actual round number (round r → index
168
- // r-1), so convergence must read the LAST PRESENT round's count using its
169
- // reported round number — not `totalRounds - 1`, which is only valid for
170
- // contiguous 1..N round numbers.
171
- const lastRound = totalRounds > 0 ? rounds[totalRounds - 1]!.round : 0
172
- const lastRoundCount =
173
- lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
185
+ // r-1), sized to the highest present round (clamped). Convergence must read
186
+ // the HIGHEST PRESENT round's count — not the last array element (rounds
187
+ // may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
188
+ // 1..N). The count index is bounded by the array length aggregateRounds
189
+ // actually allocated.
190
+ let lastRound = 0
191
+ for (const round of rounds) {
192
+ if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
193
+ if (round.round > lastRound) lastRound = round.round
194
+ }
195
+ const idx = Math.min(lastRound, findingsByRound.length) - 1
196
+ const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
174
197
  const converged = totalRounds > 0 && lastRoundCount === 0
175
198
  return {
176
199
  totalRounds,
@@ -224,8 +247,12 @@ export function buildReviewReport(input: {
224
247
  }): ReviewReport {
225
248
  // 1. Filter known-intentional per round (before cross-round dedupe).
226
249
  const filteredRounds = input.rounds.map((r) => ({
227
- round: r.round,
228
- findings: filterKnownIntentional(r.findings, input.knownIntentional),
250
+ round: typeof r?.round === 'number' ? r.round : 0,
251
+ findings: filterKnownIntentional(
252
+ Array.isArray(r?.findings) ? r.findings : [],
253
+ input.knownIntentional,
254
+ ),
255
+ readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
229
256
  }))
230
257
 
231
258
  // 2. Cross-round dedupe + per-round "first seen" tracking.
@@ -262,6 +289,9 @@ export function buildReviewReport(input: {
262
289
  maxReviewRounds: input.maxReviewRounds,
263
290
  rounds: filteredRounds,
264
291
  findings: sorted,
292
+ // Aggregate of every round's self-reported reads, so the meta-review
293
+ // coverage gate can compare against the assigned inventory.
294
+ readFiles: ([] as string[]).concat(...filteredRounds.map((r) => r.readFiles ?? [])),
265
295
  convergence: {
266
296
  totalRounds: filteredRounds.length,
267
297
  findingsByRound,
@@ -470,8 +500,8 @@ export interface RoundSchemaValidation {
470
500
  */
471
501
  export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
472
502
  return rounds.map((r) => {
473
- const issues = validateFindingsSchema(r.findings)
474
- return { round: r.round, valid: issues.length === 0, issues }
503
+ const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : [])
504
+ return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues }
475
505
  })
476
506
  }
477
507
 
@@ -491,21 +521,27 @@ export function sanitizeRounds(
491
521
  schemaValidation: RoundSchemaValidation[] | null,
492
522
  ): ReviewRound[] {
493
523
  return rounds.map((r, i) => {
524
+ // Defensive: malformed rounds must never crash the deterministic core.
525
+ const findings = Array.isArray(r?.findings) ? r.findings : []
526
+ const roundNo = typeof r?.round === 'number' ? r.round : 0
527
+ const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : []
494
528
  if (schemaValidation) {
495
529
  const issues = schemaValidation[i]?.issues ?? []
496
- if (issues.some((iss) => iss.index === -1)) return { round: r.round, findings: [] }
530
+ if (issues.some((iss) => iss.index === -1)) return { round: roundNo, findings: [], readFiles }
497
531
  const bad = new Set(issues.map((iss) => iss.index))
498
532
  return {
499
- round: r.round,
500
- findings: r.findings.filter((_, fi) => !bad.has(fi)),
533
+ round: roundNo,
534
+ findings: findings.filter((_, fi) => !bad.has(fi)),
535
+ readFiles,
501
536
  }
502
537
  }
503
538
  return {
504
- round: r.round,
505
- findings: r.findings.filter(
539
+ round: roundNo,
540
+ findings: findings.filter(
506
541
  (f): f is ReviewFinding =>
507
542
  Boolean(f) && typeof f === 'object' && !Array.isArray(f),
508
543
  ),
544
+ readFiles,
509
545
  }
510
546
  })
511
547
  }
@@ -539,6 +575,12 @@ export function reviewerTaskPrompt(input: {
539
575
  * `changedFiles`.
540
576
  */
541
577
  scopeFiles?: string[]
578
+ /**
579
+ * Per-dimension focus guidance (from personalization.dimension_focus, or the
580
+ * skill's dimension definitions). Appended to the reviewer prompt so the
581
+ * review concentrates on the areas the user cares about.
582
+ */
583
+ focus?: string
542
584
  }): string {
543
585
  const parts: string[] = []
544
586
  parts.push(
@@ -546,6 +588,9 @@ export function reviewerTaskPrompt(input: {
546
588
  `Goal: ${input.goal}`,
547
589
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
548
590
  )
591
+ if (input.focus) {
592
+ parts.push(`FOCUS: ${input.focus}`)
593
+ }
549
594
  if (input.scopeFiles && input.scopeFiles.length > 0) {
550
595
  parts.push(
551
596
  'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
@@ -592,9 +637,9 @@ export function reviewerTaskPrompt(input: {
592
637
  parts.push(
593
638
  `Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
594
639
  `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
595
- 'line (REQUIRED positive integer — the exact line you READ for an ' +
596
- 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
597
- 'issues), severity (critical/high/medium/low), summary (one line), ' +
640
+ 'line (optional; the exact line you READ for a line-targeted issue; ' +
641
+ '0 or omitted for whole-file/module-level issues), ' +
642
+ 'severity (critical/high/medium/low), summary (one line), ' +
598
643
  'failure_scenario (how/when it fails, backed by the code you actually ' +
599
644
  'read), suggested_fix (the concrete fix), ' +
600
645
  `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
@@ -672,6 +717,17 @@ export function buildReviewPlan(input: {
672
717
  reviewerPrompt: string
673
718
  findingsSchema: Record<string, unknown>
674
719
  }[] = []
720
+ // personalization.dimension_focus: [{dimension, focus}] — appended to the
721
+ // matching dimension's reviewer prompt.
722
+ const focusMap = new Map<string, string>()
723
+ const pf = input.config.personalization as { dimension_focus?: { dimension?: string; focus?: string }[] } | undefined
724
+ if (pf && Array.isArray(pf.dimension_focus)) {
725
+ for (const entry of pf.dimension_focus) {
726
+ if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
727
+ focusMap.set(entry.dimension, entry.focus)
728
+ }
729
+ }
730
+ }
675
731
  for (const d of dimensions) {
676
732
  batches.forEach((batch, index) => {
677
733
  const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
@@ -687,6 +743,7 @@ export function buildReviewPlan(input: {
687
743
  maxLines,
688
744
  changedFiles: effectiveChangedOnly ? changedFiles : undefined,
689
745
  scopeFiles: batch,
746
+ focus: focusMap.get(d),
690
747
  }),
691
748
  findingsSchema: findingsSchema(),
692
749
  })
@@ -89,14 +89,28 @@ for (let r = 1; r <= maxRounds; r++) {
89
89
  const nudge = retries > 0
90
90
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
91
91
  : ''
92
- const raw = await parallel(dims.map(dim => () => agent(
93
- 'Review dimension "' + dim + '".' +
94
- (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
95
- ' Already-known findings (do NOT re-report): ' +
96
- JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
97
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
98
- )))
99
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
92
+ const raw = await parallel(dims.map(dim => () => {
93
+ // Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
94
+ // with the assigned file inventory, EVIDENCE RULE, output language) and
95
+ // append the round-specific context the reviewers must receive the
96
+ // file inventory or the coverage machinery has nothing to enforce.
97
+ const meta = plan.dimensions.find(x => x.id === dim)
98
+ const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
99
+ ? meta.reviewerPrompt
100
+ : 'Review dimension "' + dim + '".'
101
+ const extra =
102
+ (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
103
+ '\\n Already-known findings (do NOT re-report): ' +
104
+ JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
105
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
106
+ }))
107
+ const thisRound = {
108
+ round: r,
109
+ findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
110
+ // readFiles are threaded through so the aggregate/meta-review coverage
111
+ // gate can compare self-reported reads against the assigned inventory.
112
+ readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
113
+ }
100
114
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
101
115
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
102
116
  agg = await agent(
@@ -229,6 +243,11 @@ let failedCommands = []
229
243
  phase('loop')
230
244
  for (let r = startRound; r <= maxRounds; r++) {
231
245
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
246
+ // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
247
+ await agent(
248
+ 'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
249
+ Object.assign({ label: 'log:start:r' + r }, backend)
250
+ )
232
251
  let agg = null
233
252
  let schemaInvalid = false
234
253
  let retries = 0
@@ -237,13 +256,24 @@ for (let r = startRound; r <= maxRounds; r++) {
237
256
  const nudge = retries > 0
238
257
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
239
258
  : ''
240
- const raw = await parallel(dims.map(dim => () => agent(
241
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
242
- (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
243
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
244
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
245
- )))
246
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
259
+ const raw = await parallel(dims.map(dim => () => {
260
+ // Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
261
+ // the assigned file inventory, EVIDENCE RULE, output language) plus the
262
+ // round-specific context.
263
+ const meta = plan.dimensions.find(x => x.id === dim)
264
+ const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
265
+ ? meta.reviewerPrompt
266
+ : 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
267
+ const extra =
268
+ (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
269
+ '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
270
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
271
+ }))
272
+ const thisRound = {
273
+ round: r,
274
+ findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
275
+ readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
276
+ }
247
277
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
248
278
 
249
279
  // Deterministic dedupe / known_intentional filter / severity sort for this round.
@@ -9,7 +9,7 @@
9
9
  * Checkpoint layout: `.iterate/checkpoint.json`.
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import type { JsonValue } from '@deepseek-ai/dsh-session'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
@@ -107,7 +107,9 @@ export function computeStatus(input: {
107
107
  totalRounds,
108
108
  fixedCount,
109
109
  architecturalCount,
110
- findingsCount: checkpoint?.findings.length ?? 0,
110
+ // A checkpoint may predate the `findings` field (or be hand-edited) — a
111
+ // missing findings must degrade to 0, never throw.
112
+ findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
111
113
  totalDecisionLogEntries: entries.length,
112
114
  hasCheckpoint: checkpoint !== null,
113
115
  // A checkpoint left on disk means the previous run was interrupted before
@@ -209,7 +211,12 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
209
211
  }
210
212
  try {
211
213
  mkdirSync(iterateDir(projectRoot), { recursive: true })
212
- writeFileSync(checkpointPath(projectRoot), JSON.stringify(checkpoint, null, 2), 'utf-8')
214
+ // Atomic write (temp + rename): a crash mid-write must not corrupt
215
+ // the checkpoint and silently lose the interruption state.
216
+ const cpPath = checkpointPath(projectRoot)
217
+ const tmpPath = `${cpPath}.tmp-${Date.now()}`
218
+ writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8')
219
+ renameSync(tmpPath, cpPath)
213
220
  } catch (err) {
214
221
  return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
215
222
  }
@@ -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'
@@ -263,7 +263,8 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
263
263
  return { found: false, error: resolved.reason, searched: [] }
264
264
  }
265
265
  const projectRoot = resolved.root
266
- const requested = (args.files ?? '')
266
+ // Guard: `files` must be a comma-separated string (model-controlled).
267
+ const requested = (typeof args.files === 'string' ? args.files : '')
267
268
  .split(',')
268
269
  .map((s) => s.trim().toLowerCase())
269
270
  .filter(Boolean)
@@ -295,7 +296,17 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
295
296
  // are all supported.
296
297
  const skillRoot = findSkillRoot(PLUGIN_SRC_DIR)
297
298
  const candidates: string[] = []
298
- if (args.skillDir) candidates.push(args.skillDir)
299
+ // skillDir is a model-controlled path; only honor it when it is an
300
+ // existing directory (resolve it first) — otherwise fall through to
301
+ // the auto-detected root / project root.
302
+ if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
303
+ try {
304
+ const dir = resolve(args.skillDir)
305
+ if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
306
+ } catch {
307
+ // unreadable/invalid skillDir — skip it
308
+ }
309
+ }
299
310
  if (skillRoot) candidates.push(skillRoot)
300
311
  candidates.push(projectRoot)
301
312
  result.searched = candidates
@@ -52,12 +52,19 @@ function logPath(projectRoot: string): string {
52
52
 
53
53
  /**
54
54
  * Append one entry to the decision log (JSONL format).
55
- * Returns the entry count after appending.
55
+ * Returns the entry count after appending. Never throws — a disk failure is
56
+ * surfaced through `error` so callers (fix/prune) can report the audit-trail
57
+ * miss without failing the mutation they already performed.
56
58
  */
57
- export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
58
- const filePath = logPath(projectRoot)
59
- const line = JSON.stringify(entry) + '\n'
60
- appendFileSync(filePath, line, 'utf-8')
59
+ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
60
+ let filePath: string
61
+ try {
62
+ filePath = logPath(projectRoot)
63
+ const line = JSON.stringify(entry) + '\n'
64
+ appendFileSync(filePath, line, 'utf-8')
65
+ } catch (err) {
66
+ return { count: -1, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
67
+ }
61
68
  // Count entries
62
69
  let count = 0
63
70
  try {
@@ -71,19 +78,29 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
71
78
 
72
79
  /**
73
80
  * Read all entries from the decision log.
81
+ * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
82
+ * one bad line must never empty the whole history for every reader.
74
83
  */
75
84
  export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
76
85
  const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
77
86
  if (!existsSync(filePath)) return []
87
+ let content: string
78
88
  try {
79
- const content = readFileSync(filePath, 'utf-8')
80
- return content
81
- .split('\n')
82
- .filter((l) => l.trim().length > 0)
83
- .map((l) => JSON.parse(l) as DecisionLogEntry)
89
+ content = readFileSync(filePath, 'utf-8')
84
90
  } catch {
85
91
  return []
86
92
  }
93
+ const out: DecisionLogEntry[] = []
94
+ for (const line of content.split('\n')) {
95
+ const trimmed = line.trim()
96
+ if (trimmed.length === 0) continue
97
+ try {
98
+ out.push(JSON.parse(trimmed) as DecisionLogEntry)
99
+ } catch {
100
+ // skip the corrupt line, keep the rest
101
+ }
102
+ }
103
+ return out
87
104
  }
88
105
 
89
106
  /**
package/src/tools/fix.ts CHANGED
@@ -17,8 +17,8 @@
17
17
  * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
18
  */
19
19
 
20
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
21
- import { join } from 'node:path'
20
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
21
+ import { join, sep } from 'node:path'
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
23
  import type { JsonValue } from '@deepseek-ai/dsh-session'
24
24
  import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
@@ -123,6 +123,11 @@ export function readRegistry(projectRoot: string): FixRegistry {
123
123
  try {
124
124
  const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
125
125
  if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
126
+ // Defensive: a hand-edited or partially-written registry may contain a
127
+ // round without a `records` array — normalize instead of crashing readers.
128
+ parsed.rounds = parsed.rounds.filter(
129
+ (r) => r && typeof r === 'object' && Array.isArray(r.records),
130
+ )
126
131
  return parsed
127
132
  } catch {
128
133
  return emptyRegistry()
@@ -198,11 +203,56 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
198
203
  if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
199
204
  return { ok: false, reason: 'file resolves outside the project root' }
200
205
  }
206
+ // Symlink containment: the lexical prefix check above does not resolve
207
+ // symlinks. If the target exists, verify its REAL path stays inside the REAL
208
+ // project root so a symlinked directory/file inside the repo can never route
209
+ // a fix (write/rollback/diff) outside the project.
210
+ if (existsSync(resolved)) {
211
+ let rootReal: string
212
+ let real: string
213
+ try {
214
+ rootReal = realpathSync(projectRoot)
215
+ real = realpathSync(resolved)
216
+ } catch {
217
+ return { ok: false, reason: 'failed to resolve real path for containment check' }
218
+ }
219
+ const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
220
+ if (real !== rootReal && !real.startsWith(rootPrefix)) {
221
+ return { ok: false, reason: 'file resolves outside the project root (symlink escape)' }
222
+ }
223
+ }
201
224
  return { ok: true, resolved }
202
225
  }
203
226
 
204
227
  // ─── Shared execute helpers ──────────────────────────────────────────────────
205
228
 
229
+ /**
230
+ * Minimal glob matcher for personalization.protected_paths.
231
+ * Supports `*` (any run of chars within one segment) and `**` (any chars,
232
+ * including separators). All other characters are literal. Pure, unit-testable.
233
+ */
234
+ export function globMatch(path: string, pattern: string): boolean {
235
+ if (typeof path !== 'string' || typeof pattern !== 'string') return false
236
+ // Escape regex specials except our two wildcards.
237
+ let re = ''
238
+ for (let i = 0; i < pattern.length; i++) {
239
+ const ch = pattern[i] as string
240
+ if (ch === '*') {
241
+ const isDouble = pattern[i + 1] === '*'
242
+ if (isDouble) { re += '[\\s\\S]*'; i++ } else { re += '[^/\\\\]*' }
243
+ } else if ('.[]{}()+-^$|?'.includes(ch)) {
244
+ re += '\\' + ch
245
+ } else {
246
+ re += ch
247
+ }
248
+ }
249
+ try {
250
+ return new RegExp('^' + re + '$').test(path)
251
+ } catch {
252
+ return false
253
+ }
254
+ }
255
+
206
256
  /** Read the current content of a file under the project root. */
207
257
  function readProjectFile(projectRoot: string, file: string): { ok: true; content: string } | { ok: false; reason: string } {
208
258
  const resolved = resolveProjectFile(projectRoot, file)
@@ -314,6 +364,28 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
314
364
  if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
315
365
  return { ok: false, error: 'finding.dimension must be a non-empty string' }
316
366
  }
367
+ // The finding must reference the file being fixed — the fix id and the
368
+ // rollback/diff target are derived from finding.file, so a mismatch
369
+ // would back up/restore the WRONG file.
370
+ if (finding.file !== file) {
371
+ return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` }
372
+ }
373
+ // Full finding validation, mirroring the review schema: malformed
374
+ // findings would produce lossy registry/log entries and a degraded id.
375
+ const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low'])
376
+ if (!SEVERITY_SET.has(finding.severity)) {
377
+ return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' }
378
+ }
379
+ if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
380
+ return { ok: false, error: 'finding.summary must be a non-empty string' }
381
+ }
382
+ if (typeof finding.is_atomic !== 'boolean') {
383
+ return { ok: false, error: 'finding.is_atomic must be a boolean' }
384
+ }
385
+ if (finding.line !== undefined && finding.line !== null &&
386
+ (typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
387
+ return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' }
388
+ }
317
389
 
318
390
  const current = readProjectFile(projectRoot, file)
319
391
  if (!current.ok) return { ok: false, error: current.reason }
@@ -346,6 +418,30 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
346
418
  const target = resolveProjectFile(projectRoot, file)
347
419
  if (!target.ok) return { ok: false, error: target.reason }
348
420
 
421
+ // Personalization guards (SKILL.md Phase 2): protected_paths veto the
422
+ // fix outright; forbidden_fixes veto fix approaches appearing in the
423
+ // new content. Both are security-relevant, so they are enforced here
424
+ // in the tool, not left to the model.
425
+ const pers = config.personalization as
426
+ | { protected_paths?: unknown; forbidden_fixes?: unknown }
427
+ | undefined
428
+ const protectedPaths = Array.isArray(pers?.protected_paths)
429
+ ? (pers.protected_paths as unknown[]).filter((p): p is string => typeof p === 'string' && p.length > 0)
430
+ : []
431
+ for (const pattern of protectedPaths) {
432
+ if (globMatch(file, pattern)) {
433
+ return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` }
434
+ }
435
+ }
436
+ const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
437
+ ? (pers.forbidden_fixes as unknown[]).filter((f): f is string => typeof f === 'string' && f.length > 0)
438
+ : []
439
+ for (const forbidden of forbiddenFixes) {
440
+ if (args.content.includes(forbidden)) {
441
+ return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` }
442
+ }
443
+ }
444
+
349
445
  const timestamp = new Date().toISOString()
350
446
  const backupPath = fixBackupPath(projectRoot, id, timestamp)
351
447
  try {
@@ -376,7 +472,19 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
376
472
  try {
377
473
  writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
378
474
  } catch (err) {
379
- return { ok: false, error: `failed to write fix registry: ${String(err)}` }
475
+ // Registry write failed the file was already modified but no record
476
+ // exists, so a later rollback/diff could never see it and a retry would
477
+ // back up the already-fixed content as "original". Restore the file
478
+ // from the backup to leave the tree exactly as it was.
479
+ try {
480
+ copyFileSync(backupPath, target.resolved)
481
+ } catch (restoreErr) {
482
+ return {
483
+ ok: false,
484
+ error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
485
+ }
486
+ }
487
+ return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
380
488
  }
381
489
 
382
490
  appendDecisionEntry(projectRoot, {
@@ -485,6 +593,9 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
485
593
  if (existing) {
486
594
  existing.linesAdded += r.linesAdded
487
595
  existing.linesRemoved += r.linesRemoved
596
+ // Recompute the summary from the summed counts so a multi-fix
597
+ // file's text does not contradict its accumulated numbers.
598
+ existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`
488
599
  } else {
489
600
  files.push({
490
601
  file: r.finding.file,