iterate-plugin 2.9.4 → 2.11.0
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/config-loader.js +14 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/skill-prompt.js +45 -15
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/lib/client.js +152 -80
- package/lib/parse.js +26 -17
- package/package.json +3 -3
- package/src/client/index.ts +144 -56
- package/src/config-loader.ts +12 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/skill-prompt.ts +45 -15
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/types.ts +12 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
`iterate-plugin` is the [iterate](https://github.com/jingzhao-l/iterate-skill) integration for the [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) desktop client. It brings iterate's review loop (review → triage → fix → validate → converge) directly into the dsh UI, offering **autonomous closed-loop code iteration** (normal mode) and **dry-run read-only multi-round review**.
|
|
25
25
|
|
|
26
|
-
Besides 13 pure-function tools, it ships a **build-free Web UI layer** (triage panel, convergence dashboard, stats card, theme skin, etc.) that plugs straight into dsh's existing UI slots. Configuration (`iterate.config.yaml` and the review dimensions) is identical across the other two components of the iterate ecosystem (skill / headless engine) — zero migration cost.
|
|
26
|
+
Besides 13 pure-function tools, it ships a **build-free Web UI layer** (triage panel, convergence dashboard, stats card, theme skin, etc.) that plugs straight into dsh's existing UI slots. Configuration (`iterate.config.yaml` and the review dimensions) is identical across the other two components of the iterate ecosystem ([skill](https://github.com/jingzhao-l/iterate-skill) / [headless engine](https://github.com/jingzhao-l/iterate-harness)) — zero migration cost.
|
|
27
27
|
|
|
28
28
|
## Features
|
|
29
29
|
|
package/README.zh-CN.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
`iterate-plugin` 是 [iterate](https://github.com/jingzhao-l/iterate-skill) 项目在 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 桌面客户端中的落地插件。它把 iterate 的开环审查闭环(review → triage → fix → validate → 收敛)直接带进 dsh 的界面:提供**自治闭环代码迭代**(normal 模式)与 **dry-run 纯多轮审查**(只读)两种能力。
|
|
25
25
|
|
|
26
|
-
除 13 个纯函数工具外,还内置一套**免构建的 Web UI 层**(分诊面板、收敛看板、统计卡片、主题皮肤等),直接挂在 dsh 客户端的既有 UI 槽位上。配置方式(`iterate.config.yaml`
|
|
26
|
+
除 13 个纯函数工具外,还内置一套**免构建的 Web UI 层**(分诊面板、收敛看板、统计卡片、主题皮肤等),直接挂在 dsh 客户端的既有 UI 槽位上。配置方式(`iterate.config.yaml` 与审查维度)与迭代生态的另外两个组件([技能](https://github.com/jingzhao-l/iterate-skill) / [无头引擎](https://github.com/jingzhao-l/iterate-harness))完全一致,迁移零成本。
|
|
27
27
|
|
|
28
28
|
## 特性
|
|
29
29
|
|
package/dist/config-loader.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, resolve, sep } from 'node:path';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
@@ -72,6 +72,11 @@ export function mergeConfig(base, override) {
|
|
|
72
72
|
for (const [key, value] of Object.entries(override)) {
|
|
73
73
|
if (value === undefined)
|
|
74
74
|
continue;
|
|
75
|
+
// Prototype-pollution guard: a YAML `__proto__`/`constructor`/`prototype`
|
|
76
|
+
// key must never be plain-assigned — js-yaml stores __proto__ as an own
|
|
77
|
+
// data property, and `out[key] = value` would invoke the __proto__ setter.
|
|
78
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
79
|
+
continue;
|
|
75
80
|
const baseValue = out[key];
|
|
76
81
|
if (baseValue &&
|
|
77
82
|
typeof baseValue === 'object' &&
|
|
@@ -223,8 +228,14 @@ function effectiveCwd(sessionCwd) {
|
|
|
223
228
|
if (encoded && encoded.startsWith('--') && encoded.endsWith('--')) {
|
|
224
229
|
try {
|
|
225
230
|
const decoded = decodeURIComponent(encoded.slice(2, -2).replace(/~/g, '%'));
|
|
226
|
-
|
|
227
|
-
|
|
231
|
+
// The workspace encoding drops the leading root separator (`/Volumes/…`
|
|
232
|
+
// → `Volumes-…`), so re-attach it when absent. `~<hex>` → `%<hex>` is
|
|
233
|
+
// the documented percent spelling; '-' doubles as the '/' separator, so
|
|
234
|
+
// literal dashes in a path cannot round-trip — verify the result exists
|
|
235
|
+
// and fall through otherwise.
|
|
236
|
+
const candidate = decoded && !decoded.startsWith(sep) ? sep + decoded : decoded;
|
|
237
|
+
if (candidate && candidate.startsWith(sep) && existsSync(candidate))
|
|
238
|
+
return candidate;
|
|
228
239
|
}
|
|
229
240
|
catch {
|
|
230
241
|
// malformed encoding — fall through to cwd
|
package/dist/config-write.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* The security posture mirrors the triage tool: never overwrite a malformed
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
|
-
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import yaml from 'js-yaml';
|
|
15
15
|
/** Config file name (must match config-loader). */
|
|
@@ -161,14 +161,17 @@ export function writeConfigFile(projectRoot, config) {
|
|
|
161
161
|
writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8');
|
|
162
162
|
}
|
|
163
163
|
catch (err) {
|
|
164
|
+
let rollbackError = '';
|
|
164
165
|
try {
|
|
165
166
|
if (backupPath)
|
|
166
167
|
copyFileSync(backupPath, configPath);
|
|
168
|
+
else if (existsSync(configPath))
|
|
169
|
+
rmSync(configPath, { force: true });
|
|
167
170
|
}
|
|
168
|
-
catch {
|
|
169
|
-
|
|
171
|
+
catch (rbErr) {
|
|
172
|
+
rollbackError = `; rollback also failed: ${String(rbErr)}`;
|
|
170
173
|
}
|
|
171
|
-
return { ok: false, error: `failed to write config: ${String(err)}` };
|
|
174
|
+
return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` };
|
|
172
175
|
}
|
|
173
176
|
return { ok: true, backupPath };
|
|
174
177
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -22,10 +22,17 @@
|
|
|
22
22
|
* The pure math (`countLines`, `verifyLineBounds`) is separated from the
|
|
23
23
|
* filesystem half (`verifyFinding`) to stay unit-testable without touching disk.
|
|
24
24
|
*/
|
|
25
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
26
26
|
import { resolve, sep } from 'node:path';
|
|
27
27
|
/** Sentinel for whole-file findings (line 0 or omitted means the whole file). */
|
|
28
28
|
export const WHOLE_FILE_LINE = 0;
|
|
29
|
+
/**
|
|
30
|
+
* Hard cap on a single evidence file read. `verifyFinding` only needs the
|
|
31
|
+
* line count + a NUL check; reading an unbounded file (or a device file
|
|
32
|
+
* reached through a symlink) is a memory/hang hazard, so anything larger is
|
|
33
|
+
* treated as not line-addressable.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_EVIDENCE_BYTES = 10 * 1024 * 1024;
|
|
29
36
|
/** Number of physical lines in `text`. A trailing newline does not add a line. */
|
|
30
37
|
export function countLines(text) {
|
|
31
38
|
if (text === '')
|
|
@@ -51,6 +58,22 @@ export function resolveWithin(root, rel) {
|
|
|
51
58
|
return null;
|
|
52
59
|
return resolved;
|
|
53
60
|
}
|
|
61
|
+
/** True when `candidate` is `root` itself or lexically inside `root`. */
|
|
62
|
+
function isWithin(root, candidate) {
|
|
63
|
+
if (candidate === root)
|
|
64
|
+
return true;
|
|
65
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
66
|
+
return candidate.startsWith(prefix);
|
|
67
|
+
}
|
|
68
|
+
/** best-effort realpath; falls back to the lexical path on any failure. */
|
|
69
|
+
function safeRealpath(p) {
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync(p);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
54
77
|
/**
|
|
55
78
|
* Pure check that `line` (if anchored) exists in `text`.
|
|
56
79
|
* Whole-file findings (undefined/0) are always bounds-valid.
|
|
@@ -79,6 +102,49 @@ export function verifyFinding(root, input, opts = {}) {
|
|
|
79
102
|
error: 'file_not_found',
|
|
80
103
|
};
|
|
81
104
|
}
|
|
105
|
+
// Symlink containment: resolveWithin is lexical only, but existsSync /
|
|
106
|
+
// readFileSync follow symlinks. Verify the REAL path stays inside the REAL
|
|
107
|
+
// project root so a finding path can never read (or line-count) a file
|
|
108
|
+
// outside the project via a symlinked directory or file.
|
|
109
|
+
const rootReal = safeRealpath(root);
|
|
110
|
+
const real = safeRealpath(resolved);
|
|
111
|
+
if (!isWithin(rootReal, real)) {
|
|
112
|
+
return {
|
|
113
|
+
file: relFile,
|
|
114
|
+
line,
|
|
115
|
+
lineTotal: null,
|
|
116
|
+
resolvedPath: resolved,
|
|
117
|
+
verified: false,
|
|
118
|
+
error: 'file_not_found',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
|
|
122
|
+
// multi-GB file is not a line-addressable text target. statSync follows
|
|
123
|
+
// symlinks, so a link to a device still lands here and is rejected.
|
|
124
|
+
let st;
|
|
125
|
+
try {
|
|
126
|
+
st = statSync(resolved);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return {
|
|
130
|
+
file: relFile,
|
|
131
|
+
line,
|
|
132
|
+
lineTotal: null,
|
|
133
|
+
resolvedPath: resolved,
|
|
134
|
+
verified: false,
|
|
135
|
+
error: 'file_not_found',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (!st.isFile() || st.size > MAX_EVIDENCE_BYTES) {
|
|
139
|
+
return {
|
|
140
|
+
file: relFile,
|
|
141
|
+
line,
|
|
142
|
+
lineTotal: null,
|
|
143
|
+
resolvedPath: resolved,
|
|
144
|
+
verified: false,
|
|
145
|
+
error: 'line_out_of_range',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
82
148
|
let raw;
|
|
83
149
|
try {
|
|
84
150
|
raw = readFileSync(resolved);
|
package/dist/git-scope.js
CHANGED
|
@@ -24,14 +24,31 @@ import { execFile } from 'node:child_process';
|
|
|
24
24
|
import { existsSync, statSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
/**
|
|
27
|
-
* Parse `git diff --name-only` stdout into a list of relative paths.
|
|
28
|
-
* Pure
|
|
29
|
-
*
|
|
27
|
+
* Parse `git diff --name-only -z` stdout into a list of relative paths.
|
|
28
|
+
* Pure. NUL-delimited mode is machine-safe (handles any filename); when no
|
|
29
|
+
* NUL is present (callers that did not pass -z) fall back to newline-split
|
|
30
|
+
* with C-style quote/escape unescaping for core.quotePath output.
|
|
30
31
|
*/
|
|
31
32
|
export function parseChangedFiles(stdout) {
|
|
33
|
+
if (stdout.includes('\0')) {
|
|
34
|
+
return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
35
|
+
}
|
|
32
36
|
return stdout
|
|
33
37
|
.split('\n')
|
|
34
|
-
.map((line) =>
|
|
38
|
+
.map((line) => {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
// git core.quotePath wraps paths with special characters in "..."; the
|
|
41
|
+
// content uses C-style escapes (\" \\ \t \n and \ooo octal for non-ASCII).
|
|
42
|
+
const quoted = trimmed.match(/^"(.*)"$/);
|
|
43
|
+
if (!quoted)
|
|
44
|
+
return trimmed;
|
|
45
|
+
return quoted[1]
|
|
46
|
+
.replace(/\\"/g, '"')
|
|
47
|
+
.replace(/\\\\/g, '\\')
|
|
48
|
+
.replace(/\\t/g, '\t')
|
|
49
|
+
.replace(/\\n/g, '\n')
|
|
50
|
+
.replace(/\\([0-7]{3})/g, (_m, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
51
|
+
})
|
|
35
52
|
.filter((line) => line.length > 0);
|
|
36
53
|
}
|
|
37
54
|
/**
|
|
@@ -90,9 +107,21 @@ export function runGit(args, cwd) {
|
|
|
90
107
|
* plan because git is unavailable.
|
|
91
108
|
*/
|
|
92
109
|
export async function resolveChangedFiles(root, targetBranch) {
|
|
93
|
-
|
|
110
|
+
// Option-injection guard: a branch name starting with '-' would be parsed by
|
|
111
|
+
// git as an option (e.g. --output=...), not a ref. Reject it outright.
|
|
112
|
+
if (typeof targetBranch !== 'string' || targetBranch.trim() === '' || targetBranch.startsWith('-')) {
|
|
113
|
+
return {
|
|
114
|
+
scope: 'full',
|
|
115
|
+
changedFiles: [],
|
|
116
|
+
fallbackToFull: true,
|
|
117
|
+
error: `invalid target branch "${String(targetBranch)}"`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
|
|
121
|
+
// non-ASCII), and never confused with option-like content.
|
|
122
|
+
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root);
|
|
94
123
|
if (!ok) {
|
|
95
|
-
const reason = stderr.trim() || `git diff --name-only ${targetBranch} failed`;
|
|
124
|
+
const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`;
|
|
96
125
|
return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason };
|
|
97
126
|
}
|
|
98
127
|
const existing = filterExistingFiles(root, parseChangedFiles(stdout));
|
package/dist/meta-review.js
CHANGED
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* lives here.
|
|
15
15
|
*/
|
|
16
16
|
import { sortFindings } from "./review.js";
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Number of distinct consistency checks performed by `metaReviewReport`.
|
|
19
|
+
* The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
|
|
20
|
+
* SORT_ORDER, CONVERGENCE_SUM, CONVERGENCE_FLAG, ROUND_NUMBER, ROUND_EMPTY,
|
|
21
|
+
* ROUND_GAP.
|
|
22
|
+
*/
|
|
23
|
+
export const META_REVIEW_CHECKS = 10;
|
|
19
24
|
/**
|
|
20
25
|
* How many uncovered scope files are listed in a COVERAGE_GAP hint before the
|
|
21
26
|
* remainder is folded into a "+N more" suffix.
|
|
@@ -146,9 +151,18 @@ export function metaReviewReport(report) {
|
|
|
146
151
|
'which finding nothing new is the expected success signal.');
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
// ROUND_GAP: only flag gaps WITHIN the range of actually-present round
|
|
155
|
+
// numbers. Non-contiguous starts (e.g. a resumed run beginning at round 5)
|
|
156
|
+
// and arbitrary round numbering are supported by the aggregate engine, so
|
|
157
|
+
// missing 1..N prefixes are NOT defects. Checks min..max of present rounds.
|
|
158
|
+
const present = [...seenRounds].sort((a, b) => a - b);
|
|
159
|
+
if (present.length > 0) {
|
|
160
|
+
const min = present[0];
|
|
161
|
+
const max = present[present.length - 1];
|
|
162
|
+
for (let i = min; i <= max; i++) {
|
|
163
|
+
if (!seenRounds.has(i)) {
|
|
164
|
+
add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${present.join(', ')}.`);
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
const passed = issues.length === 0;
|
package/dist/method-scope.js
CHANGED
|
@@ -70,6 +70,8 @@ const SIGNATURE_PATTERNS = [
|
|
|
70
70
|
export function collectMethodSignatures(text) {
|
|
71
71
|
const lines = text.split('\n');
|
|
72
72
|
const out = [];
|
|
73
|
+
// Set lookup instead of scanning the growing array (O(S²) → O(S)).
|
|
74
|
+
const seen = new Set();
|
|
73
75
|
for (let i = 0; i < lines.length; i++) {
|
|
74
76
|
const raw = lines[i];
|
|
75
77
|
const line = i + 1;
|
|
@@ -81,8 +83,10 @@ export function collectMethodSignatures(text) {
|
|
|
81
83
|
if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name))
|
|
82
84
|
continue;
|
|
83
85
|
// Avoid two patterns claiming the same line (e.g. TS method + arrow).
|
|
84
|
-
|
|
86
|
+
const key = `${line}|${name}`;
|
|
87
|
+
if (seen.has(key))
|
|
85
88
|
break;
|
|
89
|
+
seen.add(key);
|
|
86
90
|
out.push({ name, line });
|
|
87
91
|
break;
|
|
88
92
|
}
|
package/dist/review-scope.js
CHANGED
|
@@ -99,22 +99,25 @@ function collectChanged(changedFiles) {
|
|
|
99
99
|
return [...out].sort();
|
|
100
100
|
}
|
|
101
101
|
function collectFull(root) {
|
|
102
|
-
// Deterministic
|
|
103
|
-
//
|
|
102
|
+
// Deterministic iterative walk (explicit stack — unbounded recursion could
|
|
103
|
+
// overflow on pathologically deep trees); a code reviewer never anchors
|
|
104
|
+
// findings to lock files, images, or vendored builds.
|
|
104
105
|
const out = [];
|
|
105
|
-
const
|
|
106
|
+
const stack = [root];
|
|
107
|
+
while (stack.length > 0) {
|
|
108
|
+
const dir = stack.pop();
|
|
106
109
|
let entries;
|
|
107
110
|
try {
|
|
108
111
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
109
112
|
}
|
|
110
113
|
catch {
|
|
111
|
-
|
|
114
|
+
continue;
|
|
112
115
|
}
|
|
113
116
|
for (const entry of entries) {
|
|
114
117
|
const abs = join(dir, entry.name);
|
|
115
118
|
if (entry.isDirectory()) {
|
|
116
119
|
if (!isIgnoredDir(entry.name))
|
|
117
|
-
|
|
120
|
+
stack.push(abs);
|
|
118
121
|
continue;
|
|
119
122
|
}
|
|
120
123
|
if (!entry.isFile())
|
|
@@ -124,13 +127,14 @@ function collectFull(root) {
|
|
|
124
127
|
const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs;
|
|
125
128
|
out.push(rel.split(SEP).join(SEP));
|
|
126
129
|
}
|
|
127
|
-
}
|
|
128
|
-
walk(root);
|
|
130
|
+
}
|
|
129
131
|
return out.sort();
|
|
130
132
|
}
|
|
131
133
|
/** Split `files` into stable batches, keeping directory runs together. */
|
|
132
134
|
export function chunkFiles(files, perChunk) {
|
|
133
|
-
|
|
135
|
+
// Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
|
|
136
|
+
// chunk (current.length >= NaN is never true).
|
|
137
|
+
const size = Number.isFinite(perChunk) && perChunk >= 1 ? perChunk : DEFAULT_SCOPE_CHUNK_SIZE;
|
|
134
138
|
const ordered = [...files].sort();
|
|
135
139
|
const chunks = [];
|
|
136
140
|
let current = [];
|
package/dist/review.js
CHANGED
|
@@ -35,10 +35,12 @@ export function sortFindings(findings) {
|
|
|
35
35
|
const bySeverity = rankA - rankB;
|
|
36
36
|
if (bySeverity !== 0)
|
|
37
37
|
return bySeverity;
|
|
38
|
-
|
|
38
|
+
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
39
|
+
// validation is disabled — String()/Number() keep the comparator total.
|
|
40
|
+
const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''));
|
|
39
41
|
if (byFile !== 0)
|
|
40
42
|
return byFile;
|
|
41
|
-
return (a.line
|
|
43
|
+
return (Number(a.line) || 0) - (Number(b.line) || 0);
|
|
42
44
|
});
|
|
43
45
|
}
|
|
44
46
|
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
@@ -48,9 +50,15 @@ export function normalizeSummary(summary) {
|
|
|
48
50
|
.toLowerCase()
|
|
49
51
|
.replace(/[\s\n\t]+/g, ' ');
|
|
50
52
|
}
|
|
51
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Dedupe key: same file + same dimension + similar summary + explicit line.
|
|
55
|
+
* Including the line keeps two genuine issues with identical wording at
|
|
56
|
+
* different locations from collapsing into one (the line is omitted only when
|
|
57
|
+
* neither side anchors one, i.e. whole-file findings).
|
|
58
|
+
*/
|
|
52
59
|
export function findingKey(f) {
|
|
53
|
-
|
|
60
|
+
const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0;
|
|
61
|
+
return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`;
|
|
54
62
|
}
|
|
55
63
|
/**
|
|
56
64
|
* Remove duplicate findings within a list.
|
|
@@ -109,14 +117,22 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
109
117
|
const firstRoundByKey = new Map();
|
|
110
118
|
const merged = [];
|
|
111
119
|
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
112
|
-
// entries defensively rather than letting `firstRoundByKey` key on NaN/0
|
|
120
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
|
|
121
|
+
// crashing on null / non-array findings.
|
|
122
|
+
// Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
|
|
123
|
+
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
124
|
+
// numbers above the configured cap are clamped to the cap.
|
|
113
125
|
let maxRound = 0;
|
|
126
|
+
const roundCap = Math.max(1, maxReviewRounds);
|
|
114
127
|
for (const round of rounds) {
|
|
128
|
+
if (!round || typeof round !== 'object')
|
|
129
|
+
continue;
|
|
115
130
|
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
|
|
116
131
|
continue;
|
|
132
|
+
const findings = Array.isArray(round.findings) ? round.findings : [];
|
|
117
133
|
if (round.round > maxRound)
|
|
118
134
|
maxRound = round.round;
|
|
119
|
-
for (const f of
|
|
135
|
+
for (const f of findings) {
|
|
120
136
|
const key = findingKey(f);
|
|
121
137
|
if (seen.has(key))
|
|
122
138
|
continue;
|
|
@@ -125,8 +141,10 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
125
141
|
merged.push(f);
|
|
126
142
|
}
|
|
127
143
|
}
|
|
144
|
+
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
145
|
+
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2));
|
|
128
146
|
const findingsByRound = [];
|
|
129
|
-
for (let r = 1; r <=
|
|
147
|
+
for (let r = 1; r <= effectiveMax; r++) {
|
|
130
148
|
let count = 0;
|
|
131
149
|
for (const key of firstRoundByKey.keys()) {
|
|
132
150
|
if (firstRoundByKey.get(key) === r)
|
|
@@ -143,11 +161,20 @@ export function computeConvergence(rounds, maxReviewRounds) {
|
|
|
143
161
|
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds);
|
|
144
162
|
const totalRounds = rounds.length;
|
|
145
163
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
146
|
-
// r-1),
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
164
|
+
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
165
|
+
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
166
|
+
// may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
|
|
167
|
+
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
168
|
+
// actually allocated.
|
|
169
|
+
let lastRound = 0;
|
|
170
|
+
for (const round of rounds) {
|
|
171
|
+
if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
|
|
172
|
+
continue;
|
|
173
|
+
if (round.round > lastRound)
|
|
174
|
+
lastRound = round.round;
|
|
175
|
+
}
|
|
176
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
177
|
+
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
151
178
|
const converged = totalRounds > 0 && lastRoundCount === 0;
|
|
152
179
|
return {
|
|
153
180
|
totalRounds,
|
|
@@ -193,8 +220,9 @@ function summarize(findings) {
|
|
|
193
220
|
export function buildReviewReport(input) {
|
|
194
221
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
195
222
|
const filteredRounds = input.rounds.map((r) => ({
|
|
196
|
-
round: r.round,
|
|
197
|
-
findings: filterKnownIntentional(r.findings, input.knownIntentional),
|
|
223
|
+
round: typeof r?.round === 'number' ? r.round : 0,
|
|
224
|
+
findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
|
|
225
|
+
readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
|
|
198
226
|
}));
|
|
199
227
|
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
200
228
|
const { findings, findingsByRound } = aggregateRounds(filteredRounds, input.maxReviewRounds);
|
|
@@ -221,6 +249,9 @@ export function buildReviewReport(input) {
|
|
|
221
249
|
maxReviewRounds: input.maxReviewRounds,
|
|
222
250
|
rounds: filteredRounds,
|
|
223
251
|
findings: sorted,
|
|
252
|
+
// Aggregate of every round's self-reported reads, so the meta-review
|
|
253
|
+
// coverage gate can compare against the assigned inventory.
|
|
254
|
+
readFiles: [].concat(...filteredRounds.map((r) => r.readFiles ?? [])),
|
|
224
255
|
convergence: {
|
|
225
256
|
totalRounds: filteredRounds.length,
|
|
226
257
|
findingsByRound,
|
|
@@ -394,8 +425,8 @@ export function validateFindingsSchema(input) {
|
|
|
394
425
|
*/
|
|
395
426
|
export function validateRoundsSchema(rounds) {
|
|
396
427
|
return rounds.map((r) => {
|
|
397
|
-
const issues = validateFindingsSchema(r.findings);
|
|
398
|
-
return { round: r.round, valid: issues.length === 0, issues };
|
|
428
|
+
const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : []);
|
|
429
|
+
return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues };
|
|
399
430
|
});
|
|
400
431
|
}
|
|
401
432
|
/**
|
|
@@ -411,19 +442,25 @@ export function validateRoundsSchema(rounds) {
|
|
|
411
442
|
*/
|
|
412
443
|
export function sanitizeRounds(rounds, schemaValidation) {
|
|
413
444
|
return rounds.map((r, i) => {
|
|
445
|
+
// Defensive: malformed rounds must never crash the deterministic core.
|
|
446
|
+
const findings = Array.isArray(r?.findings) ? r.findings : [];
|
|
447
|
+
const roundNo = typeof r?.round === 'number' ? r.round : 0;
|
|
448
|
+
const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : [];
|
|
414
449
|
if (schemaValidation) {
|
|
415
450
|
const issues = schemaValidation[i]?.issues ?? [];
|
|
416
451
|
if (issues.some((iss) => iss.index === -1))
|
|
417
|
-
return { round:
|
|
452
|
+
return { round: roundNo, findings: [], readFiles };
|
|
418
453
|
const bad = new Set(issues.map((iss) => iss.index));
|
|
419
454
|
return {
|
|
420
|
-
round:
|
|
421
|
-
findings:
|
|
455
|
+
round: roundNo,
|
|
456
|
+
findings: findings.filter((_, fi) => !bad.has(fi)),
|
|
457
|
+
readFiles,
|
|
422
458
|
};
|
|
423
459
|
}
|
|
424
460
|
return {
|
|
425
|
-
round:
|
|
426
|
-
findings:
|
|
461
|
+
round: roundNo,
|
|
462
|
+
findings: findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
|
|
463
|
+
readFiles,
|
|
427
464
|
};
|
|
428
465
|
});
|
|
429
466
|
}
|
|
@@ -435,6 +472,9 @@ export function sanitizeRounds(rounds, schemaValidation) {
|
|
|
435
472
|
export function reviewerTaskPrompt(input) {
|
|
436
473
|
const parts = [];
|
|
437
474
|
parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
|
|
475
|
+
if (input.focus) {
|
|
476
|
+
parts.push(`FOCUS: ${input.focus}`);
|
|
477
|
+
}
|
|
438
478
|
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
439
479
|
parts.push('COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
440
480
|
'assigned to review. You MUST open EVERY file in this inventory with ' +
|
|
@@ -465,9 +505,9 @@ export function reviewerTaskPrompt(input) {
|
|
|
465
505
|
'disqualifying failure, and fabricated line numbers are treated as ' +
|
|
466
506
|
'poisoned evidence. Anchor every finding to real code.');
|
|
467
507
|
parts.push(`Return a JSON object: {"findings": [...], "readFiles": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
468
|
-
'line (
|
|
469
|
-
'
|
|
470
|
-
'
|
|
508
|
+
'line (optional; the exact line you READ for a line-targeted issue; ' +
|
|
509
|
+
'0 or omitted for whole-file/module-level issues), ' +
|
|
510
|
+
'severity (critical/high/medium/low), summary (one line), ' +
|
|
471
511
|
'failure_scenario (how/when it fails, backed by the code you actually ' +
|
|
472
512
|
'read), suggested_fix (the concrete fix), ' +
|
|
473
513
|
`is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`, `Write summaries and details in ${input.outputLanguage}.`);
|
|
@@ -508,6 +548,17 @@ export function buildReviewPlan(input) {
|
|
|
508
548
|
batches = [undefined];
|
|
509
549
|
}
|
|
510
550
|
const dimensionTasks = [];
|
|
551
|
+
// personalization.dimension_focus: [{dimension, focus}] — appended to the
|
|
552
|
+
// matching dimension's reviewer prompt.
|
|
553
|
+
const focusMap = new Map();
|
|
554
|
+
const pf = input.config.personalization;
|
|
555
|
+
if (pf && Array.isArray(pf.dimension_focus)) {
|
|
556
|
+
for (const entry of pf.dimension_focus) {
|
|
557
|
+
if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
|
|
558
|
+
focusMap.set(entry.dimension, entry.focus);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
511
562
|
for (const d of dimensions) {
|
|
512
563
|
batches.forEach((batch, index) => {
|
|
513
564
|
const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`;
|
|
@@ -523,6 +574,7 @@ export function buildReviewPlan(input) {
|
|
|
523
574
|
maxLines,
|
|
524
575
|
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
525
576
|
scopeFiles: batch,
|
|
577
|
+
focus: focusMap.get(d),
|
|
526
578
|
}),
|
|
527
579
|
findingsSchema: findingsSchema(),
|
|
528
580
|
});
|
package/dist/skill-prompt.js
CHANGED
|
@@ -88,14 +88,28 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
88
88
|
const nudge = retries > 0
|
|
89
89
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
90
90
|
: ''
|
|
91
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
92
|
+
// Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
|
|
93
|
+
// with the assigned file inventory, EVIDENCE RULE, output language) and
|
|
94
|
+
// append the round-specific context — the reviewers must receive the
|
|
95
|
+
// file inventory or the coverage machinery has nothing to enforce.
|
|
96
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
97
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
98
|
+
? meta.reviewerPrompt
|
|
99
|
+
: 'Review dimension "' + dim + '".'
|
|
100
|
+
const extra =
|
|
101
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
102
|
+
'\\n Already-known findings (do NOT re-report): ' +
|
|
103
|
+
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
104
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
105
|
+
}))
|
|
106
|
+
const thisRound = {
|
|
107
|
+
round: r,
|
|
108
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
109
|
+
// readFiles are threaded through so the aggregate/meta-review coverage
|
|
110
|
+
// gate can compare self-reported reads against the assigned inventory.
|
|
111
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
112
|
+
}
|
|
99
113
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
100
114
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
101
115
|
agg = await agent(
|
|
@@ -228,6 +242,11 @@ let failedCommands = []
|
|
|
228
242
|
phase('loop')
|
|
229
243
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
230
244
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
245
|
+
// Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
|
|
246
|
+
await agent(
|
|
247
|
+
'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
|
|
248
|
+
Object.assign({ label: 'log:start:r' + r }, backend)
|
|
249
|
+
)
|
|
231
250
|
let agg = null
|
|
232
251
|
let schemaInvalid = false
|
|
233
252
|
let retries = 0
|
|
@@ -236,13 +255,24 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
236
255
|
const nudge = retries > 0
|
|
237
256
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
238
257
|
: ''
|
|
239
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
258
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
259
|
+
// Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
|
|
260
|
+
// the assigned file inventory, EVIDENCE RULE, output language) plus the
|
|
261
|
+
// round-specific context.
|
|
262
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
263
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
264
|
+
? meta.reviewerPrompt
|
|
265
|
+
: 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
|
|
266
|
+
const extra =
|
|
267
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
268
|
+
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
269
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
270
|
+
}))
|
|
271
|
+
const thisRound = {
|
|
272
|
+
round: r,
|
|
273
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
274
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
275
|
+
}
|
|
246
276
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
247
277
|
|
|
248
278
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|