iterate-plugin 3.5.7 → 3.6.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 +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/dist/index.js
CHANGED
|
@@ -47,34 +47,60 @@ import { ITERATE_SKILL_PROMPT } from "./skill-prompt.js";
|
|
|
47
47
|
export const name = 'iterate-plugin';
|
|
48
48
|
export const inject = ['tools', 'systemPrompt'];
|
|
49
49
|
export function apply(ctx) {
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
registerFixTool(ctx);
|
|
58
|
-
registerDiffTool(ctx);
|
|
59
|
-
registerRollbackTool(ctx);
|
|
60
|
-
registerCheckpointTool(ctx);
|
|
61
|
-
registerStatusTool(ctx);
|
|
62
|
-
registerHistoryTool(ctx);
|
|
63
|
-
registerPruneTool(ctx);
|
|
64
|
-
registerTranscriptTool(ctx);
|
|
65
|
-
// v3.0: Quality Command Center tools
|
|
66
|
-
registerExperienceBankTool(ctx);
|
|
67
|
-
registerQualityGateTool(ctx);
|
|
68
|
-
registerDefenseEventsTool(ctx);
|
|
69
|
-
// 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
|
|
70
|
-
// and the live reviewer-activity feed onto tools/result.
|
|
50
|
+
// 0. Wire the SAFETY hooks BEFORE any tool is registered.
|
|
51
|
+
// Registration is a list of 17 independent calls; if one of them threw
|
|
52
|
+
// (duplicate name, malformed definition, hostile ctx) while the gate was
|
|
53
|
+
// registered last, the tools registered up to that point would keep
|
|
54
|
+
// running with NO `tools/pre-execute` approval gate — a fail-open hole
|
|
55
|
+
// created by load order. Registering the gate first means a partial load
|
|
56
|
+
// can only ever err on the side of MORE gating, never less.
|
|
71
57
|
registerSessionHooks(ctx);
|
|
72
58
|
registerLiveCapture(ctx);
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
59
|
+
// 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
|
|
60
|
+
// isolated: one failing tool must not take down the other 16, the gate
|
|
61
|
+
// above, or the prompt below (a partial plugin that still gates and
|
|
62
|
+
// teaches the workflow beats an aborted `apply`). Failures are logged,
|
|
63
|
+
// never swallowed silently.
|
|
64
|
+
const toolRegistrations = [
|
|
65
|
+
['iterate_config', () => registerConfigTool(ctx)],
|
|
66
|
+
['iterate_validate', () => registerValidateTool(ctx)],
|
|
67
|
+
['iterate_decision_log', () => registerDecisionLogTool(ctx)],
|
|
68
|
+
['iterate_context', () => registerContextTool(ctx)],
|
|
69
|
+
['iterate_review', () => registerReviewTool(ctx)],
|
|
70
|
+
['iterate_triage', () => registerTriageTool(ctx)],
|
|
71
|
+
['iterate_fix', () => registerFixTool(ctx)],
|
|
72
|
+
['iterate_diff', () => registerDiffTool(ctx)],
|
|
73
|
+
['iterate_rollback', () => registerRollbackTool(ctx)],
|
|
74
|
+
['iterate_checkpoint', () => registerCheckpointTool(ctx)],
|
|
75
|
+
['iterate_status', () => registerStatusTool(ctx)],
|
|
76
|
+
['iterate_history', () => registerHistoryTool(ctx)],
|
|
77
|
+
['iterate_prune', () => registerPruneTool(ctx)],
|
|
78
|
+
['iterate_transcript', () => registerTranscriptTool(ctx)],
|
|
79
|
+
// v3.0: Quality Command Center tools
|
|
80
|
+
['iterate_experience', () => registerExperienceBankTool(ctx)],
|
|
81
|
+
['iterate_quality_gate', () => registerQualityGateTool(ctx)],
|
|
82
|
+
['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
|
|
83
|
+
];
|
|
84
|
+
for (const [toolName, register] of toolRegistrations) {
|
|
85
|
+
try {
|
|
86
|
+
register();
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.warn(`[iterate] failed to register ${toolName}; continuing.`, err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 2. Inject the iterate skill prompt as a system prompt section.
|
|
93
|
+
// This teaches the model how to write iterate workflow scripts using the
|
|
94
|
+
// tools. Guarded for the same reason as above: a prompt-injection failure
|
|
95
|
+
// must not undo a load that already wired the gate and the tools.
|
|
96
|
+
try {
|
|
97
|
+
ctx.systemPrompt.section({
|
|
98
|
+
name: 'iterate-skill',
|
|
99
|
+
order: 100,
|
|
100
|
+
text: ITERATE_SKILL_PROMPT,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.warn('[iterate] failed to inject the skill prompt section; continuing.', err);
|
|
105
|
+
}
|
|
80
106
|
}
|
package/dist/jobs.js
CHANGED
|
@@ -11,10 +11,19 @@
|
|
|
11
11
|
* - `ctx.jobs` only exists when the dsh host loaded a job registry + a
|
|
12
12
|
* controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
|
|
13
13
|
* equivalent). When it is missing, `start()` throws or is absent — we
|
|
14
|
-
* detect both
|
|
15
|
-
* pure enhancement and never breaks
|
|
14
|
+
* detect both (including a throwing `start` GETTER) and fall through to
|
|
15
|
+
* plain execution, so the Job Panel is a pure enhancement and never breaks
|
|
16
|
+
* a tool call.
|
|
16
17
|
* - The registry is memory-only and panel rows are read-only (no progress
|
|
17
18
|
* updates), so these jobs are completion records, not control channels.
|
|
19
|
+
* - Cancellation is honest, not theatrical. `JobHooks.cancel` is REQUIRED by
|
|
20
|
+
* `@deepseek-ai/dsh-jobs` (the registry calls it on kill/teardown), but the
|
|
21
|
+
* wrapped tool `fn` exposes no abort channel — a panel kill cannot stop it.
|
|
22
|
+
* So `cancel` records the REQUEST (synchronous + idempotent, first reason
|
|
23
|
+
* wins), best-effort surfaces it on the record's progress line, and lets the
|
|
24
|
+
* job settle with `fn`'s TRUE outcome, with the request annotated in
|
|
25
|
+
* `detail`. Settling the record `killed` while `fn()` keeps running would
|
|
26
|
+
* show a stopped job that is still running — a lie in the Job Panel.
|
|
18
27
|
*/
|
|
19
28
|
/**
|
|
20
29
|
* Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
|
|
@@ -30,11 +39,25 @@ function safeJobs(ctx) {
|
|
|
30
39
|
return undefined;
|
|
31
40
|
}
|
|
32
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Read `jobs.start` defensively: the `start` GETTER itself can throw on a
|
|
44
|
+
* hostile/proxied registry, and that read must degrade the same way as a
|
|
45
|
+
* missing registry — never escape `runWithJob`.
|
|
46
|
+
*/
|
|
47
|
+
function safeStart(jobs) {
|
|
48
|
+
try {
|
|
49
|
+
return jobs?.start;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
33
55
|
/**
|
|
34
56
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
35
|
-
* with the execution's outcome
|
|
36
|
-
*
|
|
37
|
-
*
|
|
57
|
+
* with the execution's ACTUAL outcome (a requested cancel never falsifies it
|
|
58
|
+
* — see the header). When the host exposes no job registry (or refuses the
|
|
59
|
+
* start), `fn` runs untouched and `null` is returned — the Job Panel is an
|
|
60
|
+
* enhancement, never a dependency.
|
|
38
61
|
*
|
|
39
62
|
* @param ctx the dsh plugin context (may or may not expose `jobs`).
|
|
40
63
|
* @param kind iterate job kind registered via {@link IterateJobKind}.
|
|
@@ -44,21 +67,43 @@ function safeJobs(ctx) {
|
|
|
44
67
|
*/
|
|
45
68
|
export async function runWithJob(ctx, kind, label, fn) {
|
|
46
69
|
const jobs = safeJobs(ctx);
|
|
47
|
-
|
|
70
|
+
const start = safeStart(jobs);
|
|
71
|
+
if (!jobs || !start || typeof start !== 'function') {
|
|
48
72
|
return { result: await fn(), jobId: null };
|
|
49
73
|
}
|
|
50
74
|
let settle;
|
|
51
75
|
const done = new Promise((resolve) => {
|
|
52
76
|
settle = resolve;
|
|
53
77
|
});
|
|
78
|
+
// Honest cancel bookkeeping: the request is recorded (first reason wins),
|
|
79
|
+
// but `done` is NOT settled here — `fn()` is still running and the panel
|
|
80
|
+
// must not claim otherwise. The settlement below carries `fn`'s real
|
|
81
|
+
// outcome, annotated with the request.
|
|
82
|
+
let cancelRequested = null;
|
|
83
|
+
const annotate = (detail) => cancelRequested === null
|
|
84
|
+
? detail
|
|
85
|
+
: `${detail} (cancel requested: ${cancelRequested} — the wrapped tool call has no abort channel and ran to completion)`;
|
|
54
86
|
let jobId = null;
|
|
55
87
|
try {
|
|
56
|
-
jobId =
|
|
88
|
+
jobId = start.call(jobs, {
|
|
57
89
|
kind,
|
|
58
90
|
label,
|
|
59
|
-
run: () => ({
|
|
91
|
+
run: (job) => ({
|
|
60
92
|
done,
|
|
61
|
-
cancel: () =>
|
|
93
|
+
cancel: (reason) => {
|
|
94
|
+
if (cancelRequested !== null)
|
|
95
|
+
return; // idempotent — first reason wins
|
|
96
|
+
cancelRequested = reason ?? 'cancel requested';
|
|
97
|
+
// Best-effort panel hint while the record is stopping; the registry
|
|
98
|
+
// drops writes it cannot apply, and a throwing update must not
|
|
99
|
+
// propagate out of the registry's kill path.
|
|
100
|
+
try {
|
|
101
|
+
job?.updateProgress?.('cancel requested — waiting for the tool call to finish (no abort channel)');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* progress hint is cosmetic */
|
|
105
|
+
}
|
|
106
|
+
},
|
|
62
107
|
}),
|
|
63
108
|
});
|
|
64
109
|
}
|
|
@@ -69,13 +114,13 @@ export async function runWithJob(ctx, kind, label, fn) {
|
|
|
69
114
|
}
|
|
70
115
|
try {
|
|
71
116
|
const result = await fn();
|
|
72
|
-
settle({ status: 'completed', detail: 'done' });
|
|
117
|
+
settle({ status: 'completed', detail: annotate('done') });
|
|
73
118
|
return { result, jobId };
|
|
74
119
|
}
|
|
75
120
|
catch (error) {
|
|
76
121
|
settle({
|
|
77
122
|
status: 'failed',
|
|
78
|
-
detail: error instanceof Error ? error.message : 'execution failed',
|
|
123
|
+
detail: annotate(error instanceof Error ? error.message : 'execution failed'),
|
|
79
124
|
});
|
|
80
125
|
throw error;
|
|
81
126
|
}
|
package/dist/live.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* Safety:
|
|
19
19
|
* - Read-only observer: never mutates source files; writes only the NDJSON
|
|
20
20
|
* live file under `.iterate/`.
|
|
21
|
+
* - Privacy switch: when the project's effective config sets
|
|
22
|
+
* `observatory.capture: false`, nothing is persisted (the file stays
|
|
23
|
+
* absent/unchanged). An unreadable config keeps capture ON (defaults).
|
|
21
24
|
* - The live file is byte-capped (rewrite to last N lines when it grows too
|
|
22
25
|
* large) so it can never grow unbounded.
|
|
23
26
|
* - Any capture failure is swallowed (fire-and-forget) so it can never block
|
|
@@ -27,7 +30,7 @@ import { mkdir, readFile, stat, appendFile } from 'node:fs/promises';
|
|
|
27
30
|
import { existsSync } from 'node:fs';
|
|
28
31
|
import { writeTextAtomicAsync } from "./atomic-fs.js";
|
|
29
32
|
import { join } from 'node:path';
|
|
30
|
-
import { resolveProjectRoot } from "./config-loader.js";
|
|
33
|
+
import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
|
|
31
34
|
/** Keep at most this many live activity entries. */
|
|
32
35
|
export const LIVE_MAX_ENTRIES = 300;
|
|
33
36
|
/** Rewrite the live file when its byte size exceeds this threshold. */
|
|
@@ -51,14 +54,41 @@ function enqueueLiveWrite(task) {
|
|
|
51
54
|
liveWriteQueue = next.catch(() => { });
|
|
52
55
|
return next;
|
|
53
56
|
}
|
|
54
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* True when the project's effective config still allows live capture.
|
|
59
|
+
* `observatory.capture: false` opts the project out — nothing is persisted.
|
|
60
|
+
* Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
|
|
61
|
+
* an unreadable config (it falls back to defaults, capture on), and any
|
|
62
|
+
* unexpected loader error here also degrades to capture ON rather than
|
|
63
|
+
* silently eating activity the operator expected to see.
|
|
64
|
+
*/
|
|
65
|
+
function captureEnabled(projectRoot) {
|
|
66
|
+
try {
|
|
67
|
+
return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Append one activity record to the project's live feed (byte-capped).
|
|
75
|
+
* Never rejects: every failure (capture disabled, `.iterate` existing as a
|
|
76
|
+
* regular file, locked/unwritable feed) is swallowed — the header promises
|
|
77
|
+
* this path can never crash or block a tool call (`void appendLive(...)`
|
|
78
|
+
* would otherwise turn a rejection into an unhandled rejection).
|
|
79
|
+
*/
|
|
55
80
|
export function appendLive(projectRoot, entry) {
|
|
56
81
|
return enqueueLiveWrite(async () => {
|
|
57
|
-
const file = liveFilePath(projectRoot);
|
|
58
|
-
const line = JSON.stringify(entry) + '\n';
|
|
59
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
60
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
61
82
|
try {
|
|
83
|
+
// Privacy: capture off → do not touch the feed at all.
|
|
84
|
+
if (!captureEnabled(projectRoot))
|
|
85
|
+
return;
|
|
86
|
+
const file = liveFilePath(projectRoot);
|
|
87
|
+
const line = JSON.stringify(entry) + '\n';
|
|
88
|
+
// mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
|
|
89
|
+
// dir unwritable) — it must reject neither the caller nor the queue.
|
|
90
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
91
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
62
92
|
const st = await stat(file).catch(() => null);
|
|
63
93
|
if (st && st.size > LIVE_MAX_BYTES) {
|
|
64
94
|
const raw = await readFile(file, 'utf-8');
|
|
@@ -167,12 +197,21 @@ export async function readLive(projectRoot) {
|
|
|
167
197
|
*/
|
|
168
198
|
export function registerLiveCapture(ctx) {
|
|
169
199
|
ctx.on('tools/result', (exec) => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
200
|
+
// Defensive: a hostile/proxied exec whose getters throw (`agent`,
|
|
201
|
+
// `name`, `arguments`) must not abort the observer — unlike the guarded
|
|
202
|
+
// siblings below, an unguarded throw here would lose the record and
|
|
203
|
+
// surface an error from a read-only hook. Degrade to a no-op.
|
|
204
|
+
try {
|
|
205
|
+
const root = projectRootOf(exec);
|
|
206
|
+
if (!root)
|
|
207
|
+
return;
|
|
208
|
+
const entry = classifyTool(exec.name, exec.arguments, root);
|
|
209
|
+
if (!entry)
|
|
210
|
+
return;
|
|
211
|
+
void appendLive(root, entry);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// An observer must never throw.
|
|
215
|
+
}
|
|
177
216
|
});
|
|
178
217
|
}
|
package/dist/meta-review.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* the actual subagent-driven meta-review critique; all deterministic math
|
|
14
14
|
* lives here.
|
|
15
15
|
*/
|
|
16
|
-
import { sortFindings } from "./review.js";
|
|
16
|
+
import { isValidRoundNumber, sortFindings } from "./review.js";
|
|
17
17
|
/**
|
|
18
18
|
* Number of distinct consistency checks performed by `metaReviewReport`.
|
|
19
19
|
* The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
|
|
@@ -26,6 +26,50 @@ export const META_REVIEW_CHECKS = 10;
|
|
|
26
26
|
* remainder is folded into a "+N more" suffix.
|
|
27
27
|
*/
|
|
28
28
|
export const COVERAGE_LIST_TRUNCATE = 10;
|
|
29
|
+
/**
|
|
30
|
+
* Hard sanity ceiling on a reported round number.
|
|
31
|
+
*
|
|
32
|
+
* `rounds[].round` is model/JSON-authored and only needs to be a number to
|
|
33
|
+
* reach this audit. Round-gap detection enumerates `min..max` of the PRESENT
|
|
34
|
+
* round numbers, so an absurd value (rounds `{1, 1e15}` or a round of
|
|
35
|
+
* `Infinity`) used to turn a linear check into an effectively-infinite loop —
|
|
36
|
+
* a trivial CPU/memory denial of service (and one ROUND_GAP issue per missing
|
|
37
|
+
* round). Anything above this ceiling is rejected as an invalid round
|
|
38
|
+
* (ROUND_NUMBER) and excluded from gap enumeration instead of being walked.
|
|
39
|
+
* Generously above any real run: `config.max_rounds` is capped at 100.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_REVIEW_ROUND_NUMBER = 1000;
|
|
42
|
+
/**
|
|
43
|
+
* Max individual ROUND_GAP issues emitted per audit. The remainder is folded
|
|
44
|
+
* into a single "+N more" issue so a wide-but-legal gap can never flood the
|
|
45
|
+
* issue list (each issue carries a detail string).
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_ROUND_GAP_REPORTS = 50;
|
|
48
|
+
/**
|
|
49
|
+
* Hard bound on the round-gap enumeration span. Below
|
|
50
|
+
* MAX_REVIEW_ROUND_NUMBER on purpose: even a legal range (e.g. round 1 plus
|
|
51
|
+
* round 1000) is too wide for per-number gap reporting to mean anything, so
|
|
52
|
+
* it collapses to ONE "range too wide" issue instead of ~1000 iterations and
|
|
53
|
+
* ~1000 issues. Together with the round-number ceiling this makes the
|
|
54
|
+
* enumeration O(MAX_ROUND_GAP_SPAN) no matter what reaches it.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_ROUND_GAP_SPAN = 500;
|
|
57
|
+
/** Bounded, non-throwing list preview for issue detail strings. */
|
|
58
|
+
function previewList(values, max = 20) {
|
|
59
|
+
const shown = values
|
|
60
|
+
.slice(0, max)
|
|
61
|
+
.map((v) => (typeof v === 'number' ? String(v) : typeof v === 'string' ? v : '[object]'))
|
|
62
|
+
.join(', ');
|
|
63
|
+
return values.length > max ? `${shown}, … (+${values.length - max} more)` : shown;
|
|
64
|
+
}
|
|
65
|
+
/** Bounded, non-throwing object preview (a hostile round may be circular). */
|
|
66
|
+
function previewRound(r) {
|
|
67
|
+
if (r === null || typeof r !== 'object')
|
|
68
|
+
return String(r);
|
|
69
|
+
const roundNo = r.round;
|
|
70
|
+
const findings = r.findings;
|
|
71
|
+
return `{round: ${String(roundNo)}, findings: ${Array.isArray(findings) ? `array(${findings.length})` : typeof findings}}`;
|
|
72
|
+
}
|
|
29
73
|
/**
|
|
30
74
|
* Audit a ReviewReport for internal consistency.
|
|
31
75
|
*
|
|
@@ -79,7 +123,9 @@ export function metaReviewReport(report) {
|
|
|
79
123
|
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
80
124
|
for (const f of findings) {
|
|
81
125
|
const s = f?.severity;
|
|
82
|
-
|
|
126
|
+
// hasOwn, not `in`: `'__proto__' in sevCounts` is true via inheritance,
|
|
127
|
+
// and an out-of-spec severity must simply not be bucketed.
|
|
128
|
+
if (s && Object.hasOwn(sevCounts, s))
|
|
83
129
|
sevCounts[s]++;
|
|
84
130
|
}
|
|
85
131
|
const bucketSum = sevCounts.critical + sevCounts.high + sevCounts.medium + sevCounts.low;
|
|
@@ -116,18 +162,23 @@ export function metaReviewReport(report) {
|
|
|
116
162
|
: [];
|
|
117
163
|
const convSum = findingsByRound.reduce((a, b) => a + Number(b) || 0, 0);
|
|
118
164
|
if (convSum !== total) {
|
|
119
|
-
add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound ${
|
|
165
|
+
add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound [${previewList(findingsByRound)}] sums to ${convSum}, ` +
|
|
120
166
|
`but totalFindings is ${total}.`);
|
|
121
167
|
}
|
|
122
168
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
123
|
-
// r-1), so the "last round" is the
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
169
|
+
// r-1), so the "last round" is the HIGHEST VALID round number, not the
|
|
170
|
+
// array's last element (buildReviewReport sorts its round list, but this
|
|
171
|
+
// audit runs on arbitrary JSON, and a malformed trailing round — `1.5`,
|
|
172
|
+
// `Infinity` — must not be used as an index: Math.min(1.5, len) - 1 is
|
|
173
|
+
// fractional and reads `undefined` → 0 new → a false CONVERGENCE_FLAG).
|
|
127
174
|
const reportRounds = Array.isArray(report.rounds) ? report.rounds : [];
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
175
|
+
let lastRecordedRound = null;
|
|
176
|
+
for (const r of reportRounds) {
|
|
177
|
+
if (!r || typeof r !== 'object' || !isValidRoundNumber(r.round))
|
|
178
|
+
continue;
|
|
179
|
+
if (lastRecordedRound === null || r.round > lastRecordedRound)
|
|
180
|
+
lastRecordedRound = r.round;
|
|
181
|
+
}
|
|
131
182
|
const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
|
|
132
183
|
// Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
|
|
133
184
|
// reported round number may exceed the array length — read the SAME
|
|
@@ -144,8 +195,15 @@ export function metaReviewReport(report) {
|
|
|
144
195
|
const rounds = Array.isArray(report.rounds) ? report.rounds : [];
|
|
145
196
|
const seenRounds = new Set();
|
|
146
197
|
for (const [index, r] of rounds.entries()) {
|
|
147
|
-
|
|
148
|
-
|
|
198
|
+
// Reject everything the deterministic core would refuse: a missing,
|
|
199
|
+
// non-integer, non-finite or absurdly large round number. This is also
|
|
200
|
+
// the DoS guard for ROUND_GAP below — a round of `Infinity`/1e15 would
|
|
201
|
+
// otherwise be treated as a legitimate endpoint of the enumeration range.
|
|
202
|
+
if (!r ||
|
|
203
|
+
typeof r !== 'object' ||
|
|
204
|
+
!isValidRoundNumber(r.round) ||
|
|
205
|
+
r.round > MAX_REVIEW_ROUND_NUMBER) {
|
|
206
|
+
add('ROUND_NUMBER', 'medium', 'A round has a missing, non-integer or out-of-range round number', `round: ${previewRound(r)} (round numbers must be integers in 1..${MAX_REVIEW_ROUND_NUMBER})`);
|
|
149
207
|
continue;
|
|
150
208
|
}
|
|
151
209
|
seenRounds.add(r.round);
|
|
@@ -163,9 +221,30 @@ export function metaReviewReport(report) {
|
|
|
163
221
|
if (present.length > 0) {
|
|
164
222
|
const min = present[0];
|
|
165
223
|
const max = present[present.length - 1];
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
224
|
+
const span = max - min + 1;
|
|
225
|
+
const presentPreview = previewList(present, 20);
|
|
226
|
+
// Hard bound on the enumeration: `min`/`max` come from untrusted round
|
|
227
|
+
// numbers, so an unguarded `for (i = min; i <= max; i++)` over
|
|
228
|
+
// {1, 1e15} is an effectively-infinite loop (CPU DoS) that would also
|
|
229
|
+
// push ~1e15 issues. Rounds above MAX_REVIEW_ROUND_NUMBER are rejected
|
|
230
|
+
// above, which keeps `span` bounded on its own; this guard stays as the
|
|
231
|
+
// belt-and-braces backstop so the walk is O(MAX_ROUND_GAP_SPAN) no
|
|
232
|
+
// matter what reaches it.
|
|
233
|
+
if (span > MAX_ROUND_GAP_SPAN) {
|
|
234
|
+
add('ROUND_GAP', 'medium', `Round sequence ${min}..${max} is too wide to audit for gaps`, `${span} round numbers spanned; only rounds present were accepted: ${presentPreview}.`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const missing = [];
|
|
238
|
+
for (let i = min; i <= max; i++) {
|
|
239
|
+
if (!seenRounds.has(i))
|
|
240
|
+
missing.push(i);
|
|
241
|
+
}
|
|
242
|
+
for (const m of missing.slice(0, MAX_ROUND_GAP_REPORTS)) {
|
|
243
|
+
add('ROUND_GAP', 'medium', `Round ${m} is missing from the round sequence`, `rounds present: ${presentPreview}.`);
|
|
244
|
+
}
|
|
245
|
+
if (missing.length > MAX_ROUND_GAP_REPORTS) {
|
|
246
|
+
add('ROUND_GAP', 'medium', `${missing.length - MAX_ROUND_GAP_REPORTS} more rounds are missing (list capped)`, `${missing.length} of the ${span} numbers in ${min}..${max} are absent; only the first ` +
|
|
247
|
+
`${MAX_ROUND_GAP_REPORTS} are listed individually. rounds present: ${presentPreview}.`);
|
|
169
248
|
}
|
|
170
249
|
}
|
|
171
250
|
}
|
|
@@ -177,6 +256,35 @@ export function metaReviewReport(report) {
|
|
|
177
256
|
issues,
|
|
178
257
|
};
|
|
179
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Human-readable reason for a failed evidence attestation.
|
|
261
|
+
*
|
|
262
|
+
* `file_too_large` / `binary_file` are DISTINCT from `line_out_of_range` (see
|
|
263
|
+
* evidence.ts): the file EXISTS but cannot be line-addressed, so saying
|
|
264
|
+
* "does not exist" would send the reader looking for a missing file.
|
|
265
|
+
*/
|
|
266
|
+
function describeEvidenceViolation(v) {
|
|
267
|
+
switch (v.error) {
|
|
268
|
+
case 'file_too_large':
|
|
269
|
+
return `${v.file} is too large to line-address (over the evidence size cap)`;
|
|
270
|
+
case 'binary_file':
|
|
271
|
+
return `${v.file} is a binary (NUL-containing) file that cannot be line-addressed`;
|
|
272
|
+
case 'line_out_of_range':
|
|
273
|
+
return v.lineTotal !== undefined && v.lineTotal !== null
|
|
274
|
+
? `${v.line} is beyond this file's ${v.lineTotal} lines`
|
|
275
|
+
: `${v.file} is not a regular line-addressable file (directory/device/FIFO)`;
|
|
276
|
+
default:
|
|
277
|
+
return `${v.file} does not exist at all (verifiable read required)`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Headline for a failed evidence attestation (matches the detail above). */
|
|
281
|
+
function evidenceViolationSummary(v) {
|
|
282
|
+
const unaddressable = v.error === 'file_too_large' || v.error === 'binary_file';
|
|
283
|
+
const head = unaddressable
|
|
284
|
+
? 'Finding cannot be anchored to line-addressable code'
|
|
285
|
+
: 'Finding references non-existent code';
|
|
286
|
+
return `${head}: ${v.file}${v.line ? `:${v.line}` : ''}`;
|
|
287
|
+
}
|
|
180
288
|
/**
|
|
181
289
|
* Build the final review report: pair the source report with its meta-review
|
|
182
290
|
* verdict and a rolled-up summary. Pure and deterministic.
|
|
@@ -221,30 +329,26 @@ export function buildFinalReviewReport(report, opts = {}) {
|
|
|
221
329
|
for (const violation of evidence.results) {
|
|
222
330
|
if (violation.error === undefined)
|
|
223
331
|
continue;
|
|
224
|
-
const detail = violation
|
|
225
|
-
|
|
226
|
-
? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
|
|
227
|
-
: `${violation.file} is a binary/unreadable file not line-addressable`
|
|
228
|
-
: `${violation.file} does not exist at all (verifiable read required)`;
|
|
332
|
+
const detail = describeEvidenceViolation(violation);
|
|
333
|
+
const summary = evidenceViolationSummary(violation);
|
|
229
334
|
let roundHint = '';
|
|
230
335
|
if (report && violation.file) {
|
|
231
336
|
// Try to attribute the poisoned finding to the round that first
|
|
232
|
-
// surfaced it (best-effort; report rounds carry it).
|
|
233
|
-
|
|
234
|
-
|
|
337
|
+
// surfaced it (best-effort; report rounds carry it). `rounds` is
|
|
338
|
+
// untrusted JSON, so a null entry must not throw here.
|
|
339
|
+
for (const r of (Array.isArray(report.rounds) ? report.rounds : []) ?? []) {
|
|
340
|
+
const findings = Array.isArray(r?.findings) ? r.findings : [];
|
|
341
|
+
const matched = findings.some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
|
|
235
342
|
if (matched) {
|
|
236
|
-
roundHint = ` (round ${r.round})`;
|
|
343
|
+
roundHint = ` (round ${String(r.round)})`;
|
|
237
344
|
break;
|
|
238
345
|
}
|
|
239
346
|
}
|
|
240
347
|
}
|
|
241
|
-
const summary = `Finding references non-existent code: ${violation.file}` +
|
|
242
|
-
(violation.line ? `:${violation.line}` : '') +
|
|
243
|
-
roundHint;
|
|
244
348
|
meta.issues.push({
|
|
245
349
|
code: 'EVIDENCE_VIOLATION',
|
|
246
350
|
severity: 'critical',
|
|
247
|
-
summary,
|
|
351
|
+
summary: summary + roundHint,
|
|
248
352
|
detail: detail + '. Review results must anchor to real, read code.',
|
|
249
353
|
});
|
|
250
354
|
}
|
package/dist/method-scope.js
CHANGED
|
@@ -68,7 +68,11 @@ const SIGNATURE_PATTERNS = [
|
|
|
68
68
|
* Returns a sorted array of `{ name, line }` (1-based line numbers).
|
|
69
69
|
*/
|
|
70
70
|
export function collectMethodSignatures(text) {
|
|
71
|
-
|
|
71
|
+
// Defensive: callers hand in file content read from disk / tool args, which
|
|
72
|
+
// may be missing or wrong-typed when a fix record is malformed. A non-string
|
|
73
|
+
// would throw on `.split` inside the atomic-fix gate.
|
|
74
|
+
const source = typeof text === 'string' ? text : '';
|
|
75
|
+
const lines = source.split('\n');
|
|
72
76
|
const out = [];
|
|
73
77
|
// Set lookup instead of scanning the growing array (O(S²) → O(S)).
|
|
74
78
|
const seen = new Set();
|
|
@@ -95,7 +99,7 @@ export function collectMethodSignatures(text) {
|
|
|
95
99
|
}
|
|
96
100
|
/** Number of physical lines in `text` (a trailing newline does not add a line). */
|
|
97
101
|
export function countTextLines(text) {
|
|
98
|
-
if (text === '')
|
|
102
|
+
if (typeof text !== 'string' || text === '')
|
|
99
103
|
return 0;
|
|
100
104
|
const parts = text.split('\n');
|
|
101
105
|
return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length;
|
|
@@ -107,11 +111,14 @@ export function countTextLines(text) {
|
|
|
107
111
|
* span runs to the final non-blank line of the file.
|
|
108
112
|
*/
|
|
109
113
|
export function collectMethodSpans(text) {
|
|
110
|
-
|
|
114
|
+
// Same non-string guard as collectMethodSignatures: this runs inside the
|
|
115
|
+
// atomic-fix gate on content that may be missing.
|
|
116
|
+
const source = typeof text === 'string' ? text : '';
|
|
117
|
+
const signatures = collectMethodSignatures(source);
|
|
111
118
|
if (signatures.length === 0)
|
|
112
119
|
return [];
|
|
113
|
-
const lines =
|
|
114
|
-
const lineCount = countTextLines(
|
|
120
|
+
const lines = source.split('\n');
|
|
121
|
+
const lineCount = countTextLines(source);
|
|
115
122
|
/** Last non-blank line at or before `candidate`. */
|
|
116
123
|
function trimBlank(endCandidate, floor) {
|
|
117
124
|
let end = endCandidate;
|
|
@@ -137,6 +144,18 @@ export function collectMethodSpans(text) {
|
|
|
137
144
|
function intersects(spanStart, spanEnd, regionStart, regionEnd) {
|
|
138
145
|
return spanStart <= regionEnd && spanEnd >= regionStart;
|
|
139
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* True when a hunk's coordinates are usable for span intersection.
|
|
149
|
+
*
|
|
150
|
+
* `hunks` is a `ChangedRegion[]` that reaches this gate from a diff computed
|
|
151
|
+
* on tool-arg content; a malformed entry (`NaN`, `Infinity`, a negative or
|
|
152
|
+
* fractional line number) would build a `NaN`/negative region that either
|
|
153
|
+
* matches every method or none, silently wrong-sizing the adjacent-method
|
|
154
|
+
* verdict. Non-finite and negative starts/counts are simply skipped.
|
|
155
|
+
*/
|
|
156
|
+
function isValidRegion(start, count) {
|
|
157
|
+
return Number.isInteger(start) && start >= 0 && Number.isInteger(count) && count >= 1;
|
|
158
|
+
}
|
|
140
159
|
/**
|
|
141
160
|
* Count the distinct methods a set of diff hunks touches.
|
|
142
161
|
*
|
|
@@ -150,13 +169,17 @@ function intersects(spanStart, spanEnd, regionStart, regionEnd) {
|
|
|
150
169
|
* - Methods touched by both sides are counted once (keyed name@startLine).
|
|
151
170
|
*/
|
|
152
171
|
export function countTouchedMethods(before, after, hunks) {
|
|
153
|
-
|
|
172
|
+
// A non-array `hunks` (malformed fix record / tool arg) is "nothing
|
|
173
|
+
// changed", matching the empty-hunk case — never a throw in the fix gate.
|
|
174
|
+
if (!Array.isArray(hunks) || hunks.length === 0)
|
|
154
175
|
return 0;
|
|
155
176
|
const beforeSpans = collectMethodSpans(before);
|
|
156
177
|
const afterSpans = collectMethodSpans(after);
|
|
157
178
|
const touched = new Set();
|
|
158
179
|
for (const h of hunks) {
|
|
159
|
-
if (h
|
|
180
|
+
if (!h || typeof h !== 'object')
|
|
181
|
+
continue;
|
|
182
|
+
if (h.oldLines > 0 && isValidRegion(h.oldStart, h.oldLines)) {
|
|
160
183
|
const oldEnd = h.oldStart + h.oldLines - 1;
|
|
161
184
|
for (const s of beforeSpans) {
|
|
162
185
|
if (intersects(s.startLine, s.endLine, h.oldStart, oldEnd)) {
|
|
@@ -164,7 +187,7 @@ export function countTouchedMethods(before, after, hunks) {
|
|
|
164
187
|
}
|
|
165
188
|
}
|
|
166
189
|
}
|
|
167
|
-
if (h.newLines > 0) {
|
|
190
|
+
if (h.newLines > 0 && isValidRegion(h.newStart, h.newLines)) {
|
|
168
191
|
const newEnd = h.newStart + h.newLines - 1;
|
|
169
192
|
for (const s of afterSpans) {
|
|
170
193
|
if (intersects(s.startLine, s.endLine, h.newStart, newEnd)) {
|