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.
@@ -48,7 +48,12 @@ export function defaultConfig() {
48
48
  auto_merge: false,
49
49
  },
50
50
  validation: { command_whitelist: [], commands: {} },
51
- reviewer: { output_schema_validation: true, evidence_validation: true },
51
+ reviewer: {
52
+ output_schema_validation: true,
53
+ evidence_validation: true,
54
+ coverage_validation: true,
55
+ scope_chunk_size: 25,
56
+ },
52
57
  };
53
58
  }
54
59
  /**
package/dist/evidence.js CHANGED
@@ -30,7 +30,10 @@ export const WHOLE_FILE_LINE = 0;
30
30
  export function countLines(text) {
31
31
  if (text === '')
32
32
  return 0;
33
- const parts = text.split(/\r\n|\r|\n/);
33
+ // Mirrors Python `str.splitlines()`: split on every line separator, not just
34
+ // \r\n|\r|\n — otherwise line counts diverge from the harness on files
35
+ // containing \v \f \x1c-\x1e \x85 \u2028 \u2029.
36
+ const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
34
37
  // A trailing newline leaves an empty final element that is NOT a line
35
38
  // (mirrors Python `str.splitlines()` used by the harness).
36
39
  if (parts[parts.length - 1] === '')
@@ -76,9 +79,9 @@ export function verifyFinding(root, input, opts = {}) {
76
79
  error: 'file_not_found',
77
80
  };
78
81
  }
79
- let text;
82
+ let raw;
80
83
  try {
81
- text = readFileSync(resolved, 'utf-8');
84
+ raw = readFileSync(resolved);
82
85
  }
83
86
  catch {
84
87
  return {
@@ -90,6 +93,21 @@ export function verifyFinding(root, input, opts = {}) {
90
93
  error: 'file_not_found',
91
94
  };
92
95
  }
96
+ // A file is not line-addressable if it contains a NUL byte (binary payload).
97
+ // Anchored line numbers on a binary file cannot be trusted, so treat them the
98
+ // same as an out-of-range line rather than credulously accepting them
99
+ // (mirrors the harness `evidence.py` NUL check).
100
+ if (raw.includes(0)) {
101
+ return {
102
+ file: relFile,
103
+ line,
104
+ lineTotal: null,
105
+ resolvedPath: resolved,
106
+ verified: false,
107
+ error: 'line_out_of_range',
108
+ };
109
+ }
110
+ const text = raw.toString('utf-8');
93
111
  const { inBounds, lineTotal } = verifyLineBounds(line, text);
94
112
  if (!inBounds) {
95
113
  return {
@@ -0,0 +1,101 @@
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
+ import { execFile } from 'node:child_process';
24
+ import { existsSync, statSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ /**
27
+ * Parse `git diff --name-only` stdout into a list of relative paths.
28
+ * Pure: strips blank lines, trims whitespace, drops quotes (git can quote
29
+ * paths with special characters).
30
+ */
31
+ export function parseChangedFiles(stdout) {
32
+ return stdout
33
+ .split('\n')
34
+ .map((line) => line.trim().replace(/^"|"$/g, ''))
35
+ .filter((line) => line.length > 0);
36
+ }
37
+ /**
38
+ * Keep only entries that resolve to an existing regular file under `root`.
39
+ * Traversal-safe: rejects absolute paths and any relative path that would
40
+ * escape `root` via `..` (resolved against the root before stat).
41
+ */
42
+ export function filterExistingFiles(root, files) {
43
+ const out = [];
44
+ for (const rel of files) {
45
+ if (rel.startsWith('/') || rel.includes('\0'))
46
+ continue;
47
+ const candidate = join(root, rel);
48
+ if (!candidate.startsWith(root + '/') && candidate !== root)
49
+ continue;
50
+ try {
51
+ if (existsSync(candidate) && statSync(candidate).isFile())
52
+ out.push(rel);
53
+ }
54
+ catch {
55
+ // Unreadable entry (e.g. a broken symlink) is not a valid review target.
56
+ continue;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Decide the effective scope from the changed-file set.
63
+ * changed-only + zero files → fall back to full (SKILL.md auto-fallback).
64
+ * Pure and deterministic.
65
+ */
66
+ export function decideScope(changedFiles) {
67
+ const hasChanges = changedFiles.length > 0;
68
+ return {
69
+ scope: hasChanges ? 'changed-only' : 'full',
70
+ fallbackToFull: !hasChanges,
71
+ };
72
+ }
73
+ /**
74
+ * Run a git command in `cwd` and return stdout/stderr/exit code.
75
+ * Uses execFile (no shell), so a model-controlled branch name can never be
76
+ * interpreted as shell syntax.
77
+ */
78
+ export function runGit(args, cwd) {
79
+ return new Promise((resolve) => {
80
+ execFile('git', args, { cwd, timeout: 30_000, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PAGER: 'cat' } }, (error, stdout, stderr) => {
81
+ const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
82
+ resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
83
+ });
84
+ });
85
+ }
86
+ /**
87
+ * Resolve the changed-file set for a project.
88
+ * Any git failure (not a repo, missing target branch, etc.) degrades to a
89
+ * `full`-scope result with `error` set — the reviewer must never crash the
90
+ * plan because git is unavailable.
91
+ */
92
+ export async function resolveChangedFiles(root, targetBranch) {
93
+ const { ok, stdout, stderr } = await runGit(['diff', '--name-only', targetBranch, '--'], root);
94
+ if (!ok) {
95
+ const reason = stderr.trim() || `git diff --name-only ${targetBranch} failed`;
96
+ return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason };
97
+ }
98
+ const existing = filterExistingFiles(root, parseChangedFiles(stdout));
99
+ const decided = decideScope(existing);
100
+ return { scope: decided.scope, changedFiles: existing, fallbackToFull: decided.fallbackToFull };
101
+ }
@@ -16,6 +16,11 @@
16
16
  import { sortFindings } from "./review.js";
17
17
  /** Number of distinct consistency checks performed by `metaReviewReport`. */
18
18
  export const META_REVIEW_CHECKS = 6;
19
+ /**
20
+ * How many uncovered scope files are listed in a COVERAGE_GAP hint before the
21
+ * remainder is folded into a "+N more" suffix.
22
+ */
23
+ export const COVERAGE_LIST_TRUNCATE = 10;
19
24
  /**
20
25
  * Audit a ReviewReport for internal consistency.
21
26
  *
@@ -163,9 +168,34 @@ export function metaReviewReport(report) {
163
168
  * existing code is emitted as a critical EVIDENCE_VIOLATION and flips the
164
169
  * verdict to `needs_revision`. The audit itself reads the filesystem; this
165
170
  * function only folds the (pure, precomputed) result in.
171
+ *
172
+ * `coverage` (a CoverageResult) is a *prompt-informative* check: a scope whose
173
+ * reviewer never reported reading a meaningful share of its assigned files
174
+ * surfaces a medium COVERAGE_GAP hint (it does NOT flip the verdict — the
175
+ * subagent's actual tool-call trace is not aggregated here, so coverage can
176
+ * only advise, never adjudicate).
166
177
  */
167
178
  export function buildFinalReviewReport(report, opts = {}) {
168
179
  const meta = metaReviewReport(report);
180
+ const coverage = opts.coverage ?? null;
181
+ if (coverage !== null) {
182
+ meta.checksRun += 1;
183
+ if (coverage.uncovered.length > 0) {
184
+ const listed = coverage.uncovered.slice(0, COVERAGE_LIST_TRUNCATE).join(', ');
185
+ const extra = coverage.uncovered.length - COVERAGE_LIST_TRUNCATE > 0
186
+ ? ` (+${coverage.uncovered.length - COVERAGE_LIST_TRUNCATE} more)`
187
+ : '';
188
+ meta.issues.push({
189
+ code: 'COVERAGE_GAP',
190
+ severity: 'medium',
191
+ summary: `${coverage.uncovered.length} of ${coverage.assigned.length} scope files ` +
192
+ 'were not (self-)reported as read',
193
+ detail: `The reviewer reported reading ${coverage.covered.length}/${coverage.assigned.length} ` +
194
+ `assigned files. Uncovered: ${listed}${extra}. Best-effort coverage hint — ` +
195
+ 'verify these files were actually opened.',
196
+ });
197
+ }
198
+ }
169
199
  const evidence = opts.evidence ?? null;
170
200
  if (evidence !== null) {
171
201
  meta.checksRun += 1;
@@ -174,13 +204,29 @@ export function buildFinalReviewReport(report, opts = {}) {
174
204
  if (violation.error === undefined)
175
205
  continue;
176
206
  const detail = violation.error === 'line_out_of_range'
177
- ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
207
+ ? violation.lineTotal !== undefined && violation.lineTotal !== null
208
+ ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
209
+ : `${violation.file} is a binary/unreadable file not line-addressable`
178
210
  : `${violation.file} does not exist at all (verifiable read required)`;
211
+ let roundHint = '';
212
+ if (report && violation.file) {
213
+ // Try to attribute the poisoned finding to the round that first
214
+ // surfaced it (best-effort; report rounds carry it).
215
+ for (const r of report.rounds ?? []) {
216
+ const matched = (r.findings ?? []).some((fnd) => fnd.file === violation.file && fnd.line === violation.line);
217
+ if (matched) {
218
+ roundHint = ` (round ${r.round})`;
219
+ break;
220
+ }
221
+ }
222
+ }
223
+ const summary = `Finding references non-existent code: ${violation.file}` +
224
+ (violation.line ? `:${violation.line}` : '') +
225
+ roundHint;
179
226
  meta.issues.push({
180
227
  code: 'EVIDENCE_VIOLATION',
181
228
  severity: 'critical',
182
- summary: `Finding references non-existent code: ${violation.file}` +
183
- (violation.line ? `:${violation.line}` : ''),
229
+ summary,
184
230
  detail: detail + '. Review results must anchor to real, read code.',
185
231
  });
186
232
  }
@@ -194,6 +240,7 @@ export function buildFinalReviewReport(report, opts = {}) {
194
240
  verdict,
195
241
  source: report,
196
242
  metaReview: meta,
243
+ coverage: coverage, // preserve the coverage result (or null) on the final report
197
244
  summary: {
198
245
  totalFindings: Number(summary.totalFindings ?? 0),
199
246
  critical: Number(summary.critical ?? 0),
@@ -0,0 +1,173 @@
1
+ /**
2
+ * src/method-scope.ts — deterministic "touched method" detection for the
3
+ * atomic-fix gate.
4
+ *
5
+ * `config.atomic.max_adjacent_methods` caps how many ADJACENT methods a single
6
+ * atomic fix may touch (SKILL.md: 改动在单个函数/方法内,或最多 N 个相邻的同类方法).
7
+ * The fixer supplies only the new full-file content, so this module rebuilds a
8
+ * best-effort method map (signature line → containing span) and counts the
9
+ * distinct methods a diff's changed regions intersect. Purely textual and
10
+ * deterministic — no parsing library — so it stays unit-testable.
11
+ *
12
+ * Heuristic (documented, not hidden):
13
+ * - A "method" is a line matching a conservative, language-agnostic signature
14
+ * pattern (JS/TS `function` + arrow assignments + class methods, Python
15
+ * `def`, Swift `func`, Go `func`, Rust `fn`, Ruby `def`, PHP `function`).
16
+ * - A method's span is approximated as `signatureLine .. nextSignatureLine-1`
17
+ * (no brace matching). Changes between two signatures are attributed to the
18
+ * earlier method — exactly the "adjacent methods" granularity this
19
+ * threshold governs.
20
+ * - A diff hunk counts a method as touched when the REMOVED block intersects
21
+ * a `before` span or the ADDED block intersects an `after` span. Pure
22
+ * insertions/deletions are attributed through the side that actually
23
+ * changed, so a single-method edit counts 1 and a deleted method does not
24
+ * drag in its neighbour.
25
+ * - If no method is detected around a change, `countTouchedMethods` returns 0,
26
+ * so the `max_lines` gate remains the only constraint for non-method code.
27
+ */
28
+ /** Language keywords that never denote a method name. */
29
+ const RESERVED_WORDS = new Set([
30
+ 'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'else',
31
+ 'do', 'try', 'case', 'new', 'typeof', 'instanceof', 'in', 'of', 'class',
32
+ 'interface', 'type', 'enum', 'import', 'export', 'default', 'extends',
33
+ 'implements', 'where', 'async', 'await', 'yield', 'throw', 'delete',
34
+ 'let', 'const', 'var', 'public', 'private', 'protected', 'static',
35
+ ]);
36
+ /**
37
+ * Test-framework callables that look like method declarations but are plain
38
+ * calls (e.g. `it('…', () => { … })`). Excluding them keeps a test-only change
39
+ * from falsely tripping the adjacent-method gate.
40
+ */
41
+ const CALLABLE_NOISE = new Set([
42
+ 'it', 'test', 'describe', 'expect', 'beforeEach', 'afterEach',
43
+ 'beforeAll', 'afterAll', 'suite', 'specify',
44
+ ]);
45
+ /** Signature patterns per language family. Each capture is the method name. */
46
+ const SIGNATURE_PATTERNS = [
47
+ // JS/TS function declarations
48
+ { kind: 'ts', re: /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/, nameIndex: 1 },
49
+ // JS/TS arrow-function assignments (const f = (...) => …)
50
+ { kind: 'ts-arrow', re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/, nameIndex: 1 },
51
+ // Indented class methods (JS/TS/Java/Kotlin/C# style `name(…) {`)
52
+ { kind: 'ts-method', re: /^\s{2,}(?:(?:public|private|protected|static|async|readonly)\s+)*(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^;{}]*\)\s*\{/, nameIndex: 1 },
53
+ // Python def (module-level and class methods)
54
+ { kind: 'py', re: /^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
55
+ // Swift func
56
+ { kind: 'swift', re: /^\s*(?:(?:override|public|private|internal|fileprivate|open|static|class)\s+)*func\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
57
+ // Go func (plain + receiver)
58
+ { kind: 'go', re: /^\s*func\s+(?:\([^)]*\)\s+)?([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
59
+ // Rust fn
60
+ { kind: 'rust', re: /^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
61
+ // Ruby def (def name / def self.name / def Class.name)
62
+ { kind: 'ruby', re: /^\s*def\s+(?:(?:self|[A-Z][\w]*)\s*\.\s*)?([A-Za-z_][\w]*[!?]?)(?:\s|\(|$)/, nameIndex: 1 },
63
+ // PHP function
64
+ { kind: 'php', re: /^\s*(?:(?:public|private|protected|static)\s+)*function\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
65
+ ];
66
+ /**
67
+ * Collect method/function signatures from `text`.
68
+ * Returns a sorted array of `{ name, line }` (1-based line numbers).
69
+ */
70
+ export function collectMethodSignatures(text) {
71
+ const lines = text.split('\n');
72
+ const out = [];
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const raw = lines[i];
75
+ const line = i + 1;
76
+ for (const p of SIGNATURE_PATTERNS) {
77
+ const m = p.re.exec(raw);
78
+ if (!m)
79
+ continue;
80
+ const name = m[p.nameIndex];
81
+ if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name))
82
+ continue;
83
+ // Avoid two patterns claiming the same line (e.g. TS method + arrow).
84
+ if (out.some((s) => s.line === line && s.name === name))
85
+ break;
86
+ out.push({ name, line });
87
+ break;
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ /** Number of physical lines in `text` (a trailing newline does not add a line). */
93
+ export function countTextLines(text) {
94
+ if (text === '')
95
+ return 0;
96
+ const parts = text.split('\n');
97
+ return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length;
98
+ }
99
+ /**
100
+ * Build the approximate span owned by each signature: from its own line up to
101
+ * (but excluding) the next signature line, trimmed of trailing blank lines so
102
+ * a blank separator between two methods belongs to neither. The last method's
103
+ * span runs to the final non-blank line of the file.
104
+ */
105
+ export function collectMethodSpans(text) {
106
+ const signatures = collectMethodSignatures(text);
107
+ if (signatures.length === 0)
108
+ return [];
109
+ const lines = text.split('\n');
110
+ const lineCount = countTextLines(text);
111
+ /** Last non-blank line at or before `candidate`. */
112
+ function trimBlank(endCandidate, floor) {
113
+ let end = endCandidate;
114
+ while (end > floor) {
115
+ const raw = lines[end - 1];
116
+ if (raw === undefined || raw.trim().length === 0)
117
+ end--;
118
+ else
119
+ break;
120
+ }
121
+ return end;
122
+ }
123
+ const spans = [];
124
+ for (let i = 0; i < signatures.length; i++) {
125
+ const cur = signatures[i];
126
+ const next = signatures[i + 1];
127
+ const rawEnd = next ? next.line - 1 : lineCount;
128
+ spans.push({ name: cur.name, startLine: cur.line, endLine: trimBlank(rawEnd, cur.line) });
129
+ }
130
+ return spans;
131
+ }
132
+ /** True when `[regionStart, regionEnd]` intersects `[spanStart, spanEnd]`. */
133
+ function intersects(spanStart, spanEnd, regionStart, regionEnd) {
134
+ return spanStart <= regionEnd && spanEnd >= regionStart;
135
+ }
136
+ /**
137
+ * Count the distinct methods a set of diff hunks touches.
138
+ *
139
+ * Semantics:
140
+ * - REMOVED lines (oldLines > 0) are attributed against the `before` method
141
+ * spans; PURE insertions (oldLines === 0) skip `before` so a deletion never
142
+ * drags in the next surviving method.
143
+ * - ADDED lines (newLines > 0) are attributed against the `after` spans;
144
+ * PURE deletions skip `after` so an insertion never mis-attributes to the
145
+ * following method.
146
+ * - Methods touched by both sides are counted once (keyed name@startLine).
147
+ */
148
+ export function countTouchedMethods(before, after, hunks) {
149
+ if (hunks.length === 0)
150
+ return 0;
151
+ const beforeSpans = collectMethodSpans(before);
152
+ const afterSpans = collectMethodSpans(after);
153
+ const touched = new Set();
154
+ for (const h of hunks) {
155
+ if (h.oldLines > 0) {
156
+ const oldEnd = h.oldStart + h.oldLines - 1;
157
+ for (const s of beforeSpans) {
158
+ if (intersects(s.startLine, s.endLine, h.oldStart, oldEnd)) {
159
+ touched.add(`${s.name}@${s.startLine}`);
160
+ }
161
+ }
162
+ }
163
+ if (h.newLines > 0) {
164
+ const newEnd = h.newStart + h.newLines - 1;
165
+ for (const s of afterSpans) {
166
+ if (intersects(s.startLine, s.endLine, h.newStart, newEnd)) {
167
+ touched.add(`${s.name}@${s.startLine}`);
168
+ }
169
+ }
170
+ }
171
+ }
172
+ return touched.size;
173
+ }
@@ -0,0 +1,187 @@
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
+ import { readdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ /** Relative-scope sentinel for whole-module findings. */
26
+ export const WHOLE_FILE_LINE = 0;
27
+ /** Source extensions a full-scope walk includes. */
28
+ const SOURCE_EXTENSIONS = new Set([
29
+ '.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
30
+ '.go', '.java', '.rs', '.c', '.h', '.cc', '.cpp', '.cs',
31
+ '.swift', '.kt', '.scala', '.rb', '.php', '.sh', '.bash', '.zsh',
32
+ '.sql', '.html', '.htm', '.css', '.scss', '.vue', '.svelte',
33
+ ]);
34
+ /** Directory names always excluded from a full-scope walk. */
35
+ const IGNORED_DIRS = new Set([
36
+ '.git', '.hg', '.svn', 'node_modules', '.venv', 'venv', 'env',
37
+ '__pycache__', '.cache', '.pytest_cache', '.mypy_cache', 'dist',
38
+ 'build', 'out', '.next', '.nuxt', 'coverage', '.tox', '.idea',
39
+ '.vscode', 'target', '.release', '.dist_tmp',
40
+ ]);
41
+ /** Default chunk size for a `full` scope review (files per batch). */
42
+ export const DEFAULT_SCOPE_CHUNK_SIZE = 25;
43
+ /** Coverage ratio at/above which a scope is fully covered. */
44
+ export const COVERAGE_TARGET = 0.95;
45
+ const SEP = '/';
46
+ /**
47
+ * Canonicalize separators + dot-segments. Leading `..` PATH segments are
48
+ * PRESERVED (mirrors Python `os.path.normpath`, which never resolves beyond
49
+ * the root), so callers can still detect path-escaping (`..`) after
50
+ * normalization — a full `..`-driven traversal must not be silently folded
51
+ * into a bare filename.
52
+ */
53
+ function normalizePath(path) {
54
+ const cleaned = path.replace(/\\/g, SEP);
55
+ const parts = [];
56
+ for (const part of cleaned.split(SEP)) {
57
+ if (part === '' || part === '.')
58
+ continue;
59
+ if (part === '..') {
60
+ if (parts.length > 0)
61
+ parts.pop();
62
+ else
63
+ parts.push(part); // no root segment to pop — keep the leading '..'
64
+ continue;
65
+ }
66
+ parts.push(part);
67
+ }
68
+ return parts.join(SEP);
69
+ }
70
+ function sourceExt(path) {
71
+ const dot = path.lastIndexOf('.');
72
+ if (dot < 0)
73
+ return false;
74
+ return SOURCE_EXTENSIONS.has(path.slice(dot).toLowerCase());
75
+ }
76
+ function isIgnoredDir(name) {
77
+ return IGNORED_DIRS.has(name);
78
+ }
79
+ /** Collect the sorted relative-path inventory for a review scope. */
80
+ export function collectScopeFiles(root, opts) {
81
+ if (opts.scope === 'changed-only')
82
+ return collectChanged(opts.changedFiles ?? []);
83
+ return collectFull(root);
84
+ }
85
+ function collectChanged(changedFiles) {
86
+ const out = new Set();
87
+ for (const rel of changedFiles) {
88
+ if (typeof rel !== 'string' || !rel.trim())
89
+ continue;
90
+ if (rel === String(WHOLE_FILE_LINE))
91
+ continue;
92
+ const cleaned = normalizePath(rel);
93
+ if (cleaned.startsWith('..'))
94
+ continue;
95
+ if (!sourceExt(cleaned))
96
+ continue;
97
+ out.add(cleaned);
98
+ }
99
+ return [...out].sort();
100
+ }
101
+ function collectFull(root) {
102
+ // Deterministic recursive walk built on Node's fs; a code reviewer never
103
+ // anchors findings to lock files, images, or vendored builds.
104
+ const out = [];
105
+ const walk = (dir) => {
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(dir, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ for (const entry of entries) {
114
+ const abs = join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ if (!isIgnoredDir(entry.name))
117
+ walk(abs);
118
+ continue;
119
+ }
120
+ if (!entry.isFile())
121
+ continue;
122
+ if (!sourceExt(entry.name))
123
+ continue;
124
+ const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs;
125
+ out.push(rel.split(SEP).join(SEP));
126
+ }
127
+ };
128
+ walk(root);
129
+ return out.sort();
130
+ }
131
+ /** Split `files` into stable batches, keeping directory runs together. */
132
+ export function chunkFiles(files, perChunk) {
133
+ const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk;
134
+ const ordered = [...files].sort();
135
+ const chunks = [];
136
+ let current = [];
137
+ let lastDir;
138
+ for (const rel of ordered) {
139
+ const parent = rel.includes(SEP) ? rel.slice(0, rel.lastIndexOf(SEP)) : '.';
140
+ if (current.length > 0 && lastDir !== undefined && parent !== lastDir) {
141
+ chunks.push(current);
142
+ current = [];
143
+ lastDir = undefined;
144
+ }
145
+ current.push(rel);
146
+ lastDir = parent;
147
+ if (current.length >= size) {
148
+ chunks.push(current);
149
+ current = [];
150
+ lastDir = undefined;
151
+ }
152
+ }
153
+ if (current.length > 0)
154
+ chunks.push(current);
155
+ return chunks;
156
+ }
157
+ /** Score self-reported reads against the assigned inventory. */
158
+ export function computeCoverage(assigned, readFiles) {
159
+ const readNorm = new Set();
160
+ for (const p of readFiles ?? []) {
161
+ if (typeof p === 'string' && p)
162
+ readNorm.add(normalizePath(p));
163
+ }
164
+ const assignedSorted = [...assigned].sort();
165
+ const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)));
166
+ const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)));
167
+ const rawRatio = assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length;
168
+ const ratio = Math.round(rawRatio * 1000) / 1000;
169
+ return {
170
+ assigned: assignedSorted,
171
+ read: [...new Set((readFiles ?? []).filter((p) => typeof p === 'string'))].sort(),
172
+ covered,
173
+ uncovered,
174
+ ratio,
175
+ };
176
+ }
177
+ /** Serialize a coverage result for the tool-layer JSON wire shape. */
178
+ export function coverageToDict(c) {
179
+ return {
180
+ assigned: c.assigned,
181
+ read: c.read,
182
+ covered: c.covered,
183
+ uncovered: c.uncovered,
184
+ ratio: c.ratio,
185
+ met: c.ratio >= COVERAGE_TARGET,
186
+ };
187
+ }