iterate-plugin 2.3.7 → 2.5.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.
- package/lib/client.js +852 -0
- package/lib/parse.js +790 -0
- package/package.json +11 -4
- package/src/config-write.ts +181 -0
- package/src/index.ts +13 -4
- package/src/paths.ts +38 -0
- package/src/review.ts +13 -6
- package/src/skill-prompt.ts +117 -24
- package/src/tools/checkpoint.ts +285 -0
- package/src/tools/config.ts +64 -6
- package/src/tools/decision-log.ts +14 -4
- package/src/tools/fix.ts +565 -0
- package/src/tools/triage.ts +370 -0
- package/src/types.ts +64 -0
package/src/tools/fix.ts
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/fix.ts — structured fix system for the iterate loop.
|
|
3
|
+
*
|
|
4
|
+
* Three tools:
|
|
5
|
+
* iterate_fix — apply ONE atomic fix to a file: validates atomicity,
|
|
6
|
+
* backs up the original, writes the new content, and
|
|
7
|
+
* records a FixRecord in `.iterate/fixes/registry.json`
|
|
8
|
+
* plus an `atomic_fix` decision-log entry.
|
|
9
|
+
* iterate_diff — show the accumulated diff for a file (or a summary of
|
|
10
|
+
* every fixed file), derived from the first backup.
|
|
11
|
+
* iterate_rollback — restore a file from a fix's backup and remove the
|
|
12
|
+
* fix from the registry (append a `revert` log entry).
|
|
13
|
+
*
|
|
14
|
+
* Security model:
|
|
15
|
+
* - Only files under the resolved project root may be written.
|
|
16
|
+
* - Backups are written before any write, so a failure never destroys data.
|
|
17
|
+
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
24
|
+
import { loadEffectiveConfig, resolveProjectRoot } from '../config-loader.ts'
|
|
25
|
+
import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
|
|
26
|
+
import { appendDecisionEntry } from './decision-log.ts'
|
|
27
|
+
import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
|
|
28
|
+
|
|
29
|
+
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Deterministic 32-bit FNV-1a hash used to derive a stable fix id from a
|
|
33
|
+
* finding (same finding always maps to the same id → dedupe + rollback keys).
|
|
34
|
+
*/
|
|
35
|
+
export function hashString(input: string): string {
|
|
36
|
+
let h = 2166136261
|
|
37
|
+
for (let i = 0; i < input.length; i++) {
|
|
38
|
+
h ^= input.charCodeAt(i)
|
|
39
|
+
h = Math.imul(h, 16777619)
|
|
40
|
+
}
|
|
41
|
+
return (h >>> 0).toString(36)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stable id for a finding: file|dimension|line|summary. */
|
|
45
|
+
export function fixId(finding: Pick<ReviewFinding, 'file' | 'dimension' | 'line' | 'summary'>): string {
|
|
46
|
+
const key = `${finding.file}|${finding.dimension}|${finding.line ?? 0}|${finding.summary}`
|
|
47
|
+
return `fix-${hashString(key)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute a minimal line diff between two texts.
|
|
52
|
+
* Returns an array of hunks (empty when unchanged). Uses common-prefix/suffix
|
|
53
|
+
* trimming then reports the changed middle block — sufficient and deterministic
|
|
54
|
+
* for the small atomic edits this toolchain produces.
|
|
55
|
+
*/
|
|
56
|
+
export function diffLines(before: string, after: string): FileDiffHunk[] {
|
|
57
|
+
const a = before.split('\n')
|
|
58
|
+
const b = after.split('\n')
|
|
59
|
+
let start = 0
|
|
60
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++
|
|
61
|
+
let endA = a.length
|
|
62
|
+
let endB = b.length
|
|
63
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
64
|
+
endA--
|
|
65
|
+
endB--
|
|
66
|
+
}
|
|
67
|
+
const removed = a.slice(start, endA)
|
|
68
|
+
const added = b.slice(start, endB)
|
|
69
|
+
if (removed.length === 0 && added.length === 0) return []
|
|
70
|
+
const contentLines: string[] = []
|
|
71
|
+
for (const line of removed) contentLines.push(`- ${line}`)
|
|
72
|
+
for (const line of added) contentLines.push(`+ ${line}`)
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
oldStart: start + 1,
|
|
76
|
+
oldLines: removed.length,
|
|
77
|
+
newStart: start + 1,
|
|
78
|
+
newLines: added.length,
|
|
79
|
+
content: contentLines.join('\n'),
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Added/removed line counts for a change (derived from diffLines). */
|
|
85
|
+
export function countChangedLines(before: string, after: string): { added: number; removed: number } {
|
|
86
|
+
const hunks = diffLines(before, after)
|
|
87
|
+
let added = 0
|
|
88
|
+
let removed = 0
|
|
89
|
+
for (const h of hunks) {
|
|
90
|
+
added += h.newLines
|
|
91
|
+
removed += h.oldLines
|
|
92
|
+
}
|
|
93
|
+
return { added, removed }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Human-readable one-line diff summary. */
|
|
97
|
+
export function buildDiffSummary(hunks: FileDiffHunk[]): string {
|
|
98
|
+
if (hunks.length === 0) return 'no changes'
|
|
99
|
+
let added = 0
|
|
100
|
+
let removed = 0
|
|
101
|
+
for (const h of hunks) {
|
|
102
|
+
added += h.newLines
|
|
103
|
+
removed += h.oldLines
|
|
104
|
+
}
|
|
105
|
+
return `+${added}/-${removed} lines (${hunks.length} hunk${hunks.length === 1 ? '' : 's'})`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default empty registry. */
|
|
109
|
+
export function emptyRegistry(): FixRegistry {
|
|
110
|
+
return { rounds: [] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read the fix registry from disk (missing/corrupt → empty). */
|
|
114
|
+
export function readRegistry(projectRoot: string): FixRegistry {
|
|
115
|
+
const file = fixRegistryPath(projectRoot)
|
|
116
|
+
if (!existsSync(file)) return emptyRegistry()
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
|
|
119
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
|
|
120
|
+
return parsed
|
|
121
|
+
} catch {
|
|
122
|
+
return emptyRegistry()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Find a fix record by id across all rounds, or undefined. */
|
|
127
|
+
export function findFixRecord(registry: FixRegistry, id: string): FixRecord | undefined {
|
|
128
|
+
for (const round of registry.rounds) {
|
|
129
|
+
const found = round.records.find((r) => r.id === id)
|
|
130
|
+
if (found) return found
|
|
131
|
+
}
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** All fix records for a file, in chronological order. */
|
|
136
|
+
export function recordsForFile(registry: FixRegistry, file: string): FixRecord[] {
|
|
137
|
+
const out: FixRecord[] = []
|
|
138
|
+
for (const round of registry.rounds) {
|
|
139
|
+
for (const r of round.records) {
|
|
140
|
+
if (r.finding.file === file && r.success) out.push(r)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Insert (or replace) a record in the registry and return a NEW registry. */
|
|
147
|
+
export function upsertRecord(registry: FixRegistry, record: FixRecord): FixRegistry {
|
|
148
|
+
const rounds = registry.rounds.map((r) => ({ ...r, records: [...r.records] }))
|
|
149
|
+
let target = rounds.find((r) => r.round === record.round)
|
|
150
|
+
if (!target) {
|
|
151
|
+
target = { round: record.round, fixedCount: 0, failedCount: 0, records: [] }
|
|
152
|
+
rounds.push(target)
|
|
153
|
+
}
|
|
154
|
+
const idx = target.records.findIndex((r) => r.id === record.id)
|
|
155
|
+
if (idx >= 0) target.records[idx] = record
|
|
156
|
+
else target.records.push(record)
|
|
157
|
+
rounds.sort((a, b) => a.round - b.round)
|
|
158
|
+
return recomputeRoundCounts({ rounds })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Recompute per-round fixed/failed counts from the raw records. */
|
|
162
|
+
export function recomputeRoundCounts(registry: FixRegistry): FixRegistry {
|
|
163
|
+
return {
|
|
164
|
+
rounds: registry.rounds.map((r) => {
|
|
165
|
+
const fixedCount = r.records.filter((rec) => rec.success).length
|
|
166
|
+
const failedCount = r.records.filter((rec) => !rec.success).length
|
|
167
|
+
return { ...r, fixedCount, failedCount }
|
|
168
|
+
}),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Remove a record by id and return a NEW registry (rollback). */
|
|
173
|
+
export function removeRecord(registry: FixRegistry, id: string): FixRegistry {
|
|
174
|
+
const rounds = registry.rounds
|
|
175
|
+
.map((r) => ({ ...r, records: r.records.filter((rec) => rec.id !== id) }))
|
|
176
|
+
.filter((r) => r.records.length > 0)
|
|
177
|
+
return recomputeRoundCounts({ rounds })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Ensure a relative file path stays inside the project root.
|
|
182
|
+
* Returns `{ ok: true, resolved }` or `{ ok: false, reason }`.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveProjectFile(projectRoot: string, file: string): { ok: true; resolved: string } | { ok: false; reason: string } {
|
|
185
|
+
if (typeof file !== 'string' || file.trim().length === 0) {
|
|
186
|
+
return { ok: false, reason: 'file must be a non-empty relative path' }
|
|
187
|
+
}
|
|
188
|
+
if (file.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(file)) {
|
|
189
|
+
return { ok: false, reason: 'file must be a relative path inside the project root' }
|
|
190
|
+
}
|
|
191
|
+
const resolved = join(projectRoot, file)
|
|
192
|
+
if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
|
|
193
|
+
return { ok: false, reason: 'file resolves outside the project root' }
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, resolved }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Shared execute helpers ──────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/** Read the current content of a file under the project root. */
|
|
201
|
+
function readProjectFile(projectRoot: string, file: string): { ok: true; content: string } | { ok: false; reason: string } {
|
|
202
|
+
const resolved = resolveProjectFile(projectRoot, file)
|
|
203
|
+
if (!resolved.ok) return resolved
|
|
204
|
+
if (!existsSync(resolved.resolved)) return { ok: false, reason: `file does not exist: ${file}` }
|
|
205
|
+
try {
|
|
206
|
+
return { ok: true, content: readFileSync(resolved.resolved, 'utf-8') }
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return { ok: false, reason: `failed to read file: ${String(err)}` }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── iterate_fix ─────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Register the `iterate_fix` tool.
|
|
216
|
+
* The fixer subagent supplies the file + its NEW full content; the tool
|
|
217
|
+
* validates atomicity, backs up, writes, and records the fix.
|
|
218
|
+
*/
|
|
219
|
+
export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
220
|
+
ctx.tools.register(
|
|
221
|
+
defineTool({
|
|
222
|
+
name: 'iterate_fix',
|
|
223
|
+
description:
|
|
224
|
+
'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
225
|
+
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
226
|
+
'The tool backs up the original, enforces the atomic `max_lines` threshold (unless `force`), ' +
|
|
227
|
+
'writes the new content, and records the fix for later diff/rollback. ' +
|
|
228
|
+
'This is the ONLY sanctioned way to apply fixes in normal mode.',
|
|
229
|
+
parameters: {
|
|
230
|
+
file: {
|
|
231
|
+
type: 'string',
|
|
232
|
+
required: true,
|
|
233
|
+
description: 'Relative path of the file to fix, inside the project root.',
|
|
234
|
+
},
|
|
235
|
+
content: {
|
|
236
|
+
type: 'string',
|
|
237
|
+
required: true,
|
|
238
|
+
description: 'The NEW full content of the file after applying your fix.',
|
|
239
|
+
},
|
|
240
|
+
finding: {
|
|
241
|
+
type: 'json',
|
|
242
|
+
required: true,
|
|
243
|
+
description: 'The finding this fix addresses: {dimension, file, line?, severity, summary, failure_scenario?, suggested_fix?, is_atomic}.',
|
|
244
|
+
},
|
|
245
|
+
round: {
|
|
246
|
+
type: 'integer',
|
|
247
|
+
required: true,
|
|
248
|
+
description: 'Current iteration round (>= 1).',
|
|
249
|
+
},
|
|
250
|
+
force: {
|
|
251
|
+
type: 'boolean',
|
|
252
|
+
description: 'Skip the atomic max_lines threshold check (default: false).',
|
|
253
|
+
},
|
|
254
|
+
path: {
|
|
255
|
+
type: 'string',
|
|
256
|
+
description: 'Project root directory (default: current working directory).',
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
output: {
|
|
261
|
+
schema: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
properties: {
|
|
265
|
+
ok: { type: 'boolean', required: true },
|
|
266
|
+
id: { type: 'string' },
|
|
267
|
+
file: { type: 'string' },
|
|
268
|
+
round: { type: 'integer' },
|
|
269
|
+
linesAdded: { type: 'integer' },
|
|
270
|
+
linesRemoved: { type: 'integer' },
|
|
271
|
+
diffSummary: { type: 'string' },
|
|
272
|
+
backupPath: { type: 'string' },
|
|
273
|
+
error: { type: 'string' },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
render: (_args, value) => [
|
|
277
|
+
{ type: 'text', text: value.ok ? `${value.diffSummary ?? 'fixed'} @ ${value.file} (id: ${value.id})` : `fix failed: ${value.error}` },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async execute(args) {
|
|
282
|
+
const resolved = resolveProjectRoot(args.path)
|
|
283
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason }
|
|
284
|
+
const projectRoot = resolved.root
|
|
285
|
+
const { config } = loadEffectiveConfig(projectRoot)
|
|
286
|
+
const maxLines = config.atomic?.max_lines ?? 20
|
|
287
|
+
|
|
288
|
+
const file = typeof args.file === 'string' ? args.file : ''
|
|
289
|
+
if (!file) return { ok: false, error: 'file is required' }
|
|
290
|
+
if (typeof args.content !== 'string') return { ok: false, error: 'content must be a string' }
|
|
291
|
+
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
292
|
+
return { ok: false, error: 'round must be a positive integer' }
|
|
293
|
+
}
|
|
294
|
+
const finding = args.finding as unknown as ReviewFinding | undefined
|
|
295
|
+
if (!finding || typeof finding !== 'object') {
|
|
296
|
+
return { ok: false, error: 'finding must be an object' }
|
|
297
|
+
}
|
|
298
|
+
if (typeof finding.file !== 'string' || finding.file.trim().length === 0) {
|
|
299
|
+
return { ok: false, error: 'finding.file must be a non-empty string' }
|
|
300
|
+
}
|
|
301
|
+
if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
|
|
302
|
+
return { ok: false, error: 'finding.dimension must be a non-empty string' }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const current = readProjectFile(projectRoot, file)
|
|
306
|
+
if (!current.ok) return { ok: false, error: current.reason }
|
|
307
|
+
|
|
308
|
+
const { added, removed } = countChangedLines(current.content, args.content)
|
|
309
|
+
if (!args.force && (added > maxLines || removed > maxLines)) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
error: `Change to ${file} exceeds the atomic threshold (max_lines=${maxLines}, change is +${added}/-${removed}). ` +
|
|
313
|
+
'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const id = fixId(finding)
|
|
318
|
+
const registry = readRegistry(projectRoot)
|
|
319
|
+
if (findFixRecord(registry, id)) {
|
|
320
|
+
return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const target = resolveProjectFile(projectRoot, file)
|
|
324
|
+
if (!target.ok) return { ok: false, error: target.reason }
|
|
325
|
+
|
|
326
|
+
const timestamp = new Date().toISOString()
|
|
327
|
+
const backupPath = fixBackupPath(projectRoot, id, timestamp)
|
|
328
|
+
try {
|
|
329
|
+
mkdirSync(fixesDir(projectRoot), { recursive: true })
|
|
330
|
+
copyFileSync(target.resolved, backupPath)
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return { ok: false, error: `failed to create backup: ${String(err)}` }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
writeFileSync(target.resolved, args.content, 'utf-8')
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return { ok: false, error: `failed to write file: ${String(err)}` }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const hunks = diffLines(current.content, args.content)
|
|
342
|
+
const record: FixRecord = {
|
|
343
|
+
id,
|
|
344
|
+
timestamp,
|
|
345
|
+
round: args.round,
|
|
346
|
+
finding,
|
|
347
|
+
backupPath,
|
|
348
|
+
diffSummary: buildDiffSummary(hunks),
|
|
349
|
+
linesAdded: added,
|
|
350
|
+
linesRemoved: removed,
|
|
351
|
+
success: true,
|
|
352
|
+
}
|
|
353
|
+
const nextRegistry = upsertRecord(registry, record)
|
|
354
|
+
try {
|
|
355
|
+
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return { ok: false, error: `failed to write fix registry: ${String(err)}` }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
appendDecisionEntry(projectRoot, {
|
|
361
|
+
timestamp,
|
|
362
|
+
round: args.round,
|
|
363
|
+
type: 'atomic_fix',
|
|
364
|
+
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
id,
|
|
370
|
+
file,
|
|
371
|
+
round: args.round,
|
|
372
|
+
linesAdded: added,
|
|
373
|
+
linesRemoved: removed,
|
|
374
|
+
diffSummary: record.diffSummary,
|
|
375
|
+
backupPath,
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
}),
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── iterate_diff ────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Register the `iterate_diff` tool.
|
|
386
|
+
* Shows the accumulated change for a file (diff vs its first backup) or a
|
|
387
|
+
* summary of every file that has been fixed.
|
|
388
|
+
*/
|
|
389
|
+
export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
390
|
+
ctx.tools.register(
|
|
391
|
+
defineTool({
|
|
392
|
+
name: 'iterate_diff',
|
|
393
|
+
description:
|
|
394
|
+
'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
|
|
395
|
+
'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
|
|
396
|
+
parameters: {
|
|
397
|
+
file: {
|
|
398
|
+
type: 'string',
|
|
399
|
+
description: 'Optional relative file path to diff. When omitted, returns a per-file summary.',
|
|
400
|
+
},
|
|
401
|
+
path: {
|
|
402
|
+
type: 'string',
|
|
403
|
+
description: 'Project root directory (default: current working directory).',
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
output: {
|
|
408
|
+
schema: {
|
|
409
|
+
type: 'object',
|
|
410
|
+
additionalProperties: false,
|
|
411
|
+
properties: {
|
|
412
|
+
ok: { type: 'boolean', required: true },
|
|
413
|
+
file: { type: 'string' },
|
|
414
|
+
diff: { type: 'json' },
|
|
415
|
+
diffSummary: { type: 'string' },
|
|
416
|
+
files: { type: 'json' },
|
|
417
|
+
error: { type: 'string' },
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
render: (_args, value) => {
|
|
421
|
+
if (!value.ok) return [{ type: 'text', text: `diff failed: ${value.error}` }]
|
|
422
|
+
if (value.file) {
|
|
423
|
+
const diff = (value.diff as FileDiffHunk[] | undefined) ?? []
|
|
424
|
+
const text = diff.length === 0
|
|
425
|
+
? `No changes for ${value.file}.`
|
|
426
|
+
: diff.map((h) => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${h.content}`).join('\n\n')
|
|
427
|
+
return [{ type: 'text', text }]
|
|
428
|
+
}
|
|
429
|
+
const files = (value.files as { file: string; diffSummary: string; linesAdded: number; linesRemoved: number }[] | undefined) ?? []
|
|
430
|
+
const text = files.length === 0 ? 'No fixes have been applied yet.' : files.map((f) => `${f.file} ${f.diffSummary}`).join('\n')
|
|
431
|
+
return [{ type: 'text', text }]
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
async execute(args) {
|
|
436
|
+
const resolved = resolveProjectRoot(args.path)
|
|
437
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason }
|
|
438
|
+
const projectRoot = resolved.root
|
|
439
|
+
const registry = readRegistry(projectRoot)
|
|
440
|
+
const file = typeof args.file === 'string' && args.file.trim() ? args.file : undefined
|
|
441
|
+
|
|
442
|
+
if (file) {
|
|
443
|
+
const records = recordsForFile(registry, file)
|
|
444
|
+
const first = records[0]
|
|
445
|
+
if (!first) return { ok: false, error: `no fixes recorded for ${file}` }
|
|
446
|
+
const current = readProjectFile(projectRoot, file)
|
|
447
|
+
if (!current.ok) return { ok: false, error: current.reason }
|
|
448
|
+
let original = ''
|
|
449
|
+
try {
|
|
450
|
+
original = readFileSync(first.backupPath, 'utf-8')
|
|
451
|
+
} catch (err) {
|
|
452
|
+
return { ok: false, error: `backup missing for ${file}: ${String(err)}` }
|
|
453
|
+
}
|
|
454
|
+
const hunks = diffLines(original, current.content)
|
|
455
|
+
return { ok: true, file, diff: hunks as unknown as JsonValue, diffSummary: buildDiffSummary(hunks) }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const files: { file: string; diffSummary: string; linesAdded: number; linesRemoved: number }[] = []
|
|
459
|
+
for (const round of registry.rounds) {
|
|
460
|
+
for (const r of round.records) {
|
|
461
|
+
if (!r.success) continue
|
|
462
|
+
const existing = files.find((f) => f.file === r.finding.file)
|
|
463
|
+
if (existing) {
|
|
464
|
+
existing.linesAdded += r.linesAdded
|
|
465
|
+
existing.linesRemoved += r.linesRemoved
|
|
466
|
+
} else {
|
|
467
|
+
files.push({
|
|
468
|
+
file: r.finding.file,
|
|
469
|
+
diffSummary: r.diffSummary,
|
|
470
|
+
linesAdded: r.linesAdded,
|
|
471
|
+
linesRemoved: r.linesRemoved,
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return { ok: true, files: files as unknown as JsonValue }
|
|
477
|
+
},
|
|
478
|
+
}),
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── iterate_rollback ────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Register the `iterate_rollback` tool.
|
|
486
|
+
* Restores a file from a fix's backup and removes the fix from the registry,
|
|
487
|
+
* appending a `revert` decision-log entry. Use after a failed validation.
|
|
488
|
+
*/
|
|
489
|
+
export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
490
|
+
ctx.tools.register(
|
|
491
|
+
defineTool({
|
|
492
|
+
name: 'iterate_rollback',
|
|
493
|
+
description:
|
|
494
|
+
'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
|
|
495
|
+
'The file is restored from the fix backup, the fix is removed from the registry, ' +
|
|
496
|
+
'and a `revert` entry is appended to the decision log. Use when a round\'s validation fails.',
|
|
497
|
+
parameters: {
|
|
498
|
+
id: {
|
|
499
|
+
type: 'string',
|
|
500
|
+
required: true,
|
|
501
|
+
description: 'The fix id returned by iterate_fix.',
|
|
502
|
+
},
|
|
503
|
+
path: {
|
|
504
|
+
type: 'string',
|
|
505
|
+
description: 'Project root directory (default: current working directory).',
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
output: {
|
|
510
|
+
schema: {
|
|
511
|
+
type: 'object',
|
|
512
|
+
additionalProperties: false,
|
|
513
|
+
properties: {
|
|
514
|
+
ok: { type: 'boolean', required: true },
|
|
515
|
+
id: { type: 'string' },
|
|
516
|
+
file: { type: 'string' },
|
|
517
|
+
error: { type: 'string' },
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
render: (_args, value) => [
|
|
521
|
+
{ type: 'text', text: value.ok ? `reverted fix ${value.id} in ${value.file}` : `rollback failed: ${value.error}` },
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
async execute(args) {
|
|
526
|
+
const resolved = resolveProjectRoot(args.path)
|
|
527
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason }
|
|
528
|
+
const projectRoot = resolved.root
|
|
529
|
+
const id = typeof args.id === 'string' ? args.id : ''
|
|
530
|
+
if (!id) return { ok: false, error: 'id is required' }
|
|
531
|
+
|
|
532
|
+
const registry = readRegistry(projectRoot)
|
|
533
|
+
const record = findFixRecord(registry, id)
|
|
534
|
+
if (!record) return { ok: false, error: `fix not found: ${id}` }
|
|
535
|
+
if (!existsSync(record.backupPath)) {
|
|
536
|
+
return { ok: false, error: `backup missing for fix ${id}` }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const target = resolveProjectFile(projectRoot, record.finding.file)
|
|
540
|
+
if (!target.ok) return { ok: false, error: target.reason }
|
|
541
|
+
try {
|
|
542
|
+
copyFileSync(record.backupPath, target.resolved)
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return { ok: false, error: `failed to restore backup: ${String(err)}` }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const nextRegistry = removeRecord(registry, id)
|
|
548
|
+
try {
|
|
549
|
+
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return { ok: false, error: `failed to update fix registry: ${String(err)}` }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
appendDecisionEntry(projectRoot, {
|
|
555
|
+
timestamp: new Date().toISOString(),
|
|
556
|
+
round: record.round,
|
|
557
|
+
type: 'revert',
|
|
558
|
+
data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
return { ok: true, id, file: record.finding.file }
|
|
562
|
+
},
|
|
563
|
+
}),
|
|
564
|
+
)
|
|
565
|
+
}
|