iterate-plugin 3.5.7 → 3.7.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 +97 -22
- package/README.zh-CN.md +79 -19
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +516 -35
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +193 -0
- package/dist/index.js +61 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +54 -14
- 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 +61 -14
- 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 +106 -28
- 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/dist/working-directory.js +114 -0
- package/lib/client.js +1912 -512
- package/lib/parse.js +1001 -103
- package/package.json +16 -9
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +1031 -369
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +549 -36
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +211 -0
- package/src/index.ts +60 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +50 -12
- 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 +60 -13
- 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 +114 -23
- 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/src/working-directory.ts +126 -0
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)) {
|
package/dist/review-scope.js
CHANGED
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { readdirSync } from 'node:fs';
|
|
24
24
|
import { join, relative } from 'node:path';
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Single source of truth for the whole-file line sentinel: evidence.ts's gate
|
|
26
|
+
// and this module's inventory filters must agree on what `0` means. Evidence
|
|
27
|
+
// imports neither this module nor review.ts, so the edge is acyclic (review.ts
|
|
28
|
+
// → review-scope.ts → evidence.ts → types.ts).
|
|
29
|
+
import { WHOLE_FILE_LINE } from "./evidence.js";
|
|
30
|
+
export { WHOLE_FILE_LINE };
|
|
27
31
|
/** Source extensions a full-scope walk includes. */
|
|
28
32
|
const SOURCE_EXTENSIONS = new Set([
|
|
29
33
|
'.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
@@ -82,13 +86,19 @@ function isIgnoredDir(name) {
|
|
|
82
86
|
}
|
|
83
87
|
/** Collect the sorted relative-path inventory for a review scope. */
|
|
84
88
|
export function collectScopeFiles(root, opts) {
|
|
89
|
+
// `opts` / `scope` come from tool args: a missing or malformed options
|
|
90
|
+
// object must degrade to a full walk, never throw inside the inventory step.
|
|
91
|
+
if (!opts || typeof opts !== 'object')
|
|
92
|
+
return collectFull(root);
|
|
85
93
|
if (opts.scope === 'changed-only')
|
|
86
94
|
return collectChanged(opts.changedFiles ?? []);
|
|
87
95
|
return collectFull(root);
|
|
88
96
|
}
|
|
89
97
|
function collectChanged(changedFiles) {
|
|
98
|
+
// A non-array `changedFiles` (JSON arg) must not be iterated char-by-char.
|
|
99
|
+
const list = Array.isArray(changedFiles) ? changedFiles : [];
|
|
90
100
|
const out = new Set();
|
|
91
|
-
for (const rel of
|
|
101
|
+
for (const rel of list) {
|
|
92
102
|
if (typeof rel !== 'string' || !rel.trim())
|
|
93
103
|
continue;
|
|
94
104
|
if (rel === String(WHOLE_FILE_LINE))
|
|
@@ -145,9 +155,17 @@ function collectFull(root) {
|
|
|
145
155
|
/** Split `files` into stable batches, keeping directory runs together. */
|
|
146
156
|
export function chunkFiles(files, perChunk) {
|
|
147
157
|
// Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
|
|
148
|
-
// chunk (current.length >= NaN is never true).
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
// chunk (current.length >= NaN is never true). Math.floor: a fractional
|
|
159
|
+
// size (2.5) made chunks alternate between 2 and 3 members — the caller's
|
|
160
|
+
// "files per batch" is a count, so it must be an integer.
|
|
161
|
+
const size = Number.isFinite(perChunk) && perChunk >= 1
|
|
162
|
+
? Math.floor(perChunk)
|
|
163
|
+
: DEFAULT_SCOPE_CHUNK_SIZE;
|
|
164
|
+
// A non-array / sparse-free copy: callers pass tool-arg JSON, and sort()
|
|
165
|
+
// on a non-array (or `rel.includes` on a number) would throw.
|
|
166
|
+
const ordered = (Array.isArray(files) ? files : [])
|
|
167
|
+
.filter((rel) => typeof rel === 'string')
|
|
168
|
+
.sort();
|
|
151
169
|
const chunks = [];
|
|
152
170
|
let current = [];
|
|
153
171
|
let lastDir;
|
|
@@ -172,19 +190,24 @@ export function chunkFiles(files, perChunk) {
|
|
|
172
190
|
}
|
|
173
191
|
/** Score self-reported reads against the assigned inventory. */
|
|
174
192
|
export function computeCoverage(assigned, readFiles) {
|
|
193
|
+
// Both lists are JSON-arg-derived: `for...of` over a bare number throws and
|
|
194
|
+
// over a bare string iterates single characters into the read set. Anything
|
|
195
|
+
// non-array behaves like "nothing" (empty inventory / nothing read).
|
|
196
|
+
const assignedList = Array.isArray(assigned) ? assigned : [];
|
|
197
|
+
const readList = Array.isArray(readFiles) ? readFiles : [];
|
|
175
198
|
const readNorm = new Set();
|
|
176
|
-
for (const p of
|
|
199
|
+
for (const p of readList) {
|
|
177
200
|
if (typeof p === 'string' && p)
|
|
178
201
|
readNorm.add(normalizePath(p));
|
|
179
202
|
}
|
|
180
|
-
const assignedSorted = [...
|
|
203
|
+
const assignedSorted = [...assignedList].filter((rel) => typeof rel === 'string').sort();
|
|
181
204
|
const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)));
|
|
182
205
|
const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)));
|
|
183
206
|
const rawRatio = assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length;
|
|
184
207
|
const ratio = Math.round(rawRatio * 1000) / 1000;
|
|
185
208
|
return {
|
|
186
209
|
assigned: assignedSorted,
|
|
187
|
-
read: [...new Set(
|
|
210
|
+
read: [...new Set(readList.filter((p) => typeof p === 'string'))].sort(),
|
|
188
211
|
covered,
|
|
189
212
|
uncovered,
|
|
190
213
|
ratio,
|
package/dist/review.js
CHANGED
|
@@ -44,9 +44,44 @@ export function sortFindings(findings) {
|
|
|
44
44
|
return (Number(a?.line) || 0) - (Number(b?.line) || 0);
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Hard ceiling on the round cap the aggregation math will honor.
|
|
49
|
+
*
|
|
50
|
+
* Mirrors `MAX_MAX_ROUNDS` in `config-loader.ts` (the config bomb guard) —
|
|
51
|
+
* `test/review.test.ts` asserts the two constants stay in sync. The tool
|
|
52
|
+
* boundary only clamps the LOWER bound of `maxReviewRounds`, so an absurd
|
|
53
|
+
* caller value (1e15) would otherwise drive the `findingsByRound` allocation
|
|
54
|
+
* bound (`roundCap * 2`) straight into OOM territory.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_REVIEW_ROUNDS_CAP = 100;
|
|
57
|
+
/**
|
|
58
|
+
* Clamp an untrusted round cap to `[1, MAX_REVIEW_ROUNDS_CAP]`.
|
|
59
|
+
*
|
|
60
|
+
* Non-finite / non-numeric input (`NaN`, `Infinity`, a string) collapses to
|
|
61
|
+
* 1 — the smallest sane cap. Without this, `Math.max(1, NaN)` stayed `NaN`
|
|
62
|
+
* and `new Array(NaN)` threw `RangeError` out of `aggregateRounds`.
|
|
63
|
+
*/
|
|
64
|
+
export function clampMaxReviewRounds(value) {
|
|
65
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
66
|
+
return 1;
|
|
67
|
+
return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(value)));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* True when `n` is a round number the deterministic core accepts: a positive
|
|
71
|
+
* integer. Shared by `aggregateRounds` / `computeConvergence` /
|
|
72
|
+
* `buildReviewReport` (and re-read by the meta-review audit) so every reader
|
|
73
|
+
* of "the last round" agrees on which round numbers count — a fractional
|
|
74
|
+
* (`1.5`) or non-finite round is simply "not a round", never a convergence
|
|
75
|
+
* input that could be mis-indexed into `findingsByRound`.
|
|
76
|
+
*/
|
|
77
|
+
export function isValidRoundNumber(n) {
|
|
78
|
+
return typeof n === 'number' && Number.isInteger(n) && n >= 1;
|
|
79
|
+
}
|
|
47
80
|
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
48
81
|
export function normalizeSummary(summary) {
|
|
49
|
-
|
|
82
|
+
// Coerce: `summary` is model-authored JSON when schema validation is off,
|
|
83
|
+
// so a numeric summary must not crash `.trim()` inside the dedupe key.
|
|
84
|
+
return String(summary ?? '')
|
|
50
85
|
.trim()
|
|
51
86
|
.toLowerCase()
|
|
52
87
|
.replace(/[\s\n\t]+/g, ' ');
|
|
@@ -58,8 +93,12 @@ export function normalizeSummary(summary) {
|
|
|
58
93
|
* neither side anchors one, i.e. whole-file findings).
|
|
59
94
|
*/
|
|
60
95
|
export function findingKey(f) {
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
// Defensive reads: with `output_schema_validation: false` a finding may
|
|
97
|
+
// carry wrong-typed fields (and `f` itself may be a null list element), and
|
|
98
|
+
// this key feeds the dedupe Set on EVERY round — it must never throw.
|
|
99
|
+
const src = (f ?? {});
|
|
100
|
+
const line = typeof src.line === 'number' && src.line > 0 ? src.line : 0;
|
|
101
|
+
return `${String(src.file ?? '')}|${String(src.dimension ?? '')}|${line}|${normalizeSummary(src.summary)}`;
|
|
63
102
|
}
|
|
64
103
|
/**
|
|
65
104
|
* Remove duplicate findings within a list.
|
|
@@ -69,6 +108,10 @@ export function dedupeFindings(findings) {
|
|
|
69
108
|
const seen = new Set();
|
|
70
109
|
const out = [];
|
|
71
110
|
for (const f of findings) {
|
|
111
|
+
// A null / array entry (hostile or schema-validation-off JSON) must never
|
|
112
|
+
// reach `summarize`, which reads `f.severity` unguarded.
|
|
113
|
+
if (!f || typeof f !== 'object' || Array.isArray(f))
|
|
114
|
+
continue;
|
|
72
115
|
const key = findingKey(f);
|
|
73
116
|
if (seen.has(key))
|
|
74
117
|
continue;
|
|
@@ -84,10 +127,19 @@ export function dedupeFindings(findings) {
|
|
|
84
127
|
* - entry `line` is 0/undefined (whole file) OR equals the finding's line.
|
|
85
128
|
*/
|
|
86
129
|
export function filterKnownIntentional(findings, known) {
|
|
87
|
-
|
|
130
|
+
// `known` is config/arg JSON: a non-array truthy value must behave like
|
|
131
|
+
// "no entries", never reach `known.some` and throw.
|
|
132
|
+
if (!Array.isArray(known) || known.length === 0)
|
|
88
133
|
return findings;
|
|
89
134
|
return findings.filter((f) => {
|
|
135
|
+
// A null / non-object finding has no file/dimension to match on; drop it
|
|
136
|
+
// here so it can never crash the comparator (aggregateRounds drops it too,
|
|
137
|
+
// so the report's counts stay consistent either way).
|
|
138
|
+
if (!f || typeof f !== 'object' || Array.isArray(f))
|
|
139
|
+
return false;
|
|
90
140
|
const matched = known.some((k) => {
|
|
141
|
+
if (!k || typeof k !== 'object')
|
|
142
|
+
return false;
|
|
91
143
|
const sameFile = k.file === f.file;
|
|
92
144
|
const sameDim = k.dimension === f.dimension;
|
|
93
145
|
if (!sameFile || !sameDim)
|
|
@@ -124,16 +176,25 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
124
176
|
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
125
177
|
// numbers above the configured cap are clamped to the cap.
|
|
126
178
|
let maxRound = 0;
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
// Upper-bounded too: `Math.max(1, NaN)` used to stay NaN and blow up in
|
|
180
|
+
// `new Array(effectiveMax)`, and an unbounded cap scaled the allocation
|
|
181
|
+
// bound to whatever the caller asked for.
|
|
182
|
+
const roundCap = clampMaxReviewRounds(maxReviewRounds);
|
|
183
|
+
for (const round of Array.isArray(rounds) ? rounds : []) {
|
|
129
184
|
if (!round || typeof round !== 'object')
|
|
130
185
|
continue;
|
|
131
|
-
if (
|
|
186
|
+
if (!isValidRoundNumber(round.round))
|
|
132
187
|
continue;
|
|
133
188
|
const findings = Array.isArray(round.findings) ? round.findings : [];
|
|
134
189
|
if (round.round > maxRound)
|
|
135
190
|
maxRound = round.round;
|
|
136
191
|
for (const f of findings) {
|
|
192
|
+
// Null / non-object elements (schema validation off) would crash
|
|
193
|
+
// `findingKey`; dropping them here keeps `findingsByRound` summing to
|
|
194
|
+
// exactly the returned findings list (the meta-review's CONVERGENCE_SUM
|
|
195
|
+
// check reads that equality).
|
|
196
|
+
if (!f || typeof f !== 'object' || Array.isArray(f))
|
|
197
|
+
continue;
|
|
137
198
|
const key = findingKey(f);
|
|
138
199
|
if (seen.has(key))
|
|
139
200
|
continue;
|
|
@@ -162,8 +223,9 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
162
223
|
* Compute convergence statistics for a dry-run review.
|
|
163
224
|
*/
|
|
164
225
|
export function computeConvergence(rounds, maxReviewRounds) {
|
|
165
|
-
const
|
|
166
|
-
const
|
|
226
|
+
const list = Array.isArray(rounds) ? rounds : [];
|
|
227
|
+
const { findingsByRound } = aggregateRounds(list, maxReviewRounds);
|
|
228
|
+
const totalRounds = list.length;
|
|
167
229
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
168
230
|
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
169
231
|
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
@@ -171,15 +233,17 @@ export function computeConvergence(rounds, maxReviewRounds) {
|
|
|
171
233
|
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
172
234
|
// actually allocated.
|
|
173
235
|
let lastRound = 0;
|
|
174
|
-
for (const round of
|
|
175
|
-
if (!
|
|
236
|
+
for (const round of list) {
|
|
237
|
+
if (!isValidRoundNumber(round?.round))
|
|
176
238
|
continue;
|
|
177
239
|
if (round.round > lastRound)
|
|
178
240
|
lastRound = round.round;
|
|
179
241
|
}
|
|
180
242
|
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
181
243
|
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
182
|
-
|
|
244
|
+
// `lastRound === 0` means NO round carried an interpretable number: claiming
|
|
245
|
+
// `converged` there would bless a report the core could not actually read.
|
|
246
|
+
const converged = totalRounds > 0 && lastRound > 0 && lastRoundCount === 0;
|
|
183
247
|
return {
|
|
184
248
|
totalRounds,
|
|
185
249
|
findingsByRound,
|
|
@@ -199,7 +263,15 @@ function summarize(findings) {
|
|
|
199
263
|
high: 0,
|
|
200
264
|
medium: 0,
|
|
201
265
|
low: 0,
|
|
202
|
-
|
|
266
|
+
// NULL-PROTOTYPE map: a model-authored `dimension` of `__proto__` or
|
|
267
|
+
// `constructor` would otherwise resolve against Object.prototype — the
|
|
268
|
+
// `__proto__` setter silently discards a primitive value and `constructor`
|
|
269
|
+
// string-concatenates the Object function — so the counter never
|
|
270
|
+
// accumulated and the meta-review's DIMENSION_SUM check false-positived.
|
|
271
|
+
// A null-prototype object has no inherited accessors, so every dimension
|
|
272
|
+
// (including those three names) is a plain own data property and
|
|
273
|
+
// JSON-serializes normally.
|
|
274
|
+
byDimension: Object.create(null),
|
|
203
275
|
};
|
|
204
276
|
for (const f of findings) {
|
|
205
277
|
if (f.severity === 'critical')
|
|
@@ -223,7 +295,8 @@ function summarize(findings) {
|
|
|
223
295
|
*/
|
|
224
296
|
export function buildReviewReport(input) {
|
|
225
297
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
226
|
-
const
|
|
298
|
+
const inputRounds = Array.isArray(input.rounds) ? input.rounds : [];
|
|
299
|
+
const filteredRounds = inputRounds
|
|
227
300
|
.map((r) => ({
|
|
228
301
|
round: typeof r?.round === 'number' ? r.round : 0,
|
|
229
302
|
findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
|
|
@@ -246,7 +319,11 @@ export function buildReviewReport(input) {
|
|
|
246
319
|
// non-contiguous round sets would otherwise read the wrong count).
|
|
247
320
|
let lastRound = 0;
|
|
248
321
|
for (const r of filteredRounds) {
|
|
249
|
-
|
|
322
|
+
// Same validity rule as aggregateRounds: a fractional / NaN round is not
|
|
323
|
+
// a round, so it must never be used as an index into `findingsByRound`
|
|
324
|
+
// (Math.min(1.5, len) - 1 = 0.5 → reads `undefined` → counts as 0 new →
|
|
325
|
+
// a round that really did report findings would be read as "converged").
|
|
326
|
+
if (isValidRoundNumber(r.round) && r.round > lastRound)
|
|
250
327
|
lastRound = r.round;
|
|
251
328
|
}
|
|
252
329
|
// `lastRound` can exceed the array length when over-cap rounds were FOLDED
|
|
@@ -255,7 +332,7 @@ export function buildReviewReport(input) {
|
|
|
255
332
|
// new findings (which would falsely report "converged").
|
|
256
333
|
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
257
334
|
const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
258
|
-
const converged = filteredRounds.length > 0 && lastRoundCount === 0;
|
|
335
|
+
const converged = filteredRounds.length > 0 && lastRound > 0 && lastRoundCount === 0;
|
|
259
336
|
// Attach the normal-mode fix count to the summary (dry-run leaves it absent).
|
|
260
337
|
const computed = summarize(sorted);
|
|
261
338
|
if (input.mode === 'normal' && typeof input.fixedCount === 'number' && Number.isInteger(input.fixedCount)) {
|
|
@@ -659,7 +736,9 @@ export function buildReviewPlan(input) {
|
|
|
659
736
|
goal,
|
|
660
737
|
scope,
|
|
661
738
|
dimensions: dimensionTasks,
|
|
662
|
-
|
|
739
|
+
// Clamped with the SAME rule aggregateRounds honors, so the cap this plan
|
|
740
|
+
// advertises is never a number the aggregation math would silently clamp.
|
|
741
|
+
maxReviewRounds: clampMaxReviewRounds(input.maxReviewRounds),
|
|
663
742
|
knownIntentional: input.knownIntentional ?? [],
|
|
664
743
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
665
744
|
fallbackToFull,
|