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/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 scope = input.config.review?.scope ?? 'full';
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: dimensions.map((d) => ({
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
  }
@@ -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
- const raw = await parallel(dims.map(dim => () => agent(
65
- 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
66
- JSON.stringify(known) + '\\nReturn the findings JSON object.',
67
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
68
- )))
69
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
70
- rounds.push(thisRound)
71
- // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
72
- const agg = await agent(
73
- 'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
74
- { label: 'review:aggregate:r' + r }
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
- const raw = await parallel(dims.map(dim => () => agent(
173
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
174
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
175
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
176
- )))
177
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
178
- rounds.push(thisRound)
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
- // Deterministic dedupe / known_intentional filter / severity sort for this round.
181
- // \`fixedCount\` is threaded into the report summary so the client dashboard can
182
- // show a running "fixes applied" metric for normal mode.
183
- const agg = await agent(
184
- 'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
185
- { label: 'review:aggregate:r' + r }
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` threshold (unless `force`), ' +
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,
@@ -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
- const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional });
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 { operation: 'aggregate', mode, report: report };
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
- const finalReport = buildFinalReviewReport(source, { evidence });
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
  }