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/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 -25
- package/package.json +1 -1
- package/src/client/index.ts +18 -14
- 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/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
|
|
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.
|
|
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",
|
package/src/client/index.ts
CHANGED
|
@@ -32,7 +32,12 @@
|
|
|
32
32
|
* missing slot/theme degrades gracefully instead of crashing the UI.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
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
|
+
}
|