iterate-plugin 2.7.3 → 2.8.1
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 +13 -0
- package/README.zh-CN.md +13 -0
- package/dist/config-loader.js +6 -1
- package/dist/evidence.js +161 -0
- package/dist/git-scope.js +101 -0
- package/dist/meta-review.js +76 -1
- package/dist/method-scope.js +173 -0
- package/dist/review-scope.js +187 -0
- package/dist/review.js +226 -18
- package/dist/skill-prompt.js +69 -29
- package/dist/tools/fix.js +12 -2
- package/dist/tools/review.js +77 -5
- package/lib/client.js +3 -14
- package/package.json +1 -1
- package/src/client/index.ts +9 -12
- package/src/config-loader.ts +6 -1
- package/src/evidence.ts +213 -0
- package/src/git-scope.ts +129 -0
- package/src/meta-review.ts +94 -1
- package/src/method-scope.ts +201 -0
- package/src/review-scope.ts +203 -0
- package/src/review.ts +313 -18
- package/src/skill-prompt.ts +69 -29
- package/src/tools/fix.ts +13 -2
- package/src/tools/review.ts +91 -5
- package/src/types.ts +6 -1
package/src/review.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ReviewReport,
|
|
25
25
|
ReviewRound,
|
|
26
26
|
} from './types.ts'
|
|
27
|
+
import { DEFAULT_SCOPE_CHUNK_SIZE, chunkFiles } from './review-scope.ts'
|
|
27
28
|
|
|
28
29
|
/** Severity ordering: lower rank = more severe. */
|
|
29
30
|
export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
|
|
@@ -312,9 +313,201 @@ export function findingsSchema(): Record<string, unknown> {
|
|
|
312
313
|
],
|
|
313
314
|
},
|
|
314
315
|
},
|
|
316
|
+
readFiles: {
|
|
317
|
+
type: 'array',
|
|
318
|
+
items: { type: 'string' },
|
|
319
|
+
description:
|
|
320
|
+
'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.',
|
|
321
|
+
},
|
|
315
322
|
},
|
|
316
|
-
required: ['findings'],
|
|
323
|
+
required: ['findings', 'readFiles'],
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Output schema validation ──────────────────────────────────────────────
|
|
328
|
+
//
|
|
329
|
+
// `config.reviewer.output_schema_validation` (default true) turns on a
|
|
330
|
+
// deterministic schema gate at the `aggregate` boundary: reviewer subagent
|
|
331
|
+
// outputs are parsed as JSON by the orchestrator, but models sometimes return
|
|
332
|
+
// malformed findings (missing fields, wrong types, out-of-range severity).
|
|
333
|
+
// Before any finding reaches the deterministic core (dedupe/sort/report) —
|
|
334
|
+
// which would crash on e.g. a missing `summary` — we validate every entry
|
|
335
|
+
// against the same shape `findingsSchema()` describes and surface the issues
|
|
336
|
+
// so the workflow can retry that round (≤2 times) with a strict-JSON nudge.
|
|
337
|
+
// Schema-invalid findings are dropped from the report; the workflow never
|
|
338
|
+
// forwards them into fixes.
|
|
339
|
+
|
|
340
|
+
/** Fields every finding object MUST carry (mirrors findingsSchema().required). */
|
|
341
|
+
export const REQUIRED_FINDING_FIELDS = [
|
|
342
|
+
'dimension',
|
|
343
|
+
'file',
|
|
344
|
+
'severity',
|
|
345
|
+
'summary',
|
|
346
|
+
'failure_scenario',
|
|
347
|
+
'suggested_fix',
|
|
348
|
+
'is_atomic',
|
|
349
|
+
] as const
|
|
350
|
+
|
|
351
|
+
/** Allowed severity values (mirrors the schema enum). */
|
|
352
|
+
export const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const
|
|
353
|
+
|
|
354
|
+
/** String-typed finding fields (type check only, presence handled by REQUIRED). */
|
|
355
|
+
const STRING_FINDING_FIELDS = [
|
|
356
|
+
'dimension',
|
|
357
|
+
'file',
|
|
358
|
+
'summary',
|
|
359
|
+
'failure_scenario',
|
|
360
|
+
'suggested_fix',
|
|
361
|
+
] as const
|
|
362
|
+
|
|
363
|
+
/** One schema violation for a single finding entry. */
|
|
364
|
+
export interface FindingSchemaIssue {
|
|
365
|
+
/** Index into the findings array; -1 when the whole `findings` is malformed. */
|
|
366
|
+
index: number
|
|
367
|
+
/** Field path, e.g. "findings[2].severity". */
|
|
368
|
+
field: string
|
|
369
|
+
/** Human-readable reason. */
|
|
370
|
+
message: string
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Validate an arbitrary parsed value against the findings schema shape.
|
|
375
|
+
* Accepts the `{findings: [...]}` wrapper OR a bare findings array, so callers
|
|
376
|
+
* can validate either the raw reviewer output object or a round's findings.
|
|
377
|
+
* Pure and deterministic — never touches the filesystem.
|
|
378
|
+
*/
|
|
379
|
+
export function validateFindingsSchema(input: unknown): FindingSchemaIssue[] {
|
|
380
|
+
const raw = (input as { findings?: unknown } | null)?.findings ?? input
|
|
381
|
+
if (!Array.isArray(raw)) {
|
|
382
|
+
return [
|
|
383
|
+
{
|
|
384
|
+
index: -1,
|
|
385
|
+
field: 'findings',
|
|
386
|
+
message: 'expected a JSON array of finding objects',
|
|
387
|
+
},
|
|
388
|
+
]
|
|
317
389
|
}
|
|
390
|
+
|
|
391
|
+
const issues: FindingSchemaIssue[] = []
|
|
392
|
+
for (let i = 0; i < raw.length; i++) {
|
|
393
|
+
const item = raw[i]
|
|
394
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
395
|
+
issues.push({
|
|
396
|
+
index: i,
|
|
397
|
+
field: `findings[${i}]`,
|
|
398
|
+
message: 'expected a finding object',
|
|
399
|
+
})
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
const f = item as Record<string, unknown>
|
|
403
|
+
|
|
404
|
+
for (const key of REQUIRED_FINDING_FIELDS) {
|
|
405
|
+
if (f[key] === undefined || f[key] === null) {
|
|
406
|
+
issues.push({
|
|
407
|
+
index: i,
|
|
408
|
+
field: `findings[${i}].${key}`,
|
|
409
|
+
message: `required field "${key}" is missing`,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const key of STRING_FINDING_FIELDS) {
|
|
414
|
+
if (f[key] !== undefined && f[key] !== null && typeof f[key] !== 'string') {
|
|
415
|
+
issues.push({
|
|
416
|
+
index: i,
|
|
417
|
+
field: `findings[${i}].${key}`,
|
|
418
|
+
message: `"${key}" must be a string`,
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
f.severity !== undefined &&
|
|
424
|
+
f.severity !== null &&
|
|
425
|
+
!SEVERITY_VALUES.includes(f.severity as (typeof SEVERITY_VALUES)[number])
|
|
426
|
+
) {
|
|
427
|
+
issues.push({
|
|
428
|
+
index: i,
|
|
429
|
+
field: `findings[${i}].severity`,
|
|
430
|
+
message: `severity must be one of: ${SEVERITY_VALUES.join(', ')}`,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
if (
|
|
434
|
+
f.is_atomic !== undefined &&
|
|
435
|
+
f.is_atomic !== null &&
|
|
436
|
+
typeof f.is_atomic !== 'boolean'
|
|
437
|
+
) {
|
|
438
|
+
issues.push({
|
|
439
|
+
index: i,
|
|
440
|
+
field: `findings[${i}].is_atomic`,
|
|
441
|
+
message: 'is_atomic must be a boolean',
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
if (
|
|
445
|
+
f.line !== undefined &&
|
|
446
|
+
f.line !== null &&
|
|
447
|
+
(typeof f.line !== 'number' || !Number.isInteger(f.line) || f.line < 0)
|
|
448
|
+
) {
|
|
449
|
+
issues.push({
|
|
450
|
+
index: i,
|
|
451
|
+
field: `findings[${i}].line`,
|
|
452
|
+
message: 'line must be a non-negative integer (0 = whole-file)',
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return issues
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Per-round schema validation outcome. */
|
|
460
|
+
export interface RoundSchemaValidation {
|
|
461
|
+
round: number
|
|
462
|
+
/** True when every finding in the round conforms to the schema. */
|
|
463
|
+
valid: boolean
|
|
464
|
+
issues: FindingSchemaIssue[]
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Validate every round's findings against the findings schema.
|
|
469
|
+
* Round order matches the input `rounds` array.
|
|
470
|
+
*/
|
|
471
|
+
export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
|
|
472
|
+
return rounds.map((r) => {
|
|
473
|
+
const issues = validateFindingsSchema(r.findings)
|
|
474
|
+
return { round: r.round, valid: issues.length === 0, issues }
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Drop schema-invalid findings before they reach the deterministic core.
|
|
480
|
+
*
|
|
481
|
+
* - With a non-null `schemaValidation` (validation enabled): drop every finding
|
|
482
|
+
* flagged by a schema issue; a round-level issue (index -1) empties the round.
|
|
483
|
+
* - With `schemaValidation === null` (validation disabled): still drop entries
|
|
484
|
+
* that are not plain objects, which would crash `findingKey`/dedupe.
|
|
485
|
+
*
|
|
486
|
+
* Round order and round numbers are preserved so downstream convergence math
|
|
487
|
+
* keeps working on the sanitized stream.
|
|
488
|
+
*/
|
|
489
|
+
export function sanitizeRounds(
|
|
490
|
+
rounds: ReviewRound[],
|
|
491
|
+
schemaValidation: RoundSchemaValidation[] | null,
|
|
492
|
+
): ReviewRound[] {
|
|
493
|
+
return rounds.map((r, i) => {
|
|
494
|
+
if (schemaValidation) {
|
|
495
|
+
const issues = schemaValidation[i]?.issues ?? []
|
|
496
|
+
if (issues.some((iss) => iss.index === -1)) return { round: r.round, findings: [] }
|
|
497
|
+
const bad = new Set(issues.map((iss) => iss.index))
|
|
498
|
+
return {
|
|
499
|
+
round: r.round,
|
|
500
|
+
findings: r.findings.filter((_, fi) => !bad.has(fi)),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
round: r.round,
|
|
505
|
+
findings: r.findings.filter(
|
|
506
|
+
(f): f is ReviewFinding =>
|
|
507
|
+
Boolean(f) && typeof f === 'object' && !Array.isArray(f),
|
|
508
|
+
),
|
|
509
|
+
}
|
|
510
|
+
})
|
|
318
511
|
}
|
|
319
512
|
|
|
320
513
|
/**
|
|
@@ -331,6 +524,21 @@ export function reviewerTaskPrompt(input: {
|
|
|
331
524
|
outputLanguage: string
|
|
332
525
|
/** Atomic fix threshold from config.atomic. */
|
|
333
526
|
maxLines: number
|
|
527
|
+
/**
|
|
528
|
+
* Files to review under `changed-only` scope (relative paths, resolved via
|
|
529
|
+
* git diff against `git.target_branch`). Omitted/empty for `full` scope or
|
|
530
|
+
* when no changes were detected (auto-fallback to full).
|
|
531
|
+
*/
|
|
532
|
+
changedFiles?: string[]
|
|
533
|
+
/**
|
|
534
|
+
* The exact file inventory this reviewer is RESPONSIBLE for. When provided,
|
|
535
|
+
* a mandatory COVERAGE RULE is injected: the reviewer must actually open
|
|
536
|
+
* every listed file with read_file and return a `readFiles` array of what it
|
|
537
|
+
* opened (the enforcement half of "每个子 agent 必须逐文件读取自己负责的审查
|
|
538
|
+
* 范围"). Used for the chunked full-scope case; takes precedence over
|
|
539
|
+
* `changedFiles`.
|
|
540
|
+
*/
|
|
541
|
+
scopeFiles?: string[]
|
|
334
542
|
}): string {
|
|
335
543
|
const parts: string[] = []
|
|
336
544
|
parts.push(
|
|
@@ -338,6 +546,29 @@ export function reviewerTaskPrompt(input: {
|
|
|
338
546
|
`Goal: ${input.goal}`,
|
|
339
547
|
`Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
|
|
340
548
|
)
|
|
549
|
+
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
550
|
+
parts.push(
|
|
551
|
+
'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
552
|
+
'assigned to review. You MUST open EVERY file in this inventory with ' +
|
|
553
|
+
'the read_file tool before judging it — do not skip, skim-declare, or ' +
|
|
554
|
+
'assume any file without reading it. Files you did not actually open ' +
|
|
555
|
+
'are considered un-reviewed and will lower your coverage score. ' +
|
|
556
|
+
'Return a `readFiles` array listing every file you actually opened.',
|
|
557
|
+
'Assigned file inventory:',
|
|
558
|
+
input.scopeFiles.map((p) => `- ${p}`).join('\n'),
|
|
559
|
+
)
|
|
560
|
+
} else if (
|
|
561
|
+
input.scope === 'changed-only' &&
|
|
562
|
+
input.changedFiles &&
|
|
563
|
+
input.changedFiles.length > 0
|
|
564
|
+
) {
|
|
565
|
+
parts.push(
|
|
566
|
+
'Changed files to review (review ONLY these files; they are the diff against ' +
|
|
567
|
+
'the target branch). You MUST open EVERY listed file with read_file ' +
|
|
568
|
+
'before judging it — never skip or assume a file:',
|
|
569
|
+
input.changedFiles.map((p) => `- ${p}`).join('\n'),
|
|
570
|
+
)
|
|
571
|
+
}
|
|
341
572
|
if (input.mode === 'dry-run') {
|
|
342
573
|
parts.push(
|
|
343
574
|
'MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.',
|
|
@@ -352,10 +583,20 @@ export function reviewerTaskPrompt(input: {
|
|
|
352
583
|
parts.push('This is round 1 — report every issue you find in this dimension.')
|
|
353
584
|
}
|
|
354
585
|
parts.push(
|
|
355
|
-
|
|
586
|
+
'EVIDENCE RULE (mandatory): read every file you report on with the ' +
|
|
587
|
+
'read_file tool BEFORE judging it. NEVER report a location you did not ' +
|
|
588
|
+
'actually read — speculation about code you never inspected is a ' +
|
|
589
|
+
'disqualifying failure, and fabricated line numbers are treated as ' +
|
|
590
|
+
'poisoned evidence. Anchor every finding to real code.',
|
|
591
|
+
)
|
|
592
|
+
parts.push(
|
|
593
|
+
`Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
|
|
356
594
|
`Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
357
|
-
'line (
|
|
358
|
-
'
|
|
595
|
+
'line (REQUIRED positive integer — the exact line you READ for an ' +
|
|
596
|
+
'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
|
|
597
|
+
'issues), severity (critical/high/medium/low), summary (one line), ' +
|
|
598
|
+
'failure_scenario (how/when it fails, backed by the code you actually ' +
|
|
599
|
+
'read), suggested_fix (the concrete fix), ' +
|
|
359
600
|
`is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
|
|
360
601
|
`Write summaries and details in ${input.outputLanguage}.`,
|
|
361
602
|
)
|
|
@@ -372,6 +613,20 @@ export function buildReviewPlan(input: {
|
|
|
372
613
|
mode: 'normal' | 'dry-run'
|
|
373
614
|
maxReviewRounds: number
|
|
374
615
|
knownIntentional?: KnownIntentional[]
|
|
616
|
+
/**
|
|
617
|
+
* Files changed against `git.target_branch` (relative paths), resolved by the
|
|
618
|
+
* tool when `review.scope` is `changed-only`. When the configured scope is
|
|
619
|
+
* `changed-only` but no changes are detected, the plan auto-falls back to
|
|
620
|
+
* `full` (mirrors SKILL.md: "无改动文件时自动 fallback 为 full").
|
|
621
|
+
*/
|
|
622
|
+
changedFiles?: string[]
|
|
623
|
+
/**
|
|
624
|
+
* Pre-collected source inventory for a `full`-scope review. When provided,
|
|
625
|
+
* it is split into `config.reviewer.scope_chunk_size` batches and every
|
|
626
|
+
* (dimension × batch) pair gets its own reviewer task owning a bounded,
|
|
627
|
+
* complete inventory it must open file-by-file.
|
|
628
|
+
*/
|
|
629
|
+
scopeFiles?: string[]
|
|
375
630
|
}): {
|
|
376
631
|
mode: 'normal' | 'dry-run'
|
|
377
632
|
goal: string
|
|
@@ -379,33 +634,73 @@ export function buildReviewPlan(input: {
|
|
|
379
634
|
dimensions: { id: string; reviewerPrompt: string; findingsSchema: Record<string, unknown> }[]
|
|
380
635
|
maxReviewRounds: number
|
|
381
636
|
knownIntentional: KnownIntentional[]
|
|
637
|
+
/** Files reviewed under `changed-only` scope (empty for `full` / fallback). */
|
|
638
|
+
changedFiles: string[]
|
|
639
|
+
/** True when scope was `changed-only` but no changes were found. */
|
|
640
|
+
fallbackToFull: boolean
|
|
382
641
|
} {
|
|
383
642
|
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
384
643
|
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
385
644
|
// throwing an uncaught TypeError inside the tool's `execute`.
|
|
386
645
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
|
|
387
646
|
const goal = input.config.goal ?? ''
|
|
388
|
-
const
|
|
647
|
+
const configuredScope = input.config.review?.scope ?? 'full'
|
|
389
648
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
|
|
390
649
|
const maxLines = input.config.atomic?.max_lines ?? 20
|
|
650
|
+
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
|
|
651
|
+
// changed-only with zero detected changes → auto-fallback to full scope.
|
|
652
|
+
const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0
|
|
653
|
+
const scope: 'full' | 'changed-only' = effectiveChangedOnly ? 'changed-only' : 'full'
|
|
654
|
+
const fallbackToFull = configuredScope === 'changed-only' && changedFiles.length === 0
|
|
655
|
+
|
|
656
|
+
// Scope batching (coverage enforcement): changed-only is a single batch
|
|
657
|
+
// owning the full delta; full scope splits the collected inventory into
|
|
658
|
+
// per-chunk reviewer tasks when scopeFiles is supplied.
|
|
659
|
+
const chunkSize = Number(input.config.reviewer?.scope_chunk_size)
|
|
660
|
+
const perChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_SCOPE_CHUNK_SIZE
|
|
661
|
+
let batches: (string[] | undefined)[]
|
|
662
|
+
if (effectiveChangedOnly) {
|
|
663
|
+
batches = [undefined]
|
|
664
|
+
} else if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
665
|
+
batches = chunkFiles(input.scopeFiles, perChunk).filter((b) => b.length > 0)
|
|
666
|
+
} else {
|
|
667
|
+
batches = [undefined]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const dimensionTasks: {
|
|
671
|
+
id: string
|
|
672
|
+
reviewerPrompt: string
|
|
673
|
+
findingsSchema: Record<string, unknown>
|
|
674
|
+
}[] = []
|
|
675
|
+
for (const d of dimensions) {
|
|
676
|
+
batches.forEach((batch, index) => {
|
|
677
|
+
const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
|
|
678
|
+
dimensionTasks.push({
|
|
679
|
+
id: dimensionId,
|
|
680
|
+
reviewerPrompt: reviewerTaskPrompt({
|
|
681
|
+
dimension: d,
|
|
682
|
+
goal,
|
|
683
|
+
scope,
|
|
684
|
+
mode: input.mode,
|
|
685
|
+
alreadyKnown: [],
|
|
686
|
+
outputLanguage: language,
|
|
687
|
+
maxLines,
|
|
688
|
+
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
689
|
+
scopeFiles: batch,
|
|
690
|
+
}),
|
|
691
|
+
findingsSchema: findingsSchema(),
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
391
696
|
return {
|
|
392
697
|
mode: input.mode,
|
|
393
698
|
goal,
|
|
394
699
|
scope,
|
|
395
|
-
dimensions:
|
|
396
|
-
id: d,
|
|
397
|
-
reviewerPrompt: reviewerTaskPrompt({
|
|
398
|
-
dimension: d,
|
|
399
|
-
goal,
|
|
400
|
-
scope,
|
|
401
|
-
mode: input.mode,
|
|
402
|
-
alreadyKnown: [],
|
|
403
|
-
outputLanguage: language,
|
|
404
|
-
maxLines,
|
|
405
|
-
}),
|
|
406
|
-
findingsSchema: findingsSchema(),
|
|
407
|
-
})),
|
|
700
|
+
dimensions: dimensionTasks,
|
|
408
701
|
maxReviewRounds: input.maxReviewRounds,
|
|
409
702
|
knownIntentional: input.knownIntentional ?? [],
|
|
703
|
+
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
704
|
+
fallbackToFull,
|
|
410
705
|
}
|
|
411
706
|
}
|
package/src/skill-prompt.ts
CHANGED
|
@@ -14,7 +14,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
14
14
|
- \`iterate_validate\` — run a whitelisted validation command
|
|
15
15
|
- \`iterate_decision_log\` — append to the decision log, or read entries back for review
|
|
16
16
|
- \`iterate_context\` — read SKILL.md / ITERATE.md project context
|
|
17
|
-
- \`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.
|
|
17
|
+
- \`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.
|
|
18
18
|
- \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
|
|
19
19
|
- \`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\`
|
|
20
20
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
@@ -62,18 +62,36 @@ const rounds = [] // raw per-round findings
|
|
|
62
62
|
phase('review')
|
|
63
63
|
for (let r = 1; r <= maxRounds; r++) {
|
|
64
64
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
let agg = null
|
|
66
|
+
let schemaInvalid = false
|
|
67
|
+
let retries = 0
|
|
68
|
+
do {
|
|
69
|
+
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
70
|
+
const nudge = retries > 0
|
|
71
|
+
? '\\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).'
|
|
72
|
+
: ''
|
|
73
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
74
|
+
'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
|
|
75
|
+
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
|
|
76
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
77
|
+
)))
|
|
78
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
79
|
+
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
80
|
+
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
81
|
+
agg = await agent(
|
|
82
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
83
|
+
{ label: 'review:aggregate:r' + r }
|
|
84
|
+
)
|
|
85
|
+
// reviewer.output_schema_validation (default on): aggregate returns per-round
|
|
86
|
+
// schemaValidation; retry the just-finished round (≤2 times) when invalid.
|
|
87
|
+
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
88
|
+
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
89
|
+
: false
|
|
90
|
+
if (schemaInvalid && retries < 2) {
|
|
91
|
+
retries += 1
|
|
92
|
+
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
93
|
+
}
|
|
94
|
+
} while (schemaInvalid && retries <= 2)
|
|
77
95
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
78
96
|
// list stays bounded and reviewers never see the same issue twice.
|
|
79
97
|
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
@@ -122,10 +140,12 @@ return {
|
|
|
122
140
|
|
|
123
141
|
Key rules for dry-run:
|
|
124
142
|
- **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
|
|
143
|
+
- **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
144
|
- Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
|
|
145
|
+
- **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
146
|
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
127
147
|
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
128
|
-
- **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 \`finalReport.verdict\` is \`approved\` only when the report passes every check; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
148
|
+
- **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.
|
|
129
149
|
- Only a single \`report\` entry may be appended to the decision log; nothing else is written.
|
|
130
150
|
|
|
131
151
|
### Normal-mode workflow (autonomous closed loop)
|
|
@@ -169,21 +189,39 @@ let failedCommands = []
|
|
|
169
189
|
phase('loop')
|
|
170
190
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
171
191
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
let agg = null
|
|
193
|
+
let schemaInvalid = false
|
|
194
|
+
let retries = 0
|
|
195
|
+
do {
|
|
196
|
+
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
197
|
+
const nudge = retries > 0
|
|
198
|
+
? '\\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).'
|
|
199
|
+
: ''
|
|
200
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
201
|
+
'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
|
|
202
|
+
'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
|
|
203
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
204
|
+
)))
|
|
205
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
206
|
+
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
179
207
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
208
|
+
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
209
|
+
// \`fixedCount\` is threaded into the report summary so the client dashboard can
|
|
210
|
+
// show a running "fixes applied" metric for normal mode.
|
|
211
|
+
agg = await agent(
|
|
212
|
+
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
|
|
213
|
+
{ label: 'review:aggregate:r' + r }
|
|
214
|
+
)
|
|
215
|
+
// reviewer.output_schema_validation (default on): aggregate returns per-round
|
|
216
|
+
// schemaValidation; retry the just-finished round (≤2 times) when invalid.
|
|
217
|
+
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
218
|
+
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
219
|
+
: false
|
|
220
|
+
if (schemaInvalid && retries < 2) {
|
|
221
|
+
retries += 1
|
|
222
|
+
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
223
|
+
}
|
|
224
|
+
} while (schemaInvalid && retries <= 2)
|
|
187
225
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
188
226
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
189
227
|
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
@@ -320,6 +358,7 @@ return {
|
|
|
320
358
|
Key rules for normal mode:
|
|
321
359
|
- 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
360
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
361
|
+
- **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
362
|
- 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
363
|
- **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
364
|
- **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).
|
|
@@ -329,11 +368,12 @@ Key rules for normal mode:
|
|
|
329
368
|
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
330
369
|
|
|
331
370
|
### Finding schema (for reviewer agents)
|
|
332
|
-
{ "dimension": string, "file": string (relative path), "line": number (
|
|
371
|
+
{ "dimension": string, "file": string (relative path), "line": number (REQUIRED for line-targeted issues — the exact line you READ; use 0 for whole-file/module-level issues),
|
|
333
372
|
"severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
|
|
334
373
|
"failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
|
|
335
374
|
"is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
|
|
336
375
|
Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lines lines change). Architectural = everything else.
|
|
376
|
+
Every finding MUST reference a file the reviewer actually read (read_file) and a real location — never speculate about code that was never inspected. Fabricated paths/lines are poisoned evidence and fail the meta-review evidence gate.
|
|
337
377
|
|
|
338
378
|
### Workflow meta
|
|
339
379
|
Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
|
package/src/tools/fix.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { join } from 'node:path'
|
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
24
24
|
import { loadEffectiveConfig, resolveProjectRoot } from '../config-loader.ts'
|
|
25
|
+
import { countTouchedMethods } from '../method-scope.ts'
|
|
25
26
|
import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
|
|
26
27
|
import { appendDecisionEntry } from './decision-log.ts'
|
|
27
28
|
import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
|
|
@@ -228,7 +229,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
228
229
|
description:
|
|
229
230
|
'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
230
231
|
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
231
|
-
'The tool backs up the original, enforces the atomic `max_lines`
|
|
232
|
+
'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
|
|
232
233
|
'writes the new content, and records the fix for later diff/rollback. ' +
|
|
233
234
|
'This is the ONLY sanctioned way to apply fixes in normal mode.',
|
|
234
235
|
parameters: {
|
|
@@ -289,6 +290,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
289
290
|
const projectRoot = resolved.root
|
|
290
291
|
const { config } = loadEffectiveConfig(projectRoot)
|
|
291
292
|
const maxLines = config.atomic?.max_lines ?? 20
|
|
293
|
+
const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3
|
|
292
294
|
|
|
293
295
|
const file = typeof args.file === 'string' ? args.file : ''
|
|
294
296
|
if (!file) return { ok: false, error: 'file is required' }
|
|
@@ -316,6 +318,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
316
318
|
const current = readProjectFile(projectRoot, file)
|
|
317
319
|
if (!current.ok) return { ok: false, error: current.reason }
|
|
318
320
|
|
|
321
|
+
const hunks = diffLines(current.content, args.content)
|
|
319
322
|
const { added, removed } = countChangedLines(current.content, args.content)
|
|
320
323
|
if (!args.force && (added > maxLines || removed > maxLines)) {
|
|
321
324
|
return {
|
|
@@ -325,6 +328,15 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
325
328
|
}
|
|
326
329
|
}
|
|
327
330
|
|
|
331
|
+
const touchedMethods = countTouchedMethods(current.content, args.content, hunks)
|
|
332
|
+
if (!args.force && touchedMethods > maxAdjacentMethods) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
|
|
336
|
+
'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
328
340
|
const id = fixId(finding)
|
|
329
341
|
const registry = readRegistry(projectRoot)
|
|
330
342
|
if (findFixRecord(registry, id)) {
|
|
@@ -349,7 +361,6 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
349
361
|
return { ok: false, error: `failed to write file: ${String(err)}` }
|
|
350
362
|
}
|
|
351
363
|
|
|
352
|
-
const hunks = diffLines(current.content, args.content)
|
|
353
364
|
const record: FixRecord = {
|
|
354
365
|
id,
|
|
355
366
|
timestamp,
|