iterate-plugin 3.5.7 → 3.7.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/README.md +97 -22
- package/README.zh-CN.md +79 -19
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +516 -35
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +193 -0
- package/dist/index.js +61 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +54 -14
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +61 -14
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +106 -28
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1912 -512
- package/lib/parse.js +1001 -103
- package/package.json +16 -9
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +1031 -369
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +549 -36
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +211 -0
- package/src/index.ts +60 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +50 -12
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +60 -13
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +114 -23
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
- package/src/working-directory.ts +126 -0
package/dist/tools/fix.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
20
|
import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
|
|
21
|
-
import { dirname, join, sep } from 'node:path';
|
|
21
|
+
import { dirname, isAbsolute, join, normalize, sep } from 'node:path';
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
23
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
24
|
+
import { withProjectLock } from "../file-lock.js";
|
|
24
25
|
import { runWithJob } from "../jobs.js";
|
|
25
26
|
import { countTouchedMethods } from "../method-scope.js";
|
|
26
27
|
import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
|
|
@@ -47,11 +48,27 @@ export function fixId(finding) {
|
|
|
47
48
|
const key = `${finding.file}|${finding.dimension}|${finding.line ?? 0}|${finding.summary}`;
|
|
48
49
|
return `fix-${hashString(key)}`;
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Exact-alignment budget for the changed middle block: a dynamic-programming
|
|
53
|
+
* LCS pass over `m × n` cells. Beyond these bounds diffLines falls back to the
|
|
54
|
+
* legacy single-hunk report (coarser counts, but never a hang), so a
|
|
55
|
+
* pathological multi-thousand-line input cannot stall the tool loop.
|
|
56
|
+
*/
|
|
57
|
+
export const DIFF_MAX_REGION_CELLS = 20_000;
|
|
58
|
+
export const DIFF_MAX_REGION_LINES = 5_000;
|
|
50
59
|
/**
|
|
51
60
|
* Compute a minimal line diff between two texts.
|
|
52
|
-
* Returns an array of hunks (empty when unchanged).
|
|
53
|
-
*
|
|
54
|
-
*
|
|
61
|
+
* Returns an array of hunks (empty when unchanged).
|
|
62
|
+
*
|
|
63
|
+
* Common prefix/suffix are trimmed first, then the changed middle block is
|
|
64
|
+
* aligned EXACTLY (suffix-LCS dynamic programming, bounded by
|
|
65
|
+
* DIFF_MAX_REGION_CELLS / DIFF_MAX_REGION_LINES) and split into one hunk per
|
|
66
|
+
* run of changed lines separated by unchanged lines. This keeps counts honest:
|
|
67
|
+
* changing line 1 AND line 100 of a 200-line file reports {added:2, removed:2}
|
|
68
|
+
* across two hunks, not {100,100} for the whole span — the atomic `max_lines`
|
|
69
|
+
* gate and the persisted FixRecord line counts are derived from these numbers.
|
|
70
|
+
* When the region exceeds the budget, the old single-hunk behavior is kept so
|
|
71
|
+
* huge inputs degrade to coarse-but-correct counts instead of hanging.
|
|
55
72
|
*/
|
|
56
73
|
export function diffLines(before, after) {
|
|
57
74
|
const a = before.split('\n');
|
|
@@ -69,6 +86,13 @@ export function diffLines(before, after) {
|
|
|
69
86
|
const added = b.slice(start, endB);
|
|
70
87
|
if (removed.length === 0 && added.length === 0)
|
|
71
88
|
return [];
|
|
89
|
+
return alignRegion(removed, added, start) ?? singleRegionHunk(removed, added, start);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Legacy single-hunk report for the trimmed changed block (also the bounded
|
|
93
|
+
* fallback when `alignRegion` refuses an over-budget region).
|
|
94
|
+
*/
|
|
95
|
+
function singleRegionHunk(removed, added, start) {
|
|
72
96
|
const contentLines = [];
|
|
73
97
|
for (const line of removed)
|
|
74
98
|
contentLines.push(`- ${line}`);
|
|
@@ -84,6 +108,81 @@ export function diffLines(before, after) {
|
|
|
84
108
|
},
|
|
85
109
|
];
|
|
86
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Exactly align the trimmed changed block and split it into per-spot hunks
|
|
113
|
+
* (each maximal run of changed lines, separated by at least one unchanged
|
|
114
|
+
* line). Returns `null` when the region exceeds the DP budget so the caller
|
|
115
|
+
* can fall back to {@link singleRegionHunk}.
|
|
116
|
+
*
|
|
117
|
+
* Hunk coordinates follow GNU `diff -U0` conventions: for a pure insertion
|
|
118
|
+
* `oldStart` is the line BEFORE the insertion point (oldLines = 0), for a pure
|
|
119
|
+
* deletion `newStart` is the line before the deletion point (newLines = 0),
|
|
120
|
+
* and for a replacement both point at the replaced line.
|
|
121
|
+
*/
|
|
122
|
+
function alignRegion(removed, added, start) {
|
|
123
|
+
const m = removed.length;
|
|
124
|
+
const n = added.length;
|
|
125
|
+
if (m > DIFF_MAX_REGION_LINES || n > DIFF_MAX_REGION_LINES || m * n > DIFF_MAX_REGION_CELLS)
|
|
126
|
+
return null;
|
|
127
|
+
// dp[i][j] = LCS length of removed[i..] / added[j..] (suffix table).
|
|
128
|
+
const stride = n + 1;
|
|
129
|
+
const dp = new Int32Array((m + 1) * stride);
|
|
130
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
131
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
132
|
+
dp[i * stride + j] = removed[i] === added[j]
|
|
133
|
+
? dp[(i + 1) * stride + j + 1] + 1
|
|
134
|
+
: Math.max(dp[(i + 1) * stride + j], dp[i * stride + j + 1]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const hunks = [];
|
|
138
|
+
let open = null;
|
|
139
|
+
const closeHunk = () => {
|
|
140
|
+
if (!open)
|
|
141
|
+
return;
|
|
142
|
+
hunks.push({
|
|
143
|
+
// oldLines > 0 → first removed line (1-based); pure insertion → the
|
|
144
|
+
// line before the insertion point (= its 0-based index).
|
|
145
|
+
oldStart: start + open.iOpen + (open.oldLines > 0 ? 1 : 0),
|
|
146
|
+
oldLines: open.oldLines,
|
|
147
|
+
newStart: start + open.jOpen + (open.newLines > 0 ? 1 : 0),
|
|
148
|
+
newLines: open.newLines,
|
|
149
|
+
content: open.content.join('\n'),
|
|
150
|
+
});
|
|
151
|
+
open = null;
|
|
152
|
+
};
|
|
153
|
+
let i = 0; // region index into removed
|
|
154
|
+
let j = 0; // region index into added
|
|
155
|
+
while (i < m || j < n) {
|
|
156
|
+
// A matching pair always belongs to SOME optimal alignment (CLRS
|
|
157
|
+
// x_i = y_j ⇒ LCS(i,j) = 1 + LCS(i+1,j+1)), so greedily closing the
|
|
158
|
+
// current hunk here is exact, not heuristic.
|
|
159
|
+
if (i < m && j < n && removed[i] === added[j]) {
|
|
160
|
+
closeHunk();
|
|
161
|
+
i++;
|
|
162
|
+
j++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Diverged: consume whichever side the suffix table says costs less.
|
|
166
|
+
// Ties prefer removal so a plain replacement reads `- old / + new`.
|
|
167
|
+
const takeRemoved = i >= m ? false : j >= n ? true : dp[(i + 1) * stride + j] >= dp[i * stride + j + 1];
|
|
168
|
+
if (takeRemoved) {
|
|
169
|
+
if (!open)
|
|
170
|
+
open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] };
|
|
171
|
+
open.oldLines++;
|
|
172
|
+
open.content.push(`- ${removed[i]}`);
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
if (!open)
|
|
177
|
+
open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] };
|
|
178
|
+
open.newLines++;
|
|
179
|
+
open.content.push(`+ ${added[j]}`);
|
|
180
|
+
j++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
closeHunk();
|
|
184
|
+
return hunks;
|
|
185
|
+
}
|
|
87
186
|
/** Added/removed line counts for a change (derived from diffLines). */
|
|
88
187
|
export function countChangedLines(before, after) {
|
|
89
188
|
const hunks = diffLines(before, after);
|
|
@@ -124,11 +223,17 @@ export function readRegistry(projectRoot) {
|
|
|
124
223
|
// contain a round without a `records` array, or records that are missing
|
|
125
224
|
// their id / finding object — all of which would make readers
|
|
126
225
|
// (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
|
|
226
|
+
// Numeric counters are also floored to non-negative integers: the status
|
|
227
|
+
// output schema declares them `type: 'integer'`, so a `fixedCount: 1.5`
|
|
228
|
+
// must never reach iterate_status.
|
|
229
|
+
const intCount = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
|
|
127
230
|
parsed.rounds = parsed.rounds
|
|
128
231
|
.filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
|
|
129
232
|
.map((r) => ({
|
|
130
233
|
...r,
|
|
131
234
|
round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
|
|
235
|
+
fixedCount: intCount(r.fixedCount),
|
|
236
|
+
failedCount: intCount(r.failedCount),
|
|
132
237
|
records: r.records.filter((rec) => !!rec &&
|
|
133
238
|
typeof rec === 'object' &&
|
|
134
239
|
typeof rec.id === 'string' &&
|
|
@@ -151,12 +256,18 @@ export function findFixRecord(registry, id) {
|
|
|
151
256
|
}
|
|
152
257
|
return undefined;
|
|
153
258
|
}
|
|
154
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* All fix records for a file, in chronological order.
|
|
261
|
+
* Both sides are canonicalized with {@link normalizeProjectPath} so a record
|
|
262
|
+
* persisted as `./README.md` (pre-normalization builds) still matches a
|
|
263
|
+
* query for `README.md` and vice versa.
|
|
264
|
+
*/
|
|
155
265
|
export function recordsForFile(registry, file) {
|
|
266
|
+
const want = normalizeProjectPath(file);
|
|
156
267
|
const out = [];
|
|
157
268
|
for (const round of registry.rounds) {
|
|
158
269
|
for (const r of round.records) {
|
|
159
|
-
if (r.finding.file ===
|
|
270
|
+
if (normalizeProjectPath(r.finding.file) === want && r.success)
|
|
160
271
|
out.push(r);
|
|
161
272
|
}
|
|
162
273
|
}
|
|
@@ -242,6 +353,63 @@ export function resolveProjectFile(projectRoot, file) {
|
|
|
242
353
|
}
|
|
243
354
|
return { ok: true, resolved };
|
|
244
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Canonicalize a model-supplied relative path ONCE at argument intake.
|
|
358
|
+
*
|
|
359
|
+
* Strips a leading `./`, collapses duplicate separators, and resolves `.` /
|
|
360
|
+
* `..` segments lexically so the containment check, the protected-path glob,
|
|
361
|
+
* the persisted FixRecord, and every later diff/rollback lookup all compare
|
|
362
|
+
* the SAME string — a raw `./README.md` must not dodge a `README.md` veto
|
|
363
|
+
* glob, and `src/../README.md` must record as `README.md`.
|
|
364
|
+
* Absolute paths and `..` escapes survive normalization and are rejected by
|
|
365
|
+
* {@link resolveProjectFile} afterwards. Pure, exported for unit tests.
|
|
366
|
+
*/
|
|
367
|
+
export function normalizeProjectPath(file) {
|
|
368
|
+
// An empty intake must stay empty (node's normalize('') === '.') so
|
|
369
|
+
// resolveProjectFile still rejects it instead of resolving to the root.
|
|
370
|
+
if (typeof file !== 'string' || file === '')
|
|
371
|
+
return '';
|
|
372
|
+
return normalize(file);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Validate a registry-recorded backup path before it is read (iterate_diff)
|
|
376
|
+
* or used to overwrite a project file (iterate_rollback).
|
|
377
|
+
*
|
|
378
|
+
* The registry is a plain JSON file on disk: a hand-edited/corrupt record can
|
|
379
|
+
* point `backupPath` anywhere, turning diff into an arbitrary-file read and
|
|
380
|
+
* rollback into arbitrary-content injection. A backup must stay inside the
|
|
381
|
+
* project's fixes directory — lexically AND via realpath when it exists (so a
|
|
382
|
+
* symlink planted inside `.iterate/fixes` cannot smuggle content either).
|
|
383
|
+
*/
|
|
384
|
+
export function resolveBackupPath(projectRoot, backupPath) {
|
|
385
|
+
if (typeof backupPath !== 'string' || backupPath.trim().length === 0) {
|
|
386
|
+
return { ok: false, reason: 'backup path is missing from the fix record' };
|
|
387
|
+
}
|
|
388
|
+
const fixes = fixesDir(projectRoot);
|
|
389
|
+
// Relative entries are anchored at the project root (never the process cwd)
|
|
390
|
+
// so `../../etc/passwd` fails the containment check below.
|
|
391
|
+
const resolved = isAbsolute(backupPath) ? normalize(backupPath) : join(projectRoot, backupPath);
|
|
392
|
+
const prefix = fixes.endsWith(sep) ? fixes : fixes + sep;
|
|
393
|
+
if (resolved !== fixes && !resolved.startsWith(prefix)) {
|
|
394
|
+
return { ok: false, reason: `backup path escapes the fixes directory: ${backupPath}` };
|
|
395
|
+
}
|
|
396
|
+
if (existsSync(resolved)) {
|
|
397
|
+
let realFixes;
|
|
398
|
+
let real;
|
|
399
|
+
try {
|
|
400
|
+
realFixes = realpathSync(fixes);
|
|
401
|
+
real = realpathSync(resolved);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return { ok: false, reason: `failed to resolve real path for backup containment check: ${backupPath}` };
|
|
405
|
+
}
|
|
406
|
+
const realPrefix = realFixes.endsWith(sep) ? realFixes : realFixes + sep;
|
|
407
|
+
if (real !== realFixes && !real.startsWith(realPrefix)) {
|
|
408
|
+
return { ok: false, reason: `backup path escapes the fixes directory (symlink): ${backupPath}` };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { ok: true, resolved };
|
|
412
|
+
}
|
|
245
413
|
// ─── Shared execute helpers ──────────────────────────────────────────────────
|
|
246
414
|
/**
|
|
247
415
|
* Minimal glob matcher for personalization.protected_paths.
|
|
@@ -381,9 +549,15 @@ export function registerFixTool(ctx) {
|
|
|
381
549
|
const { config } = loadEffectiveConfig(projectRoot);
|
|
382
550
|
const maxLines = config.atomic?.max_lines ?? 20;
|
|
383
551
|
const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3;
|
|
384
|
-
|
|
385
|
-
|
|
552
|
+
// Canonicalize ONCE at intake: `./README.md` and `src/../README.md`
|
|
553
|
+
// must behave exactly like `README.md` downstream — the protected-path
|
|
554
|
+
// glob, the fix id, the persisted FixRecord, and later diff/rollback
|
|
555
|
+
// lookups all compare this normalized form (escapes/absolute paths
|
|
556
|
+
// survive normalization and are rejected by resolveProjectFile).
|
|
557
|
+
const rawFile = typeof args.file === 'string' ? args.file : '';
|
|
558
|
+
if (!rawFile)
|
|
386
559
|
return { ok: false, error: 'file is required' };
|
|
560
|
+
const file = normalizeProjectPath(rawFile);
|
|
387
561
|
if (typeof args.content !== 'string')
|
|
388
562
|
return { ok: false, error: 'content must be a string' };
|
|
389
563
|
if (args.content.length > MAX_FIX_CONTENT_CHARS) {
|
|
@@ -395,21 +569,23 @@ export function registerFixTool(ctx) {
|
|
|
395
569
|
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
396
570
|
return { ok: false, error: 'round must be a positive integer' };
|
|
397
571
|
}
|
|
398
|
-
const
|
|
399
|
-
if (!
|
|
572
|
+
const rawFinding = args.finding;
|
|
573
|
+
if (!rawFinding || typeof rawFinding !== 'object') {
|
|
400
574
|
return { ok: false, error: 'finding must be an object' };
|
|
401
575
|
}
|
|
402
|
-
if (typeof
|
|
576
|
+
if (typeof rawFinding.file !== 'string' || rawFinding.file.trim().length === 0) {
|
|
403
577
|
return { ok: false, error: 'finding.file must be a non-empty string' };
|
|
404
578
|
}
|
|
405
|
-
if (typeof
|
|
579
|
+
if (typeof rawFinding.dimension !== 'string' || rawFinding.dimension.trim().length === 0) {
|
|
406
580
|
return { ok: false, error: 'finding.dimension must be a non-empty string' };
|
|
407
581
|
}
|
|
408
582
|
// The finding must reference the file being fixed — the fix id and the
|
|
409
583
|
// rollback/diff target are derived from finding.file, so a mismatch
|
|
410
|
-
// would back up/restore the WRONG file.
|
|
584
|
+
// would back up/restore the WRONG file. Both sides are compared in
|
|
585
|
+
// their NORMALIZED form (`./src/a.ts` and `src/a.ts` are the same fix).
|
|
586
|
+
const finding = { ...rawFinding, file: normalizeProjectPath(rawFinding.file) };
|
|
411
587
|
if (finding.file !== file) {
|
|
412
|
-
return { ok: false, error: `finding.file ("${
|
|
588
|
+
return { ok: false, error: `finding.file ("${rawFinding.file}") must match the file being fixed ("${rawFile}")` };
|
|
413
589
|
}
|
|
414
590
|
// Full finding validation, mirroring the review schema: malformed
|
|
415
591
|
// findings would produce lossy registry/log entries and a degraded id.
|
|
@@ -448,90 +624,102 @@ export function registerFixTool(ctx) {
|
|
|
448
624
|
};
|
|
449
625
|
}
|
|
450
626
|
const id = fixId(finding);
|
|
451
|
-
const registry = readRegistry(projectRoot);
|
|
452
|
-
if (findFixRecord(registry, id)) {
|
|
453
|
-
return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
|
|
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
|
-
}
|
|
465
|
-
const target = resolveProjectFile(projectRoot, file);
|
|
466
|
-
if (!target.ok)
|
|
467
|
-
return { ok: false, error: target.reason };
|
|
468
|
-
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
469
|
-
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
470
|
-
// new content. Both are security-relevant, so they are enforced here
|
|
471
|
-
// in the tool, not left to the model.
|
|
472
|
-
const pers = config.personalization;
|
|
473
|
-
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
474
|
-
? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
|
|
475
|
-
: [];
|
|
476
|
-
for (const pattern of protectedPaths) {
|
|
477
|
-
if (globMatch(file, pattern)) {
|
|
478
|
-
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
|
|
482
|
-
? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
|
|
483
|
-
: [];
|
|
484
|
-
for (const forbidden of forbiddenFixes) {
|
|
485
|
-
if (args.content.includes(forbidden)) {
|
|
486
|
-
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
627
|
const timestamp = new Date().toISOString();
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
writeTextAtomic(target.resolved, args.content);
|
|
500
|
-
}
|
|
501
|
-
catch (err) {
|
|
502
|
-
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
503
|
-
}
|
|
504
|
-
const record = {
|
|
505
|
-
id,
|
|
506
|
-
timestamp,
|
|
507
|
-
round: args.round,
|
|
508
|
-
finding,
|
|
509
|
-
backupPath,
|
|
510
|
-
diffSummary: buildDiffSummary(hunks),
|
|
511
|
-
linesAdded: added,
|
|
512
|
-
linesRemoved: removed,
|
|
513
|
-
success: true,
|
|
514
|
-
};
|
|
515
|
-
const nextRegistry = upsertRecord(registry, record);
|
|
516
|
-
try {
|
|
517
|
-
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
518
|
-
}
|
|
519
|
-
catch (err) {
|
|
520
|
-
// Registry write failed → the file was already modified but no record
|
|
521
|
-
// exists, so a later rollback/diff could never see it and a retry would
|
|
522
|
-
// back up the already-fixed content as "original". Restore the file
|
|
523
|
-
// from the backup atomically to leave the tree exactly as it was.
|
|
524
|
-
try {
|
|
525
|
-
writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
|
|
628
|
+
// Cross-process read-modify-write guard: the registry read (dup check)
|
|
629
|
+
// and the record write form ONE window — two plugin processes racing
|
|
630
|
+
// here could each read the old registry and silently drop the other's
|
|
631
|
+
// update. The shared advisory lock (src/file-lock.ts) serializes the
|
|
632
|
+
// window; the decision-log append runs after it is released.
|
|
633
|
+
const applied = withProjectLock(projectRoot, 'fix-registry', () => {
|
|
634
|
+
const registry = readRegistry(projectRoot);
|
|
635
|
+
if (findFixRecord(registry, id)) {
|
|
636
|
+
return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
|
|
526
637
|
}
|
|
527
|
-
|
|
638
|
+
// No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
|
|
639
|
+
// file unchanged) must never burn a backup, a write, or a registry/success
|
|
640
|
+
// record. Placed after the registry check so a re-sent fix of an id that
|
|
641
|
+
// was ALREADY fixed is still reported as "already fixed this run".
|
|
642
|
+
if (added === 0 && removed === 0) {
|
|
528
643
|
return {
|
|
529
644
|
ok: false,
|
|
530
|
-
error: `
|
|
645
|
+
error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
|
|
531
646
|
};
|
|
532
647
|
}
|
|
533
|
-
|
|
534
|
-
|
|
648
|
+
const target = resolveProjectFile(projectRoot, file);
|
|
649
|
+
if (!target.ok)
|
|
650
|
+
return { ok: false, error: target.reason };
|
|
651
|
+
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
652
|
+
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
653
|
+
// new content. Both are security-relevant, so they are enforced here
|
|
654
|
+
// in the tool, not left to the model. The glob runs against the
|
|
655
|
+
// NORMALIZED `file`, so `./README.md` cannot dodge a `README.md` veto.
|
|
656
|
+
const pers = config.personalization;
|
|
657
|
+
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
658
|
+
? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
|
|
659
|
+
: [];
|
|
660
|
+
for (const pattern of protectedPaths) {
|
|
661
|
+
if (globMatch(file, pattern)) {
|
|
662
|
+
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
|
|
666
|
+
? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
|
|
667
|
+
: [];
|
|
668
|
+
for (const forbidden of forbiddenFixes) {
|
|
669
|
+
if (args.content.includes(forbidden)) {
|
|
670
|
+
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const backupPath = fixBackupPath(projectRoot, id, timestamp);
|
|
674
|
+
try {
|
|
675
|
+
mkdirSync(fixesDir(projectRoot), { recursive: true });
|
|
676
|
+
copyFileSync(target.resolved, backupPath);
|
|
677
|
+
}
|
|
678
|
+
catch (err) {
|
|
679
|
+
return { ok: false, error: `failed to create backup: ${String(err)}` };
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
writeTextAtomic(target.resolved, args.content);
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
686
|
+
}
|
|
687
|
+
const record = {
|
|
688
|
+
id,
|
|
689
|
+
timestamp,
|
|
690
|
+
round: args.round,
|
|
691
|
+
finding,
|
|
692
|
+
backupPath,
|
|
693
|
+
diffSummary: buildDiffSummary(hunks),
|
|
694
|
+
linesAdded: added,
|
|
695
|
+
linesRemoved: removed,
|
|
696
|
+
success: true,
|
|
697
|
+
};
|
|
698
|
+
const nextRegistry = upsertRecord(registry, record);
|
|
699
|
+
try {
|
|
700
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
// Registry write failed → the file was already modified but no record
|
|
704
|
+
// exists, so a later rollback/diff could never see it and a retry would
|
|
705
|
+
// back up the already-fixed content as "original". Restore the file
|
|
706
|
+
// from the backup atomically to leave the tree exactly as it was.
|
|
707
|
+
try {
|
|
708
|
+
writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
|
|
709
|
+
}
|
|
710
|
+
catch (restoreErr) {
|
|
711
|
+
return {
|
|
712
|
+
ok: false,
|
|
713
|
+
error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
|
|
717
|
+
}
|
|
718
|
+
return { ok: true, record, backupPath };
|
|
719
|
+
});
|
|
720
|
+
if (!applied.ok)
|
|
721
|
+
return applied;
|
|
722
|
+
const { record, backupPath } = applied;
|
|
535
723
|
const logRes = appendDecisionEntry(projectRoot, {
|
|
536
724
|
timestamp,
|
|
537
725
|
round: args.round,
|
|
@@ -615,18 +803,26 @@ export function registerDiffTool(ctx) {
|
|
|
615
803
|
return { ok: false, error: resolved.reason };
|
|
616
804
|
const projectRoot = resolved.root;
|
|
617
805
|
const registry = readRegistry(projectRoot);
|
|
618
|
-
const file = typeof args.file === 'string' && args.file.trim()
|
|
806
|
+
const file = typeof args.file === 'string' && args.file.trim()
|
|
807
|
+
? normalizeProjectPath(args.file)
|
|
808
|
+
: undefined;
|
|
619
809
|
if (file) {
|
|
620
810
|
const records = recordsForFile(registry, file);
|
|
621
811
|
const first = records[0];
|
|
622
812
|
if (!first)
|
|
623
813
|
return { ok: false, error: `no fixes recorded for ${file}` };
|
|
814
|
+
// Containment: the registry is on-disk JSON that a hand edit can
|
|
815
|
+
// point anywhere — a tampered backupPath must never turn this
|
|
816
|
+
// read-only diff into an arbitrary-file read.
|
|
817
|
+
const backup = resolveBackupPath(projectRoot, first.backupPath);
|
|
818
|
+
if (!backup.ok)
|
|
819
|
+
return { ok: false, error: `invalid backup for ${file}: ${backup.reason}` };
|
|
624
820
|
const current = readProjectFile(projectRoot, file);
|
|
625
821
|
if (!current.ok)
|
|
626
822
|
return { ok: false, error: current.reason };
|
|
627
823
|
let original = '';
|
|
628
824
|
try {
|
|
629
|
-
original = readFileSync(
|
|
825
|
+
original = readFileSync(backup.resolved, 'utf-8');
|
|
630
826
|
}
|
|
631
827
|
catch (err) {
|
|
632
828
|
return { ok: false, error: `backup missing for ${file}: ${String(err)}` };
|
|
@@ -727,31 +923,63 @@ export function registerRollbackTool(ctx) {
|
|
|
727
923
|
const id = typeof args.id === 'string' ? args.id : '';
|
|
728
924
|
if (!id)
|
|
729
925
|
return { ok: false, error: 'id is required' };
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
926
|
+
// One locked read-modify-write window (see src/file-lock.ts): the
|
|
927
|
+
// registry read, the file restore, and the record removal must not
|
|
928
|
+
// interleave with another process's fix/rollback or the update is lost.
|
|
929
|
+
const restored = withProjectLock(projectRoot, 'fix-registry', () => {
|
|
930
|
+
const registry = readRegistry(projectRoot);
|
|
931
|
+
const record = findFixRecord(registry, id);
|
|
932
|
+
if (!record)
|
|
933
|
+
return { ok: false, error: `fix not found: ${id}` };
|
|
934
|
+
// LIFO safety: restoring an OLDER backup would silently destroy every
|
|
935
|
+
// NEWER successful fix to the same file — their records would stay
|
|
936
|
+
// success:true while the bytes they wrote are gone. Refuse and name
|
|
937
|
+
// the clobbered ids so the caller can roll back in reverse order first.
|
|
938
|
+
const fileRecords = recordsForFile(registry, record.finding.file);
|
|
939
|
+
const targetIdx = fileRecords.findIndex((r) => r.id === id);
|
|
940
|
+
const newer = targetIdx >= 0
|
|
941
|
+
? fileRecords.slice(targetIdx + 1)
|
|
942
|
+
: fileRecords.filter((r) => r.id !== id && r.timestamp > record.timestamp);
|
|
943
|
+
if (newer.length > 0) {
|
|
944
|
+
const clobbered = newer.map((r) => r.id).join(', ');
|
|
945
|
+
return {
|
|
946
|
+
ok: false,
|
|
947
|
+
error: `rollback refused: fix ${id} is older than fix(es) ${clobbered} on ${record.finding.file} — ` +
|
|
948
|
+
'restoring its backup would destroy them. Roll back the later fix(es) first (LIFO order).',
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
// Containment: the registry is on-disk JSON that a hand edit can
|
|
952
|
+
// point anywhere — a tampered backupPath must never inject arbitrary
|
|
953
|
+
// content into the project file.
|
|
954
|
+
const backup = resolveBackupPath(projectRoot, record.backupPath);
|
|
955
|
+
if (!backup.ok)
|
|
956
|
+
return { ok: false, error: `invalid backup for fix ${id}: ${backup.reason}` };
|
|
957
|
+
if (!existsSync(backup.resolved)) {
|
|
958
|
+
return { ok: false, error: `backup missing for fix ${id}` };
|
|
959
|
+
}
|
|
960
|
+
const target = resolveProjectFile(projectRoot, record.finding.file);
|
|
961
|
+
if (!target.ok)
|
|
962
|
+
return { ok: false, error: target.reason };
|
|
963
|
+
try {
|
|
964
|
+
// Atomic restore: never leave a truncated source file if we crash
|
|
965
|
+
// mid-restore (matches the writeTextAtomic guarantee used by apply).
|
|
966
|
+
writeTextAtomic(target.resolved, readFileSync(backup.resolved, 'utf-8'));
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
return { ok: false, error: `failed to restore backup: ${String(err)}` };
|
|
970
|
+
}
|
|
971
|
+
const nextRegistry = removeRecord(registry, id);
|
|
972
|
+
try {
|
|
973
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
return { ok: false, error: `failed to update fix registry: ${String(err)}` };
|
|
977
|
+
}
|
|
978
|
+
return { ok: true, record };
|
|
979
|
+
});
|
|
980
|
+
if (!restored.ok)
|
|
981
|
+
return restored;
|
|
982
|
+
const record = restored.record;
|
|
755
983
|
const logRes = appendDecisionEntry(projectRoot, {
|
|
756
984
|
timestamp: new Date().toISOString(),
|
|
757
985
|
round: record.round,
|