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/lib/client.js CHANGED
@@ -2,11 +2,9 @@ window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
2
2
  var module = { exports: {} };
3
3
  var exports = module.exports;
4
4
  "use strict";
5
- var __create = Object.create;
6
5
  var __defProp = Object.defineProperty;
7
6
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
7
  var __getOwnPropNames = Object.getOwnPropertyNames;
9
- var __getProtoOf = Object.getPrototypeOf;
10
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
11
9
  var __export = (target, all) => {
12
10
  for (var name2 in all)
@@ -20,14 +18,6 @@ var __copyProps = (to, from, except, desc) => {
20
18
  }
21
19
  return to;
22
20
  };
23
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
- // If the importer is in node compatibility mode or this is not an ESM
25
- // file that has been converted to a CommonJS file using a Babel-
26
- // compatible transform (i.e. "__esModule" has not been set), then set
27
- // "default" to the CommonJS "module.exports" for node compatibility.
28
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
- mod
30
- ));
31
21
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
22
 
33
23
  // src/client/index.ts
@@ -38,7 +28,6 @@ __export(index_exports, {
38
28
  name: () => name
39
29
  });
40
30
  module.exports = __toCommonJS(index_exports);
41
- var React = __toESM(require("react"), 1);
42
31
 
43
32
  // lib/parse.js
44
33
  var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
@@ -682,6 +671,7 @@ function buildRuntimeStatusGuide() {
682
671
  }
683
672
 
684
673
  // src/client/index.ts
674
+ var React = require("react");
685
675
  var name = "iterate-plugin";
686
676
  var inject = ["slots", "theme"];
687
677
  var PLUGIN_TAG = "iterate-ui";
