iterate-plugin 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,7 @@ export function defaultConfig(): IterateConfig {
49
49
  auto_merge: false,
50
50
  },
51
51
  validation: { command_whitelist: [], commands: {} },
52
- reviewer: { output_schema_validation: true },
52
+ reviewer: { output_schema_validation: true, evidence_validation: true },
53
53
  }
54
54
  }
55
55
 
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Deterministic code-evidence verification for review findings.
3
+ *
4
+ * Mirror of `iterate_harness/iterate/evidence.py` for the iterate-plugin.
5
+ *
6
+ * The iterate review loop requires that reviewer subagent findings ANCHOR to
7
+ * real code instead of speculating. This module enforces it:
8
+ *
9
+ * - a finding's `file` must resolve to an existing file under the project root
10
+ * (traversal-safe), otherwise evidence is poisoned (`file_not_found`);
11
+ * - a finding with an explicit line must reference a line that actually exists
12
+ * in that file (`line_out_of_range`);
13
+ * - a whole-file finding (line 0 / undefined) must still reference an existing
14
+ * file, so even structural findings cannot point at nothing;
15
+ * - `readVerified` is a best-effort, NON-gating hint: the plugin's reviewers are
16
+ * subagents whose reads are not aggregated here, so it is only set when a
17
+ * read set is explicitly provided and never fails the audit.
18
+ *
19
+ * Gate rule (user preference): ANY localizable finding with poisoned evidence
20
+ * flips the whole audit to `passed: false`, so the meta-review forces revision.
21
+ *
22
+ * The pure math (`countLines`, `verifyLineBounds`) is separated from the
23
+ * filesystem half (`verifyFinding`) to stay unit-testable without touching disk.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { resolve, sep } from 'node:path'
28
+ import type { ReviewFinding } from './types.ts'
29
+
30
+ /** Sentinel for whole-file findings (line 0 or omitted means the whole file). */
31
+ export const WHOLE_FILE_LINE = 0
32
+
33
+ export type EvidenceError = 'file_not_found' | 'line_out_of_range'
34
+
35
+ /** Per-finding attestation result. */
36
+ export interface FindingEvidence {
37
+ file: string
38
+ line: number | null
39
+ lineTotal: number | null
40
+ resolvedPath: string | null
41
+ verified: boolean
42
+ error?: EvidenceError
43
+ /** True/False only when a read-set is supplied; undefined = not checkable. */
44
+ readVerified?: boolean
45
+ }
46
+
47
+ /** Aggregate attestation over a findings list. */
48
+ export interface EvidenceAudit {
49
+ checked: number
50
+ results: FindingEvidence[]
51
+ }
52
+
53
+ /** A single finding object that exposes `file` / `line` (for verification). */
54
+ interface Locatable {
55
+ file?: string
56
+ line?: number
57
+ }
58
+
59
+ /** Number of physical lines in `text`. A trailing newline does not add a line. */
60
+ export function countLines(text: string): number {
61
+ if (text === '') return 0
62
+ const parts = text.split(/\r\n|\r|\n/)
63
+ // A trailing newline leaves an empty final element that is NOT a line
64
+ // (mirrors Python `str.splitlines()` used by the harness).
65
+ if (parts[parts.length - 1] === '') return parts.length - 1
66
+ return parts.length
67
+ }
68
+
69
+ /** Resolve `root/rel` and reject any path escaping `root` (returns null). */
70
+ export function resolveWithin(root: string, rel: string): string | null {
71
+ const resolved = resolve(root, rel)
72
+ const rootResolved = resolve(root)
73
+ if (resolved === rootResolved) return resolved
74
+ const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep
75
+ if (!resolved.startsWith(prefix)) return null
76
+ return resolved
77
+ }
78
+
79
+ /**
80
+ * Pure check that `line` (if anchored) exists in `text`.
81
+ * Whole-file findings (undefined/0) are always bounds-valid.
82
+ */
83
+ export function verifyLineBounds(
84
+ line: number | null | undefined,
85
+ text: string,
86
+ ): { inBounds: boolean; lineTotal: number } {
87
+ const lineTotal = countLines(text)
88
+ if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
89
+ return { inBounds: true, lineTotal }
90
+ }
91
+ if (line < 1) return { inBounds: false, lineTotal }
92
+ return { inBounds: line <= lineTotal, lineTotal }
93
+ }
94
+
95
+ /** Verify a single finding's location against the real filesystem. */
96
+ export function verifyFinding(
97
+ root: string,
98
+ input: Locatable,
99
+ opts: { readSet?: Set<string> } = {},
100
+ ): FindingEvidence {
101
+ const relFile = input.file ?? ''
102
+ const line = typeof input.line === 'number' ? input.line : null
103
+ const resolved = resolveWithin(root, relFile)
104
+
105
+ if (resolved === null || !existsSync(resolved)) {
106
+ return {
107
+ file: relFile,
108
+ line,
109
+ lineTotal: null,
110
+ resolvedPath: resolved,
111
+ verified: false,
112
+ error: 'file_not_found',
113
+ }
114
+ }
115
+
116
+ let text: string
117
+ try {
118
+ text = readFileSync(resolved, 'utf-8')
119
+ } catch {
120
+ return {
121
+ file: relFile,
122
+ line,
123
+ lineTotal: null,
124
+ resolvedPath: resolved,
125
+ verified: false,
126
+ error: 'file_not_found',
127
+ }
128
+ }
129
+
130
+ const { inBounds, lineTotal } = verifyLineBounds(line, text)
131
+ if (!inBounds) {
132
+ return {
133
+ file: relFile,
134
+ line,
135
+ lineTotal,
136
+ resolvedPath: resolved,
137
+ verified: false,
138
+ error: 'line_out_of_range',
139
+ }
140
+ }
141
+
142
+ const outcome: FindingEvidence = {
143
+ file: relFile,
144
+ line,
145
+ lineTotal,
146
+ resolvedPath: resolved,
147
+ verified: true,
148
+ }
149
+ if (opts.readSet !== undefined) {
150
+ outcome.readVerified = opts.readSet.has(resolved)
151
+ }
152
+ return outcome
153
+ }
154
+
155
+ /** Attest every finding in a list. */
156
+ export function verifyFindings(
157
+ root: string,
158
+ findings: Locatable[],
159
+ opts: { readSet?: Set<string> } = {},
160
+ ): EvidenceAudit {
161
+ const results = findings.map((f) => verifyFinding(root, f, opts))
162
+ return { checked: results.length, results }
163
+ }
164
+
165
+ /** `passed` is true only when no real existence failure exists (read is a hint). */
166
+ export function evidencePassed(audit: EvidenceAudit): boolean {
167
+ return audit.results.every((r) => r.error === undefined)
168
+ }
169
+
170
+ /** Violating (non-grounded) results. */
171
+ export function evidenceViolations(audit: EvidenceAudit): FindingEvidence[] {
172
+ return audit.results.filter((r) => r.error !== undefined)
173
+ }
174
+
175
+ /** Serialize an audit for tool payloads (pure). */
176
+ export function evidenceToPlain(audit: EvidenceAudit): Record<string, unknown> {
177
+ const computable = audit.results.filter((r) => r.readVerified !== undefined)
178
+ const readRatio =
179
+ computable.length === 0
180
+ ? null
181
+ : Number(
182
+ (
183
+ computable.filter((r) => r.readVerified === true).length / computable.length
184
+ ).toFixed(3),
185
+ )
186
+ return {
187
+ checked: audit.checked,
188
+ passed: evidencePassed(audit),
189
+ violations: audit.results
190
+ .filter((r) => r.error !== undefined)
191
+ .map((r) => ({ file: r.file, line: r.line, lineTotal: r.lineTotal, verified: r.verified, error: r.error })),
192
+ readVerifiedRatio: readRatio,
193
+ }
194
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { ReviewFinding, ReviewReport } from './types.ts'
18
+ import type { EvidenceAudit } from './evidence.ts'
18
19
  import { sortFindings } from './review.ts'
19
20
 
20
21
  /** A single defect found while auditing a review report. */
@@ -278,9 +279,41 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
278
279
  /**
279
280
  * Build the final review report: pair the source report with its meta-review
280
281
  * verdict and a rolled-up summary. Pure and deterministic.
282
+ *
283
+ * `evidence` (an EvidenceAudit produced against the real repo) is the hard
284
+ * code-evidence gate: every finding whose file/line does not resolve to
285
+ * existing code is emitted as a critical EVIDENCE_VIOLATION and flips the
286
+ * verdict to `needs_revision`. The audit itself reads the filesystem; this
287
+ * function only folds the (pure, precomputed) result in.
281
288
  */
282
- export function buildFinalReviewReport(report: ReviewReport): FinalReviewReport {
289
+ export function buildFinalReviewReport(
290
+ report: ReviewReport,
291
+ opts: { evidence?: EvidenceAudit | null } = {},
292
+ ): FinalReviewReport {
283
293
  const meta = metaReviewReport(report)
294
+ const evidence = opts.evidence ?? null
295
+ if (evidence !== null) {
296
+ meta.checksRun += 1
297
+ if (evidence.results.some((r) => r.error !== undefined)) {
298
+ for (const violation of evidence.results) {
299
+ if (violation.error === undefined) continue
300
+ const detail =
301
+ violation.error === 'line_out_of_range'
302
+ ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
303
+ : `${violation.file} does not exist at all (verifiable read required)`
304
+ meta.issues.push({
305
+ code: 'EVIDENCE_VIOLATION',
306
+ severity: 'critical',
307
+ summary:
308
+ `Finding references non-existent code: ${violation.file}` +
309
+ (violation.line ? `:${violation.line}` : ''),
310
+ detail: detail + '. Review results must anchor to real, read code.',
311
+ })
312
+ }
313
+ meta.passed = false
314
+ meta.verdict = 'revise'
315
+ }
316
+ }
284
317
  const summary = report?.summary ?? {}
285
318
  const verdict: FinalReviewVerdict = meta.passed ? 'approved' : 'needs_revision'
286
319
  return {
package/src/review.ts CHANGED
@@ -351,11 +351,21 @@ export function reviewerTaskPrompt(input: {
351
351
  } else {
352
352
  parts.push('This is round 1 — report every issue you find in this dimension.')
353
353
  }
354
+ parts.push(
355
+ 'EVIDENCE RULE (mandatory): read every file you report on with the ' +
356
+ 'read_file tool BEFORE judging it. NEVER report a location you did not ' +
357
+ 'actually read — speculation about code you never inspected is a ' +
358
+ 'disqualifying failure, and fabricated line numbers are treated as ' +
359
+ 'poisoned evidence. Anchor every finding to real code.',
360
+ )
354
361
  parts.push(
355
362
  `Return a JSON object: {"findings": [...]}.`,
356
363
  `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
357
- 'line (optional integer), severity (critical/high/medium/low), summary (one line), ' +
358
- 'failure_scenario (how/when it fails, specific evidence), suggested_fix (the concrete fix), ' +
364
+ 'line (REQUIRED positive integer the exact line you READ for an ' +
365
+ 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
366
+ 'issues), severity (critical/high/medium/low), summary (one line), ' +
367
+ 'failure_scenario (how/when it fails, backed by the code you actually ' +
368
+ 'read), suggested_fix (the concrete fix), ' +
359
369
  `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
360
370
  `Write summaries and details in ${input.outputLanguage}.`,
361
371
  )
@@ -122,10 +122,11 @@ return {
122
122
 
123
123
  Key rules for dry-run:
124
124
  - **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
125
+ - **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
126
  - Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
126
127
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
127
128
  - 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.
129
+ - **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
130
  - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
130
131
 
131
132
  ### Normal-mode workflow (autonomous closed loop)
@@ -329,11 +330,12 @@ Key rules for normal mode:
329
330
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
330
331
 
331
332
  ### Finding schema (for reviewer agents)
332
- { "dimension": string, "file": string (relative path), "line": number (optional),
333
+ { "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
334
  "severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
334
335
  "failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
335
336
  "is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
336
337
  Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lines lines change). Architectural = everything else.
338
+ 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
339
 
338
340
  ### Workflow meta
339
341
  Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
@@ -3,6 +3,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-session'
3
3
  import { loadEffectiveConfig, resolveProjectRoot } from '../config-loader.ts'
4
4
  import { buildReviewPlan, buildReviewReport } from '../review.ts'
5
5
  import { buildFinalReviewReport, metaReviewReport } from '../meta-review.ts'
6
+ import { evidenceToPlain, verifyFindings } from '../evidence.ts'
6
7
  import type { KnownIntentional, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
7
8
 
8
9
  /** Default round cap when neither the arg nor config provides one. */
@@ -94,6 +95,7 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
94
95
  found: { type: 'boolean' },
95
96
  plan: { type: 'json' },
96
97
  report: { type: 'json' },
98
+ evidence: { type: 'json' },
97
99
  finalReport: { type: 'json' },
98
100
  error: { type: 'string' },
99
101
  },
@@ -165,12 +167,19 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
165
167
  }
166
168
  }
167
169
  const audit = metaReviewReport(source)
168
- const finalReport = buildFinalReviewReport(source)
170
+ // Hard code-evidence gate (default on): every finding's file/line is
171
+ // validated against real files on disk before folding into the final
172
+ // verdict. Disable via config `reviewer.evidence_validation: false`.
173
+ const evidenceEnabled = config.reviewer?.evidence_validation !== false
174
+ const findings: ReviewFinding[] = Array.isArray(source.findings) ? source.findings : []
175
+ const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null
176
+ const finalReport = buildFinalReviewReport(source, { evidence })
169
177
  return {
170
178
  operation: 'meta-review',
171
179
  mode,
172
180
  found: true,
173
181
  report: audit as unknown as JsonValue,
182
+ evidence: evidence ? (evidenceToPlain(evidence) as unknown as JsonValue) : null,
174
183
  finalReport: finalReport as unknown as JsonValue,
175
184
  }
176
185
  }
package/src/types.ts CHANGED
@@ -16,7 +16,7 @@ export interface IterateConfig {
16
16
  command_whitelist: string[]
17
17
  commands: Record<string, string[]>
18
18
  }
19
- reviewer: { output_schema_validation: boolean }
19
+ reviewer: { output_schema_validation: boolean; evidence_validation: boolean }
20
20
  onboarding?: Record<string, unknown>
21
21
  personalization?: Record<string, unknown>
22
22
  }