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/README.md
CHANGED
|
@@ -229,6 +229,19 @@ All tests pass:
|
|
|
229
229
|
- **212 unit tests green**, type-check clean
|
|
230
230
|
- Coverage: dedupe, filter, sort, multi-round convergence, meta-review audit, path safety, timeout clamping, config read/write + rollback, triage merge, diff computation, checkpoint validation, fix registry, history read + filter, prune cleanup report + dry-run semantics, UI pure functions (select-all key, runtime status guide).
|
|
231
231
|
|
|
232
|
+
## ⚠️ Disclaimer
|
|
233
|
+
|
|
234
|
+
This project is provided "AS IS", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement.
|
|
235
|
+
|
|
236
|
+
**Automated code review and fixing carries inherent risk.** All changes produced in normal mode are generated by AI models and may introduce bugs, regressions, or unintended behavior. Before merging, you should:
|
|
237
|
+
|
|
238
|
+
- Review every diff before applying it to your main branch or pushing.
|
|
239
|
+
- Make sure your project is under git control and can be rolled back (`git restore`, revert, or restore from backup).
|
|
240
|
+
- Run your project's own test suite and build checks after each round of fixes.
|
|
241
|
+
- Never run this on secrets, credentials, `.env`, or files that must not be modified — configure `protected_paths` accordingly.
|
|
242
|
+
|
|
243
|
+
Users are solely responsible for the code that is generated, modified, or committed as a result of using this project. By using it, you acknowledge that neither the maintainers nor contributors are liable for any loss, damage, or legal consequences arising from its use.
|
|
244
|
+
|
|
232
245
|
## License
|
|
233
246
|
|
|
234
247
|
MIT
|
package/README.zh-CN.md
CHANGED
|
@@ -229,6 +229,19 @@ npm test
|
|
|
229
229
|
- **212 个单元测试全绿**,类型检查通过
|
|
230
230
|
- 覆盖:去重、过滤、排序、多轮收敛、meta-review 审计、路径安全、超时钳制、配置读写与回滚、triage 合并、diff 计算、checkpoint 校验、修复注册表、历史读取与过滤、prune 清理报告与 dry-run 语义、UI 纯函数(select-all 键、运行时状态指引)等
|
|
231
231
|
|
|
232
|
+
## ⚠️ 免责声明
|
|
233
|
+
|
|
234
|
+
本项目按「现状」(AS IS)提供,不附带任何明示或暗示的担保,包括但不限于对适销性、特定用途适用性及不侵权性的担保。
|
|
235
|
+
|
|
236
|
+
**自动化的代码审查与修复存在固有风险。** normal 模式下产生的改动均由 AI 模型生成,可能引入缺陷、回归或非预期行为。在合并改动前,你应当:
|
|
237
|
+
|
|
238
|
+
- 在应用到主分支或推送前,逐条 review 每一处 diff。
|
|
239
|
+
- 确保项目处于 git 版本控制之下,并可随时回滚(`git restore`、revert 或从备份恢复)。
|
|
240
|
+
- 在每轮修复后运行项目自身的测试与构建检查。
|
|
241
|
+
- 切勿在密钥、凭证、`.env` 或任何不允许修改的文件上运行本项目;请在配置中设置 `protected_paths` 予以保护。
|
|
242
|
+
|
|
243
|
+
使用者需为本项目使用过程中所产生、修改或提交的代码负全部责任。使用本项目即表示你同意:维护者与贡献者不对因使用本项目而导致的任何损失、损害或法律后果承担责任。
|
|
244
|
+
|
|
232
245
|
## License
|
|
233
246
|
|
|
234
247
|
MIT
|
package/dist/config-loader.js
CHANGED
|
@@ -48,7 +48,12 @@ export function defaultConfig() {
|
|
|
48
48
|
auto_merge: false,
|
|
49
49
|
},
|
|
50
50
|
validation: { command_whitelist: [], commands: {} },
|
|
51
|
-
reviewer: {
|
|
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
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic code-evidence verification for review findings.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of `iterate_harness/iterate/evidence.py` for the iterate-plugin.
|
|
5
|
+
*
|
|
6
|
+
* The iterate review loop requires that reviewer subagent findings ANCHOR to
|
|
7
|
+
* real code instead of speculating. This module enforces it:
|
|
8
|
+
*
|
|
9
|
+
* - a finding's `file` must resolve to an existing file under the project root
|
|
10
|
+
* (traversal-safe), otherwise evidence is poisoned (`file_not_found`);
|
|
11
|
+
* - a finding with an explicit line must reference a line that actually exists
|
|
12
|
+
* in that file (`line_out_of_range`);
|
|
13
|
+
* - a whole-file finding (line 0 / undefined) must still reference an existing
|
|
14
|
+
* file, so even structural findings cannot point at nothing;
|
|
15
|
+
* - `readVerified` is a best-effort, NON-gating hint: the plugin's reviewers are
|
|
16
|
+
* subagents whose reads are not aggregated here, so it is only set when a
|
|
17
|
+
* read set is explicitly provided and never fails the audit.
|
|
18
|
+
*
|
|
19
|
+
* Gate rule (user preference): ANY localizable finding with poisoned evidence
|
|
20
|
+
* flips the whole audit to `passed: false`, so the meta-review forces revision.
|
|
21
|
+
*
|
|
22
|
+
* The pure math (`countLines`, `verifyLineBounds`) is separated from the
|
|
23
|
+
* filesystem half (`verifyFinding`) to stay unit-testable without touching disk.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { resolve, sep } from 'node:path';
|
|
27
|
+
/** Sentinel for whole-file findings (line 0 or omitted means the whole file). */
|
|
28
|
+
export const WHOLE_FILE_LINE = 0;
|
|
29
|
+
/** Number of physical lines in `text`. A trailing newline does not add a line. */
|
|
30
|
+
export function countLines(text) {
|
|
31
|
+
if (text === '')
|
|
32
|
+
return 0;
|
|
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]/);
|
|
37
|
+
// A trailing newline leaves an empty final element that is NOT a line
|
|
38
|
+
// (mirrors Python `str.splitlines()` used by the harness).
|
|
39
|
+
if (parts[parts.length - 1] === '')
|
|
40
|
+
return parts.length - 1;
|
|
41
|
+
return parts.length;
|
|
42
|
+
}
|
|
43
|
+
/** Resolve `root/rel` and reject any path escaping `root` (returns null). */
|
|
44
|
+
export function resolveWithin(root, rel) {
|
|
45
|
+
const resolved = resolve(root, rel);
|
|
46
|
+
const rootResolved = resolve(root);
|
|
47
|
+
if (resolved === rootResolved)
|
|
48
|
+
return resolved;
|
|
49
|
+
const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
|
|
50
|
+
if (!resolved.startsWith(prefix))
|
|
51
|
+
return null;
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Pure check that `line` (if anchored) exists in `text`.
|
|
56
|
+
* Whole-file findings (undefined/0) are always bounds-valid.
|
|
57
|
+
*/
|
|
58
|
+
export function verifyLineBounds(line, text) {
|
|
59
|
+
const lineTotal = countLines(text);
|
|
60
|
+
if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
|
|
61
|
+
return { inBounds: true, lineTotal };
|
|
62
|
+
}
|
|
63
|
+
if (line < 1)
|
|
64
|
+
return { inBounds: false, lineTotal };
|
|
65
|
+
return { inBounds: line <= lineTotal, lineTotal };
|
|
66
|
+
}
|
|
67
|
+
/** Verify a single finding's location against the real filesystem. */
|
|
68
|
+
export function verifyFinding(root, input, opts = {}) {
|
|
69
|
+
const relFile = input.file ?? '';
|
|
70
|
+
const line = typeof input.line === 'number' ? input.line : null;
|
|
71
|
+
const resolved = resolveWithin(root, relFile);
|
|
72
|
+
if (resolved === null || !existsSync(resolved)) {
|
|
73
|
+
return {
|
|
74
|
+
file: relFile,
|
|
75
|
+
line,
|
|
76
|
+
lineTotal: null,
|
|
77
|
+
resolvedPath: resolved,
|
|
78
|
+
verified: false,
|
|
79
|
+
error: 'file_not_found',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = readFileSync(resolved);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {
|
|
88
|
+
file: relFile,
|
|
89
|
+
line,
|
|
90
|
+
lineTotal: null,
|
|
91
|
+
resolvedPath: resolved,
|
|
92
|
+
verified: false,
|
|
93
|
+
error: 'file_not_found',
|
|
94
|
+
};
|
|
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');
|
|
111
|
+
const { inBounds, lineTotal } = verifyLineBounds(line, text);
|
|
112
|
+
if (!inBounds) {
|
|
113
|
+
return {
|
|
114
|
+
file: relFile,
|
|
115
|
+
line,
|
|
116
|
+
lineTotal,
|
|
117
|
+
resolvedPath: resolved,
|
|
118
|
+
verified: false,
|
|
119
|
+
error: 'line_out_of_range',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const outcome = {
|
|
123
|
+
file: relFile,
|
|
124
|
+
line,
|
|
125
|
+
lineTotal,
|
|
126
|
+
resolvedPath: resolved,
|
|
127
|
+
verified: true,
|
|
128
|
+
};
|
|
129
|
+
if (opts.readSet !== undefined) {
|
|
130
|
+
outcome.readVerified = opts.readSet.has(resolved);
|
|
131
|
+
}
|
|
132
|
+
return outcome;
|
|
133
|
+
}
|
|
134
|
+
/** Attest every finding in a list. */
|
|
135
|
+
export function verifyFindings(root, findings, opts = {}) {
|
|
136
|
+
const results = findings.map((f) => verifyFinding(root, f, opts));
|
|
137
|
+
return { checked: results.length, results };
|
|
138
|
+
}
|
|
139
|
+
/** `passed` is true only when no real existence failure exists (read is a hint). */
|
|
140
|
+
export function evidencePassed(audit) {
|
|
141
|
+
return audit.results.every((r) => r.error === undefined);
|
|
142
|
+
}
|
|
143
|
+
/** Violating (non-grounded) results. */
|
|
144
|
+
export function evidenceViolations(audit) {
|
|
145
|
+
return audit.results.filter((r) => r.error !== undefined);
|
|
146
|
+
}
|
|
147
|
+
/** Serialize an audit for tool payloads (pure). */
|
|
148
|
+
export function evidenceToPlain(audit) {
|
|
149
|
+
const computable = audit.results.filter((r) => r.readVerified !== undefined);
|
|
150
|
+
const readRatio = computable.length === 0
|
|
151
|
+
? null
|
|
152
|
+
: Number((computable.filter((r) => r.readVerified === true).length / computable.length).toFixed(3));
|
|
153
|
+
return {
|
|
154
|
+
checked: audit.checked,
|
|
155
|
+
passed: evidencePassed(audit),
|
|
156
|
+
violations: audit.results
|
|
157
|
+
.filter((r) => r.error !== undefined)
|
|
158
|
+
.map((r) => ({ file: r.file, line: r.line, lineTotal: r.lineTotal, verified: r.verified, error: r.error })),
|
|
159
|
+
readVerifiedRatio: readRatio,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/meta-review.js
CHANGED
|
@@ -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
|
*
|
|
@@ -157,15 +162,85 @@ export function metaReviewReport(report) {
|
|
|
157
162
|
/**
|
|
158
163
|
* Build the final review report: pair the source report with its meta-review
|
|
159
164
|
* verdict and a rolled-up summary. Pure and deterministic.
|
|
165
|
+
*
|
|
166
|
+
* `evidence` (an EvidenceAudit produced against the real repo) is the hard
|
|
167
|
+
* code-evidence gate: every finding whose file/line does not resolve to
|
|
168
|
+
* existing code is emitted as a critical EVIDENCE_VIOLATION and flips the
|
|
169
|
+
* verdict to `needs_revision`. The audit itself reads the filesystem; this
|
|
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).
|
|
160
177
|
*/
|
|
161
|
-
export function buildFinalReviewReport(report) {
|
|
178
|
+
export function buildFinalReviewReport(report, opts = {}) {
|
|
162
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
|
+
}
|
|
199
|
+
const evidence = opts.evidence ?? null;
|
|
200
|
+
if (evidence !== null) {
|
|
201
|
+
meta.checksRun += 1;
|
|
202
|
+
if (evidence.results.some((r) => r.error !== undefined)) {
|
|
203
|
+
for (const violation of evidence.results) {
|
|
204
|
+
if (violation.error === undefined)
|
|
205
|
+
continue;
|
|
206
|
+
const detail = violation.error === 'line_out_of_range'
|
|
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`
|
|
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;
|
|
226
|
+
meta.issues.push({
|
|
227
|
+
code: 'EVIDENCE_VIOLATION',
|
|
228
|
+
severity: 'critical',
|
|
229
|
+
summary,
|
|
230
|
+
detail: detail + '. Review results must anchor to real, read code.',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
meta.passed = false;
|
|
234
|
+
meta.verdict = 'revise';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
163
237
|
const summary = report?.summary ?? {};
|
|
164
238
|
const verdict = meta.passed ? 'approved' : 'needs_revision';
|
|
165
239
|
return {
|
|
166
240
|
verdict,
|
|
167
241
|
source: report,
|
|
168
242
|
metaReview: meta,
|
|
243
|
+
coverage: coverage, // preserve the coverage result (or null) on the final report
|
|
169
244
|
summary: {
|
|
170
245
|
totalFindings: Number(summary.totalFindings ?? 0),
|
|
171
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
|
+
}
|