iterate-plugin 2.6.0 → 2.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 +10 -0
- package/dist/config-loader.js +171 -0
- package/dist/config-write.js +174 -0
- package/dist/index.js +58 -0
- package/dist/meta-review.js +181 -0
- package/dist/paths.js +32 -0
- package/dist/review.js +328 -0
- package/dist/skill-prompt.js +337 -0
- package/dist/tools/checkpoint.js +260 -0
- package/dist/tools/config.js +134 -0
- package/dist/tools/context.js +160 -0
- package/dist/tools/decision-log.js +162 -0
- package/dist/tools/fix.js +553 -0
- package/dist/tools/history.js +138 -0
- package/dist/tools/prune.js +268 -0
- package/dist/tools/review.js +159 -0
- package/dist/tools/triage.js +333 -0
- package/dist/tools/validate.js +164 -0
- package/dist/types.js +1 -0
- package/package.json +7 -3
- package/src/meta-review.ts +14 -1
- package/src/review.ts +33 -5
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { resolveProjectRoot } from "../config-loader.js";
|
|
6
|
+
const CONFIG_FILE = 'iterate.config.yaml';
|
|
7
|
+
/** Personalization key that holds the known-intentional list. */
|
|
8
|
+
const PERSONALIZATION_KEY = 'personalization';
|
|
9
|
+
const KNOWN_INTENTIONAL_KEY = 'known_intentional';
|
|
10
|
+
/** Max entries per single `apply` call. */
|
|
11
|
+
const MAX_ENTRIES = 500;
|
|
12
|
+
/** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
|
|
13
|
+
const WHOLE_FILE_LINE = 0;
|
|
14
|
+
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Normalize a caller-supplied `line` value.
|
|
17
|
+
* Returns a positive integer, or `undefined` when the value is absent,
|
|
18
|
+
* non-numeric, or non-positive (which is the "whole file" semantics).
|
|
19
|
+
*
|
|
20
|
+
* @param {unknown} line
|
|
21
|
+
* @returns {number | undefined}
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeEntryLine(line) {
|
|
24
|
+
if (typeof line !== 'number' || !Number.isInteger(line))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (line <= 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
return line;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate an array of triage entries. Each entry must be an object with
|
|
32
|
+
* non-empty string `file` / `dimension` / `reason`, and an optional positive
|
|
33
|
+
* integer `line`.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} entries
|
|
36
|
+
* @returns {string[]} Validation error messages (empty when valid).
|
|
37
|
+
*/
|
|
38
|
+
export function validateTriageEntries(entries) {
|
|
39
|
+
const errors = [];
|
|
40
|
+
if (!Array.isArray(entries)) {
|
|
41
|
+
errors.push('entries must be an array');
|
|
42
|
+
return errors;
|
|
43
|
+
}
|
|
44
|
+
if (entries.length > MAX_ENTRIES) {
|
|
45
|
+
errors.push(`entries must not exceed ${MAX_ENTRIES} items (got ${entries.length})`);
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < entries.length; i++) {
|
|
49
|
+
const prefix = `entries[${i}]`;
|
|
50
|
+
const e = entries[i];
|
|
51
|
+
if (!e || typeof e !== 'object') {
|
|
52
|
+
errors.push(`${prefix} must be an object`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const entry = e;
|
|
56
|
+
if (typeof entry.file !== 'string' || entry.file.trim().length === 0) {
|
|
57
|
+
errors.push(`${prefix}.file must be a non-empty string`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof entry.dimension !== 'string' || entry.dimension.trim().length === 0) {
|
|
60
|
+
errors.push(`${prefix}.dimension must be a non-empty string`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim().length === 0) {
|
|
63
|
+
errors.push(`${prefix}.reason must be a non-empty string`);
|
|
64
|
+
}
|
|
65
|
+
if (entry.line !== undefined && normalizeEntryLine(entry.line) === undefined) {
|
|
66
|
+
errors.push(`${prefix}.line must be a positive integer when present`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return errors;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the dedupe key for a known-intentional entry.
|
|
73
|
+
* Semantics mirror review.ts filterKnownIntentional: a whole-file entry
|
|
74
|
+
* (`line` 0/undefined) is distinct from a line-specific one.
|
|
75
|
+
*
|
|
76
|
+
* @param {KnownIntentional} entry
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function entryKey(entry) {
|
|
80
|
+
const line = normalizeEntryLine(entry.line) ?? WHOLE_FILE_LINE;
|
|
81
|
+
return `${entry.file}|${entry.dimension}|${line}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Merge incoming entries into the existing known-intentional list.
|
|
85
|
+
* Existing entries are never mutated; incoming entries whose key already
|
|
86
|
+
* exists are skipped. Returns the merged list plus add/skip counts.
|
|
87
|
+
*
|
|
88
|
+
* @param {KnownIntentional[]} existing
|
|
89
|
+
* @param {KnownIntentional[]} incoming
|
|
90
|
+
* @returns {{ merged: KnownIntentional[], added: number, skipped: number }}
|
|
91
|
+
*/
|
|
92
|
+
export function mergeKnownIntentional(existing, incoming) {
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const merged = [];
|
|
95
|
+
for (const entry of existing) {
|
|
96
|
+
const key = entryKey(entry);
|
|
97
|
+
if (!seen.has(key)) {
|
|
98
|
+
seen.add(key);
|
|
99
|
+
merged.push(entry);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let added = 0;
|
|
103
|
+
let skipped = 0;
|
|
104
|
+
for (const entry of incoming) {
|
|
105
|
+
const key = entryKey(entry);
|
|
106
|
+
if (seen.has(key)) {
|
|
107
|
+
skipped++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
seen.add(key);
|
|
111
|
+
merged.push(entry);
|
|
112
|
+
added++;
|
|
113
|
+
}
|
|
114
|
+
return { merged, added, skipped };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build a NEW config object with `personalization.known_intentional` set to
|
|
118
|
+
* the merged entries. All other top-level fields are preserved unchanged.
|
|
119
|
+
* Returns a deep-enough copy so the caller can serialize it safely.
|
|
120
|
+
*
|
|
121
|
+
* @param {Record<string, unknown>} config
|
|
122
|
+
* @param {KnownIntentional[]} entries
|
|
123
|
+
* @returns {Record<string, unknown>}
|
|
124
|
+
*/
|
|
125
|
+
export function buildConfigWithKnownIntentional(config, entries) {
|
|
126
|
+
const next = { ...config };
|
|
127
|
+
const personalization = next[PERSONALIZATION_KEY] && typeof next[PERSONALIZATION_KEY] === 'object'
|
|
128
|
+
? { ...next[PERSONALIZATION_KEY] }
|
|
129
|
+
: {};
|
|
130
|
+
personalization[KNOWN_INTENTIONAL_KEY] = entries;
|
|
131
|
+
next[PERSONALIZATION_KEY] = personalization;
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
/** Read the raw known-intentional list from a config object (may be absent). */
|
|
135
|
+
export function readKnownIntentional(config) {
|
|
136
|
+
const personalization = config[PERSONALIZATION_KEY];
|
|
137
|
+
if (!personalization || typeof personalization !== 'object')
|
|
138
|
+
return [];
|
|
139
|
+
const known = personalization[KNOWN_INTENTIONAL_KEY];
|
|
140
|
+
if (!Array.isArray(known))
|
|
141
|
+
return [];
|
|
142
|
+
return known.filter((e) => !!e &&
|
|
143
|
+
typeof e === 'object' &&
|
|
144
|
+
typeof e.file === 'string');
|
|
145
|
+
}
|
|
146
|
+
/** Build a filesystem-safe backup suffix from the current time. */
|
|
147
|
+
export function backupSuffix(now = new Date()) {
|
|
148
|
+
return now.toISOString().replace(/[:.]/g, '-');
|
|
149
|
+
}
|
|
150
|
+
// ─── File I/O ───────────────────────────────────────────────────────────────
|
|
151
|
+
/** Load the raw config object (empty when the file is missing). */
|
|
152
|
+
function readConfigFile(configPath) {
|
|
153
|
+
if (!existsSync(configPath))
|
|
154
|
+
return {};
|
|
155
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
156
|
+
const parsed = yaml.load(content);
|
|
157
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
158
|
+
// A config that exists but is not a YAML mapping must NOT be silently
|
|
159
|
+
// treated as empty: writing over it would destroy user data. Callers
|
|
160
|
+
// surface this as an error and refuse to write.
|
|
161
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
|
162
|
+
}
|
|
163
|
+
return parsed;
|
|
164
|
+
}
|
|
165
|
+
/** Apply the triage entries: backup, merge, write, rollback on failure. */
|
|
166
|
+
function applyEntries(projectRoot, incoming) {
|
|
167
|
+
const configPath = join(projectRoot, CONFIG_FILE);
|
|
168
|
+
let config;
|
|
169
|
+
try {
|
|
170
|
+
config = readConfigFile(configPath);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
// The file exists but is malformed — refuse to overwrite user data.
|
|
174
|
+
return { ok: false, error: `Failed to read config: ${String(err)}` };
|
|
175
|
+
}
|
|
176
|
+
const existing = readKnownIntentional(config);
|
|
177
|
+
const { merged, added, skipped } = mergeKnownIntentional(existing, incoming);
|
|
178
|
+
const nextConfig = buildConfigWithKnownIntentional(config, merged);
|
|
179
|
+
const hadFile = existsSync(configPath);
|
|
180
|
+
const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null;
|
|
181
|
+
if (backupPath) {
|
|
182
|
+
try {
|
|
183
|
+
copyFileSync(configPath, backupPath);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: `Failed to create backup: ${String(err)}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const yamlText = yaml.dump(nextConfig, { noRefs: true });
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(configPath, yamlText, 'utf-8');
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
// Rollback: restore the backup (or delete the file we just created).
|
|
198
|
+
try {
|
|
199
|
+
if (backupPath)
|
|
200
|
+
copyFileSync(backupPath, configPath);
|
|
201
|
+
else if (existsSync(configPath))
|
|
202
|
+
writeFileSync(configPath, '', 'utf-8');
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Rollback failure is reported, not swallowed silently.
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: `Failed to write config: ${String(err)}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Register the `iterate_triage` tool.
|
|
216
|
+
*
|
|
217
|
+
* Completes the findings-triage closed loop: the client triage panel marks
|
|
218
|
+
* findings as "known intentional" (a), and this tool writes those entries
|
|
219
|
+
* into `iterate.config.yaml` under `personalization.known_intentional` so the
|
|
220
|
+
* next review round filters them out (review.ts filterKnownIntentional).
|
|
221
|
+
*
|
|
222
|
+
* Operations:
|
|
223
|
+
* - `apply`: merge validated entries into the config (dedupe by
|
|
224
|
+
* file|dimension|line), with an automatic timestamped backup and
|
|
225
|
+
* rollback if the write fails.
|
|
226
|
+
* - `list`: read back the current known_intentional entries.
|
|
227
|
+
*/
|
|
228
|
+
export function registerTriageTool(ctx) {
|
|
229
|
+
ctx.tools.register(defineTool({
|
|
230
|
+
name: 'iterate_triage',
|
|
231
|
+
description: 'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
|
|
232
|
+
'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
|
|
233
|
+
'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
|
|
234
|
+
'backed up before writing. Use `list` to read the current entries. ' +
|
|
235
|
+
'The client browser cannot write files, so this tool is the write-back channel for the triage panel.',
|
|
236
|
+
parameters: {
|
|
237
|
+
operation: {
|
|
238
|
+
type: 'string',
|
|
239
|
+
required: true,
|
|
240
|
+
description: '"apply" to merge entries into the config, "list" to read them back.',
|
|
241
|
+
enum: ['apply', 'list'],
|
|
242
|
+
},
|
|
243
|
+
entries: {
|
|
244
|
+
type: 'json',
|
|
245
|
+
description: 'For `apply`: array of known-intentional entries, e.g. ' +
|
|
246
|
+
'[{"file":"src/a.ts","line":42,"dimension":"security","reason":"..."}]. ' +
|
|
247
|
+
'Each entry needs non-empty string file/dimension/reason; line is an optional positive integer ' +
|
|
248
|
+
'(omitted = whole file).',
|
|
249
|
+
},
|
|
250
|
+
path: {
|
|
251
|
+
type: 'string',
|
|
252
|
+
description: 'Project root directory (default: current working directory).',
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
output: {
|
|
256
|
+
schema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
properties: {
|
|
260
|
+
operation: { type: 'string', required: true },
|
|
261
|
+
added: { type: 'integer' },
|
|
262
|
+
skipped: { type: 'integer' },
|
|
263
|
+
count: { type: 'integer' },
|
|
264
|
+
path: { type: 'string' },
|
|
265
|
+
backupPath: { type: 'string' },
|
|
266
|
+
entries: { type: 'json' },
|
|
267
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
268
|
+
error: { type: 'string' },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
render: (_args, value) => [
|
|
272
|
+
{ type: 'text', text: JSON.stringify(value, null, 2) },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
async execute(args) {
|
|
276
|
+
const resolved = resolveProjectRoot(args.path);
|
|
277
|
+
if (!resolved.ok) {
|
|
278
|
+
return { operation: args.operation, error: resolved.reason };
|
|
279
|
+
}
|
|
280
|
+
const projectRoot = resolved.root;
|
|
281
|
+
const configPath = join(projectRoot, CONFIG_FILE);
|
|
282
|
+
if (args.operation === 'list') {
|
|
283
|
+
let config;
|
|
284
|
+
try {
|
|
285
|
+
config = readConfigFile(configPath);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
return { operation: 'list', error: `Failed to read config: ${String(err)}` };
|
|
289
|
+
}
|
|
290
|
+
const entries = readKnownIntentional(config);
|
|
291
|
+
return {
|
|
292
|
+
operation: 'list',
|
|
293
|
+
count: entries.length,
|
|
294
|
+
path: configPath,
|
|
295
|
+
entries: entries,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (args.operation === 'apply') {
|
|
299
|
+
const validation = validateTriageEntries(args.entries);
|
|
300
|
+
if (validation.length > 0) {
|
|
301
|
+
return { operation: 'apply', errors: validation, error: 'Invalid entries.' };
|
|
302
|
+
}
|
|
303
|
+
const incoming = args.entries.map((e) => {
|
|
304
|
+
const raw = e;
|
|
305
|
+
return {
|
|
306
|
+
file: String(raw.file),
|
|
307
|
+
...(normalizeEntryLine(raw.line) !== undefined
|
|
308
|
+
? { line: normalizeEntryLine(raw.line) }
|
|
309
|
+
: {}),
|
|
310
|
+
dimension: String(raw.dimension),
|
|
311
|
+
reason: String(raw.reason),
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
const result = applyEntries(projectRoot, incoming);
|
|
315
|
+
if (!result.ok) {
|
|
316
|
+
return { operation: 'apply', error: result.error };
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
operation: 'apply',
|
|
320
|
+
added: result.added,
|
|
321
|
+
skipped: result.skipped,
|
|
322
|
+
count: result.count,
|
|
323
|
+
path: result.configPath,
|
|
324
|
+
backupPath: result.backupPath ?? undefined,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
operation: args.operation,
|
|
329
|
+
error: 'Unknown operation. Use "apply" or "list".',
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import { loadEffectiveConfig, isCommandAllowed, flattenCommands, resolveProjectRoot, } from "../config-loader.js";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
5
|
+
/** Hard ceiling on a single validation command's runtime, so a model cannot
|
|
6
|
+
* pin the tool open indefinitely via an unbounded `timeout` argument. */
|
|
7
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a caller-supplied timeout (ms) to a sane range.
|
|
10
|
+
* Non-finite / non-positive values fall back to the default; any value above
|
|
11
|
+
* the ceiling is capped. Pure function, unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
export function clampTimeout(ms) {
|
|
14
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) {
|
|
15
|
+
return DEFAULT_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
return Math.min(ms, MAX_TIMEOUT_MS);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run a single shell command with timeout and return structured results.
|
|
21
|
+
* Pure function (no side effects beyond the exec call).
|
|
22
|
+
*/
|
|
23
|
+
async function runCommand(command, cwd, timeoutMs) {
|
|
24
|
+
const start = performance.now();
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
exec(command, {
|
|
27
|
+
cwd,
|
|
28
|
+
timeout: timeoutMs,
|
|
29
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
30
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
31
|
+
}, (error, stdout, stderr) => {
|
|
32
|
+
const durationMs = Math.round(performance.now() - start);
|
|
33
|
+
// error.code is the exit code when the command ran; error.killed means timeout
|
|
34
|
+
resolve({
|
|
35
|
+
command,
|
|
36
|
+
exitCode: error?.code ?? (error ? 1 : 0),
|
|
37
|
+
stdout: stdout ?? '',
|
|
38
|
+
stderr: stderr ?? '',
|
|
39
|
+
timedOut: error?.killed === true,
|
|
40
|
+
durationMs,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Register the `iterate_validate` tool.
|
|
47
|
+
* Runs validation commands defined in iterate.config.yaml `validation.commands`.
|
|
48
|
+
* Enforces exact-match — a command not listed there (exactly) is rejected.
|
|
49
|
+
*/
|
|
50
|
+
export function registerValidateTool(ctx) {
|
|
51
|
+
ctx.tools.register(defineTool({
|
|
52
|
+
name: 'iterate_validate',
|
|
53
|
+
description: 'Run a validation command that is PRECONFIGURED in iterate.config.yaml `validation.commands`. ' +
|
|
54
|
+
'The command must exactly match one of the configured commands (they are the only ones the user trusts). ' +
|
|
55
|
+
'Returns exit code, stdout, stderr, and duration. ' +
|
|
56
|
+
'Use this after making fixes to verify correctness.',
|
|
57
|
+
parameters: {
|
|
58
|
+
command: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
required: true,
|
|
61
|
+
description: 'One of the commands listed in iterate.config.yaml validation.commands (exact match required, e.g. "pytest tests/ -x -q").',
|
|
62
|
+
},
|
|
63
|
+
path: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'Project root directory (default: current working directory).',
|
|
66
|
+
},
|
|
67
|
+
timeout: {
|
|
68
|
+
type: 'integer',
|
|
69
|
+
description: 'Timeout in milliseconds (default: 120000).',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
output: {
|
|
73
|
+
schema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
properties: {
|
|
77
|
+
allowed: { type: 'boolean', required: true },
|
|
78
|
+
command: { type: 'string', required: true },
|
|
79
|
+
exitCode: { type: 'integer', required: true },
|
|
80
|
+
stdout: { type: 'string', required: true },
|
|
81
|
+
stderr: { type: 'string', required: true },
|
|
82
|
+
timedOut: { type: 'boolean', required: true },
|
|
83
|
+
durationMs: { type: 'integer', required: true },
|
|
84
|
+
rejectReason: { type: 'string' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
render: (_args, value) => [
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
text: value.allowed
|
|
91
|
+
? [
|
|
92
|
+
`Command: ${value.command}`,
|
|
93
|
+
`Exit code: ${value.exitCode}`,
|
|
94
|
+
`Duration: ${value.durationMs}ms`,
|
|
95
|
+
value.timedOut ? '⚠ Timed out' : '',
|
|
96
|
+
'',
|
|
97
|
+
value.stdout ? `[stdout]\n${value.stdout}` : '',
|
|
98
|
+
value.stderr ? `[stderr]\n${value.stderr}` : '',
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join('\n')
|
|
102
|
+
: `Command rejected: ${value.rejectReason}`,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
async execute(args) {
|
|
107
|
+
const resolved = resolveProjectRoot(args.path);
|
|
108
|
+
if (!resolved.ok) {
|
|
109
|
+
return {
|
|
110
|
+
allowed: false,
|
|
111
|
+
command: args.command,
|
|
112
|
+
exitCode: -1,
|
|
113
|
+
stdout: '',
|
|
114
|
+
stderr: '',
|
|
115
|
+
timedOut: false,
|
|
116
|
+
durationMs: 0,
|
|
117
|
+
rejectReason: resolved.reason,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const projectRoot = resolved.root;
|
|
121
|
+
// Effective config = defaults merged with project overrides. Never null.
|
|
122
|
+
const { config, source } = loadEffectiveConfig(projectRoot);
|
|
123
|
+
const timeout = clampTimeout(args.timeout);
|
|
124
|
+
// Only commands predefined in validation.commands may run — the
|
|
125
|
+
// user trusts exactly these, and nothing else. This replaces the
|
|
126
|
+
// old prefix-match whitelist, which let e.g. `python3 -c "..."`
|
|
127
|
+
// slip through on a `python3` prefix.
|
|
128
|
+
const predefinedCommands = flattenCommands(config.validation.commands);
|
|
129
|
+
if (predefinedCommands.length === 0) {
|
|
130
|
+
return {
|
|
131
|
+
allowed: false,
|
|
132
|
+
command: args.command,
|
|
133
|
+
exitCode: -1,
|
|
134
|
+
stdout: '',
|
|
135
|
+
stderr: '',
|
|
136
|
+
timedOut: false,
|
|
137
|
+
durationMs: 0,
|
|
138
|
+
rejectReason: (source === 'defaults'
|
|
139
|
+
? 'No iterate.config.yaml at project root — running on built-in defaults, which configure NO trusted validation commands. '
|
|
140
|
+
: 'No validation.commands configured in iterate.config.yaml. ') +
|
|
141
|
+
'Nothing can be validated until you define trusted commands in `validation.commands`.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!isCommandAllowed(args.command, predefinedCommands)) {
|
|
145
|
+
return {
|
|
146
|
+
allowed: false,
|
|
147
|
+
command: args.command,
|
|
148
|
+
exitCode: -1,
|
|
149
|
+
stdout: '',
|
|
150
|
+
stderr: '',
|
|
151
|
+
timedOut: false,
|
|
152
|
+
durationMs: 0,
|
|
153
|
+
rejectReason: `Command must exactly match a command predefined in iterate.config.yaml validation.commands. ` +
|
|
154
|
+
`Allowed commands: ${predefinedCommands.join(' | ')}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const result = await runCommand(args.command, projectRoot, timeout);
|
|
158
|
+
return {
|
|
159
|
+
allowed: true,
|
|
160
|
+
...result,
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"src",
|
|
39
39
|
"lib",
|
|
40
|
+
"dist",
|
|
40
41
|
"cordis.patch.yml",
|
|
41
42
|
"README.md",
|
|
42
43
|
"LICENSE"
|
|
@@ -44,11 +45,14 @@
|
|
|
44
45
|
"exports": {
|
|
45
46
|
".": {
|
|
46
47
|
"types": "./src/index.ts",
|
|
47
|
-
"default": "./
|
|
48
|
+
"default": "./dist/index.js"
|
|
48
49
|
},
|
|
49
|
-
"./client": "./lib/client.js"
|
|
50
|
+
"./client": "./lib/client.js",
|
|
51
|
+
"./package.json": "./package.json"
|
|
50
52
|
},
|
|
51
53
|
"scripts": {
|
|
54
|
+
"build": "tsc -p tsconfig.build.json",
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
52
56
|
"typecheck": "tsc --noEmit",
|
|
53
57
|
"test": "tsx --test test/*.test.ts",
|
|
54
58
|
"test:validate": "tsx --test test/validate.test.ts"
|
package/src/meta-review.ts
CHANGED
|
@@ -205,7 +205,20 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
205
205
|
`but totalFindings is ${total}.`,
|
|
206
206
|
)
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
209
|
+
// r-1), so the "last round" is the LAST RECORDED round's reported number, not
|
|
210
|
+
// the array's last index (the array is sized to the highest round, which only
|
|
211
|
+
// equals the record count for contiguous 1..N round numbers). Read the flag
|
|
212
|
+
// consistency the same way buildReviewReport/computeConvergence set it.
|
|
213
|
+
const reportRounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
214
|
+
const lastRecordedRound =
|
|
215
|
+
reportRounds.length > 0 && typeof reportRounds[reportRounds.length - 1]?.round === 'number'
|
|
216
|
+
? reportRounds[reportRounds.length - 1]!.round
|
|
217
|
+
: null
|
|
218
|
+
const lastRoundNew =
|
|
219
|
+
lastRecordedRound !== null && lastRecordedRound > 0
|
|
220
|
+
? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
|
|
221
|
+
: null
|
|
209
222
|
const expectedConverged = lastRoundNew === 0
|
|
210
223
|
if (report.convergence?.converged !== expectedConverged) {
|
|
211
224
|
add(
|
package/src/review.ts
CHANGED
|
@@ -106,7 +106,12 @@ export function filterKnownIntentional(
|
|
|
106
106
|
* Merge per-round findings into one globally-deduped stream while tracking
|
|
107
107
|
* which round first surfaced each finding. This is the deterministic core of
|
|
108
108
|
* "反复多轮审查直至收敛":
|
|
109
|
-
* - `findingsByRound
|
|
109
|
+
* - `findingsByRound` = number of GLOBALLY new findings first seen in round r,
|
|
110
|
+
* indexed by the actual `round` number (round r → index r-1). The array is
|
|
111
|
+
* sized to the highest round number encountered, so non-contiguous round
|
|
112
|
+
* numbers (e.g. a resumed run that starts at round 5, or a caller that only
|
|
113
|
+
* passes `[{round: 3}]`) still yield correct counts instead of being
|
|
114
|
+
* collapsed onto wrong indices.
|
|
110
115
|
* - `converged` = the last executed round produced 0 new findings
|
|
111
116
|
* - `stoppedReason` = 'converged' | 'max_rounds_reached'
|
|
112
117
|
*/
|
|
@@ -122,7 +127,12 @@ export function aggregateRounds(
|
|
|
122
127
|
const firstRoundByKey = new Map<string, number>()
|
|
123
128
|
const merged: ReviewFinding[] = []
|
|
124
129
|
|
|
130
|
+
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
131
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0.
|
|
132
|
+
let maxRound = 0
|
|
125
133
|
for (const round of rounds) {
|
|
134
|
+
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
|
|
135
|
+
if (round.round > maxRound) maxRound = round.round
|
|
126
136
|
for (const f of round.findings) {
|
|
127
137
|
const key = findingKey(f)
|
|
128
138
|
if (seen.has(key)) continue
|
|
@@ -133,7 +143,7 @@ export function aggregateRounds(
|
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
const findingsByRound: number[] = []
|
|
136
|
-
for (let r = 1; r <=
|
|
146
|
+
for (let r = 1; r <= maxRound; r++) {
|
|
137
147
|
let count = 0
|
|
138
148
|
for (const key of firstRoundByKey.keys()) {
|
|
139
149
|
if (firstRoundByKey.get(key) === r) count++
|
|
@@ -153,7 +163,13 @@ export function computeConvergence(
|
|
|
153
163
|
): ReviewReport['convergence'] {
|
|
154
164
|
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
|
|
155
165
|
const totalRounds = rounds.length
|
|
156
|
-
|
|
166
|
+
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
167
|
+
// r-1), so convergence must read the LAST PRESENT round's count using its
|
|
168
|
+
// reported round number — not `totalRounds - 1`, which is only valid for
|
|
169
|
+
// contiguous 1..N round numbers.
|
|
170
|
+
const lastRound = totalRounds > 0 ? rounds[totalRounds - 1]!.round : 0
|
|
171
|
+
const lastRoundCount =
|
|
172
|
+
lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
|
|
157
173
|
const converged = totalRounds > 0 && lastRoundCount === 0
|
|
158
174
|
return {
|
|
159
175
|
totalRounds,
|
|
@@ -218,6 +234,18 @@ export function buildReviewReport(input: {
|
|
|
218
234
|
// 3. Severity sort the global result.
|
|
219
235
|
const sorted = sortFindings(findings)
|
|
220
236
|
|
|
237
|
+
// 4. Convergence. Must be identical to `computeConvergence`: `findingsByRound`
|
|
238
|
+
// is indexed by the actual round number (round r → index r-1) and sized to
|
|
239
|
+
// the highest round, so convergence reads the LAST PRESENT round's count
|
|
240
|
+
// using its reported round number — NOT `filteredRounds.length - 1`, which
|
|
241
|
+
// is only valid for contiguous 1..N round numbers (resumed iterations and
|
|
242
|
+
// non-contiguous round sets would otherwise read the wrong count).
|
|
243
|
+
const lastRound =
|
|
244
|
+
filteredRounds.length > 0 ? filteredRounds[filteredRounds.length - 1]!.round : 0
|
|
245
|
+
const lastRoundCount =
|
|
246
|
+
lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
|
|
247
|
+
const converged = filteredRounds.length > 0 && lastRoundCount === 0
|
|
248
|
+
|
|
221
249
|
return {
|
|
222
250
|
mode: input.mode,
|
|
223
251
|
goal: input.goal,
|
|
@@ -228,11 +256,11 @@ export function buildReviewReport(input: {
|
|
|
228
256
|
convergence: {
|
|
229
257
|
totalRounds: filteredRounds.length,
|
|
230
258
|
findingsByRound,
|
|
231
|
-
converged
|
|
259
|
+
converged,
|
|
232
260
|
stoppedReason:
|
|
233
261
|
filteredRounds.length === 0
|
|
234
262
|
? 'max_rounds_reached'
|
|
235
|
-
:
|
|
263
|
+
: converged
|
|
236
264
|
? 'converged'
|
|
237
265
|
: 'max_rounds_reached',
|
|
238
266
|
},
|