iterate-plugin 2.7.3 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/README.zh-CN.md +13 -0
- package/dist/config-loader.js +6 -1
- package/dist/evidence.js +161 -0
- package/dist/git-scope.js +101 -0
- package/dist/meta-review.js +76 -1
- package/dist/method-scope.js +173 -0
- package/dist/review-scope.js +187 -0
- package/dist/review.js +226 -18
- package/dist/skill-prompt.js +69 -29
- package/dist/tools/fix.js +12 -2
- package/dist/tools/review.js +77 -5
- package/lib/client.js +3 -14
- package/package.json +1 -1
- package/src/client/index.ts +9 -12
- package/src/config-loader.ts +6 -1
- package/src/evidence.ts +213 -0
- package/src/git-scope.ts +129 -0
- package/src/meta-review.ts +94 -1
- package/src/method-scope.ts +201 -0
- package/src/review-scope.ts +203 -0
- package/src/review.ts +313 -18
- package/src/skill-prompt.ts +69 -29
- package/src/tools/fix.ts +13 -2
- package/src/tools/review.ts +91 -5
- package/src/types.ts +6 -1
package/src/meta-review.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ReviewFinding, ReviewReport } from './types.ts'
|
|
18
|
+
import type { EvidenceAudit } from './evidence.ts'
|
|
19
|
+
import type { CoverageResult } from './review-scope.ts'
|
|
18
20
|
import { sortFindings } from './review.ts'
|
|
19
21
|
|
|
20
22
|
/** A single defect found while auditing a review report. */
|
|
@@ -51,6 +53,9 @@ export interface FinalReviewReport {
|
|
|
51
53
|
source: ReviewReport
|
|
52
54
|
/** Deterministic audit of the source report's internal consistency. */
|
|
53
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
|
|
54
59
|
/** Rolled-up summary that mirrors the source but adds the verdict. */
|
|
55
60
|
summary: {
|
|
56
61
|
totalFindings: number
|
|
@@ -68,6 +73,12 @@ export interface FinalReviewReport {
|
|
|
68
73
|
/** Number of distinct consistency checks performed by `metaReviewReport`. */
|
|
69
74
|
export const META_REVIEW_CHECKS = 6
|
|
70
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
|
+
|
|
71
82
|
/**
|
|
72
83
|
* Audit a ReviewReport for internal consistency.
|
|
73
84
|
*
|
|
@@ -278,15 +289,97 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
278
289
|
/**
|
|
279
290
|
* Build the final review report: pair the source report with its meta-review
|
|
280
291
|
* verdict and a rolled-up summary. Pure and deterministic.
|
|
292
|
+
*
|
|
293
|
+
* `evidence` (an EvidenceAudit produced against the real repo) is the hard
|
|
294
|
+
* code-evidence gate: every finding whose file/line does not resolve to
|
|
295
|
+
* existing code is emitted as a critical EVIDENCE_VIOLATION and flips the
|
|
296
|
+
* verdict to `needs_revision`. The audit itself reads the filesystem; this
|
|
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).
|
|
281
304
|
*/
|
|
282
|
-
export function buildFinalReviewReport(
|
|
305
|
+
export function buildFinalReviewReport(
|
|
306
|
+
report: ReviewReport,
|
|
307
|
+
opts: {
|
|
308
|
+
evidence?: EvidenceAudit | null
|
|
309
|
+
coverage?: CoverageResult | null
|
|
310
|
+
} = {},
|
|
311
|
+
): FinalReviewReport {
|
|
283
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
|
+
}
|
|
335
|
+
const evidence = opts.evidence ?? null
|
|
336
|
+
if (evidence !== null) {
|
|
337
|
+
meta.checksRun += 1
|
|
338
|
+
if (evidence.results.some((r) => r.error !== undefined)) {
|
|
339
|
+
for (const violation of evidence.results) {
|
|
340
|
+
if (violation.error === undefined) continue
|
|
341
|
+
const detail =
|
|
342
|
+
violation.error === 'line_out_of_range'
|
|
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`
|
|
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
|
|
365
|
+
meta.issues.push({
|
|
366
|
+
code: 'EVIDENCE_VIOLATION',
|
|
367
|
+
severity: 'critical',
|
|
368
|
+
summary,
|
|
369
|
+
detail: detail + '. Review results must anchor to real, read code.',
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
meta.passed = false
|
|
373
|
+
meta.verdict = 'revise'
|
|
374
|
+
}
|
|
375
|
+
}
|
|
284
376
|
const summary = report?.summary ?? {}
|
|
285
377
|
const verdict: FinalReviewVerdict = meta.passed ? 'approved' : 'needs_revision'
|
|
286
378
|
return {
|
|
287
379
|
verdict,
|
|
288
380
|
source: report,
|
|
289
381
|
metaReview: meta,
|
|
382
|
+
coverage: coverage, // preserve the coverage result (or null) on the final report
|
|
290
383
|
summary: {
|
|
291
384
|
totalFindings: Number(summary.totalFindings ?? 0),
|
|
292
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
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
|
|
24
|
+
import { readdirSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
export interface CoverageResult {
|
|
28
|
+
assigned: string[]
|
|
29
|
+
read: string[]
|
|
30
|
+
covered: string[]
|
|
31
|
+
uncovered: string[]
|
|
32
|
+
ratio: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Relative-scope sentinel for whole-module findings. */
|
|
36
|
+
export const WHOLE_FILE_LINE = 0
|
|
37
|
+
|
|
38
|
+
/** Source extensions a full-scope walk includes. */
|
|
39
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
40
|
+
'.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
41
|
+
'.go', '.java', '.rs', '.c', '.h', '.cc', '.cpp', '.cs',
|
|
42
|
+
'.swift', '.kt', '.scala', '.rb', '.php', '.sh', '.bash', '.zsh',
|
|
43
|
+
'.sql', '.html', '.htm', '.css', '.scss', '.vue', '.svelte',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/** Directory names always excluded from a full-scope walk. */
|
|
47
|
+
const IGNORED_DIRS = new Set([
|
|
48
|
+
'.git', '.hg', '.svn', 'node_modules', '.venv', 'venv', 'env',
|
|
49
|
+
'__pycache__', '.cache', '.pytest_cache', '.mypy_cache', 'dist',
|
|
50
|
+
'build', 'out', '.next', '.nuxt', 'coverage', '.tox', '.idea',
|
|
51
|
+
'.vscode', 'target', '.release', '.dist_tmp',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
/** Default chunk size for a `full` scope review (files per batch). */
|
|
55
|
+
export const DEFAULT_SCOPE_CHUNK_SIZE = 25
|
|
56
|
+
|
|
57
|
+
/** Coverage ratio at/above which a scope is fully covered. */
|
|
58
|
+
export const COVERAGE_TARGET = 0.95
|
|
59
|
+
|
|
60
|
+
const SEP = '/'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Canonicalize separators + dot-segments. Leading `..` PATH segments are
|
|
64
|
+
* PRESERVED (mirrors Python `os.path.normpath`, which never resolves beyond
|
|
65
|
+
* the root), so callers can still detect path-escaping (`..`) after
|
|
66
|
+
* normalization — a full `..`-driven traversal must not be silently folded
|
|
67
|
+
* into a bare filename.
|
|
68
|
+
*/
|
|
69
|
+
function normalizePath(path: string): string {
|
|
70
|
+
const cleaned = path.replace(/\\/g, SEP)
|
|
71
|
+
const parts: string[] = []
|
|
72
|
+
for (const part of cleaned.split(SEP)) {
|
|
73
|
+
if (part === '' || part === '.') continue
|
|
74
|
+
if (part === '..') {
|
|
75
|
+
if (parts.length > 0) parts.pop()
|
|
76
|
+
else parts.push(part) // no root segment to pop — keep the leading '..'
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
parts.push(part)
|
|
80
|
+
}
|
|
81
|
+
return parts.join(SEP)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sourceExt(path: string): boolean {
|
|
85
|
+
const dot = path.lastIndexOf('.')
|
|
86
|
+
if (dot < 0) return false
|
|
87
|
+
return SOURCE_EXTENSIONS.has(path.slice(dot).toLowerCase())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isIgnoredDir(name: string): boolean {
|
|
91
|
+
return IGNORED_DIRS.has(name)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Collect the sorted relative-path inventory for a review scope. */
|
|
95
|
+
export function collectScopeFiles(
|
|
96
|
+
root: string,
|
|
97
|
+
opts: { scope: 'full' | 'changed-only'; changedFiles?: string[] },
|
|
98
|
+
): string[] {
|
|
99
|
+
if (opts.scope === 'changed-only') return collectChanged(opts.changedFiles ?? [])
|
|
100
|
+
return collectFull(root)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collectChanged(changedFiles: string[]): string[] {
|
|
104
|
+
const out = new Set<string>()
|
|
105
|
+
for (const rel of changedFiles) {
|
|
106
|
+
if (typeof rel !== 'string' || !rel.trim()) continue
|
|
107
|
+
if (rel === String(WHOLE_FILE_LINE)) continue
|
|
108
|
+
const cleaned = normalizePath(rel)
|
|
109
|
+
if (cleaned.startsWith('..')) continue
|
|
110
|
+
if (!sourceExt(cleaned)) continue
|
|
111
|
+
out.add(cleaned)
|
|
112
|
+
}
|
|
113
|
+
return [...out].sort()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectFull(root: string): string[] {
|
|
117
|
+
// Deterministic recursive walk built on Node's fs; a code reviewer never
|
|
118
|
+
// anchors findings to lock files, images, or vendored builds.
|
|
119
|
+
const out: string[] = []
|
|
120
|
+
const walk = (dir: string): void => {
|
|
121
|
+
let entries: import('node:fs').Dirent[]
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
124
|
+
} catch {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const abs = join(dir, entry.name)
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
if (!isIgnoredDir(entry.name)) walk(abs)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (!entry.isFile()) continue
|
|
134
|
+
if (!sourceExt(entry.name)) continue
|
|
135
|
+
const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs
|
|
136
|
+
out.push(rel.split(SEP).join(SEP))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
walk(root)
|
|
140
|
+
return out.sort()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Split `files` into stable batches, keeping directory runs together. */
|
|
144
|
+
export function chunkFiles(files: string[], perChunk?: number): string[][] {
|
|
145
|
+
const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk
|
|
146
|
+
const ordered = [...files].sort()
|
|
147
|
+
const chunks: string[][] = []
|
|
148
|
+
let current: string[] = []
|
|
149
|
+
let lastDir: string | undefined
|
|
150
|
+
for (const rel of ordered) {
|
|
151
|
+
const parent = rel.includes(SEP) ? rel.slice(0, rel.lastIndexOf(SEP)) : '.'
|
|
152
|
+
if (current.length > 0 && lastDir !== undefined && parent !== lastDir) {
|
|
153
|
+
chunks.push(current)
|
|
154
|
+
current = []
|
|
155
|
+
lastDir = undefined
|
|
156
|
+
}
|
|
157
|
+
current.push(rel)
|
|
158
|
+
lastDir = parent
|
|
159
|
+
if (current.length >= size) {
|
|
160
|
+
chunks.push(current)
|
|
161
|
+
current = []
|
|
162
|
+
lastDir = undefined
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (current.length > 0) chunks.push(current)
|
|
166
|
+
return chunks
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Score self-reported reads against the assigned inventory. */
|
|
170
|
+
export function computeCoverage(
|
|
171
|
+
assigned: string[],
|
|
172
|
+
readFiles?: string[] | null,
|
|
173
|
+
): CoverageResult {
|
|
174
|
+
const readNorm = new Set<string>()
|
|
175
|
+
for (const p of readFiles ?? []) {
|
|
176
|
+
if (typeof p === 'string' && p) readNorm.add(normalizePath(p))
|
|
177
|
+
}
|
|
178
|
+
const assignedSorted = [...assigned].sort()
|
|
179
|
+
const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)))
|
|
180
|
+
const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)))
|
|
181
|
+
const rawRatio =
|
|
182
|
+
assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length
|
|
183
|
+
const ratio = Math.round(rawRatio * 1000) / 1000
|
|
184
|
+
return {
|
|
185
|
+
assigned: assignedSorted,
|
|
186
|
+
read: [...new Set((readFiles ?? []).filter((p): p is string => typeof p === 'string'))].sort(),
|
|
187
|
+
covered,
|
|
188
|
+
uncovered,
|
|
189
|
+
ratio,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Serialize a coverage result for the tool-layer JSON wire shape. */
|
|
194
|
+
export function coverageToDict(c: CoverageResult): Record<string, unknown> {
|
|
195
|
+
return {
|
|
196
|
+
assigned: c.assigned,
|
|
197
|
+
read: c.read,
|
|
198
|
+
covered: c.covered,
|
|
199
|
+
uncovered: c.uncovered,
|
|
200
|
+
ratio: c.ratio,
|
|
201
|
+
met: c.ratio >= COVERAGE_TARGET,
|
|
202
|
+
}
|
|
203
|
+
}
|