@@ -983,19 +973,6 @@ function setThemeEnabled(enabled) {
983
973
  if (enabled) applyThemeSkin();
984
974
  else clearThemeSkin();
985
975
  }
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
976
  function latestReport(session) {
1000
977
  if (!session) return null;
1001
978
  const raw = scanSessionForReport(session) || findReportInObject(session, void 0, 24);
@@ -1017,7 +994,8 @@ function TrendChart({ points }) {
1017
994
  }
1018
995
  function ConvergenceDashboard(props) {
1019
996
  const [pulseKey, setPulseKey] = React.useState(0);
1020
- const report = latestReport(sessionSnapshot(props));
997
+ const session = props && props.session ? props.session : null;
998
+ const report = latestReport(session);
1021
999
  React.useEffect(() => {
1022
1000
  if (!report) return;
1023
1001
  const cur = getCurrentRound(report);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
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",
@@ -32,7 +32,12 @@
32
32
  * missing slot/theme degrades gracefully instead of crashing the UI.
33
33
  */
34
34
 
35
- import * as React from 'react'
35
+ // dsh's __ModuleLoader__ provides require() to access platform modules.
36
+ // Using require() directly (not import) avoids esbuild's __toESM wrapper,
37
+ // which copies React's properties into a new object and can break internal
38
+ // bindings. Official dsh plugins (e.g. dsh-client-ui-goal) use the same pattern.
39
+ declare function require(name: string): unknown
40
+ const React = require('react') as typeof import('react')
36
41
  import {
37
42
  findReportInObject,
38
43
  scanSessionForReport,
@@ -492,17 +497,6 @@ function setThemeEnabled(enabled: boolean): void {
492
497
 
493
498
  // ─── Components (React.createElement trees) ──────────────────────────────────
494
499
 
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
500
  /** Find the latest report inside a session snapshot (normalized). */
507
501
  function latestReport(session: unknown): ReviewReport | null {
508
502
  if (!session) return null
@@ -526,10 +520,20 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
526
520
  return React.createElement('div', { className: 'iterate-trend', title: '各轮发现数量趋势' }, ...bars)
527
521
  }
528
522
 
529
- /** Dashboard: live convergence strip above the composer. */
523
+ /** Dashboard: live convergence strip above the composer.
524
+ *
525
+ * The `conversation.input.dock` slot's owner share is `InputZone`, which
526
+ * provides `session` as a point-in-time ConversationSnapshot directly — no
527
+ * subscription needed. Per the slot contract: "Read only `session`/`input`
528
+ * off the owner share — both are point-in-time snapshots re-rendered for
529
+ * you, never subscribe."
530
+ */
530
531
  function ConvergenceDashboard(props: SlotProps) {
531
532
  const [pulseKey, setPulseKey] = React.useState(0)
532
- const report = latestReport(sessionSnapshot(props))
533
+ // The `conversation.input.dock` slot owner share (InputZone) provides
534
+ // `session` as a point-in-time snapshot — read it directly, never subscribe.
535
+ const session = props && props.session ? props.session : null
536
+ const report = latestReport(session)
533
537
 
534
538
  React.useEffect(() => {
535
539
  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, evidence_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
 
package/src/evidence.ts CHANGED
@@ -59,7 +59,10 @@ interface Locatable {
59
59
  /** Number of physical lines in `text`. A trailing newline does not add a line. */
60
60
  export function countLines(text: string): number {
61
61
  if (text === '') return 0
62
- const parts = text.split(/\r\n|\r|\n/)
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]/)
63
66
  // A trailing newline leaves an empty final element that is NOT a line
64
67
  // (mirrors Python `str.splitlines()` used by the harness).
65
68
  if (parts[parts.length - 1] === '') return parts.length - 1
@@ -113,9 +116,9 @@ export function verifyFinding(
113
116
  }
114
117
  }
115
118
 
116
- let text: string
119
+ let raw: Buffer
117
120
  try {
118
- text = readFileSync(resolved, 'utf-8')
121
+ raw = readFileSync(resolved)
119
122
  } catch {
120
123
  return {
121
124
  file: relFile,
@@ -127,6 +130,22 @@ export function verifyFinding(
127
130
  }
128
131
  }
129
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')
130
149
  const { inBounds, lineTotal } = verifyLineBounds(line, text)
131
150
  if (!inBounds) {
132
151
  return {
@@ -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
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type { ReviewFinding, ReviewReport } from './types.ts'
18
18
  import type { EvidenceAudit } from './evidence.ts'
19
+ import type { CoverageResult } from './review-scope.ts'
19
20
  import { sortFindings } from './review.ts'
20
21
 
21
22
  /** A single defect found while auditing a review report. */
@@ -52,6 +53,9 @@ export interface FinalReviewReport {
52
53
  source: ReviewReport
53
54
  /** Deterministic audit of the source report's internal consistency. */
54
55
  metaReview: MetaReviewResult
56
+ /** Prompt-informative scope coverage result (absent when coverage validation
57
+ * is disabled or there is nothing to compare). Never flips the verdict. */
58
+ coverage?: CoverageResult | null
55
59
  /** Rolled-up summary that mirrors the source but adds the verdict. */
56
60
  summary: {
57
61
  totalFindings: number
@@ -69,6 +73,12 @@ export interface FinalReviewReport {
69
73
  /** Number of distinct consistency checks performed by `metaReviewReport`. */
70
74
  export const META_REVIEW_CHECKS = 6
71
75
 
76
+ /**
77
+ * How many uncovered scope files are listed in a COVERAGE_GAP hint before the
78
+ * remainder is folded into a "+N more" suffix.
79
+ */
80
+ export const COVERAGE_LIST_TRUNCATE = 10
81
+
72
82
  /**
73
83
  * Audit a ReviewReport for internal consistency.
74
84
  *
@@ -285,12 +295,43 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
285
295
  * existing code is emitted as a critical EVIDENCE_VIOLATION and flips the
286
296
  * verdict to `needs_revision`. The audit itself reads the filesystem; this
287
297
  * function only folds the (pure, precomputed) result in.
298
+ *
299
+ * `coverage` (a CoverageResult) is a *prompt-informative* check: a scope whose
300
+ * reviewer never reported reading a meaningful share of its assigned files
301
+ * surfaces a medium COVERAGE_GAP hint (it does NOT flip the verdict — the
302
+ * subagent's actual tool-call trace is not aggregated here, so coverage can
303
+ * only advise, never adjudicate).
288
304
  */
289
305
  export function buildFinalReviewReport(
290
306
  report: ReviewReport,
291
- opts: { evidence?: EvidenceAudit | null } = {},
307
+ opts: {
308
+ evidence?: EvidenceAudit | null
309
+ coverage?: CoverageResult | null
310
+ } = {},
292
311
  ): FinalReviewReport {
293
312
  const meta = metaReviewReport(report)
313
+ const coverage = opts.coverage ?? null
314
+ if (coverage !== null) {
315
+ meta.checksRun += 1
316
+ if (coverage.uncovered.length > 0) {
317
+ const listed = coverage.uncovered.slice(0, COVERAGE_LIST_TRUNCATE).join(', ')
318
+ const extra =
319
+ coverage.uncovered.length - COVERAGE_LIST_TRUNCATE > 0
320
+ ? ` (+${coverage.uncovered.length - COVERAGE_LIST_TRUNCATE} more)`
321
+ : ''
322
+ meta.issues.push({
323
+ code: 'COVERAGE_GAP',
324
+ severity: 'medium',
325
+ summary:
326
+ `${coverage.uncovered.length} of ${coverage.assigned.length} scope files ` +
327
+ 'were not (self-)reported as read',
328
+ detail:
329
+ `The reviewer reported reading ${coverage.covered.length}/${coverage.assigned.length} ` +
330
+ `assigned files. Uncovered: ${listed}${extra}. Best-effort coverage hint — ` +
331
+ 'verify these files were actually opened.',
332
+ })
333
+ }
334
+ }
294
335
  const evidence = opts.evidence ?? null
295
336
  if (evidence !== null) {
296
337
  meta.checksRun += 1
@@ -299,14 +340,32 @@ export function buildFinalReviewReport(
299
340
  if (violation.error === undefined) continue
300
341
  const detail =
301
342
  violation.error === 'line_out_of_range'
302
- ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
343
+ ? violation.lineTotal !== undefined && violation.lineTotal !== null
344
+ ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
345
+ : `${violation.file} is a binary/unreadable file not line-addressable`
303
346
  : `${violation.file} does not exist at all (verifiable read required)`
347
+ let roundHint = ''
348
+ if (report && violation.file) {
349
+ // Try to attribute the poisoned finding to the round that first
350
+ // surfaced it (best-effort; report rounds carry it).
351
+ for (const r of report.rounds ?? []) {
352
+ const matched = (r.findings ?? []).some(
353
+ (fnd) => fnd.file === violation.file && fnd.line === violation.line,
354
+ )
355
+ if (matched) {
356
+ roundHint = ` (round ${r.round})`
357
+ break
358
+ }
359
+ }
360
+ }
361
+ const summary =
362
+ `Finding references non-existent code: ${violation.file}` +
363
+ (violation.line ? `:${violation.line}` : '') +
364
+ roundHint
304
365
  meta.issues.push({
305
366
  code: 'EVIDENCE_VIOLATION',
306
367
  severity: 'critical',
307
- summary:
308
- `Finding references non-existent code: ${violation.file}` +
309
- (violation.line ? `:${violation.line}` : ''),
368
+ summary,
310
369
  detail: detail + '. Review results must anchor to real, read code.',
311
370
  })
312
371
  }
@@ -320,6 +379,7 @@ export function buildFinalReviewReport(
320
379
  verdict,
321
380
  source: report,
322
381
  metaReview: meta,
382
+ coverage: coverage, // preserve the coverage result (or null) on the final report
323
383
  summary: {
324
384
  totalFindings: Number(summary.totalFindings ?? 0),
325
385
  critical: Number(summary.critical ?? 0),
@@ -0,0 +1,201 @@
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
+
29
+ /** A method/function signature found in source text. */
30
+ export interface MethodSignature {
31
+ name: string
32
+ /** 1-based line of the signature. */
33
+ line: number
34
+ }
35
+
36
+ /** Approximate span a signature "owns": signature line → before the next. */
37
+ export interface MethodSpan {
38
+ name: string
39
+ startLine: number
40
+ endLine: number
41
+ }
42
+
43
+ /** One changed region (unified-diff hunk, line numbers are 1-based). */
44
+ export interface ChangedRegion {
45
+ oldStart: number
46
+ oldLines: number
47
+ newStart: number
48
+ newLines: number
49
+ }
50
+
51
+ /** Language keywords that never denote a method name. */
52
+ const RESERVED_WORDS = new Set([
53
+ 'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'else',
54
+ 'do', 'try', 'case', 'new', 'typeof', 'instanceof', 'in', 'of', 'class',
55
+ 'interface', 'type', 'enum', 'import', 'export', 'default', 'extends',
56
+ 'implements', 'where', 'async', 'await', 'yield', 'throw', 'delete',
57
+ 'let', 'const', 'var', 'public', 'private', 'protected', 'static',
58
+ ])
59
+
60
+ /**
61
+ * Test-framework callables that look like method declarations but are plain
62
+ * calls (e.g. `it('…', () => { … })`). Excluding them keeps a test-only change
63
+ * from falsely tripping the adjacent-method gate.
64
+ */
65
+ const CALLABLE_NOISE = new Set([
66
+ 'it', 'test', 'describe', 'expect', 'beforeEach', 'afterEach',
67
+ 'beforeAll', 'afterAll', 'suite', 'specify',
68
+ ])
69
+
70
+ /** Signature patterns per language family. Each capture is the method name. */
71
+ const SIGNATURE_PATTERNS: Array<{ kind: string; re: RegExp; nameIndex: number }> = [
72
+ // JS/TS function declarations
73
+ { kind: 'ts', re: /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/, nameIndex: 1 },
74
+ // JS/TS arrow-function assignments (const f = (...) => …)
75
+ { 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 },
76
+ // Indented class methods (JS/TS/Java/Kotlin/C# style `name(…) {`)
77
+ { 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 },
78
+ // Python def (module-level and class methods)
79
+ { kind: 'py', re: /^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
80
+ // Swift func
81
+ { kind: 'swift', re: /^\s*(?:(?:override|public|private|internal|fileprivate|open|static|class)\s+)*func\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
82
+ // Go func (plain + receiver)
83
+ { kind: 'go', re: /^\s*func\s+(?:\([^)]*\)\s+)?([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
84
+ // Rust fn
85
+ { kind: 'rust', re: /^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
86
+ // Ruby def (def name / def self.name / def Class.name)
87
+ { kind: 'ruby', re: /^\s*def\s+(?:(?:self|[A-Z][\w]*)\s*\.\s*)?([A-Za-z_][\w]*[!?]?)(?:\s|\(|$)/, nameIndex: 1 },
88
+ // PHP function
89
+ { kind: 'php', re: /^\s*(?:(?:public|private|protected|static)\s+)*function\s+([A-Za-z_][\w]*)\s*\(/, nameIndex: 1 },
90
+ ]
91
+
92
+ /**
93
+ * Collect method/function signatures from `text`.
94
+ * Returns a sorted array of `{ name, line }` (1-based line numbers).
95
+ */
96
+ export function collectMethodSignatures(text: string): MethodSignature[] {
97
+ const lines = text.split('\n')
98
+ const out: MethodSignature[] = []
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const raw = lines[i]!
101
+ const line = i + 1
102
+ for (const p of SIGNATURE_PATTERNS) {
103
+ const m = p.re.exec(raw)
104
+ if (!m) continue
105
+ const name = m[p.nameIndex]
106
+ if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name)) continue
107
+ // Avoid two patterns claiming the same line (e.g. TS method + arrow).
108
+ if (out.some((s) => s.line === line && s.name === name)) break
109
+ out.push({ name, line })
110
+ break
111
+ }
112
+ }
113
+ return out
114
+ }
115
+
116
+ /** Number of physical lines in `text` (a trailing newline does not add a line). */
117
+ export function countTextLines(text: string): number {
118
+ if (text === '') return 0
119
+ const parts = text.split('\n')
120
+ return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length
121
+ }
122
+
123
+ /**
124
+ * Build the approximate span owned by each signature: from its own line up to
125
+ * (but excluding) the next signature line, trimmed of trailing blank lines so
126
+ * a blank separator between two methods belongs to neither. The last method's
127
+ * span runs to the final non-blank line of the file.
128
+ */
129
+ export function collectMethodSpans(text: string): MethodSpan[] {
130
+ const signatures = collectMethodSignatures(text)
131
+ if (signatures.length === 0) return []
132
+ const lines = text.split('\n')
133
+ const lineCount = countTextLines(text)
134
+
135
+ /** Last non-blank line at or before `candidate`. */
136
+ function trimBlank(endCandidate: number, floor: number): number {
137
+ let end = endCandidate
138
+ while (end > floor) {
139
+ const raw = lines[end - 1]
140
+ if (raw === undefined || raw.trim().length === 0) end--
141
+ else break
142
+ }
143
+ return end
144
+ }
145
+
146
+ const spans: MethodSpan[] = []
147
+ for (let i = 0; i < signatures.length; i++) {
148
+ const cur = signatures[i]!
149
+ const next = signatures[i + 1]
150
+ const rawEnd = next ? next.line - 1 : lineCount
151
+ spans.push({ name: cur.name, startLine: cur.line, endLine: trimBlank(rawEnd, cur.line) })
152
+ }
153
+ return spans
154
+ }
155
+
156
+ /** True when `[regionStart, regionEnd]` intersects `[spanStart, spanEnd]`. */
157
+ function intersects(spanStart: number, spanEnd: number, regionStart: number, regionEnd: number): boolean {
158
+ return spanStart <= regionEnd && spanEnd >= regionStart
159
+ }
160
+
161
+ /**
162
+ * Count the distinct methods a set of diff hunks touches.
163
+ *
164
+ * Semantics:
165
+ * - REMOVED lines (oldLines > 0) are attributed against the `before` method
166
+ * spans; PURE insertions (oldLines === 0) skip `before` so a deletion never
167
+ * drags in the next surviving method.
168
+ * - ADDED lines (newLines > 0) are attributed against the `after` spans;
169
+ * PURE deletions skip `after` so an insertion never mis-attributes to the
170
+ * following method.
171
+ * - Methods touched by both sides are counted once (keyed name@startLine).
172
+ */
173
+ export function countTouchedMethods(
174
+ before: string,
175
+ after: string,
176
+ hunks: ChangedRegion[],
177
+ ): number {
178
+ if (hunks.length === 0) return 0
179
+ const beforeSpans = collectMethodSpans(before)
180
+ const afterSpans = collectMethodSpans(after)
181
+ const touched = new Set<string>()
182
+ for (const h of hunks) {
183
+ if (h.oldLines > 0) {
184
+ const oldEnd = h.oldStart + h.oldLines - 1
185
+ for (const s of beforeSpans) {
186
+ if (intersects(s.startLine, s.endLine, h.oldStart, oldEnd)) {
187
+ touched.add(`${s.name}@${s.startLine}`)
188
+ }
189
+ }
190
+ }
191
+ if (h.newLines > 0) {
192
+ const newEnd = h.newStart + h.newLines - 1
193
+ for (const s of afterSpans) {
194
+ if (intersects(s.startLine, s.endLine, h.newStart, newEnd)) {
195
+ touched.add(`${s.name}@${s.startLine}`)
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return touched.size
201
+ }