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/dist/tools/fix.js
CHANGED
|
@@ -20,6 +20,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
22
22
|
import { loadEffectiveConfig, resolveProjectRoot } from "../config-loader.js";
|
|
23
|
+
import { countTouchedMethods } from "../method-scope.js";
|
|
23
24
|
import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
|
|
24
25
|
import { appendDecisionEntry } from "./decision-log.js";
|
|
25
26
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -218,7 +219,7 @@ export function registerFixTool(ctx) {
|
|
|
218
219
|
name: 'iterate_fix',
|
|
219
220
|
description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
220
221
|
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
221
|
-
'The tool backs up the original, enforces the atomic `max_lines`
|
|
222
|
+
'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
|
|
222
223
|
'writes the new content, and records the fix for later diff/rollback. ' +
|
|
223
224
|
'This is the ONLY sanctioned way to apply fixes in normal mode.',
|
|
224
225
|
parameters: {
|
|
@@ -278,6 +279,7 @@ export function registerFixTool(ctx) {
|
|
|
278
279
|
const projectRoot = resolved.root;
|
|
279
280
|
const { config } = loadEffectiveConfig(projectRoot);
|
|
280
281
|
const maxLines = config.atomic?.max_lines ?? 20;
|
|
282
|
+
const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3;
|
|
281
283
|
const file = typeof args.file === 'string' ? args.file : '';
|
|
282
284
|
if (!file)
|
|
283
285
|
return { ok: false, error: 'file is required' };
|
|
@@ -305,6 +307,7 @@ export function registerFixTool(ctx) {
|
|
|
305
307
|
const current = readProjectFile(projectRoot, file);
|
|
306
308
|
if (!current.ok)
|
|
307
309
|
return { ok: false, error: current.reason };
|
|
310
|
+
const hunks = diffLines(current.content, args.content);
|
|
308
311
|
const { added, removed } = countChangedLines(current.content, args.content);
|
|
309
312
|
if (!args.force && (added > maxLines || removed > maxLines)) {
|
|
310
313
|
return {
|
|
@@ -313,6 +316,14 @@ export function registerFixTool(ctx) {
|
|
|
313
316
|
'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
|
|
314
317
|
};
|
|
315
318
|
}
|
|
319
|
+
const touchedMethods = countTouchedMethods(current.content, args.content, hunks);
|
|
320
|
+
if (!args.force && touchedMethods > maxAdjacentMethods) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
|
|
324
|
+
'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
316
327
|
const id = fixId(finding);
|
|
317
328
|
const registry = readRegistry(projectRoot);
|
|
318
329
|
if (findFixRecord(registry, id)) {
|
|
@@ -336,7 +347,6 @@ export function registerFixTool(ctx) {
|
|
|
336
347
|
catch (err) {
|
|
337
348
|
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
338
349
|
}
|
|
339
|
-
const hunks = diffLines(current.content, args.content);
|
|
340
350
|
const record = {
|
|
341
351
|
id,
|
|
342
352
|
timestamp,
|
package/dist/tools/review.js
CHANGED
|
@@ -1,7 +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
|
+
import { evidenceToPlain, verifyFindings } from "../evidence.js";
|
|
6
|
+
import { collectScopeFiles, computeCoverage, coverageToDict, } from "../review-scope.js";
|
|
7
|
+
import { resolveChangedFiles } from "../git-scope.js";
|
|
5
8
|
/** Default round cap when neither the arg nor config provides one. */
|
|
6
9
|
const DEFAULT_MAX_REVIEW_ROUNDS = 3;
|
|
7
10
|
/**
|
|
@@ -82,6 +85,19 @@ export function registerReviewTool(ctx) {
|
|
|
82
85
|
found: { type: 'boolean' },
|
|
83
86
|
plan: { type: 'json' },
|
|
84
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
|
+
},
|
|
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
|
+
},
|
|
85
101
|
finalReport: { type: 'json' },
|
|
86
102
|
error: { type: 'string' },
|
|
87
103
|
},
|
|
@@ -104,7 +120,23 @@ export function registerReviewTool(ctx) {
|
|
|
104
120
|
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
105
121
|
const knownIntentional = config.personalization
|
|
106
122
|
?.known_intentional;
|
|
107
|
-
|
|
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 });
|
|
108
140
|
return { operation: 'plan', mode, found: true, plan: plan };
|
|
109
141
|
}
|
|
110
142
|
if (args.operation === 'aggregate') {
|
|
@@ -126,16 +158,32 @@ export function registerReviewTool(ctx) {
|
|
|
126
158
|
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
127
159
|
const goal = args.goal ?? config.goal ?? '';
|
|
128
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);
|
|
129
172
|
const report = buildReviewReport({
|
|
130
173
|
mode,
|
|
131
174
|
goal,
|
|
132
175
|
dimensions,
|
|
133
176
|
maxReviewRounds,
|
|
134
|
-
rounds,
|
|
177
|
+
rounds: cleanRounds,
|
|
135
178
|
knownIntentional: args.knownIntentional,
|
|
136
179
|
fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
|
|
137
180
|
});
|
|
138
|
-
return {
|
|
181
|
+
return {
|
|
182
|
+
operation: 'aggregate',
|
|
183
|
+
mode,
|
|
184
|
+
report: report,
|
|
185
|
+
schemaValidation: schemaValidation,
|
|
186
|
+
};
|
|
139
187
|
}
|
|
140
188
|
if (args.operation === 'meta-review') {
|
|
141
189
|
const source = args.report;
|
|
@@ -147,12 +195,36 @@ export function registerReviewTool(ctx) {
|
|
|
147
195
|
};
|
|
148
196
|
}
|
|
149
197
|
const audit = metaReviewReport(source);
|
|
150
|
-
|
|
198
|
+
// Hard code-evidence gate (default on): every finding's file/line is
|
|
199
|
+
// validated against real files on disk before folding into the final
|
|
200
|
+
// verdict. Disable via config `reviewer.evidence_validation: false`.
|
|
201
|
+
const evidenceEnabled = config.reviewer?.evidence_validation !== false;
|
|
202
|
+
const findings = Array.isArray(source.findings) ? source.findings : [];
|
|
203
|
+
const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
|
|
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 });
|
|
151
221
|
return {
|
|
152
222
|
operation: 'meta-review',
|
|
153
223
|
mode,
|
|
154
224
|
found: true,
|
|
155
225
|
report: audit,
|
|
226
|
+
evidence: evidence ? evidenceToPlain(evidence) : null,
|
|
227
|
+
coverage: coverage ? coverageToDict(coverage) : null,
|
|
156
228
|
finalReport: finalReport,
|
|
157
229
|
};
|
|
158
230
|
}
|
package/lib/client.js
CHANGED
|
@@ -983,19 +983,6 @@ function setThemeEnabled(enabled) {
|
|
|
983
983
|
if (enabled) applyThemeSkin();
|
|
984
984
|
else clearThemeSkin();
|
|
985
985
|
}
|
|
986
|
-
function sessionSnapshot(props) {
|
|
987
|
-
let session = null;
|
|
988
|
-
const useSession = props && typeof props.useSession === "function" ? props.useSession : null;
|
|
989
|
-
if (useSession) {
|
|
990
|
-
try {
|
|
991
|
-
session = useSession();
|
|
992
|
-
} catch (err) {
|
|
993
|
-
log("useSession failed", err);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
if (!session && props && props.session) session = props.session;
|
|
997
|
-
return session;
|
|
998
|
-
}
|
|
999
986
|
function latestReport(session) {
|
|
1000
987
|
if (!session) return null;
|
|
1001
988
|
const raw = scanSessionForReport(session) || findReportInObject(session, void 0, 24);
|
|
@@ -1017,7 +1004,9 @@ function TrendChart({ points }) {
|
|
|
1017
1004
|
}
|
|
1018
1005
|
function ConvergenceDashboard(props) {
|
|
1019
1006
|
const [pulseKey, setPulseKey] = React.useState(0);
|
|
1020
|
-
const
|
|
1007
|
+
const useSession = props && typeof props.useSession === "function" ? props.useSession : null;
|
|
1008
|
+
const session = useSession ? useSession((s) => s) : props && props.session ? props.session : null;
|
|
1009
|
+
const report = latestReport(session);
|
|
1021
1010
|
React.useEffect(() => {
|
|
1022
1011
|
if (!report) return;
|
|
1023
1012
|
const cur = getCurrentRound(report);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/client/index.ts
CHANGED
|
@@ -492,17 +492,6 @@ function setThemeEnabled(enabled: boolean): void {
|
|
|
492
492
|
|
|
493
493
|
// ─── Components (React.createElement trees) ──────────────────────────────────
|
|
494
494
|
|
|
495
|
-
/** Obtain a session snapshot defensively from the slot props. */
|
|
496
|
-
function sessionSnapshot(props: SlotProps) {
|
|
497
|
-
let session: unknown = null
|
|
498
|
-
const useSession = props && typeof props.useSession === 'function' ? props.useSession as () => unknown : null
|
|
499
|
-
if (useSession) {
|
|
500
|
-
try { session = useSession() } catch (err) { log('useSession failed', err) }
|
|
501
|
-
}
|
|
502
|
-
if (!session && props && props.session) session = props.session
|
|
503
|
-
return session
|
|
504
|
-
}
|
|
505
|
-
|
|
506
495
|
/** Find the latest report inside a session snapshot (normalized). */
|
|
507
496
|
function latestReport(session: unknown): ReviewReport | null {
|
|
508
497
|
if (!session) return null
|
|
@@ -529,7 +518,15 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
|
|
|
529
518
|
/** Dashboard: live convergence strip above the composer. */
|
|
530
519
|
function ConvergenceDashboard(props: SlotProps) {
|
|
531
520
|
const [pulseKey, setPulseKey] = React.useState(0)
|
|
532
|
-
|
|
521
|
+
// useSession is a SnapshotSelectorHook — it requires a selector fn (identity
|
|
522
|
+
// returns the whole snapshot). Call it unconditionally at the top level to
|
|
523
|
+
// satisfy the React hooks contract; the owner share (props.session) is the
|
|
524
|
+
// fallback when the hook is absent.
|
|
525
|
+
const useSession = props && typeof props.useSession === 'function'
|
|
526
|
+
? props.useSession as (sel: (s: unknown) => unknown) => unknown
|
|
527
|
+
: null
|
|
528
|
+
const session = useSession ? useSession((s: unknown) => s) : (props && props.session ? props.session : null)
|
|
529
|
+
const report = latestReport(session)
|
|
533
530
|
|
|
534
531
|
React.useEffect(() => {
|
|
535
532
|
if (!report) return
|
package/src/config-loader.ts
CHANGED
|
@@ -49,7 +49,12 @@ export function defaultConfig(): IterateConfig {
|
|
|
49
49
|
auto_merge: false,
|
|
50
50
|
},
|
|
51
51
|
validation: { command_whitelist: [], commands: {} },
|
|
52
|
-
reviewer: {
|
|
52
|
+
reviewer: {
|
|
53
|
+
output_schema_validation: true,
|
|
54
|
+
evidence_validation: true,
|
|
55
|
+
coverage_validation: true,
|
|
56
|
+
scope_chunk_size: 25,
|
|
57
|
+
},
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
|
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
// Mirrors Python `str.splitlines()`: split on every line separator, not just
|
|
63
|
+
// \r\n|\r|\n — otherwise line counts diverge from the harness on files
|
|
64
|
+
// containing \v \f \x1c-\x1e \x85 \u2028 \u2029.
|
|
65
|
+
const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/)
|
|
66
|
+
// A trailing newline leaves an empty final element that is NOT a line
|
|
67
|
+
// (mirrors Python `str.splitlines()` used by the harness).
|
|
68
|
+
if (parts[parts.length - 1] === '') return parts.length - 1
|
|
69
|
+
return parts.length
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolve `root/rel` and reject any path escaping `root` (returns null). */
|
|
73
|
+
export function resolveWithin(root: string, rel: string): string | null {
|
|
74
|
+
const resolved = resolve(root, rel)
|
|
75
|
+
const rootResolved = resolve(root)
|
|
76
|
+
if (resolved === rootResolved) return resolved
|
|
77
|
+
const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep
|
|
78
|
+
if (!resolved.startsWith(prefix)) return null
|
|
79
|
+
return resolved
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pure check that `line` (if anchored) exists in `text`.
|
|
84
|
+
* Whole-file findings (undefined/0) are always bounds-valid.
|
|
85
|
+
*/
|
|
86
|
+
export function verifyLineBounds(
|
|
87
|
+
line: number | null | undefined,
|
|
88
|
+
text: string,
|
|
89
|
+
): { inBounds: boolean; lineTotal: number } {
|
|
90
|
+
const lineTotal = countLines(text)
|
|
91
|
+
if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
|
|
92
|
+
return { inBounds: true, lineTotal }
|
|
93
|
+
}
|
|
94
|
+
if (line < 1) return { inBounds: false, lineTotal }
|
|
95
|
+
return { inBounds: line <= lineTotal, lineTotal }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Verify a single finding's location against the real filesystem. */
|
|
99
|
+
export function verifyFinding(
|
|
100
|
+
root: string,
|
|
101
|
+
input: Locatable,
|
|
102
|
+
opts: { readSet?: Set<string> } = {},
|
|
103
|
+
): FindingEvidence {
|
|
104
|
+
const relFile = input.file ?? ''
|
|
105
|
+
const line = typeof input.line === 'number' ? input.line : null
|
|
106
|
+
const resolved = resolveWithin(root, relFile)
|
|
107
|
+
|
|
108
|
+
if (resolved === null || !existsSync(resolved)) {
|
|
109
|
+
return {
|
|
110
|
+
file: relFile,
|
|
111
|
+
line,
|
|
112
|
+
lineTotal: null,
|
|
113
|
+
resolvedPath: resolved,
|
|
114
|
+
verified: false,
|
|
115
|
+
error: 'file_not_found',
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let raw: Buffer
|
|
120
|
+
try {
|
|
121
|
+
raw = readFileSync(resolved)
|
|
122
|
+
} catch {
|
|
123
|
+
return {
|
|
124
|
+
file: relFile,
|
|
125
|
+
line,
|
|
126
|
+
lineTotal: null,
|
|
127
|
+
resolvedPath: resolved,
|
|
128
|
+
verified: false,
|
|
129
|
+
error: 'file_not_found',
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A file is not line-addressable if it contains a NUL byte (binary payload).
|
|
134
|
+
// Anchored line numbers on a binary file cannot be trusted, so treat them the
|
|
135
|
+
// same as an out-of-range line rather than credulously accepting them
|
|
136
|
+
// (mirrors the harness `evidence.py` NUL check).
|
|
137
|
+
if (raw.includes(0)) {
|
|
138
|
+
return {
|
|
139
|
+
file: relFile,
|
|
140
|
+
line,
|
|
141
|
+
lineTotal: null,
|
|
142
|
+
resolvedPath: resolved,
|
|
143
|
+
verified: false,
|
|
144
|
+
error: 'line_out_of_range',
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const text = raw.toString('utf-8')
|
|
149
|
+
const { inBounds, lineTotal } = verifyLineBounds(line, text)
|
|
150
|
+
if (!inBounds) {
|
|
151
|
+
return {
|
|
152
|
+
file: relFile,
|
|
153
|
+
line,
|
|
154
|
+
lineTotal,
|
|
155
|
+
resolvedPath: resolved,
|
|
156
|
+
verified: false,
|
|
157
|
+
error: 'line_out_of_range',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const outcome: FindingEvidence = {
|
|
162
|
+
file: relFile,
|
|
163
|
+
line,
|
|
164
|
+
lineTotal,
|
|
165
|
+
resolvedPath: resolved,
|
|
166
|
+
verified: true,
|
|
167
|
+
}
|
|
168
|
+
if (opts.readSet !== undefined) {
|
|
169
|
+
outcome.readVerified = opts.readSet.has(resolved)
|
|
170
|
+
}
|
|
171
|
+
return outcome
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Attest every finding in a list. */
|
|
175
|
+
export function verifyFindings(
|
|
176
|
+
root: string,
|
|
177
|
+
findings: Locatable[],
|
|
178
|
+
opts: { readSet?: Set<string> } = {},
|
|
179
|
+
): EvidenceAudit {
|
|
180
|
+
const results = findings.map((f) => verifyFinding(root, f, opts))
|
|
181
|
+
return { checked: results.length, results }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `passed` is true only when no real existence failure exists (read is a hint). */
|
|
185
|
+
export function evidencePassed(audit: EvidenceAudit): boolean {
|
|
186
|
+
return audit.results.every((r) => r.error === undefined)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Violating (non-grounded) results. */
|
|
190
|
+
export function evidenceViolations(audit: EvidenceAudit): FindingEvidence[] {
|
|
191
|
+
return audit.results.filter((r) => r.error !== undefined)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Serialize an audit for tool payloads (pure). */
|
|
195
|
+
export function evidenceToPlain(audit: EvidenceAudit): Record<string, unknown> {
|
|
196
|
+
const computable = audit.results.filter((r) => r.readVerified !== undefined)
|
|
197
|
+
const readRatio =
|
|
198
|
+
computable.length === 0
|
|
199
|
+
? null
|
|
200
|
+
: Number(
|
|
201
|
+
(
|
|
202
|
+
computable.filter((r) => r.readVerified === true).length / computable.length
|
|
203
|
+
).toFixed(3),
|
|
204
|
+
)
|
|
205
|
+
return {
|
|
206
|
+
checked: audit.checked,
|
|
207
|
+
passed: evidencePassed(audit),
|
|
208
|
+
violations: audit.results
|
|
209
|
+
.filter((r) => r.error !== undefined)
|
|
210
|
+
.map((r) => ({ file: r.file, line: r.line, lineTotal: r.lineTotal, verified: r.verified, error: r.error })),
|
|
211
|
+
readVerifiedRatio: readRatio,
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/git-scope.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/git-scope.ts — resolve the `changed-only` review scope for the iterate
|
|
3
|
+
* workflow.
|
|
4
|
+
*
|
|
5
|
+
* When `iterate.config.yaml` sets `review.scope: changed-only`, reviewers must
|
|
6
|
+
* only examine files that changed against `git.target_branch`. This module
|
|
7
|
+
* resolves that file set deterministically:
|
|
8
|
+
*
|
|
9
|
+
* 1. run `git diff --name-only <target_branch> --` in the project root
|
|
10
|
+
* (working-tree diff vs the target branch — captures both staged and
|
|
11
|
+
* unstaged changes, which is what an iterate round produces);
|
|
12
|
+
* 2. keep only entries that resolve to an existing file under the project
|
|
13
|
+
* root (path-traversal-safe — a hostile diff line must never leak a path
|
|
14
|
+
* outside the root);
|
|
15
|
+
* 3. when the configured scope is `changed-only` but ZERO files changed, the
|
|
16
|
+
* plan auto-falls back to `full` (mirrors SKILL.md: "无改动文件时自动
|
|
17
|
+
* fallback 为 full").
|
|
18
|
+
*
|
|
19
|
+
* The pure math (`parseChangedFiles`, `filterExistingFiles`, `decideScope`) is
|
|
20
|
+
* separated from the process call (`runGit`) so it is unit-testable without a
|
|
21
|
+
* git repo.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from 'node:child_process'
|
|
25
|
+
import { existsSync, statSync } from 'node:fs'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
/** A resolved changed-only scope result. */
|
|
29
|
+
export interface GitScopeResult {
|
|
30
|
+
/** Effective scope for the review plan. */
|
|
31
|
+
scope: 'full' | 'changed-only'
|
|
32
|
+
/** Files to review (relative paths). Empty for `full` / fallback. */
|
|
33
|
+
changedFiles: string[]
|
|
34
|
+
/** True when the configured scope was changed-only but no changes were found. */
|
|
35
|
+
fallbackToFull: boolean
|
|
36
|
+
/** Non-empty when git resolution itself failed (scope falls back to full). */
|
|
37
|
+
error?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse `git diff --name-only` stdout into a list of relative paths.
|
|
42
|
+
* Pure: strips blank lines, trims whitespace, drops quotes (git can quote
|
|
43
|
+
* paths with special characters).
|
|
44
|
+
*/
|
|
45
|
+
export function parseChangedFiles(stdout: string): string[] {
|
|
46
|
+
return stdout
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => line.trim().replace(/^"|"$/g, ''))
|
|
49
|
+
.filter((line) => line.length > 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keep only entries that resolve to an existing regular file under `root`.
|
|
54
|
+
* Traversal-safe: rejects absolute paths and any relative path that would
|
|
55
|
+
* escape `root` via `..` (resolved against the root before stat).
|
|
56
|
+
*/
|
|
57
|
+
export function filterExistingFiles(root: string, files: string[]): string[] {
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const rel of files) {
|
|
60
|
+
if (rel.startsWith('/') || rel.includes('\0')) continue
|
|
61
|
+
const candidate = join(root, rel)
|
|
62
|
+
if (!candidate.startsWith(root + '/') && candidate !== root) continue
|
|
63
|
+
try {
|
|
64
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) out.push(rel)
|
|
65
|
+
} catch {
|
|
66
|
+
// Unreadable entry (e.g. a broken symlink) is not a valid review target.
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide the effective scope from the changed-file set.
|
|
75
|
+
* changed-only + zero files → fall back to full (SKILL.md auto-fallback).
|
|
76
|
+
* Pure and deterministic.
|
|
77
|
+
*/
|
|
78
|
+
export function decideScope(changedFiles: string[]): {
|
|
79
|
+
scope: 'full' | 'changed-only'
|
|
80
|
+
fallbackToFull: boolean
|
|
81
|
+
} {
|
|
82
|
+
const hasChanges = changedFiles.length > 0
|
|
83
|
+
return {
|
|
84
|
+
scope: hasChanges ? 'changed-only' : 'full',
|
|
85
|
+
fallbackToFull: !hasChanges,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run a git command in `cwd` and return stdout/stderr/exit code.
|
|
91
|
+
* Uses execFile (no shell), so a model-controlled branch name can never be
|
|
92
|
+
* interpreted as shell syntax.
|
|
93
|
+
*/
|
|
94
|
+
export function runGit(
|
|
95
|
+
args: string[],
|
|
96
|
+
cwd: string,
|
|
97
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
execFile(
|
|
100
|
+
'git',
|
|
101
|
+
args,
|
|
102
|
+
{ cwd, timeout: 30_000, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PAGER: 'cat' } },
|
|
103
|
+
(error, stdout, stderr) => {
|
|
104
|
+
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
105
|
+
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the changed-file set for a project.
|
|
113
|
+
* Any git failure (not a repo, missing target branch, etc.) degrades to a
|
|
114
|
+
* `full`-scope result with `error` set — the reviewer must never crash the
|
|
115
|
+
* plan because git is unavailable.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveChangedFiles(
|
|
118
|
+
root: string,
|
|
119
|
+
targetBranch: string,
|
|
120
|
+
): Promise<GitScopeResult> {
|
|
121
|
+
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', targetBranch, '--'], root)
|
|
122
|
+
if (!ok) {
|
|
123
|
+
const reason = stderr.trim() || `git diff --name-only ${targetBranch} failed`
|
|
124
|
+
return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
|
|
125
|
+
}
|
|
126
|
+
const existing = filterExistingFiles(root, parseChangedFiles(stdout))
|
|
127
|
+
const decided = decideScope(existing)
|
|
128
|
+
return { scope: decided.scope, changedFiles: existing, fallbackToFull: decided.fallbackToFull }
|
|
129
|
+
}
|