iterate-plugin 3.5.4 → 3.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +8 -3
- package/dist/config-write.js +50 -9
- package/dist/evidence.js +28 -1
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +2 -2
- package/dist/review-scope.js +6 -2
- package/dist/review.js +7 -6
- package/dist/skill-prompt.js +3 -3
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/fix.js +24 -3
- package/dist/tools/prune.js +35 -21
- package/dist/tools/triage.js +3 -2
- package/dist/transcript.js +58 -32
- package/lib/client.js +4 -0
- package/lib/parse.js +6 -0
- package/package.json +8 -7
- package/src/atomic-fs.ts +20 -4
- package/src/config-loader.ts +8 -3
- package/src/config-write.ts +59 -9
- package/src/evidence.ts +29 -1
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +2 -2
- package/src/review-scope.ts +6 -2
- package/src/review.ts +7 -6
- package/src/skill-prompt.ts +3 -3
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/fix.ts +25 -3
- package/src/tools/prune.ts +30 -18
- package/src/tools/triage.ts +3 -2
- package/src/transcript.ts +59 -29
package/dist/atomic-fs.js
CHANGED
|
@@ -48,12 +48,21 @@ function tempPathFor(filePath) {
|
|
|
48
48
|
*/
|
|
49
49
|
export function writeTextAtomic(filePath, data) {
|
|
50
50
|
const tmp = tempPathFor(filePath);
|
|
51
|
-
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(tmp, data, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
// The write itself failed (disk full, permission, …): the temp file may
|
|
56
|
+
// already be partially written, so remove it before propagating — a
|
|
57
|
+
// failed write must never litter the directory either.
|
|
58
|
+
cleanupTemp(tmp);
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
52
61
|
try {
|
|
53
62
|
renameSync(tmp, filePath);
|
|
54
63
|
}
|
|
55
64
|
catch (err) {
|
|
56
|
-
// Best-effort temp cleanup so a failed
|
|
65
|
+
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
57
66
|
cleanupTemp(tmp);
|
|
58
67
|
throw err;
|
|
59
68
|
}
|
|
@@ -61,12 +70,21 @@ export function writeTextAtomic(filePath, data) {
|
|
|
61
70
|
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
62
71
|
export async function writeTextAtomicAsync(filePath, data) {
|
|
63
72
|
const tmp = tempPathFor(filePath);
|
|
64
|
-
|
|
73
|
+
try {
|
|
74
|
+
await writeFile(tmp, data, 'utf-8');
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// The write itself failed (disk full, permission, …): the temp file may
|
|
78
|
+
// already be partially written, so remove it before propagating — a
|
|
79
|
+
// failed write must never litter the directory either.
|
|
80
|
+
await cleanupTempAsync(tmp);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
65
83
|
try {
|
|
66
84
|
await rename(tmp, filePath);
|
|
67
85
|
}
|
|
68
86
|
catch (err) {
|
|
69
|
-
// Best-effort temp cleanup so a failed
|
|
87
|
+
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
70
88
|
await cleanupTempAsync(tmp);
|
|
71
89
|
throw err;
|
|
72
90
|
}
|
package/dist/config-loader.js
CHANGED
|
@@ -10,7 +10,10 @@ export function loadConfig(projectRoot) {
|
|
|
10
10
|
try {
|
|
11
11
|
const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8');
|
|
12
12
|
const parsed = yaml.load(content);
|
|
13
|
-
|
|
13
|
+
// A YAML sequence root (e.g. a list of findings or a `- foo` file) must
|
|
14
|
+
// not masquerade as a config object — treating it as one would merge its
|
|
15
|
+
// indices into the config (config bomb). Reject it, not coerce it.
|
|
16
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
14
17
|
return null;
|
|
15
18
|
return parsed;
|
|
16
19
|
}
|
|
@@ -141,7 +144,7 @@ export function flattenCommands(commands) {
|
|
|
141
144
|
*/
|
|
142
145
|
export function validateConfig(config) {
|
|
143
146
|
const errors = [];
|
|
144
|
-
if (!config || typeof config !== 'object') {
|
|
147
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
145
148
|
errors.push('root');
|
|
146
149
|
return errors;
|
|
147
150
|
}
|
|
@@ -190,7 +193,9 @@ export function validateConfig(config) {
|
|
|
190
193
|
* the DSH runtime injects per-session env into tool sub-processes).
|
|
191
194
|
*/
|
|
192
195
|
export function resolveProjectRoot(input, sessionCwd) {
|
|
193
|
-
|
|
196
|
+
// A non-string input (number, array, …) from a hostile tool invocation must
|
|
197
|
+
// be treated as "no explicit path" instead of crashing on `.trim()`.
|
|
198
|
+
const raw = (typeof input === 'string' ? input : '').trim();
|
|
194
199
|
// A NUL byte can never name a real path and makes `resolve()` (and every
|
|
195
200
|
// downstream fs call) throw — treat it as unsafe input, not a throw path.
|
|
196
201
|
if (raw.includes('\0')) {
|
package/dist/config-write.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* The security posture mirrors the triage tool: never overwrite a malformed
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
|
-
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
12
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { basename, dirname, join } from 'node:path';
|
|
14
14
|
import yaml from 'js-yaml';
|
|
15
15
|
import { writeTextAtomic } from "./atomic-fs.js";
|
|
16
16
|
/** Config file name (must match config-loader). */
|
|
@@ -19,6 +19,39 @@ export const CONFIG_FILE = 'iterate.config.yaml';
|
|
|
19
19
|
export function configBackupSuffix(now = new Date()) {
|
|
20
20
|
return now.toISOString().replace(/[:.]/g, '-');
|
|
21
21
|
}
|
|
22
|
+
/** Upper bound on configurable iteration rounds (config bomb guard). */
|
|
23
|
+
export const MAX_MAX_ROUNDS = 100;
|
|
24
|
+
/** Upper bound on `atomic.max_lines` (a single fix never needs more). */
|
|
25
|
+
export const MAX_ATOMIC_MAX_LINES = 10_000;
|
|
26
|
+
/** Upper bound on `atomic.max_adjacent_methods`. */
|
|
27
|
+
export const MAX_MAX_ADJACENT_METHODS = 200;
|
|
28
|
+
/** Keep at most this many timestamped config backups (older ones are removed). */
|
|
29
|
+
export const MAX_CONFIG_BACKUPS = 5;
|
|
30
|
+
/**
|
|
31
|
+
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
32
|
+
* every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
|
|
33
|
+
* filesystem failure here must never fail the write that just succeeded.
|
|
34
|
+
* @returns the absolute paths of the backups that were removed.
|
|
35
|
+
*/
|
|
36
|
+
export function pruneOldConfigBackups(configPath, keep = MAX_CONFIG_BACKUPS) {
|
|
37
|
+
const removed = [];
|
|
38
|
+
try {
|
|
39
|
+
const dir = dirname(configPath);
|
|
40
|
+
const prefix = `${basename(configPath)}.bak-`;
|
|
41
|
+
const matches = existsSync(dir)
|
|
42
|
+
? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
|
|
43
|
+
: [];
|
|
44
|
+
const doomed = matches.slice(0, Math.max(0, matches.length - keep));
|
|
45
|
+
for (const f of doomed) {
|
|
46
|
+
rmSync(join(dir, f), { force: true });
|
|
47
|
+
removed.push(join(dir, f));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Best-effort cleanup — never surface a cleanup failure.
|
|
52
|
+
}
|
|
53
|
+
return removed;
|
|
54
|
+
}
|
|
22
55
|
/**
|
|
23
56
|
* Validate a partial config update.
|
|
24
57
|
* Returns an array of error strings (empty when the update is valid).
|
|
@@ -40,8 +73,9 @@ export function validateConfigUpdates(updates) {
|
|
|
40
73
|
}
|
|
41
74
|
}
|
|
42
75
|
if ('max_rounds' in updates) {
|
|
43
|
-
if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
|
|
44
|
-
|
|
76
|
+
if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
|
|
77
|
+
updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS) {
|
|
78
|
+
errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`);
|
|
45
79
|
}
|
|
46
80
|
}
|
|
47
81
|
if ('review' in updates) {
|
|
@@ -59,11 +93,11 @@ export function validateConfigUpdates(updates) {
|
|
|
59
93
|
errors.push('updates.atomic must be an object');
|
|
60
94
|
}
|
|
61
95
|
else {
|
|
62
|
-
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
|
|
63
|
-
errors.push(
|
|
96
|
+
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
|
|
97
|
+
errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`);
|
|
64
98
|
}
|
|
65
|
-
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
|
|
66
|
-
errors.push(
|
|
99
|
+
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
|
|
100
|
+
errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`);
|
|
67
101
|
}
|
|
68
102
|
}
|
|
69
103
|
}
|
|
@@ -164,7 +198,10 @@ export function readRawConfig(configPath) {
|
|
|
164
198
|
catch {
|
|
165
199
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
|
166
200
|
}
|
|
167
|
-
|
|
201
|
+
// A YAML sequence root (array) must not masquerade as a config object —
|
|
202
|
+
// `typeof [] === 'object'`, so the presence check alone would accept a
|
|
203
|
+
// config file that is actually a list. Writing over it would destroy data.
|
|
204
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
168
205
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
|
169
206
|
}
|
|
170
207
|
return parsed;
|
|
@@ -201,5 +238,9 @@ export function writeConfigFile(projectRoot, config) {
|
|
|
201
238
|
}
|
|
202
239
|
return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` };
|
|
203
240
|
}
|
|
241
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
242
|
+
// project never collects an unbounded pile of config snapshots.
|
|
243
|
+
if (backupPath)
|
|
244
|
+
pruneOldConfigBackups(configPath);
|
|
204
245
|
return { ok: true, backupPath };
|
|
205
246
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -89,8 +89,35 @@ export function verifyLineBounds(line, text) {
|
|
|
89
89
|
}
|
|
90
90
|
/** Verify a single finding's location against the real filesystem. */
|
|
91
91
|
export function verifyFinding(root, input, opts = {}) {
|
|
92
|
-
|
|
92
|
+
// A null element / non-object finding (hostile or model-authored JSON) must
|
|
93
|
+
// be reported as poisoned evidence, never crash `path.resolve` downstream.
|
|
94
|
+
if (!input || typeof input !== 'object') {
|
|
95
|
+
return {
|
|
96
|
+
file: '',
|
|
97
|
+
line: null,
|
|
98
|
+
lineTotal: null,
|
|
99
|
+
resolvedPath: null,
|
|
100
|
+
verified: false,
|
|
101
|
+
error: 'file_not_found',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// A non-string `file` (number, object, …) would make `resolve` throw
|
|
105
|
+
// ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
|
|
106
|
+
const relFile = typeof input.file === 'string' ? input.file : '';
|
|
93
107
|
const line = typeof input.line === 'number' ? input.line : null;
|
|
108
|
+
// "" resolves to the project ROOT (resolve(root, '') === root), which then
|
|
109
|
+
// trips the not-a-regular-file branch as `line_out_of_range` — but there is
|
|
110
|
+
// no file reference at all. Report it as `file_not_found` upfront.
|
|
111
|
+
if (relFile === '') {
|
|
112
|
+
return {
|
|
113
|
+
file: '',
|
|
114
|
+
line,
|
|
115
|
+
lineTotal: null,
|
|
116
|
+
resolvedPath: null,
|
|
117
|
+
verified: false,
|
|
118
|
+
error: 'file_not_found',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
94
121
|
const resolved = resolveWithin(root, relFile);
|
|
95
122
|
if (resolved === null || !existsSync(resolved)) {
|
|
96
123
|
return {
|
package/dist/git-scope.js
CHANGED
|
@@ -89,7 +89,10 @@ function decodeQuotedPath(content) {
|
|
|
89
89
|
}
|
|
90
90
|
export function parseChangedFiles(stdout) {
|
|
91
91
|
if (stdout.includes('\0')) {
|
|
92
|
-
|
|
92
|
+
// NUL mode: git emits exact names with no surrounding whitespace, so any
|
|
93
|
+
// trim would CORRUPT a filename that legitimately starts/ends with spaces
|
|
94
|
+
// (git supports such names). Split and drop only the trailing empty entry.
|
|
95
|
+
return stdout.split('\0').filter((s) => s.length > 0);
|
|
93
96
|
}
|
|
94
97
|
return stdout
|
|
95
98
|
.split('\n')
|
|
@@ -147,9 +150,15 @@ export function decideScope(changedFiles) {
|
|
|
147
150
|
* Uses execFile (no shell), so a model-controlled branch name can never be
|
|
148
151
|
* interpreted as shell syntax.
|
|
149
152
|
*/
|
|
150
|
-
export function runGit(args, cwd) {
|
|
153
|
+
export function runGit(args, cwd, opts = {}) {
|
|
151
154
|
return new Promise((resolve) => {
|
|
152
|
-
execFile('git', args, {
|
|
155
|
+
execFile('git', args, {
|
|
156
|
+
cwd,
|
|
157
|
+
timeout: 30_000,
|
|
158
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
159
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
160
|
+
signal: opts.signal,
|
|
161
|
+
}, (error, stdout, stderr) => {
|
|
153
162
|
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
|
|
154
163
|
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
|
|
155
164
|
});
|
|
@@ -161,7 +170,7 @@ export function runGit(args, cwd) {
|
|
|
161
170
|
* `full`-scope result with `error` set — the reviewer must never crash the
|
|
162
171
|
* plan because git is unavailable.
|
|
163
172
|
*/
|
|
164
|
-
export async function resolveChangedFiles(root, targetBranch) {
|
|
173
|
+
export async function resolveChangedFiles(root, targetBranch, opts = {}) {
|
|
165
174
|
// Option-injection guard: a branch name starting with '-' would be parsed by
|
|
166
175
|
// git as an option (e.g. --output=...), not a ref. Reject it outright.
|
|
167
176
|
if (typeof targetBranch !== 'string' || targetBranch.trim() === '' || targetBranch.startsWith('-')) {
|
|
@@ -174,7 +183,7 @@ export async function resolveChangedFiles(root, targetBranch) {
|
|
|
174
183
|
}
|
|
175
184
|
// -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
|
|
176
185
|
// non-ASCII), and never confused with option-like content.
|
|
177
|
-
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root);
|
|
186
|
+
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root, opts);
|
|
178
187
|
if (!ok) {
|
|
179
188
|
const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`;
|
|
180
189
|
return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason };
|
package/dist/jobs.js
CHANGED
|
@@ -16,6 +16,20 @@
|
|
|
16
16
|
* - The registry is memory-only and panel rows are read-only (no progress
|
|
17
17
|
* updates), so these jobs are completion records, not control channels.
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
|
|
21
|
+
* `jobs` getter throws (or is missing) when no job registry is present. A
|
|
22
|
+
* Proxy throw on property access must degrade to "no jobs", never crash the
|
|
23
|
+
* tool execution we are about to wrap.
|
|
24
|
+
*/
|
|
25
|
+
function safeJobs(ctx) {
|
|
26
|
+
try {
|
|
27
|
+
return ctx?.jobs;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
/**
|
|
20
34
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
21
35
|
* with the execution's outcome. When the host exposes no job registry (or
|
|
@@ -29,7 +43,7 @@
|
|
|
29
43
|
* @returns the registry-issued job id, or `null` when unavailable.
|
|
30
44
|
*/
|
|
31
45
|
export async function runWithJob(ctx, kind, label, fn) {
|
|
32
|
-
const jobs = ctx
|
|
46
|
+
const jobs = safeJobs(ctx);
|
|
33
47
|
if (!jobs || typeof jobs.start !== 'function') {
|
|
34
48
|
return { result: await fn(), jobId: null };
|
|
35
49
|
}
|
package/dist/live.js
CHANGED
|
@@ -36,6 +36,45 @@ export const LIVE_MAX_BYTES = 64 * 1024;
|
|
|
36
36
|
export function liveFilePath(projectRoot) {
|
|
37
37
|
return join(projectRoot, '.iterate', 'transcript-live.ndjson');
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Module-level in-process queue serializing live-feed writes. The trim path
|
|
41
|
+
* (read → rewrite → append) is a read-modify-write: two concurrent tool
|
|
42
|
+
* results could interleave and one's rewrite would silently drop the other's
|
|
43
|
+
* freshly-appended line. Chaining every append onto one tail makes the whole
|
|
44
|
+
* read+rewrite+append atomic within the process (the only realistic mode for
|
|
45
|
+
* the plugin); cross-process racing remains a theoretical hazard, but the
|
|
46
|
+
* cap-trim makes a lost line a cosmetic issue, not a correctness one.
|
|
47
|
+
*/
|
|
48
|
+
let liveWriteQueue = Promise.resolve();
|
|
49
|
+
function enqueueLiveWrite(task) {
|
|
50
|
+
const next = liveWriteQueue.then(task, task);
|
|
51
|
+
liveWriteQueue = next.catch(() => { });
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
/** Append one activity record to the project's live feed (byte-capped). */
|
|
55
|
+
export function appendLive(projectRoot, entry) {
|
|
56
|
+
return enqueueLiveWrite(async () => {
|
|
57
|
+
const file = liveFilePath(projectRoot);
|
|
58
|
+
const line = JSON.stringify(entry) + '\n';
|
|
59
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
60
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
61
|
+
try {
|
|
62
|
+
const st = await stat(file).catch(() => null);
|
|
63
|
+
if (st && st.size > LIVE_MAX_BYTES) {
|
|
64
|
+
const raw = await readFile(file, 'utf-8');
|
|
65
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
66
|
+
const tail = lines.slice(-LIVE_MAX_ENTRIES);
|
|
67
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
68
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
69
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n');
|
|
70
|
+
}
|
|
71
|
+
await appendFile(file, line, 'utf-8');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Fire-and-forget: never let live capture break a tool call.
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
39
78
|
/** Resolve the project root a tool execution belongs to, if any. */
|
|
40
79
|
function projectRootOf(exec) {
|
|
41
80
|
const cwd = exec.agent?.session?.header?.cwd;
|
|
@@ -95,28 +134,6 @@ export function classifyTool(name, args, projectRoot) {
|
|
|
95
134
|
target = name;
|
|
96
135
|
return { ts: new Date().toISOString(), type, tool: name, target };
|
|
97
136
|
}
|
|
98
|
-
/** Append one activity record to the project's live feed (byte-capped). */
|
|
99
|
-
export async function appendLive(projectRoot, entry) {
|
|
100
|
-
const file = liveFilePath(projectRoot);
|
|
101
|
-
const line = JSON.stringify(entry) + '\n';
|
|
102
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
103
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
104
|
-
try {
|
|
105
|
-
const st = await stat(file).catch(() => null);
|
|
106
|
-
if (st && st.size > LIVE_MAX_BYTES) {
|
|
107
|
-
const raw = await readFile(file, 'utf-8');
|
|
108
|
-
const lines = raw.split('\n').filter(Boolean);
|
|
109
|
-
const tail = lines.slice(-LIVE_MAX_ENTRIES);
|
|
110
|
-
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
111
|
-
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
112
|
-
await writeTextAtomicAsync(file, tail.join('\n') + '\n');
|
|
113
|
-
}
|
|
114
|
-
await appendFile(file, line, 'utf-8');
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
// Fire-and-forget: never let live capture break a tool call.
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
137
|
/** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
|
|
121
138
|
export async function readLive(projectRoot) {
|
|
122
139
|
const file = liveFilePath(projectRoot);
|
package/dist/meta-review.js
CHANGED
|
@@ -99,7 +99,7 @@ export function metaReviewReport(report) {
|
|
|
99
99
|
if (dimSum !== total) {
|
|
100
100
|
add('DIMENSION_SUM', 'high', 'byDimension counts do not sum to totalFindings', `byDimension sums to ${dimSum}, but totalFindings is ${total}.`);
|
|
101
101
|
}
|
|
102
|
-
const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension));
|
|
102
|
+
const invalidDim = findings.find((f) => !!f && !dimensions.includes(f?.dimension));
|
|
103
103
|
if (invalidDim) {
|
|
104
104
|
add('DIMENSION_UNKNOWN', 'medium', `Finding references unknown dimension "${invalidDim.dimension}"`, `dimension "${invalidDim.dimension}" is not in report.dimensions ` +
|
|
105
105
|
`(${dimensions.join(', ') || 'none'}).`);
|
|
@@ -227,7 +227,7 @@ export function buildFinalReviewReport(report, opts = {}) {
|
|
|
227
227
|
// Try to attribute the poisoned finding to the round that first
|
|
228
228
|
// surfaced it (best-effort; report rounds carry it).
|
|
229
229
|
for (const r of report.rounds ?? []) {
|
|
230
|
-
const matched = (r.findings ?? []).some((fnd) => fnd
|
|
230
|
+
const matched = (r.findings ?? []).some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
|
|
231
231
|
if (matched) {
|
|
232
232
|
roundHint = ` (round ${r.round})`;
|
|
233
233
|
break;
|
package/dist/review-scope.js
CHANGED
|
@@ -57,10 +57,14 @@ function normalizePath(path) {
|
|
|
57
57
|
if (part === '' || part === '.')
|
|
58
58
|
continue;
|
|
59
59
|
if (part === '..') {
|
|
60
|
-
|
|
60
|
+
// Mirrors Python `os.path.normpath`: a leading `..` is preserved and a
|
|
61
|
+
// second `..` is NEVER collapsed into a bare `/` away — otherwise a
|
|
62
|
+
// traversal like `../../evil` folds into the single segment `evil` and
|
|
63
|
+
// the escape becomes undetectable downstream.
|
|
64
|
+
if (parts.length > 0 && parts[parts.length - 1] !== '..')
|
|
61
65
|
parts.pop();
|
|
62
66
|
else
|
|
63
|
-
parts.push(part);
|
|
67
|
+
parts.push(part);
|
|
64
68
|
continue;
|
|
65
69
|
}
|
|
66
70
|
parts.push(part);
|
package/dist/review.js
CHANGED
|
@@ -28,19 +28,20 @@ export const SEVERITY_RANK = {
|
|
|
28
28
|
export function sortFindings(findings) {
|
|
29
29
|
return [...findings].sort((a, b) => {
|
|
30
30
|
// Guard against an out-of-spec severity string (e.g. from a model that
|
|
31
|
-
// bypassed the schema)
|
|
32
|
-
// the
|
|
33
|
-
|
|
34
|
-
const
|
|
31
|
+
// bypassed the schema) AND against a null list element (model-authored
|
|
32
|
+
// JSON): treat both as the least severe so NaN never enters the
|
|
33
|
+
// comparator and ordering stays deterministic.
|
|
34
|
+
const rankA = SEVERITY_RANK[a?.severity] ?? SEVERITY_RANK.low;
|
|
35
|
+
const rankB = SEVERITY_RANK[b?.severity] ?? SEVERITY_RANK.low;
|
|
35
36
|
const bySeverity = rankA - rankB;
|
|
36
37
|
if (bySeverity !== 0)
|
|
37
38
|
return bySeverity;
|
|
38
39
|
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
39
40
|
// validation is disabled — String()/Number() keep the comparator total.
|
|
40
|
-
const byFile = String(a
|
|
41
|
+
const byFile = String(a?.file ?? '').localeCompare(String(b?.file ?? ''));
|
|
41
42
|
if (byFile !== 0)
|
|
42
43
|
return byFile;
|
|
43
|
-
return (Number(a
|
|
44
|
+
return (Number(a?.line) || 0) - (Number(b?.line) || 0);
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
package/dist/skill-prompt.js
CHANGED
|
@@ -356,10 +356,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
356
356
|
const byFile = {}
|
|
357
357
|
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
358
358
|
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
359
|
-
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
359
|
+
'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
|
|
360
360
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
361
|
-
'iterate_fix({ file:
|
|
362
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file:
|
|
361
|
+
'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
362
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
|
|
363
363
|
'read its line statistics (lines added/removed). ' +
|
|
364
364
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
365
365
|
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
@@ -1,9 +1,127 @@
|
|
|
1
|
-
import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, readFileSync, mkdirSync, existsSync, openSync, writeSync, closeSync, unlinkSync, statSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
5
5
|
const LOG_DIR = '.iterate';
|
|
6
6
|
const LOG_FILE = 'decision-log.jsonl';
|
|
7
|
+
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// The prune rewrite (temp + atomic rename) can silently discard an audit line
|
|
10
|
+
// from a CONCURRENT appender whose fd was opened against the pre-rename inode:
|
|
11
|
+
// the appender writes to the unlinked inode, the post-rewrite re-read sees a
|
|
12
|
+
// "stable" file, and the fresh entry is lost. In-process safety is guaranteed
|
|
13
|
+
// by synchronous I/O, but a second plugin process appending to the same
|
|
14
|
+
// project realises the race. The log is serialized with a tiny advisory lock
|
|
15
|
+
// file (exclusive create, pid stamped, stale-stealable) so `append` and the
|
|
16
|
+
// prune rewrite mutually exclude across processes. Best-effort: contention
|
|
17
|
+
// timeouts degrade to "proceed unlocked" (the bounded retry loop in prune.ts
|
|
18
|
+
// stays as defense-in-depth), never to a crash.
|
|
19
|
+
const LOCK_FILE = '.decision-log.lock';
|
|
20
|
+
const LOCK_WAIT_MS = 5000;
|
|
21
|
+
const LOCK_STALE_MS = 10000;
|
|
22
|
+
const LOCK_POLL_MS = 25;
|
|
23
|
+
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
24
|
+
function processAlive(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return err.code === 'EPERM';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
36
|
+
function sleepSync(ms) {
|
|
37
|
+
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
38
|
+
Atomics.wait(sab, 0, 0, ms);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Acquire the decision-log lock for `projectRoot`.
|
|
42
|
+
* Returns a release function (always callable; a no-op when the lock could
|
|
43
|
+
* not be taken — never wedge an append behind a vanished holder).
|
|
44
|
+
*/
|
|
45
|
+
export function acquireLogLock(projectRoot) {
|
|
46
|
+
const dir = join(projectRoot, LOG_DIR);
|
|
47
|
+
if (!existsSync(dir)) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return () => { };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
56
|
+
const start = Date.now();
|
|
57
|
+
for (;;) {
|
|
58
|
+
let fd = null;
|
|
59
|
+
try {
|
|
60
|
+
fd = openSync(lockPath, 'wx');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code !== 'EEXIST')
|
|
64
|
+
return () => { };
|
|
65
|
+
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
66
|
+
let stale = false;
|
|
67
|
+
try {
|
|
68
|
+
const st = statSync(lockPath);
|
|
69
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS)
|
|
70
|
+
stale = true;
|
|
71
|
+
else {
|
|
72
|
+
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
73
|
+
if (!processAlive(pid))
|
|
74
|
+
stale = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
stale = true; // unreadable/vanishing lock → retry as stale
|
|
79
|
+
}
|
|
80
|
+
if (stale) {
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(lockPath);
|
|
83
|
+
}
|
|
84
|
+
catch { /* another holder stole it */ }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (Date.now() - start > LOCK_WAIT_MS)
|
|
88
|
+
return () => { };
|
|
89
|
+
sleepSync(LOCK_POLL_MS);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Owned the lock: stamp the holder pid, then release once on return.
|
|
93
|
+
try {
|
|
94
|
+
writeSync(fd, String(process.pid));
|
|
95
|
+
}
|
|
96
|
+
catch { /* pid stamp is advisory */ }
|
|
97
|
+
try {
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
catch { /* best-effort */ }
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released)
|
|
104
|
+
return;
|
|
105
|
+
released = true;
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(lockPath);
|
|
108
|
+
}
|
|
109
|
+
catch { /* already gone */ }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
114
|
+
const entryCountCache = new Map();
|
|
115
|
+
/** Line-count the log file from scratch (entries = non-empty JSON lines). */
|
|
116
|
+
function recountEntries(filePath) {
|
|
117
|
+
try {
|
|
118
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
119
|
+
return content.split('\n').filter((l) => l.trim().length > 0).length;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
7
125
|
/** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
|
|
8
126
|
const VALID_ENTRY_TYPES = new Set([
|
|
9
127
|
'round_start',
|
|
@@ -50,25 +168,32 @@ function logPath(projectRoot) {
|
|
|
50
168
|
* miss without failing the mutation they already performed.
|
|
51
169
|
*/
|
|
52
170
|
export function appendDecisionEntry(projectRoot, entry) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const line = JSON.stringify(entry) + '\n';
|
|
57
|
-
appendFileSync(filePath, line, 'utf-8');
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
|
|
61
|
-
}
|
|
62
|
-
// Count entries
|
|
63
|
-
let count = 0;
|
|
171
|
+
// Serialize with the cross-process log lock so an append can never interleave
|
|
172
|
+
// with a prune rewrite (which renames the file under us).
|
|
173
|
+
const release = acquireLogLock(projectRoot);
|
|
64
174
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
175
|
+
const filePath = logPath(projectRoot);
|
|
176
|
+
try {
|
|
177
|
+
const line = JSON.stringify(entry) + '\n';
|
|
178
|
+
const prevSize = existsSync(filePath) ? statSync(filePath).size : 0;
|
|
179
|
+
appendFileSync(filePath, line, 'utf-8');
|
|
180
|
+
// Count entries via the size/count cache: when nothing else changed the
|
|
181
|
+
// file since our last append (same byte size), count is just +1 — no full
|
|
182
|
+
// re-read. Falls back to a full re-count whenever the cache is stale.
|
|
183
|
+
const cached = entryCountCache.get(filePath);
|
|
184
|
+
const count = cached && cached.size === prevSize
|
|
185
|
+
? cached.count + 1
|
|
186
|
+
: recountEntries(filePath);
|
|
187
|
+
entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count });
|
|
188
|
+
return { count, path: filePath };
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
|
|
192
|
+
}
|
|
67
193
|
}
|
|
68
|
-
|
|
69
|
-
|
|
194
|
+
finally {
|
|
195
|
+
release();
|
|
70
196
|
}
|
|
71
|
-
return { count, path: filePath };
|
|
72
197
|
}
|
|
73
198
|
/**
|
|
74
199
|
* Read all entries from the decision log, plus the count of corrupt lines.
|