iterate-plugin 2.8.0 → 2.8.2
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/config-loader.js +6 -1
- package/dist/evidence.js +21 -3
- package/dist/git-scope.js +101 -0
- package/dist/meta-review.js +50 -3
- package/dist/method-scope.js +173 -0
- package/dist/review-scope.js +187 -0
- package/dist/review.js +216 -16
- package/dist/skill-prompt.js +65 -27
- package/dist/tools/fix.js +12 -2
- package/dist/tools/review.js +68 -5
- package/lib/client.js +3 -25
- package/package.json +1 -1
- package/src/client/index.ts +18 -14
- package/src/config-loader.ts +6 -1
- package/src/evidence.ts +22 -3
- package/src/git-scope.ts +129 -0
- package/src/meta-review.ts +65 -5
- package/src/method-scope.ts +201 -0
- package/src/review-scope.ts +203 -0
- package/src/review.ts +301 -16
- package/src/skill-prompt.ts +65 -27
- package/src/tools/fix.ts +13 -2
- package/src/tools/review.ts +82 -5
- package/src/types.ts +6 -1
package/dist/review.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* and stop when a round yields 0 new findings or the round cap is reached.
|
|
17
17
|
* All deterministic math lives here so it can be unit-tested.
|
|
18
18
|
*/
|
|
19
|
+
import { DEFAULT_SCOPE_CHUNK_SIZE, chunkFiles } from "./review-scope.js";
|
|
19
20
|
/** Severity ordering: lower rank = more severe. */
|
|
20
21
|
export const SEVERITY_RANK = {
|
|
21
22
|
critical: 0,
|
|
@@ -269,10 +270,163 @@ export function findingsSchema() {
|
|
|
269
270
|
],
|
|
270
271
|
},
|
|
271
272
|
},
|
|
273
|
+
readFiles: {
|
|
274
|
+
type: 'array',
|
|
275
|
+
items: { type: 'string' },
|
|
276
|
+
description: 'Every file you actually opened with read_file while reviewing your assigned scope. Used to audit coverage; files you never opened count as un-reviewed.',
|
|
277
|
+
},
|
|
272
278
|
},
|
|
273
|
-
required: ['findings'],
|
|
279
|
+
required: ['findings', 'readFiles'],
|
|
274
280
|
};
|
|
275
281
|
}
|
|
282
|
+
// ─── Output schema validation ──────────────────────────────────────────────
|
|
283
|
+
//
|
|
284
|
+
// `config.reviewer.output_schema_validation` (default true) turns on a
|
|
285
|
+
// deterministic schema gate at the `aggregate` boundary: reviewer subagent
|
|
286
|
+
// outputs are parsed as JSON by the orchestrator, but models sometimes return
|
|
287
|
+
// malformed findings (missing fields, wrong types, out-of-range severity).
|
|
288
|
+
// Before any finding reaches the deterministic core (dedupe/sort/report) —
|
|
289
|
+
// which would crash on e.g. a missing `summary` — we validate every entry
|
|
290
|
+
// against the same shape `findingsSchema()` describes and surface the issues
|
|
291
|
+
// so the workflow can retry that round (≤2 times) with a strict-JSON nudge.
|
|
292
|
+
// Schema-invalid findings are dropped from the report; the workflow never
|
|
293
|
+
// forwards them into fixes.
|
|
294
|
+
/** Fields every finding object MUST carry (mirrors findingsSchema().required). */
|
|
295
|
+
export const REQUIRED_FINDING_FIELDS = [
|
|
296
|
+
'dimension',
|
|
297
|
+
'file',
|
|
298
|
+
'severity',
|
|
299
|
+
'summary',
|
|
300
|
+
'failure_scenario',
|
|
301
|
+
'suggested_fix',
|
|
302
|
+
'is_atomic',
|
|
303
|
+
];
|
|
304
|
+
/** Allowed severity values (mirrors the schema enum). */
|
|
305
|
+
export const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'];
|
|
306
|
+
/** String-typed finding fields (type check only, presence handled by REQUIRED). */
|
|
307
|
+
const STRING_FINDING_FIELDS = [
|
|
308
|
+
'dimension',
|
|
309
|
+
'file',
|
|
310
|
+
'summary',
|
|
311
|
+
'failure_scenario',
|
|
312
|
+
'suggested_fix',
|
|
313
|
+
];
|
|
314
|
+
/**
|
|
315
|
+
* Validate an arbitrary parsed value against the findings schema shape.
|
|
316
|
+
* Accepts the `{findings: [...]}` wrapper OR a bare findings array, so callers
|
|
317
|
+
* can validate either the raw reviewer output object or a round's findings.
|
|
318
|
+
* Pure and deterministic — never touches the filesystem.
|
|
319
|
+
*/
|
|
320
|
+
export function validateFindingsSchema(input) {
|
|
321
|
+
const raw = input?.findings ?? input;
|
|
322
|
+
if (!Array.isArray(raw)) {
|
|
323
|
+
return [
|
|
324
|
+
{
|
|
325
|
+
index: -1,
|
|
326
|
+
field: 'findings',
|
|
327
|
+
message: 'expected a JSON array of finding objects',
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
const issues = [];
|
|
332
|
+
for (let i = 0; i < raw.length; i++) {
|
|
333
|
+
const item = raw[i];
|
|
334
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
335
|
+
issues.push({
|
|
336
|
+
index: i,
|
|
337
|
+
field: `findings[${i}]`,
|
|
338
|
+
message: 'expected a finding object',
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const f = item;
|
|
343
|
+
for (const key of REQUIRED_FINDING_FIELDS) {
|
|
344
|
+
if (f[key] === undefined || f[key] === null) {
|
|
345
|
+
issues.push({
|
|
346
|
+
index: i,
|
|
347
|
+
field: `findings[${i}].${key}`,
|
|
348
|
+
message: `required field "${key}" is missing`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const key of STRING_FINDING_FIELDS) {
|
|
353
|
+
if (f[key] !== undefined && f[key] !== null && typeof f[key] !== 'string') {
|
|
354
|
+
issues.push({
|
|
355
|
+
index: i,
|
|
356
|
+
field: `findings[${i}].${key}`,
|
|
357
|
+
message: `"${key}" must be a string`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (f.severity !== undefined &&
|
|
362
|
+
f.severity !== null &&
|
|
363
|
+
!SEVERITY_VALUES.includes(f.severity)) {
|
|
364
|
+
issues.push({
|
|
365
|
+
index: i,
|
|
366
|
+
field: `findings[${i}].severity`,
|
|
367
|
+
message: `severity must be one of: ${SEVERITY_VALUES.join(', ')}`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (f.is_atomic !== undefined &&
|
|
371
|
+
f.is_atomic !== null &&
|
|
372
|
+
typeof f.is_atomic !== 'boolean') {
|
|
373
|
+
issues.push({
|
|
374
|
+
index: i,
|
|
375
|
+
field: `findings[${i}].is_atomic`,
|
|
376
|
+
message: 'is_atomic must be a boolean',
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (f.line !== undefined &&
|
|
380
|
+
f.line !== null &&
|
|
381
|
+
(typeof f.line !== 'number' || !Number.isInteger(f.line) || f.line < 0)) {
|
|
382
|
+
issues.push({
|
|
383
|
+
index: i,
|
|
384
|
+
field: `findings[${i}].line`,
|
|
385
|
+
message: 'line must be a non-negative integer (0 = whole-file)',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return issues;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Validate every round's findings against the findings schema.
|
|
393
|
+
* Round order matches the input `rounds` array.
|
|
394
|
+
*/
|
|
395
|
+
export function validateRoundsSchema(rounds) {
|
|
396
|
+
return rounds.map((r) => {
|
|
397
|
+
const issues = validateFindingsSchema(r.findings);
|
|
398
|
+
return { round: r.round, valid: issues.length === 0, issues };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Drop schema-invalid findings before they reach the deterministic core.
|
|
403
|
+
*
|
|
404
|
+
* - With a non-null `schemaValidation` (validation enabled): drop every finding
|
|
405
|
+
* flagged by a schema issue; a round-level issue (index -1) empties the round.
|
|
406
|
+
* - With `schemaValidation === null` (validation disabled): still drop entries
|
|
407
|
+
* that are not plain objects, which would crash `findingKey`/dedupe.
|
|
408
|
+
*
|
|
409
|
+
* Round order and round numbers are preserved so downstream convergence math
|
|
410
|
+
* keeps working on the sanitized stream.
|
|
411
|
+
*/
|
|
412
|
+
export function sanitizeRounds(rounds, schemaValidation) {
|
|
413
|
+
return rounds.map((r, i) => {
|
|
414
|
+
if (schemaValidation) {
|
|
415
|
+
const issues = schemaValidation[i]?.issues ?? [];
|
|
416
|
+
if (issues.some((iss) => iss.index === -1))
|
|
417
|
+
return { round: r.round, findings: [] };
|
|
418
|
+
const bad = new Set(issues.map((iss) => iss.index));
|
|
419
|
+
return {
|
|
420
|
+
round: r.round,
|
|
421
|
+
findings: r.findings.filter((_, fi) => !bad.has(fi)),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
round: r.round,
|
|
426
|
+
findings: r.findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
}
|
|
276
430
|
/**
|
|
277
431
|
* Build the task prompt for one dimension's reviewer subagent.
|
|
278
432
|
* In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
|
|
@@ -281,6 +435,21 @@ export function findingsSchema() {
|
|
|
281
435
|
export function reviewerTaskPrompt(input) {
|
|
282
436
|
const parts = [];
|
|
283
437
|
parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
|
|
438
|
+
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
439
|
+
parts.push('COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
440
|
+
'assigned to review. You MUST open EVERY file in this inventory with ' +
|
|
441
|
+
'the read_file tool before judging it — do not skip, skim-declare, or ' +
|
|
442
|
+
'assume any file without reading it. Files you did not actually open ' +
|
|
443
|
+
'are considered un-reviewed and will lower your coverage score. ' +
|
|
444
|
+
'Return a `readFiles` array listing every file you actually opened.', 'Assigned file inventory:', input.scopeFiles.map((p) => `- ${p}`).join('\n'));
|
|
445
|
+
}
|
|
446
|
+
else if (input.scope === 'changed-only' &&
|
|
447
|
+
input.changedFiles &&
|
|
448
|
+
input.changedFiles.length > 0) {
|
|
449
|
+
parts.push('Changed files to review (review ONLY these files; they are the diff against ' +
|
|
450
|
+
'the target branch). You MUST open EVERY listed file with read_file ' +
|
|
451
|
+
'before judging it — never skip or assume a file:', input.changedFiles.map((p) => `- ${p}`).join('\n'));
|
|
452
|
+
}
|
|
284
453
|
if (input.mode === 'dry-run') {
|
|
285
454
|
parts.push('MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.');
|
|
286
455
|
}
|
|
@@ -295,7 +464,7 @@ export function reviewerTaskPrompt(input) {
|
|
|
295
464
|
'actually read — speculation about code you never inspected is a ' +
|
|
296
465
|
'disqualifying failure, and fabricated line numbers are treated as ' +
|
|
297
466
|
'poisoned evidence. Anchor every finding to real code.');
|
|
298
|
-
parts.push(`Return a JSON object: {"findings": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
467
|
+
parts.push(`Return a JSON object: {"findings": [...], "readFiles": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
299
468
|
'line (REQUIRED positive integer — the exact line you READ for an ' +
|
|
300
469
|
'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
|
|
301
470
|
'issues), severity (critical/high/medium/low), summary (one line), ' +
|
|
@@ -315,27 +484,58 @@ export function buildReviewPlan(input) {
|
|
|
315
484
|
// throwing an uncaught TypeError inside the tool's `execute`.
|
|
316
485
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English';
|
|
317
486
|
const goal = input.config.goal ?? '';
|
|
318
|
-
const
|
|
487
|
+
const configuredScope = input.config.review?.scope ?? 'full';
|
|
319
488
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
|
|
320
489
|
const maxLines = input.config.atomic?.max_lines ?? 20;
|
|
490
|
+
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
|
|
491
|
+
// changed-only with zero detected changes → auto-fallback to full scope.
|
|
492
|
+
const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0;
|
|
493
|
+
const scope = effectiveChangedOnly ? 'changed-only' : 'full';
|
|
494
|
+
const fallbackToFull = configuredScope === 'changed-only' && changedFiles.length === 0;
|
|
495
|
+
// Scope batching (coverage enforcement): changed-only is a single batch
|
|
496
|
+
// owning the full delta; full scope splits the collected inventory into
|
|
497
|
+
// per-chunk reviewer tasks when scopeFiles is supplied.
|
|
498
|
+
const chunkSize = Number(input.config.reviewer?.scope_chunk_size);
|
|
499
|
+
const perChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_SCOPE_CHUNK_SIZE;
|
|
500
|
+
let batches;
|
|
501
|
+
if (effectiveChangedOnly) {
|
|
502
|
+
batches = [undefined];
|
|
503
|
+
}
|
|
504
|
+
else if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
505
|
+
batches = chunkFiles(input.scopeFiles, perChunk).filter((b) => b.length > 0);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
batches = [undefined];
|
|
509
|
+
}
|
|
510
|
+
const dimensionTasks = [];
|
|
511
|
+
for (const d of dimensions) {
|
|
512
|
+
batches.forEach((batch, index) => {
|
|
513
|
+
const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`;
|
|
514
|
+
dimensionTasks.push({
|
|
515
|
+
id: dimensionId,
|
|
516
|
+
reviewerPrompt: reviewerTaskPrompt({
|
|
517
|
+
dimension: d,
|
|
518
|
+
goal,
|
|
519
|
+
scope,
|
|
520
|
+
mode: input.mode,
|
|
521
|
+
alreadyKnown: [],
|
|
522
|
+
outputLanguage: language,
|
|
523
|
+
maxLines,
|
|
524
|
+
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
525
|
+
scopeFiles: batch,
|
|
526
|
+
}),
|
|
527
|
+
findingsSchema: findingsSchema(),
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
321
531
|
return {
|
|
322
532
|
mode: input.mode,
|
|
323
533
|
goal,
|
|
324
534
|
scope,
|
|
325
|
-
dimensions:
|
|
326
|
-
id: d,
|
|
327
|
-
reviewerPrompt: reviewerTaskPrompt({
|
|
328
|
-
dimension: d,
|
|
329
|
-
goal,
|
|
330
|
-
scope,
|
|
331
|
-
mode: input.mode,
|
|
332
|
-
alreadyKnown: [],
|
|
333
|
-
outputLanguage: language,
|
|
334
|
-
maxLines,
|
|
335
|
-
}),
|
|
336
|
-
findingsSchema: findingsSchema(),
|
|
337
|
-
})),
|
|
535
|
+
dimensions: dimensionTasks,
|
|
338
536
|
maxReviewRounds: input.maxReviewRounds,
|
|
339
537
|
knownIntentional: input.knownIntentional ?? [],
|
|
538
|
+
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
539
|
+
fallbackToFull,
|
|
340
540
|
};
|
|
341
541
|
}
|
package/dist/skill-prompt.js
CHANGED
|
@@ -13,7 +13,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
13
13
|
- \`iterate_validate\` — run a whitelisted validation command
|
|
14
14
|
- \`iterate_decision_log\` — append to the decision log, or read entries back for review
|
|
15
15
|
- \`iterate_context\` — read SKILL.md / ITERATE.md project context
|
|
16
|
-
- \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan; \`aggregate\` dedupes/merges findings and computes convergence; \`meta-review\` audits a built report for internal consistency (counts, buckets, sorting, convergence math) and returns a final report with an \`approved\` / \`needs_revision\` verdict. Purely computational.
|
|
16
|
+
- \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan (for \`review.scope: changed-only\`, it resolves the git-diff file set against \`git.target_branch\` and auto-falls back to \`full\` when nothing changed); \`aggregate\` dedupes/merges findings, validates every finding against the findings schema when \`reviewer.output_schema_validation\` is on (dropping invalid entries and reporting them via \`schemaValidation\`), and computes convergence; \`meta-review\` audits a built report for internal consistency (counts, buckets, sorting, convergence math) and returns a final report with an \`approved\` / \`needs_revision\` verdict. Purely computational.
|
|
17
17
|
- \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
|
|
18
18
|
- \`iterate_fix\` — apply ONE atomic fix: backs up the file, enforces the atomic max_lines threshold, writes the new content, and records the fix (id + diff summary) in \`.iterate/fixes/registry.json\`
|
|
19
19
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
@@ -61,18 +61,36 @@ const rounds = [] // raw per-round findings
|
|
|
61
61
|
phase('review')
|
|
62
62
|
for (let r = 1; r <= maxRounds; r++) {
|
|
63
63
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
64
|
+
let agg = null
|
|
65
|
+
let schemaInvalid = false
|
|
66
|
+
let retries = 0
|
|
67
|
+
do {
|
|
68
|
+
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
69
|
+
const nudge = retries > 0
|
|
70
|
+
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
71
|
+
: ''
|
|
72
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
73
|
+
'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
|
|
74
|
+
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
|
|
75
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
76
|
+
)))
|
|
77
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
78
|
+
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
79
|
+
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
80
|
+
agg = await agent(
|
|
81
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
82
|
+
{ label: 'review:aggregate:r' + r }
|
|
83
|
+
)
|
|
84
|
+
// reviewer.output_schema_validation (default on): aggregate returns per-round
|
|
85
|
+
// schemaValidation; retry the just-finished round (≤2 times) when invalid.
|
|
86
|
+
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
87
|
+
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
88
|
+
: false
|
|
89
|
+
if (schemaInvalid && retries < 2) {
|
|
90
|
+
retries += 1
|
|
91
|
+
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
92
|
+
}
|
|
93
|
+
} while (schemaInvalid && retries <= 2)
|
|
76
94
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
77
95
|
// list stays bounded and reviewers never see the same issue twice.
|
|
78
96
|
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
@@ -123,6 +141,7 @@ Key rules for dry-run:
|
|
|
123
141
|
- **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
|
|
124
142
|
- **Every reviewer MUST actually read each file it reports on (read_file) BEFORE judging it, and anchor every finding to a real location. Fabricated file paths or invented line numbers are poisoned evidence and fail the run.** Subagents never report on code they didn't inspect.
|
|
125
143
|
- Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
|
|
144
|
+
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), \`aggregate\` validates every finding against the findings schema and returns \`schemaValidation\` (per-round {round, valid, issues}). If the just-finished round is invalid, retry its reviewers up to 2 times with the strict-JSON nudge (see the loop above), then re-aggregate. Schema-invalid findings are dropped by \`aggregate\` and must NEVER be fed back as known findings or reported as converged.
|
|
126
145
|
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
127
146
|
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
128
147
|
- **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The meta-review ALSO runs the hard code-evidence gate (default on): every finding's file/line is validated against real files on disk, so any fabricated location surfaces as a critical \`EVIDENCE_VIOLATION\` and flips the verdict to \`needs_revision\`. The \`finalReport.verdict\` is \`approved\` only when the report passes every check AND every finding anchors to real, read code; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
@@ -169,21 +188,39 @@ let failedCommands = []
|
|
|
169
188
|
phase('loop')
|
|
170
189
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
171
190
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
let agg = null
|
|
192
|
+
let schemaInvalid = false
|
|
193
|
+
let retries = 0
|
|
194
|
+
do {
|
|
195
|
+
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
196
|
+
const nudge = retries > 0
|
|
197
|
+
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
198
|
+
: ''
|
|
199
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
200
|
+
'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
|
|
201
|
+
'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
|
|
202
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
203
|
+
)))
|
|
204
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
205
|
+
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
179
206
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
207
|
+
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
208
|
+
// \`fixedCount\` is threaded into the report summary so the client dashboard can
|
|
209
|
+
// show a running "fixes applied" metric for normal mode.
|
|
210
|
+
agg = await agent(
|
|
211
|
+
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
|
|
212
|
+
{ label: 'review:aggregate:r' + r }
|
|
213
|
+
)
|
|
214
|
+
// reviewer.output_schema_validation (default on): aggregate returns per-round
|
|
215
|
+
// schemaValidation; retry the just-finished round (≤2 times) when invalid.
|
|
216
|
+
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
217
|
+
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
218
|
+
: false
|
|
219
|
+
if (schemaInvalid && retries < 2) {
|
|
220
|
+
retries += 1
|
|
221
|
+
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
222
|
+
}
|
|
223
|
+
} while (schemaInvalid && retries <= 2)
|
|
187
224
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
188
225
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
189
226
|
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
@@ -320,6 +357,7 @@ return {
|
|
|
320
357
|
Key rules for normal mode:
|
|
321
358
|
- Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
|
|
322
359
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
360
|
+
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`.
|
|
323
361
|
- Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
|
|
324
362
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
325
363
|
- **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed).
|
package/dist/tools/fix.js
CHANGED
|
@@ -20,6 +20,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
22
22
|
import { loadEffectiveConfig, resolveProjectRoot } from "../config-loader.js";
|
|
23
|
+
import { countTouchedMethods } from "../method-scope.js";
|
|
23
24
|
import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
|
|
24
25
|
import { appendDecisionEntry } from "./decision-log.js";
|
|
25
26
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -218,7 +219,7 @@ export function registerFixTool(ctx) {
|
|
|
218
219
|
name: 'iterate_fix',
|
|
219
220
|
description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
220
221
|
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
221
|
-
'The tool backs up the original, enforces the atomic `max_lines`
|
|
222
|
+
'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
|
|
222
223
|
'writes the new content, and records the fix for later diff/rollback. ' +
|
|
223
224
|
'This is the ONLY sanctioned way to apply fixes in normal mode.',
|
|
224
225
|
parameters: {
|
|
@@ -278,6 +279,7 @@ export function registerFixTool(ctx) {
|
|
|
278
279
|
const projectRoot = resolved.root;
|
|
279
280
|
const { config } = loadEffectiveConfig(projectRoot);
|
|
280
281
|
const maxLines = config.atomic?.max_lines ?? 20;
|
|
282
|
+
const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3;
|
|
281
283
|
const file = typeof args.file === 'string' ? args.file : '';
|
|
282
284
|
if (!file)
|
|
283
285
|
return { ok: false, error: 'file is required' };
|
|
@@ -305,6 +307,7 @@ export function registerFixTool(ctx) {
|
|
|
305
307
|
const current = readProjectFile(projectRoot, file);
|
|
306
308
|
if (!current.ok)
|
|
307
309
|
return { ok: false, error: current.reason };
|
|
310
|
+
const hunks = diffLines(current.content, args.content);
|
|
308
311
|
const { added, removed } = countChangedLines(current.content, args.content);
|
|
309
312
|
if (!args.force && (added > maxLines || removed > maxLines)) {
|
|
310
313
|
return {
|
|
@@ -313,6 +316,14 @@ export function registerFixTool(ctx) {
|
|
|
313
316
|
'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
|
|
314
317
|
};
|
|
315
318
|
}
|
|
319
|
+
const touchedMethods = countTouchedMethods(current.content, args.content, hunks);
|
|
320
|
+
if (!args.force && touchedMethods > maxAdjacentMethods) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
|
|
324
|
+
'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
316
327
|
const id = fixId(finding);
|
|
317
328
|
const registry = readRegistry(projectRoot);
|
|
318
329
|
if (findFixRecord(registry, id)) {
|
|
@@ -336,7 +347,6 @@ export function registerFixTool(ctx) {
|
|
|
336
347
|
catch (err) {
|
|
337
348
|
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
338
349
|
}
|
|
339
|
-
const hunks = diffLines(current.content, args.content);
|
|
340
350
|
const record = {
|
|
341
351
|
id,
|
|
342
352
|
timestamp,
|
package/dist/tools/review.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { loadEffectiveConfig, resolveProjectRoot } from "../config-loader.js";
|
|
3
|
-
import { buildReviewPlan, buildReviewReport } from "../review.js";
|
|
3
|
+
import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, } from "../review.js";
|
|
4
4
|
import { buildFinalReviewReport, metaReviewReport } from "../meta-review.js";
|
|
5
5
|
import { evidenceToPlain, verifyFindings } from "../evidence.js";
|
|
6
|
+
import { collectScopeFiles, computeCoverage, coverageToDict, } from "../review-scope.js";
|
|
7
|
+
import { resolveChangedFiles } from "../git-scope.js";
|
|
6
8
|
/** Default round cap when neither the arg nor config provides one. */
|
|
7
9
|
const DEFAULT_MAX_REVIEW_ROUNDS = 3;
|
|
8
10
|
/**
|
|
@@ -83,7 +85,19 @@ export function registerReviewTool(ctx) {
|
|
|
83
85
|
found: { type: 'boolean' },
|
|
84
86
|
plan: { type: 'json' },
|
|
85
87
|
report: { type: 'json' },
|
|
88
|
+
schemaValidation: {
|
|
89
|
+
type: 'json',
|
|
90
|
+
description: 'For `aggregate`: per-round schema validation results (round, valid, issues). ' +
|
|
91
|
+
'Present only when reviewer.output_schema_validation is enabled; the workflow ' +
|
|
92
|
+
'retries rounds with valid=false (≤2 times) before forwarding findings.',
|
|
93
|
+
},
|
|
86
94
|
evidence: { type: 'json' },
|
|
95
|
+
coverage: {
|
|
96
|
+
type: 'json',
|
|
97
|
+
description: 'For `meta-review`: prompt-informative scope coverage result ' +
|
|
98
|
+
'(assigned vs self-reported reads). Present only when ' +
|
|
99
|
+
'reviewer.coverage_validation is enabled and readFiles were supplied.',
|
|
100
|
+
},
|
|
87
101
|
finalReport: { type: 'json' },
|
|
88
102
|
error: { type: 'string' },
|
|
89
103
|
},
|
|
@@ -106,7 +120,23 @@ export function registerReviewTool(ctx) {
|
|
|
106
120
|
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
107
121
|
const knownIntentional = config.personalization
|
|
108
122
|
?.known_intentional;
|
|
109
|
-
|
|
123
|
+
// changed-only scope: resolve the changed-file set against
|
|
124
|
+
// git.target_branch before building the plan so reviewers get the
|
|
125
|
+
// concrete file list (and the plan auto-falls back to full when there
|
|
126
|
+
// are no changes). git failures degrade to a full-scope plan.
|
|
127
|
+
let changedFiles;
|
|
128
|
+
if (config.review?.scope === 'changed-only') {
|
|
129
|
+
const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
|
|
130
|
+
changedFiles = gitScope.changedFiles;
|
|
131
|
+
}
|
|
132
|
+
// Full-codebase review: pre-collect the source inventory so
|
|
133
|
+
// buildReviewPlan can batch it into per-chunk reviewer tasks
|
|
134
|
+
// (coverage enforcement).
|
|
135
|
+
let scopeFiles;
|
|
136
|
+
if (config.review?.scope === 'full') {
|
|
137
|
+
scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' });
|
|
138
|
+
}
|
|
139
|
+
const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles });
|
|
110
140
|
return { operation: 'plan', mode, found: true, plan: plan };
|
|
111
141
|
}
|
|
112
142
|
if (args.operation === 'aggregate') {
|
|
@@ -128,16 +158,32 @@ export function registerReviewTool(ctx) {
|
|
|
128
158
|
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
129
159
|
const goal = args.goal ?? config.goal ?? '';
|
|
130
160
|
const dimensions = config.dimensions ?? [];
|
|
161
|
+
// Output schema validation gate (reviewer.output_schema_validation,
|
|
162
|
+
// default true): validate every round's findings against the findings
|
|
163
|
+
// schema, then drop schema-invalid entries before the deterministic
|
|
164
|
+
// core so malformed reviewer output can never crash dedupe/sort or
|
|
165
|
+
// leak into fixes. The `schemaValidation` array is surfaced so the
|
|
166
|
+
// workflow can retry failing rounds (≤2 times) with a strict-JSON
|
|
167
|
+
// nudge. When disabled, non-object entries are still dropped for
|
|
168
|
+
// crash-safety.
|
|
169
|
+
const schemaEnabled = config.reviewer?.output_schema_validation !== false;
|
|
170
|
+
const schemaValidation = schemaEnabled ? validateRoundsSchema(rounds) : null;
|
|
171
|
+
const cleanRounds = sanitizeRounds(rounds, schemaValidation);
|
|
131
172
|
const report = buildReviewReport({
|
|
132
173
|
mode,
|
|
133
174
|
goal,
|
|
134
175
|
dimensions,
|
|
135
176
|
maxReviewRounds,
|
|
136
|
-
rounds,
|
|
177
|
+
rounds: cleanRounds,
|
|
137
178
|
knownIntentional: args.knownIntentional,
|
|
138
179
|
fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
|
|
139
180
|
});
|
|
140
|
-
return {
|
|
181
|
+
return {
|
|
182
|
+
operation: 'aggregate',
|
|
183
|
+
mode,
|
|
184
|
+
report: report,
|
|
185
|
+
schemaValidation: schemaValidation,
|
|
186
|
+
};
|
|
141
187
|
}
|
|
142
188
|
if (args.operation === 'meta-review') {
|
|
143
189
|
const source = args.report;
|
|
@@ -155,13 +201,30 @@ export function registerReviewTool(ctx) {
|
|
|
155
201
|
const evidenceEnabled = config.reviewer?.evidence_validation !== false;
|
|
156
202
|
const findings = Array.isArray(source.findings) ? source.findings : [];
|
|
157
203
|
const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
|
|
158
|
-
|
|
204
|
+
// Prompt-informative coverage: compare the reviewer's self-reported
|
|
205
|
+
// reads against the assigned scope inventory (never flips the
|
|
206
|
+
// verdict). Disable via config `reviewer.coverage_validation: false`.
|
|
207
|
+
const coverageEnabled = config.reviewer?.coverage_validation !== false;
|
|
208
|
+
let coverage = null;
|
|
209
|
+
if (coverageEnabled) {
|
|
210
|
+
const assigned = collectScopeFiles(projectRoot, {
|
|
211
|
+
scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
|
|
212
|
+
});
|
|
213
|
+
const readFiles = Array.isArray(source.readFiles)
|
|
214
|
+
? source.readFiles
|
|
215
|
+
: null;
|
|
216
|
+
if (readFiles && readFiles.length > 0) {
|
|
217
|
+
coverage = computeCoverage(assigned, readFiles);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const finalReport = buildFinalReviewReport(source, { evidence, coverage });
|
|
159
221
|
return {
|
|
160
222
|
operation: 'meta-review',
|
|
161
223
|
mode,
|
|
162
224
|
found: true,
|
|
163
225
|
report: audit,
|
|
164
226
|
evidence: evidence ? evidenceToPlain(evidence) : null,
|
|
227
|
+
coverage: coverage ? coverageToDict(coverage) : null,
|
|
165
228
|
finalReport: finalReport,
|
|
166
229
|
};
|
|
167
230
|
}
|