iterate-plugin 3.5.6 → 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 +417 -67
- 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 +122 -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 +156 -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 +15 -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 +417 -67
- 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 +127 -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 +160 -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/meta-review.ts
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ReviewFinding, ReviewReport } from './types.ts'
|
|
18
|
-
import type { EvidenceAudit } from './evidence.ts'
|
|
18
|
+
import type { EvidenceAudit, FindingEvidence } from './evidence.ts'
|
|
19
19
|
import type { CoverageResult } from './review-scope.ts'
|
|
20
|
-
import { sortFindings } from './review.ts'
|
|
20
|
+
import { isValidRoundNumber, sortFindings } from './review.ts'
|
|
21
21
|
|
|
22
22
|
/** A single defect found while auditing a review report. */
|
|
23
23
|
export interface MetaReviewIssue {
|
|
@@ -84,6 +84,56 @@ export const META_REVIEW_CHECKS = 10
|
|
|
84
84
|
*/
|
|
85
85
|
export const COVERAGE_LIST_TRUNCATE = 10
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Hard sanity ceiling on a reported round number.
|
|
89
|
+
*
|
|
90
|
+
* `rounds[].round` is model/JSON-authored and only needs to be a number to
|
|
91
|
+
* reach this audit. Round-gap detection enumerates `min..max` of the PRESENT
|
|
92
|
+
* round numbers, so an absurd value (rounds `{1, 1e15}` or a round of
|
|
93
|
+
* `Infinity`) used to turn a linear check into an effectively-infinite loop —
|
|
94
|
+
* a trivial CPU/memory denial of service (and one ROUND_GAP issue per missing
|
|
95
|
+
* round). Anything above this ceiling is rejected as an invalid round
|
|
96
|
+
* (ROUND_NUMBER) and excluded from gap enumeration instead of being walked.
|
|
97
|
+
* Generously above any real run: `config.max_rounds` is capped at 100.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_REVIEW_ROUND_NUMBER = 1000
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Max individual ROUND_GAP issues emitted per audit. The remainder is folded
|
|
103
|
+
* into a single "+N more" issue so a wide-but-legal gap can never flood the
|
|
104
|
+
* issue list (each issue carries a detail string).
|
|
105
|
+
*/
|
|
106
|
+
export const MAX_ROUND_GAP_REPORTS = 50
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hard bound on the round-gap enumeration span. Below
|
|
110
|
+
* MAX_REVIEW_ROUND_NUMBER on purpose: even a legal range (e.g. round 1 plus
|
|
111
|
+
* round 1000) is too wide for per-number gap reporting to mean anything, so
|
|
112
|
+
* it collapses to ONE "range too wide" issue instead of ~1000 iterations and
|
|
113
|
+
* ~1000 issues. Together with the round-number ceiling this makes the
|
|
114
|
+
* enumeration O(MAX_ROUND_GAP_SPAN) no matter what reaches it.
|
|
115
|
+
*/
|
|
116
|
+
export const MAX_ROUND_GAP_SPAN = 500
|
|
117
|
+
|
|
118
|
+
/** Bounded, non-throwing list preview for issue detail strings. */
|
|
119
|
+
function previewList(values: readonly unknown[], max = 20): string {
|
|
120
|
+
const shown = values
|
|
121
|
+
.slice(0, max)
|
|
122
|
+
.map((v) => (typeof v === 'number' ? String(v) : typeof v === 'string' ? v : '[object]'))
|
|
123
|
+
.join(', ')
|
|
124
|
+
return values.length > max ? `${shown}, … (+${values.length - max} more)` : shown
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Bounded, non-throwing object preview (a hostile round may be circular). */
|
|
128
|
+
function previewRound(r: unknown): string {
|
|
129
|
+
if (r === null || typeof r !== 'object') return String(r)
|
|
130
|
+
const roundNo = (r as { round?: unknown }).round
|
|
131
|
+
const findings = (r as { findings?: unknown }).findings
|
|
132
|
+
return `{round: ${String(roundNo)}, findings: ${
|
|
133
|
+
Array.isArray(findings) ? `array(${findings.length})` : typeof findings
|
|
134
|
+
}}`
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
/**
|
|
88
138
|
* Audit a ReviewReport for internal consistency.
|
|
89
139
|
*
|
|
@@ -154,7 +204,9 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
154
204
|
>
|
|
155
205
|
for (const f of findings) {
|
|
156
206
|
const s = f?.severity as ReviewFinding['severity'] | undefined
|
|
157
|
-
|
|
207
|
+
// hasOwn, not `in`: `'__proto__' in sevCounts` is true via inheritance,
|
|
208
|
+
// and an out-of-spec severity must simply not be bucketed.
|
|
209
|
+
if (s && Object.hasOwn(sevCounts, s)) sevCounts[s]++
|
|
158
210
|
}
|
|
159
211
|
const bucketSum = sevCounts.critical + sevCounts.high + sevCounts.medium + sevCounts.low
|
|
160
212
|
const declaredSeveritySum =
|
|
@@ -217,20 +269,22 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
217
269
|
'CONVERGENCE_SUM',
|
|
218
270
|
'high',
|
|
219
271
|
'convergence.findingsByRound does not sum to totalFindings',
|
|
220
|
-
`findingsByRound ${
|
|
272
|
+
`findingsByRound [${previewList(findingsByRound)}] sums to ${convSum}, ` +
|
|
221
273
|
`but totalFindings is ${total}.`,
|
|
222
274
|
)
|
|
223
275
|
}
|
|
224
276
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
225
|
-
// r-1), so the "last round" is the
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
277
|
+
// r-1), so the "last round" is the HIGHEST VALID round number, not the
|
|
278
|
+
// array's last element (buildReviewReport sorts its round list, but this
|
|
279
|
+
// audit runs on arbitrary JSON, and a malformed trailing round — `1.5`,
|
|
280
|
+
// `Infinity` — must not be used as an index: Math.min(1.5, len) - 1 is
|
|
281
|
+
// fractional and reads `undefined` → 0 new → a false CONVERGENCE_FLAG).
|
|
229
282
|
const reportRounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
283
|
+
let lastRecordedRound: number | null = null
|
|
284
|
+
for (const r of reportRounds) {
|
|
285
|
+
if (!r || typeof r !== 'object' || !isValidRoundNumber(r.round)) continue
|
|
286
|
+
if (lastRecordedRound === null || r.round > lastRecordedRound) lastRecordedRound = r.round
|
|
287
|
+
}
|
|
234
288
|
const lastRoundNew =
|
|
235
289
|
lastRecordedRound !== null && lastRecordedRound > 0
|
|
236
290
|
// Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
|
|
@@ -254,12 +308,21 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
254
308
|
const rounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
255
309
|
const seenRounds = new Set<number>()
|
|
256
310
|
for (const [index, r] of rounds.entries()) {
|
|
257
|
-
|
|
311
|
+
// Reject everything the deterministic core would refuse: a missing,
|
|
312
|
+
// non-integer, non-finite or absurdly large round number. This is also
|
|
313
|
+
// the DoS guard for ROUND_GAP below — a round of `Infinity`/1e15 would
|
|
314
|
+
// otherwise be treated as a legitimate endpoint of the enumeration range.
|
|
315
|
+
if (
|
|
316
|
+
!r ||
|
|
317
|
+
typeof r !== 'object' ||
|
|
318
|
+
!isValidRoundNumber(r.round) ||
|
|
319
|
+
r.round > MAX_REVIEW_ROUND_NUMBER
|
|
320
|
+
) {
|
|
258
321
|
add(
|
|
259
322
|
'ROUND_NUMBER',
|
|
260
323
|
'medium',
|
|
261
|
-
'A round has a missing or
|
|
262
|
-
`round: ${
|
|
324
|
+
'A round has a missing, non-integer or out-of-range round number',
|
|
325
|
+
`round: ${previewRound(r)} (round numbers must be integers in 1..${MAX_REVIEW_ROUND_NUMBER})`,
|
|
263
326
|
)
|
|
264
327
|
continue
|
|
265
328
|
}
|
|
@@ -283,13 +346,42 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
283
346
|
if (present.length > 0) {
|
|
284
347
|
const min = present[0]!
|
|
285
348
|
const max = present[present.length - 1]!
|
|
286
|
-
|
|
287
|
-
|
|
349
|
+
const span = max - min + 1
|
|
350
|
+
const presentPreview = previewList(present, 20)
|
|
351
|
+
// Hard bound on the enumeration: `min`/`max` come from untrusted round
|
|
352
|
+
// numbers, so an unguarded `for (i = min; i <= max; i++)` over
|
|
353
|
+
// {1, 1e15} is an effectively-infinite loop (CPU DoS) that would also
|
|
354
|
+
// push ~1e15 issues. Rounds above MAX_REVIEW_ROUND_NUMBER are rejected
|
|
355
|
+
// above, which keeps `span` bounded on its own; this guard stays as the
|
|
356
|
+
// belt-and-braces backstop so the walk is O(MAX_ROUND_GAP_SPAN) no
|
|
357
|
+
// matter what reaches it.
|
|
358
|
+
if (span > MAX_ROUND_GAP_SPAN) {
|
|
359
|
+
add(
|
|
360
|
+
'ROUND_GAP',
|
|
361
|
+
'medium',
|
|
362
|
+
`Round sequence ${min}..${max} is too wide to audit for gaps`,
|
|
363
|
+
`${span} round numbers spanned; only rounds present were accepted: ${presentPreview}.`,
|
|
364
|
+
)
|
|
365
|
+
} else {
|
|
366
|
+
const missing: number[] = []
|
|
367
|
+
for (let i = min; i <= max; i++) {
|
|
368
|
+
if (!seenRounds.has(i)) missing.push(i)
|
|
369
|
+
}
|
|
370
|
+
for (const m of missing.slice(0, MAX_ROUND_GAP_REPORTS)) {
|
|
371
|
+
add(
|
|
372
|
+
'ROUND_GAP',
|
|
373
|
+
'medium',
|
|
374
|
+
`Round ${m} is missing from the round sequence`,
|
|
375
|
+
`rounds present: ${presentPreview}.`,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
if (missing.length > MAX_ROUND_GAP_REPORTS) {
|
|
288
379
|
add(
|
|
289
380
|
'ROUND_GAP',
|
|
290
381
|
'medium',
|
|
291
|
-
|
|
292
|
-
|
|
382
|
+
`${missing.length - MAX_ROUND_GAP_REPORTS} more rounds are missing (list capped)`,
|
|
383
|
+
`${missing.length} of the ${span} numbers in ${min}..${max} are absent; only the first ` +
|
|
384
|
+
`${MAX_ROUND_GAP_REPORTS} are listed individually. rounds present: ${presentPreview}.`,
|
|
293
385
|
)
|
|
294
386
|
}
|
|
295
387
|
}
|
|
@@ -304,6 +396,37 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
304
396
|
}
|
|
305
397
|
}
|
|
306
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Human-readable reason for a failed evidence attestation.
|
|
401
|
+
*
|
|
402
|
+
* `file_too_large` / `binary_file` are DISTINCT from `line_out_of_range` (see
|
|
403
|
+
* evidence.ts): the file EXISTS but cannot be line-addressed, so saying
|
|
404
|
+
* "does not exist" would send the reader looking for a missing file.
|
|
405
|
+
*/
|
|
406
|
+
function describeEvidenceViolation(v: FindingEvidence): string {
|
|
407
|
+
switch (v.error) {
|
|
408
|
+
case 'file_too_large':
|
|
409
|
+
return `${v.file} is too large to line-address (over the evidence size cap)`
|
|
410
|
+
case 'binary_file':
|
|
411
|
+
return `${v.file} is a binary (NUL-containing) file that cannot be line-addressed`
|
|
412
|
+
case 'line_out_of_range':
|
|
413
|
+
return v.lineTotal !== undefined && v.lineTotal !== null
|
|
414
|
+
? `${v.line} is beyond this file's ${v.lineTotal} lines`
|
|
415
|
+
: `${v.file} is not a regular line-addressable file (directory/device/FIFO)`
|
|
416
|
+
default:
|
|
417
|
+
return `${v.file} does not exist at all (verifiable read required)`
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Headline for a failed evidence attestation (matches the detail above). */
|
|
422
|
+
function evidenceViolationSummary(v: FindingEvidence): string {
|
|
423
|
+
const unaddressable = v.error === 'file_too_large' || v.error === 'binary_file'
|
|
424
|
+
const head = unaddressable
|
|
425
|
+
? 'Finding cannot be anchored to line-addressable code'
|
|
426
|
+
: 'Finding references non-existent code'
|
|
427
|
+
return `${head}: ${v.file}${v.line ? `:${v.line}` : ''}`
|
|
428
|
+
}
|
|
429
|
+
|
|
307
430
|
/**
|
|
308
431
|
* Build the final review report: pair the source report with its meta-review
|
|
309
432
|
* verdict and a rolled-up summary. Pure and deterministic.
|
|
@@ -356,34 +479,28 @@ export function buildFinalReviewReport(
|
|
|
356
479
|
if (evidence.results.some((r) => r.error !== undefined)) {
|
|
357
480
|
for (const violation of evidence.results) {
|
|
358
481
|
if (violation.error === undefined) continue
|
|
359
|
-
const detail =
|
|
360
|
-
|
|
361
|
-
? violation.lineTotal !== undefined && violation.lineTotal !== null
|
|
362
|
-
? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
|
|
363
|
-
: `${violation.file} is a binary/unreadable file not line-addressable`
|
|
364
|
-
: `${violation.file} does not exist at all (verifiable read required)`
|
|
482
|
+
const detail = describeEvidenceViolation(violation)
|
|
483
|
+
const summary = evidenceViolationSummary(violation)
|
|
365
484
|
let roundHint = ''
|
|
366
485
|
if (report && violation.file) {
|
|
367
486
|
// Try to attribute the poisoned finding to the round that first
|
|
368
|
-
// surfaced it (best-effort; report rounds carry it).
|
|
369
|
-
|
|
370
|
-
|
|
487
|
+
// surfaced it (best-effort; report rounds carry it). `rounds` is
|
|
488
|
+
// untrusted JSON, so a null entry must not throw here.
|
|
489
|
+
for (const r of (Array.isArray(report.rounds) ? report.rounds : []) ?? []) {
|
|
490
|
+
const findings = Array.isArray(r?.findings) ? r.findings : []
|
|
491
|
+
const matched = findings.some(
|
|
371
492
|
(fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
|
|
372
493
|
)
|
|
373
494
|
if (matched) {
|
|
374
|
-
roundHint = ` (round ${r.round})`
|
|
495
|
+
roundHint = ` (round ${String(r.round)})`
|
|
375
496
|
break
|
|
376
497
|
}
|
|
377
498
|
}
|
|
378
499
|
}
|
|
379
|
-
const summary =
|
|
380
|
-
`Finding references non-existent code: ${violation.file}` +
|
|
381
|
-
(violation.line ? `:${violation.line}` : '') +
|
|
382
|
-
roundHint
|
|
383
500
|
meta.issues.push({
|
|
384
501
|
code: 'EVIDENCE_VIOLATION',
|
|
385
502
|
severity: 'critical',
|
|
386
|
-
summary,
|
|
503
|
+
summary: summary + roundHint,
|
|
387
504
|
detail: detail + '. Review results must anchor to real, read code.',
|
|
388
505
|
})
|
|
389
506
|
}
|
package/src/method-scope.ts
CHANGED
|
@@ -94,7 +94,11 @@ const SIGNATURE_PATTERNS: Array<{ kind: string; re: RegExp; nameIndex: number }>
|
|
|
94
94
|
* Returns a sorted array of `{ name, line }` (1-based line numbers).
|
|
95
95
|
*/
|
|
96
96
|
export function collectMethodSignatures(text: string): MethodSignature[] {
|
|
97
|
-
|
|
97
|
+
// Defensive: callers hand in file content read from disk / tool args, which
|
|
98
|
+
// may be missing or wrong-typed when a fix record is malformed. A non-string
|
|
99
|
+
// would throw on `.split` inside the atomic-fix gate.
|
|
100
|
+
const source = typeof text === 'string' ? text : ''
|
|
101
|
+
const lines = source.split('\n')
|
|
98
102
|
const out: MethodSignature[] = []
|
|
99
103
|
// Set lookup instead of scanning the growing array (O(S²) → O(S)).
|
|
100
104
|
const seen = new Set<string>()
|
|
@@ -119,7 +123,7 @@ export function collectMethodSignatures(text: string): MethodSignature[] {
|
|
|
119
123
|
|
|
120
124
|
/** Number of physical lines in `text` (a trailing newline does not add a line). */
|
|
121
125
|
export function countTextLines(text: string): number {
|
|
122
|
-
if (text === '') return 0
|
|
126
|
+
if (typeof text !== 'string' || text === '') return 0
|
|
123
127
|
const parts = text.split('\n')
|
|
124
128
|
return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length
|
|
125
129
|
}
|
|
@@ -131,10 +135,13 @@ export function countTextLines(text: string): number {
|
|
|
131
135
|
* span runs to the final non-blank line of the file.
|
|
132
136
|
*/
|
|
133
137
|
export function collectMethodSpans(text: string): MethodSpan[] {
|
|
134
|
-
|
|
138
|
+
// Same non-string guard as collectMethodSignatures: this runs inside the
|
|
139
|
+
// atomic-fix gate on content that may be missing.
|
|
140
|
+
const source = typeof text === 'string' ? text : ''
|
|
141
|
+
const signatures = collectMethodSignatures(source)
|
|
135
142
|
if (signatures.length === 0) return []
|
|
136
|
-
const lines =
|
|
137
|
-
const lineCount = countTextLines(
|
|
143
|
+
const lines = source.split('\n')
|
|
144
|
+
const lineCount = countTextLines(source)
|
|
138
145
|
|
|
139
146
|
/** Last non-blank line at or before `candidate`. */
|
|
140
147
|
function trimBlank(endCandidate: number, floor: number): number {
|
|
@@ -162,6 +169,19 @@ function intersects(spanStart: number, spanEnd: number, regionStart: number, reg
|
|
|
162
169
|
return spanStart <= regionEnd && spanEnd >= regionStart
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
/**
|
|
173
|
+
* True when a hunk's coordinates are usable for span intersection.
|
|
174
|
+
*
|
|
175
|
+
* `hunks` is a `ChangedRegion[]` that reaches this gate from a diff computed
|
|
176
|
+
* on tool-arg content; a malformed entry (`NaN`, `Infinity`, a negative or
|
|
177
|
+
* fractional line number) would build a `NaN`/negative region that either
|
|
178
|
+
* matches every method or none, silently wrong-sizing the adjacent-method
|
|
179
|
+
* verdict. Non-finite and negative starts/counts are simply skipped.
|
|
180
|
+
*/
|
|
181
|
+
function isValidRegion(start: number, count: number): boolean {
|
|
182
|
+
return Number.isInteger(start) && start >= 0 && Number.isInteger(count) && count >= 1
|
|
183
|
+
}
|
|
184
|
+
|
|
165
185
|
/**
|
|
166
186
|
* Count the distinct methods a set of diff hunks touches.
|
|
167
187
|
*
|
|
@@ -179,12 +199,15 @@ export function countTouchedMethods(
|
|
|
179
199
|
after: string,
|
|
180
200
|
hunks: ChangedRegion[],
|
|
181
201
|
): number {
|
|
182
|
-
|
|
202
|
+
// A non-array `hunks` (malformed fix record / tool arg) is "nothing
|
|
203
|
+
// changed", matching the empty-hunk case — never a throw in the fix gate.
|
|
204
|
+
if (!Array.isArray(hunks) || hunks.length === 0) return 0
|
|
183
205
|
const beforeSpans = collectMethodSpans(before)
|
|
184
206
|
const afterSpans = collectMethodSpans(after)
|
|
185
207
|
const touched = new Set<string>()
|
|
186
208
|
for (const h of hunks) {
|
|
187
|
-
if (h
|
|
209
|
+
if (!h || typeof h !== 'object') continue
|
|
210
|
+
if (h.oldLines > 0 && isValidRegion(h.oldStart, h.oldLines)) {
|
|
188
211
|
const oldEnd = h.oldStart + h.oldLines - 1
|
|
189
212
|
for (const s of beforeSpans) {
|
|
190
213
|
if (intersects(s.startLine, s.endLine, h.oldStart, oldEnd)) {
|
|
@@ -192,7 +215,7 @@ export function countTouchedMethods(
|
|
|
192
215
|
}
|
|
193
216
|
}
|
|
194
217
|
}
|
|
195
|
-
if (h.newLines > 0) {
|
|
218
|
+
if (h.newLines > 0 && isValidRegion(h.newStart, h.newLines)) {
|
|
196
219
|
const newEnd = h.newStart + h.newLines - 1
|
|
197
220
|
for (const s of afterSpans) {
|
|
198
221
|
if (intersects(s.startLine, s.endLine, h.newStart, newEnd)) {
|
package/src/review-scope.ts
CHANGED
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
import { readdirSync } from 'node:fs'
|
|
25
25
|
import { join, relative } from 'node:path'
|
|
26
|
+
// Single source of truth for the whole-file line sentinel: evidence.ts's gate
|
|
27
|
+
// and this module's inventory filters must agree on what `0` means. Evidence
|
|
28
|
+
// imports neither this module nor review.ts, so the edge is acyclic (review.ts
|
|
29
|
+
// → review-scope.ts → evidence.ts → types.ts).
|
|
30
|
+
import { WHOLE_FILE_LINE } from './evidence.ts'
|
|
31
|
+
|
|
32
|
+
export { WHOLE_FILE_LINE }
|
|
26
33
|
|
|
27
34
|
export interface CoverageResult {
|
|
28
35
|
assigned: string[]
|
|
@@ -32,9 +39,6 @@ export interface CoverageResult {
|
|
|
32
39
|
ratio: number
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
/** Relative-scope sentinel for whole-module findings. */
|
|
36
|
-
export const WHOLE_FILE_LINE = 0
|
|
37
|
-
|
|
38
42
|
/** Source extensions a full-scope walk includes. */
|
|
39
43
|
const SOURCE_EXTENSIONS = new Set([
|
|
40
44
|
'.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
@@ -100,13 +104,18 @@ export function collectScopeFiles(
|
|
|
100
104
|
root: string,
|
|
101
105
|
opts: { scope: 'full' | 'changed-only'; changedFiles?: string[] },
|
|
102
106
|
): string[] {
|
|
107
|
+
// `opts` / `scope` come from tool args: a missing or malformed options
|
|
108
|
+
// object must degrade to a full walk, never throw inside the inventory step.
|
|
109
|
+
if (!opts || typeof opts !== 'object') return collectFull(root)
|
|
103
110
|
if (opts.scope === 'changed-only') return collectChanged(opts.changedFiles ?? [])
|
|
104
111
|
return collectFull(root)
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
function collectChanged(changedFiles: string[]): string[] {
|
|
115
|
+
// A non-array `changedFiles` (JSON arg) must not be iterated char-by-char.
|
|
116
|
+
const list = Array.isArray(changedFiles) ? changedFiles : []
|
|
108
117
|
const out = new Set<string>()
|
|
109
|
-
for (const rel of
|
|
118
|
+
for (const rel of list) {
|
|
110
119
|
if (typeof rel !== 'string' || !rel.trim()) continue
|
|
111
120
|
if (rel === String(WHOLE_FILE_LINE)) continue
|
|
112
121
|
const cleaned = normalizePath(rel)
|
|
@@ -156,9 +165,18 @@ function collectFull(root: string): string[] {
|
|
|
156
165
|
/** Split `files` into stable batches, keeping directory runs together. */
|
|
157
166
|
export function chunkFiles(files: string[], perChunk?: number): string[][] {
|
|
158
167
|
// Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
|
|
159
|
-
// chunk (current.length >= NaN is never true).
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
// chunk (current.length >= NaN is never true). Math.floor: a fractional
|
|
169
|
+
// size (2.5) made chunks alternate between 2 and 3 members — the caller's
|
|
170
|
+
// "files per batch" is a count, so it must be an integer.
|
|
171
|
+
const size =
|
|
172
|
+
Number.isFinite(perChunk) && (perChunk as number) >= 1
|
|
173
|
+
? Math.floor(perChunk as number)
|
|
174
|
+
: DEFAULT_SCOPE_CHUNK_SIZE
|
|
175
|
+
// A non-array / sparse-free copy: callers pass tool-arg JSON, and sort()
|
|
176
|
+
// on a non-array (or `rel.includes` on a number) would throw.
|
|
177
|
+
const ordered = (Array.isArray(files) ? files : [])
|
|
178
|
+
.filter((rel): rel is string => typeof rel === 'string')
|
|
179
|
+
.sort()
|
|
162
180
|
const chunks: string[][] = []
|
|
163
181
|
let current: string[] = []
|
|
164
182
|
let lastDir: string | undefined
|
|
@@ -186,11 +204,16 @@ export function computeCoverage(
|
|
|
186
204
|
assigned: string[],
|
|
187
205
|
readFiles?: string[] | null,
|
|
188
206
|
): CoverageResult {
|
|
207
|
+
// Both lists are JSON-arg-derived: `for...of` over a bare number throws and
|
|
208
|
+
// over a bare string iterates single characters into the read set. Anything
|
|
209
|
+
// non-array behaves like "nothing" (empty inventory / nothing read).
|
|
210
|
+
const assignedList = Array.isArray(assigned) ? assigned : []
|
|
211
|
+
const readList = Array.isArray(readFiles) ? readFiles : []
|
|
189
212
|
const readNorm = new Set<string>()
|
|
190
|
-
for (const p of
|
|
213
|
+
for (const p of readList) {
|
|
191
214
|
if (typeof p === 'string' && p) readNorm.add(normalizePath(p))
|
|
192
215
|
}
|
|
193
|
-
const assignedSorted = [...
|
|
216
|
+
const assignedSorted = [...assignedList].filter((rel): rel is string => typeof rel === 'string').sort()
|
|
194
217
|
const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)))
|
|
195
218
|
const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)))
|
|
196
219
|
const rawRatio =
|
|
@@ -198,7 +221,7 @@ export function computeCoverage(
|
|
|
198
221
|
const ratio = Math.round(rawRatio * 1000) / 1000
|
|
199
222
|
return {
|
|
200
223
|
assigned: assignedSorted,
|
|
201
|
-
read: [...new Set(
|
|
224
|
+
read: [...new Set(readList.filter((p): p is string => typeof p === 'string'))].sort(),
|
|
202
225
|
covered,
|
|
203
226
|
uncovered,
|
|
204
227
|
ratio,
|
package/src/review.ts
CHANGED
|
@@ -54,9 +54,46 @@ export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
|
54
54
|
})
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Hard ceiling on the round cap the aggregation math will honor.
|
|
59
|
+
*
|
|
60
|
+
* Mirrors `MAX_MAX_ROUNDS` in `config-loader.ts` (the config bomb guard) —
|
|
61
|
+
* `test/review.test.ts` asserts the two constants stay in sync. The tool
|
|
62
|
+
* boundary only clamps the LOWER bound of `maxReviewRounds`, so an absurd
|
|
63
|
+
* caller value (1e15) would otherwise drive the `findingsByRound` allocation
|
|
64
|
+
* bound (`roundCap * 2`) straight into OOM territory.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_REVIEW_ROUNDS_CAP = 100
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clamp an untrusted round cap to `[1, MAX_REVIEW_ROUNDS_CAP]`.
|
|
70
|
+
*
|
|
71
|
+
* Non-finite / non-numeric input (`NaN`, `Infinity`, a string) collapses to
|
|
72
|
+
* 1 — the smallest sane cap. Without this, `Math.max(1, NaN)` stayed `NaN`
|
|
73
|
+
* and `new Array(NaN)` threw `RangeError` out of `aggregateRounds`.
|
|
74
|
+
*/
|
|
75
|
+
export function clampMaxReviewRounds(value: unknown): number {
|
|
76
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return 1
|
|
77
|
+
return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(value)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True when `n` is a round number the deterministic core accepts: a positive
|
|
82
|
+
* integer. Shared by `aggregateRounds` / `computeConvergence` /
|
|
83
|
+
* `buildReviewReport` (and re-read by the meta-review audit) so every reader
|
|
84
|
+
* of "the last round" agrees on which round numbers count — a fractional
|
|
85
|
+
* (`1.5`) or non-finite round is simply "not a round", never a convergence
|
|
86
|
+
* input that could be mis-indexed into `findingsByRound`.
|
|
87
|
+
*/
|
|
88
|
+
export function isValidRoundNumber(n: unknown): n is number {
|
|
89
|
+
return typeof n === 'number' && Number.isInteger(n) && n >= 1
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
58
93
|
export function normalizeSummary(summary: string): string {
|
|
59
|
-
|
|
94
|
+
// Coerce: `summary` is model-authored JSON when schema validation is off,
|
|
95
|
+
// so a numeric summary must not crash `.trim()` inside the dedupe key.
|
|
96
|
+
return String(summary ?? '')
|
|
60
97
|
.trim()
|
|
61
98
|
.toLowerCase()
|
|
62
99
|
.replace(/[\s\n\t]+/g, ' ')
|
|
@@ -69,8 +106,14 @@ export function normalizeSummary(summary: string): string {
|
|
|
69
106
|
* neither side anchors one, i.e. whole-file findings).
|
|
70
107
|
*/
|
|
71
108
|
export function findingKey(f: ReviewFinding): string {
|
|
72
|
-
|
|
73
|
-
|
|
109
|
+
// Defensive reads: with `output_schema_validation: false` a finding may
|
|
110
|
+
// carry wrong-typed fields (and `f` itself may be a null list element), and
|
|
111
|
+
// this key feeds the dedupe Set on EVERY round — it must never throw.
|
|
112
|
+
const src = (f ?? {}) as Partial<ReviewFinding>
|
|
113
|
+
const line = typeof src.line === 'number' && src.line > 0 ? src.line : 0
|
|
114
|
+
return `${String(src.file ?? '')}|${String(src.dimension ?? '')}|${line}|${normalizeSummary(
|
|
115
|
+
src.summary as string,
|
|
116
|
+
)}`
|
|
74
117
|
}
|
|
75
118
|
|
|
76
119
|
/**
|
|
@@ -81,6 +124,9 @@ export function dedupeFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
|
81
124
|
const seen = new Set<string>()
|
|
82
125
|
const out: ReviewFinding[] = []
|
|
83
126
|
for (const f of findings) {
|
|
127
|
+
// A null / array entry (hostile or schema-validation-off JSON) must never
|
|
128
|
+
// reach `summarize`, which reads `f.severity` unguarded.
|
|
129
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) continue
|
|
84
130
|
const key = findingKey(f)
|
|
85
131
|
if (seen.has(key)) continue
|
|
86
132
|
seen.add(key)
|
|
@@ -99,9 +145,16 @@ export function filterKnownIntentional(
|
|
|
99
145
|
findings: ReviewFinding[],
|
|
100
146
|
known: KnownIntentional[] | undefined,
|
|
101
147
|
): ReviewFinding[] {
|
|
102
|
-
|
|
148
|
+
// `known` is config/arg JSON: a non-array truthy value must behave like
|
|
149
|
+
// "no entries", never reach `known.some` and throw.
|
|
150
|
+
if (!Array.isArray(known) || known.length === 0) return findings
|
|
103
151
|
return findings.filter((f) => {
|
|
152
|
+
// A null / non-object finding has no file/dimension to match on; drop it
|
|
153
|
+
// here so it can never crash the comparator (aggregateRounds drops it too,
|
|
154
|
+
// so the report's counts stay consistent either way).
|
|
155
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) return false
|
|
104
156
|
const matched = known.some((k) => {
|
|
157
|
+
if (!k || typeof k !== 'object') return false
|
|
105
158
|
const sameFile = k.file === f.file
|
|
106
159
|
const sameDim = k.dimension === f.dimension
|
|
107
160
|
if (!sameFile || !sameDim) return false
|
|
@@ -145,13 +198,21 @@ export function aggregateRounds(
|
|
|
145
198
|
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
146
199
|
// numbers above the configured cap are clamped to the cap.
|
|
147
200
|
let maxRound = 0
|
|
148
|
-
|
|
149
|
-
|
|
201
|
+
// Upper-bounded too: `Math.max(1, NaN)` used to stay NaN and blow up in
|
|
202
|
+
// `new Array(effectiveMax)`, and an unbounded cap scaled the allocation
|
|
203
|
+
// bound to whatever the caller asked for.
|
|
204
|
+
const roundCap = clampMaxReviewRounds(maxReviewRounds)
|
|
205
|
+
for (const round of Array.isArray(rounds) ? rounds : []) {
|
|
150
206
|
if (!round || typeof round !== 'object') continue
|
|
151
|
-
if (
|
|
207
|
+
if (!isValidRoundNumber(round.round)) continue
|
|
152
208
|
const findings = Array.isArray(round.findings) ? round.findings : []
|
|
153
209
|
if (round.round > maxRound) maxRound = round.round
|
|
154
210
|
for (const f of findings) {
|
|
211
|
+
// Null / non-object elements (schema validation off) would crash
|
|
212
|
+
// `findingKey`; dropping them here keeps `findingsByRound` summing to
|
|
213
|
+
// exactly the returned findings list (the meta-review's CONVERGENCE_SUM
|
|
214
|
+
// check reads that equality).
|
|
215
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) continue
|
|
155
216
|
const key = findingKey(f)
|
|
156
217
|
if (seen.has(key)) continue
|
|
157
218
|
seen.add(key)
|
|
@@ -185,8 +246,9 @@ export function computeConvergence(
|
|
|
185
246
|
rounds: ReviewRound[],
|
|
186
247
|
maxReviewRounds: number,
|
|
187
248
|
): ReviewReport['convergence'] {
|
|
188
|
-
const
|
|
189
|
-
const
|
|
249
|
+
const list = Array.isArray(rounds) ? rounds : []
|
|
250
|
+
const { findingsByRound } = aggregateRounds(list, maxReviewRounds)
|
|
251
|
+
const totalRounds = list.length
|
|
190
252
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
191
253
|
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
192
254
|
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
@@ -194,13 +256,15 @@ export function computeConvergence(
|
|
|
194
256
|
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
195
257
|
// actually allocated.
|
|
196
258
|
let lastRound = 0
|
|
197
|
-
for (const round of
|
|
198
|
-
if (!
|
|
259
|
+
for (const round of list) {
|
|
260
|
+
if (!isValidRoundNumber(round?.round)) continue
|
|
199
261
|
if (round.round > lastRound) lastRound = round.round
|
|
200
262
|
}
|
|
201
263
|
const idx = Math.min(lastRound, findingsByRound.length) - 1
|
|
202
264
|
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
|
|
203
|
-
|
|
265
|
+
// `lastRound === 0` means NO round carried an interpretable number: claiming
|
|
266
|
+
// `converged` there would bless a report the core could not actually read.
|
|
267
|
+
const converged = totalRounds > 0 && lastRound > 0 && lastRoundCount === 0
|
|
204
268
|
return {
|
|
205
269
|
totalRounds,
|
|
206
270
|
findingsByRound,
|
|
@@ -222,7 +286,15 @@ function summarize(findings: ReviewFinding[]): ReviewReport['summary'] {
|
|
|
222
286
|
high: 0,
|
|
223
287
|
medium: 0,
|
|
224
288
|
low: 0,
|
|
225
|
-
|
|
289
|
+
// NULL-PROTOTYPE map: a model-authored `dimension` of `__proto__` or
|
|
290
|
+
// `constructor` would otherwise resolve against Object.prototype — the
|
|
291
|
+
// `__proto__` setter silently discards a primitive value and `constructor`
|
|
292
|
+
// string-concatenates the Object function — so the counter never
|
|
293
|
+
// accumulated and the meta-review's DIMENSION_SUM check false-positived.
|
|
294
|
+
// A null-prototype object has no inherited accessors, so every dimension
|
|
295
|
+
// (including those three names) is a plain own data property and
|
|
296
|
+
// JSON-serializes normally.
|
|
297
|
+
byDimension: Object.create(null) as Record<string, number>,
|
|
226
298
|
}
|
|
227
299
|
for (const f of findings) {
|
|
228
300
|
if (f.severity === 'critical') summary.critical++
|
|
@@ -252,7 +324,8 @@ export function buildReviewReport(input: {
|
|
|
252
324
|
fixedCount?: number
|
|
253
325
|
}): ReviewReport {
|
|
254
326
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
255
|
-
const
|
|
327
|
+
const inputRounds = Array.isArray(input.rounds) ? input.rounds : []
|
|
328
|
+
const filteredRounds = inputRounds
|
|
256
329
|
.map((r) => ({
|
|
257
330
|
round: typeof r?.round === 'number' ? r.round : 0,
|
|
258
331
|
findings: filterKnownIntentional(
|
|
@@ -284,7 +357,11 @@ export function buildReviewReport(input: {
|
|
|
284
357
|
// non-contiguous round sets would otherwise read the wrong count).
|
|
285
358
|
let lastRound = 0
|
|
286
359
|
for (const r of filteredRounds) {
|
|
287
|
-
|
|
360
|
+
// Same validity rule as aggregateRounds: a fractional / NaN round is not
|
|
361
|
+
// a round, so it must never be used as an index into `findingsByRound`
|
|
362
|
+
// (Math.min(1.5, len) - 1 = 0.5 → reads `undefined` → counts as 0 new →
|
|
363
|
+
// a round that really did report findings would be read as "converged").
|
|
364
|
+
if (isValidRoundNumber(r.round) && r.round > lastRound) lastRound = r.round
|
|
288
365
|
}
|
|
289
366
|
// `lastRound` can exceed the array length when over-cap rounds were FOLDED
|
|
290
367
|
// into the last slot (see aggregateRounds). Read the same clamped index
|
|
@@ -293,7 +370,7 @@ export function buildReviewReport(input: {
|
|
|
293
370
|
const idx = Math.min(lastRound, findingsByRound.length) - 1
|
|
294
371
|
const lastRoundCount =
|
|
295
372
|
lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
|
|
296
|
-
const converged = filteredRounds.length > 0 && lastRoundCount === 0
|
|
373
|
+
const converged = filteredRounds.length > 0 && lastRound > 0 && lastRoundCount === 0
|
|
297
374
|
|
|
298
375
|
// Attach the normal-mode fix count to the summary (dry-run leaves it absent).
|
|
299
376
|
const computed = summarize(sorted)
|
|
@@ -858,7 +935,9 @@ export function buildReviewPlan(input: {
|
|
|
858
935
|
goal,
|
|
859
936
|
scope,
|
|
860
937
|
dimensions: dimensionTasks,
|
|
861
|
-
|
|
938
|
+
// Clamped with the SAME rule aggregateRounds honors, so the cap this plan
|
|
939
|
+
// advertises is never a number the aggregation math would silently clamp.
|
|
940
|
+
maxReviewRounds: clampMaxReviewRounds(input.maxReviewRounds),
|
|
862
941
|
knownIntentional: input.knownIntentional ?? [],
|
|
863
942
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
864
943
|
fallbackToFull,
|