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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-inventory collection, chunking, and coverage scoring for review scope.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `harness/iterate-harness/.../review_scope.py`. The iterate review
|
|
5
|
+
* loop must force each reviewer subagent to actually open EVERY file in the
|
|
6
|
+
* scope it is responsible for (not silently skip or assume files). This
|
|
7
|
+
* module supplies the deterministic building blocks:
|
|
8
|
+
*
|
|
9
|
+
* - `collectScopeFiles`: produce the sorted relative-path inventory for a
|
|
10
|
+
* review scope (changed-only delta, or a full walk filtered to source files
|
|
11
|
+
* and stripped of dependency/build/vendor dirs).
|
|
12
|
+
* - `chunkFiles`: split a large inventory into stable batches so `full`
|
|
13
|
+
* reviews stay bounded; consecutive files from the same directory are kept
|
|
14
|
+
* together to avoid splitting a module's review across two reviewers.
|
|
15
|
+
* - `computeCoverage`: compare a reviewer's self-reported `readFiles` against
|
|
16
|
+
* the inventory it was assigned, returning a coverage ratio plus the list of
|
|
17
|
+
* files that were not opened. Consumed by meta-review as a
|
|
18
|
+
* *prompt-informative* metric (never a hard gate).
|
|
19
|
+
*
|
|
20
|
+
* Pure math (chunkFiles / computeCoverage) has no I/O so it unit-tests
|
|
21
|
+
* cleanly; collectScopeFiles walks the filesystem.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readdirSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
export interface CoverageResult {
|
|
28
|
+
assigned: string[]
|
|
29
|
+
read: string[]
|
|
30
|
+
covered: string[]
|
|
31
|
+
uncovered: string[]
|
|
32
|
+
ratio: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Relative-scope sentinel for whole-module findings. */
|
|
36
|
+
export const WHOLE_FILE_LINE = 0
|
|
37
|
+
|
|
38
|
+
/** Source extensions a full-scope walk includes. */
|
|
39
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
40
|
+
'.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
41
|
+
'.go', '.java', '.rs', '.c', '.h', '.cc', '.cpp', '.cs',
|
|
42
|
+
'.swift', '.kt', '.scala', '.rb', '.php', '.sh', '.bash', '.zsh',
|
|
43
|
+
'.sql', '.html', '.htm', '.css', '.scss', '.vue', '.svelte',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/** Directory names always excluded from a full-scope walk. */
|
|
47
|
+
const IGNORED_DIRS = new Set([
|
|
48
|
+
'.git', '.hg', '.svn', 'node_modules', '.venv', 'venv', 'env',
|
|
49
|
+
'__pycache__', '.cache', '.pytest_cache', '.mypy_cache', 'dist',
|
|
50
|
+
'build', 'out', '.next', '.nuxt', 'coverage', '.tox', '.idea',
|
|
51
|
+
'.vscode', 'target', '.release', '.dist_tmp',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
/** Default chunk size for a `full` scope review (files per batch). */
|
|
55
|
+
export const DEFAULT_SCOPE_CHUNK_SIZE = 25
|
|
56
|
+
|
|
57
|
+
/** Coverage ratio at/above which a scope is fully covered. */
|
|
58
|
+
export const COVERAGE_TARGET = 0.95
|
|
59
|
+
|
|
60
|
+
const SEP = '/'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Canonicalize separators + dot-segments. Leading `..` PATH segments are
|
|
64
|
+
* PRESERVED (mirrors Python `os.path.normpath`, which never resolves beyond
|
|
65
|
+
* the root), so callers can still detect path-escaping (`..`) after
|
|
66
|
+
* normalization — a full `..`-driven traversal must not be silently folded
|
|
67
|
+
* into a bare filename.
|
|
68
|
+
*/
|
|
69
|
+
function normalizePath(path: string): string {
|
|
70
|
+
const cleaned = path.replace(/\\/g, SEP)
|
|
71
|
+
const parts: string[] = []
|
|
72
|
+
for (const part of cleaned.split(SEP)) {
|
|
73
|
+
if (part === '' || part === '.') continue
|
|
74
|
+
if (part === '..') {
|
|
75
|
+
if (parts.length > 0) parts.pop()
|
|
76
|
+
else parts.push(part) // no root segment to pop — keep the leading '..'
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
parts.push(part)
|
|
80
|
+
}
|
|
81
|
+
return parts.join(SEP)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sourceExt(path: string): boolean {
|
|
85
|
+
const dot = path.lastIndexOf('.')
|
|
86
|
+
if (dot < 0) return false
|
|
87
|
+
return SOURCE_EXTENSIONS.has(path.slice(dot).toLowerCase())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isIgnoredDir(name: string): boolean {
|
|
91
|
+
return IGNORED_DIRS.has(name)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Collect the sorted relative-path inventory for a review scope. */
|
|
95
|
+
export function collectScopeFiles(
|
|
96
|
+
root: string,
|
|
97
|
+
opts: { scope: 'full' | 'changed-only'; changedFiles?: string[] },
|
|
98
|
+
): string[] {
|
|
99
|
+
if (opts.scope === 'changed-only') return collectChanged(opts.changedFiles ?? [])
|
|
100
|
+
return collectFull(root)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collectChanged(changedFiles: string[]): string[] {
|
|
104
|
+
const out = new Set<string>()
|
|
105
|
+
for (const rel of changedFiles) {
|
|
106
|
+
if (typeof rel !== 'string' || !rel.trim()) continue
|
|
107
|
+
if (rel === String(WHOLE_FILE_LINE)) continue
|
|
108
|
+
const cleaned = normalizePath(rel)
|
|
109
|
+
if (cleaned.startsWith('..')) continue
|
|
110
|
+
if (!sourceExt(cleaned)) continue
|
|
111
|
+
out.add(cleaned)
|
|
112
|
+
}
|
|
113
|
+
return [...out].sort()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectFull(root: string): string[] {
|
|
117
|
+
// Deterministic recursive walk built on Node's fs; a code reviewer never
|
|
118
|
+
// anchors findings to lock files, images, or vendored builds.
|
|
119
|
+
const out: string[] = []
|
|
120
|
+
const walk = (dir: string): void => {
|
|
121
|
+
let entries: import('node:fs').Dirent[]
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
124
|
+
} catch {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const abs = join(dir, entry.name)
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
if (!isIgnoredDir(entry.name)) walk(abs)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (!entry.isFile()) continue
|
|
134
|
+
if (!sourceExt(entry.name)) continue
|
|
135
|
+
const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs
|
|
136
|
+
out.push(rel.split(SEP).join(SEP))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
walk(root)
|
|
140
|
+
return out.sort()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Split `files` into stable batches, keeping directory runs together. */
|
|
144
|
+
export function chunkFiles(files: string[], perChunk?: number): string[][] {
|
|
145
|
+
const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk
|
|
146
|
+
const ordered = [...files].sort()
|
|
147
|
+
const chunks: string[][] = []
|
|
148
|
+
let current: string[] = []
|
|
149
|
+
let lastDir: string | undefined
|
|
150
|
+
for (const rel of ordered) {
|
|
151
|
+
const parent = rel.includes(SEP) ? rel.slice(0, rel.lastIndexOf(SEP)) : '.'
|
|
152
|
+
if (current.length > 0 && lastDir !== undefined && parent !== lastDir) {
|
|
153
|
+
chunks.push(current)
|
|
154
|
+
current = []
|
|
155
|
+
lastDir = undefined
|
|
156
|
+
}
|
|
157
|
+
current.push(rel)
|
|
158
|
+
lastDir = parent
|
|
159
|
+
if (current.length >= size) {
|
|
160
|
+
chunks.push(current)
|
|
161
|
+
current = []
|
|
162
|
+
lastDir = undefined
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (current.length > 0) chunks.push(current)
|
|
166
|
+
return chunks
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Score self-reported reads against the assigned inventory. */
|
|
170
|
+
export function computeCoverage(
|
|
171
|
+
assigned: string[],
|
|
172
|
+
readFiles?: string[] | null,
|
|
173
|
+
): CoverageResult {
|
|
174
|
+
const readNorm = new Set<string>()
|
|
175
|
+
for (const p of readFiles ?? []) {
|
|
176
|
+
if (typeof p === 'string' && p) readNorm.add(normalizePath(p))
|
|
177
|
+
}
|
|
178
|
+
const assignedSorted = [...assigned].sort()
|
|
179
|
+
const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)))
|
|
180
|
+
const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)))
|
|
181
|
+
const rawRatio =
|
|
182
|
+
assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length
|
|
183
|
+
const ratio = Math.round(rawRatio * 1000) / 1000
|
|
184
|
+
return {
|
|
185
|
+
assigned: assignedSorted,
|
|
186
|
+
read: [...new Set((readFiles ?? []).filter((p): p is string => typeof p === 'string'))].sort(),
|
|
187
|
+
covered,
|
|
188
|
+
uncovered,
|
|
189
|
+
ratio,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Serialize a coverage result for the tool-layer JSON wire shape. */
|
|
194
|
+
export function coverageToDict(c: CoverageResult): Record<string, unknown> {
|
|
195
|
+
return {
|
|
196
|
+
assigned: c.assigned,
|
|
197
|
+
read: c.read,
|
|
198
|
+
covered: c.covered,
|
|
199
|
+
uncovered: c.uncovered,
|
|
200
|
+
ratio: c.ratio,
|
|
201
|
+
met: c.ratio >= COVERAGE_TARGET,
|
|
202
|
+
}
|
|
203
|
+
}
|
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.',
|
|
@@ -359,7 +590,7 @@ export function reviewerTaskPrompt(input: {
|
|
|
359
590
|
'poisoned evidence. Anchor every finding to real code.',
|
|
360
591
|
)
|
|
361
592
|
parts.push(
|
|
362
|
-
`Return a JSON object: {"findings": [...]}.`,
|
|
593
|
+
`Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
|
|
363
594
|
`Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
364
595
|
'line (REQUIRED positive integer — the exact line you READ for an ' +
|
|
365
596
|
'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
|
|
@@ -382,6 +613,20 @@ export function buildReviewPlan(input: {
|
|
|
382
613
|
mode: 'normal' | 'dry-run'
|
|
383
614
|
maxReviewRounds: number
|
|
384
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[]
|
|
385
630
|
}): {
|
|
386
631
|
mode: 'normal' | 'dry-run'
|
|
387
632
|
goal: string
|
|
@@ -389,33 +634,73 @@ export function buildReviewPlan(input: {
|
|
|
389
634
|
dimensions: { id: string; reviewerPrompt: string; findingsSchema: Record<string, unknown> }[]
|
|
390
635
|
maxReviewRounds: number
|
|
391
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
|
|
392
641
|
} {
|
|
393
642
|
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
394
643
|
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
395
644
|
// throwing an uncaught TypeError inside the tool's `execute`.
|
|
396
645
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
|
|
397
646
|
const goal = input.config.goal ?? ''
|
|
398
|
-
const
|
|
647
|
+
const configuredScope = input.config.review?.scope ?? 'full'
|
|
399
648
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
|
|
400
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
|
+
|
|
401
696
|
return {
|
|
402
697
|
mode: input.mode,
|
|
403
698
|
goal,
|
|
404
699
|
scope,
|
|
405
|
-
dimensions:
|
|
406
|
-
id: d,
|
|
407
|
-
reviewerPrompt: reviewerTaskPrompt({
|
|
408
|
-
dimension: d,
|
|
409
|
-
goal,
|
|
410
|
-
scope,
|
|
411
|
-
mode: input.mode,
|
|
412
|
-
alreadyKnown: [],
|
|
413
|
-
outputLanguage: language,
|
|
414
|
-
maxLines,
|
|
415
|
-
}),
|
|
416
|
-
findingsSchema: findingsSchema(),
|
|
417
|
-
})),
|
|
700
|
+
dimensions: dimensionTasks,
|
|
418
701
|
maxReviewRounds: input.maxReviewRounds,
|
|
419
702
|
knownIntentional: input.knownIntentional ?? [],
|
|
703
|
+
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
704
|
+
fallbackToFull,
|
|
420
705
|
}
|
|
421
706
|
}
|