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/dist/tools/fix.js CHANGED
@@ -20,6 +20,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
20
20
  import { join } from 'node:path';
21
21
  import { defineTool } from '@deepseek-ai/dsh-tools';
22
22
  import { loadEffectiveConfig, resolveProjectRoot } from "../config-loader.js";
23
+ import { countTouchedMethods } from "../method-scope.js";
23
24
  import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
24
25
  import { appendDecisionEntry } from "./decision-log.js";
25
26
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -218,7 +219,7 @@ export function registerFixTool(ctx) {
218
219
  name: 'iterate_fix',
219
220
  description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
220
221
  'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
221
- 'The tool backs up the original, enforces the atomic `max_lines` threshold (unless `force`), ' +
222
+ 'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
222
223
  'writes the new content, and records the fix for later diff/rollback. ' +
223
224
  'This is the ONLY sanctioned way to apply fixes in normal mode.',
224
225
  parameters: {
@@ -278,6 +279,7 @@ export function registerFixTool(ctx) {
278
279
  const projectRoot = resolved.root;
279
280
  const { config } = loadEffectiveConfig(projectRoot);
280
281
  const maxLines = config.atomic?.max_lines ?? 20;
282
+ const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3;
281
283
  const file = typeof args.file === 'string' ? args.file : '';
282
284
  if (!file)
283
285
  return { ok: false, error: 'file is required' };
@@ -305,6 +307,7 @@ export function registerFixTool(ctx) {
305
307
  const current = readProjectFile(projectRoot, file);
306
308
  if (!current.ok)
307
309
  return { ok: false, error: current.reason };
310
+ const hunks = diffLines(current.content, args.content);
308
311
  const { added, removed } = countChangedLines(current.content, args.content);
309
312
  if (!args.force && (added > maxLines || removed > maxLines)) {
310
313
  return {
@@ -313,6 +316,14 @@ export function registerFixTool(ctx) {
313
316
  'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
314
317
  };
315
318
  }
319
+ const touchedMethods = countTouchedMethods(current.content, args.content, hunks);
320
+ if (!args.force && touchedMethods > maxAdjacentMethods) {
321
+ return {
322
+ ok: false,
323
+ error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
324
+ 'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
325
+ };
326
+ }
316
327
  const id = fixId(finding);
317
328
  const registry = readRegistry(projectRoot);
318
329
  if (findFixRecord(registry, id)) {
@@ -336,7 +347,6 @@ export function registerFixTool(ctx) {
336
347
  catch (err) {
337
348
  return { ok: false, error: `failed to write file: ${String(err)}` };
338
349
  }
339
- const hunks = diffLines(current.content, args.content);
340
350
  const record = {
341
351
  id,
342
352
  timestamp,
@@ -1,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
- const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional });
123
+ // changed-only scope: resolve the changed-file set against
124
+ // git.target_branch before building the plan so reviewers get the
125
+ // concrete file list (and the plan auto-falls back to full when there
126
+ // are no changes). git failures degrade to a full-scope plan.
127
+ let changedFiles;
128
+ if (config.review?.scope === 'changed-only') {
129
+ const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
130
+ changedFiles = gitScope.changedFiles;
131
+ }
132
+ // Full-codebase review: pre-collect the source inventory so
133
+ // buildReviewPlan can batch it into per-chunk reviewer tasks
134
+ // (coverage enforcement).
135
+ let scopeFiles;
136
+ if (config.review?.scope === 'full') {
137
+ scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' });
138
+ }
139
+ const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles });
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 { operation: 'aggregate', mode, report: report };
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
- const finalReport = buildFinalReviewReport(source);
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 report = latestReport(sessionSnapshot(props));
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.7.3",
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",
@@ -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
- const report = latestReport(sessionSnapshot(props))
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
@@ -49,7 +49,12 @@ 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: {
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
 
@@ -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
+ }
@@ -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
+ }