iterate-plugin 2.8.0 → 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/config-loader.js +6 -1
- package/dist/evidence.js +21 -3
- package/dist/git-scope.js +101 -0
- package/dist/meta-review.js +50 -3
- package/dist/method-scope.js +173 -0
- package/dist/review-scope.js +187 -0
- package/dist/review.js +216 -16
- package/dist/skill-prompt.js +65 -27
- package/dist/tools/fix.js +12 -2
- package/dist/tools/review.js +68 -5
- package/lib/client.js +3 -14
- package/package.json +1 -1
- package/src/client/index.ts +9 -12
- package/src/config-loader.ts +6 -1
- package/src/evidence.ts +22 -3
- package/src/git-scope.ts +129 -0
- package/src/meta-review.ts +65 -5
- package/src/method-scope.ts +201 -0
- package/src/review-scope.ts +203 -0
- package/src/review.ts +301 -16
- package/src/skill-prompt.ts +65 -27
- package/src/tools/fix.ts +13 -2
- package/src/tools/review.ts +82 -5
- package/src/types.ts +6 -1
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
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
|
-
|
|
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
|
|
119
|
+
let raw: Buffer
|
|
117
120
|
try {
|
|
118
|
-
|
|
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 {
|
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
|
+
}
|
package/src/meta-review.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
?
|
|
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
|
+
}
|