iterate-plugin 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/config-loader.js +14 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/skill-prompt.js +45 -15
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/lib/client.js +152 -80
- package/lib/parse.js +26 -17
- package/package.json +1 -1
- package/src/client/index.ts +144 -56
- package/src/config-loader.ts +12 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/skill-prompt.ts +45 -15
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/types.ts +12 -0
package/src/review-scope.ts
CHANGED
|
@@ -114,20 +114,23 @@ function collectChanged(changedFiles: string[]): string[] {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function collectFull(root: string): string[] {
|
|
117
|
-
// Deterministic
|
|
118
|
-
//
|
|
117
|
+
// Deterministic iterative walk (explicit stack — unbounded recursion could
|
|
118
|
+
// overflow on pathologically deep trees); a code reviewer never anchors
|
|
119
|
+
// findings to lock files, images, or vendored builds.
|
|
119
120
|
const out: string[] = []
|
|
120
|
-
const
|
|
121
|
+
const stack: string[] = [root]
|
|
122
|
+
while (stack.length > 0) {
|
|
123
|
+
const dir = stack.pop()!
|
|
121
124
|
let entries: import('node:fs').Dirent[]
|
|
122
125
|
try {
|
|
123
126
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
124
127
|
} catch {
|
|
125
|
-
|
|
128
|
+
continue
|
|
126
129
|
}
|
|
127
130
|
for (const entry of entries) {
|
|
128
131
|
const abs = join(dir, entry.name)
|
|
129
132
|
if (entry.isDirectory()) {
|
|
130
|
-
if (!isIgnoredDir(entry.name))
|
|
133
|
+
if (!isIgnoredDir(entry.name)) stack.push(abs)
|
|
131
134
|
continue
|
|
132
135
|
}
|
|
133
136
|
if (!entry.isFile()) continue
|
|
@@ -136,13 +139,14 @@ function collectFull(root: string): string[] {
|
|
|
136
139
|
out.push(rel.split(SEP).join(SEP))
|
|
137
140
|
}
|
|
138
141
|
}
|
|
139
|
-
walk(root)
|
|
140
142
|
return out.sort()
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
/** Split `files` into stable batches, keeping directory runs together. */
|
|
144
146
|
export function chunkFiles(files: string[], perChunk?: number): string[][] {
|
|
145
|
-
|
|
147
|
+
// Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
|
|
148
|
+
// chunk (current.length >= NaN is never true).
|
|
149
|
+
const size = Number.isFinite(perChunk) && (perChunk as number) >= 1 ? (perChunk as number) : DEFAULT_SCOPE_CHUNK_SIZE
|
|
146
150
|
const ordered = [...files].sort()
|
|
147
151
|
const chunks: string[][] = []
|
|
148
152
|
let current: string[] = []
|
package/src/review.ts
CHANGED
|
@@ -44,9 +44,11 @@ export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
|
44
44
|
const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low
|
|
45
45
|
const bySeverity = rankA - rankB
|
|
46
46
|
if (bySeverity !== 0) return bySeverity
|
|
47
|
-
|
|
47
|
+
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
48
|
+
// validation is disabled — String()/Number() keep the comparator total.
|
|
49
|
+
const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''))
|
|
48
50
|
if (byFile !== 0) return byFile
|
|
49
|
-
return (a.line
|
|
51
|
+
return (Number(a.line) || 0) - (Number(b.line) || 0)
|
|
50
52
|
})
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -58,9 +60,15 @@ export function normalizeSummary(summary: string): string {
|
|
|
58
60
|
.replace(/[\s\n\t]+/g, ' ')
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Dedupe key: same file + same dimension + similar summary + explicit line.
|
|
65
|
+
* Including the line keeps two genuine issues with identical wording at
|
|
66
|
+
* different locations from collapsing into one (the line is omitted only when
|
|
67
|
+
* neither side anchors one, i.e. whole-file findings).
|
|
68
|
+
*/
|
|
62
69
|
export function findingKey(f: ReviewFinding): string {
|
|
63
|
-
|
|
70
|
+
const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0
|
|
71
|
+
return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
/**
|
|
@@ -129,12 +137,19 @@ export function aggregateRounds(
|
|
|
129
137
|
const merged: ReviewFinding[] = []
|
|
130
138
|
|
|
131
139
|
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
132
|
-
// entries defensively rather than letting `firstRoundByKey` key on NaN/0
|
|
140
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
|
|
141
|
+
// crashing on null / non-array findings.
|
|
142
|
+
// Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
|
|
143
|
+
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
144
|
+
// numbers above the configured cap are clamped to the cap.
|
|
133
145
|
let maxRound = 0
|
|
146
|
+
const roundCap = Math.max(1, maxReviewRounds)
|
|
134
147
|
for (const round of rounds) {
|
|
148
|
+
if (!round || typeof round !== 'object') continue
|
|
135
149
|
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
|
|
150
|
+
const findings = Array.isArray(round.findings) ? round.findings : []
|
|
136
151
|
if (round.round > maxRound) maxRound = round.round
|
|
137
|
-
for (const f of
|
|
152
|
+
for (const f of findings) {
|
|
138
153
|
const key = findingKey(f)
|
|
139
154
|
if (seen.has(key)) continue
|
|
140
155
|
seen.add(key)
|
|
@@ -142,9 +157,11 @@ export function aggregateRounds(
|
|
|
142
157
|
merged.push(f)
|
|
143
158
|
}
|
|
144
159
|
}
|
|
160
|
+
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
161
|
+
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
|
|
145
162
|
|
|
146
163
|
const findingsByRound: number[] = []
|
|
147
|
-
for (let r = 1; r <=
|
|
164
|
+
for (let r = 1; r <= effectiveMax; r++) {
|
|
148
165
|
let count = 0
|
|
149
166
|
for (const key of firstRoundByKey.keys()) {
|
|
150
167
|
if (firstRoundByKey.get(key) === r) count++
|
|
@@ -165,12 +182,18 @@ export function computeConvergence(
|
|
|
165
182
|
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
|
|
166
183
|
const totalRounds = rounds.length
|
|
167
184
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
168
|
-
// r-1),
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
186
|
+
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
187
|
+
// may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
|
|
188
|
+
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
189
|
+
// actually allocated.
|
|
190
|
+
let lastRound = 0
|
|
191
|
+
for (const round of rounds) {
|
|
192
|
+
if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
|
|
193
|
+
if (round.round > lastRound) lastRound = round.round
|
|
194
|
+
}
|
|
195
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1
|
|
196
|
+
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
|
|
174
197
|
const converged = totalRounds > 0 && lastRoundCount === 0
|
|
175
198
|
return {
|
|
176
199
|
totalRounds,
|
|
@@ -224,8 +247,12 @@ export function buildReviewReport(input: {
|
|
|
224
247
|
}): ReviewReport {
|
|
225
248
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
226
249
|
const filteredRounds = input.rounds.map((r) => ({
|
|
227
|
-
round: r.round,
|
|
228
|
-
findings: filterKnownIntentional(
|
|
250
|
+
round: typeof r?.round === 'number' ? r.round : 0,
|
|
251
|
+
findings: filterKnownIntentional(
|
|
252
|
+
Array.isArray(r?.findings) ? r.findings : [],
|
|
253
|
+
input.knownIntentional,
|
|
254
|
+
),
|
|
255
|
+
readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
|
|
229
256
|
}))
|
|
230
257
|
|
|
231
258
|
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
@@ -262,6 +289,9 @@ export function buildReviewReport(input: {
|
|
|
262
289
|
maxReviewRounds: input.maxReviewRounds,
|
|
263
290
|
rounds: filteredRounds,
|
|
264
291
|
findings: sorted,
|
|
292
|
+
// Aggregate of every round's self-reported reads, so the meta-review
|
|
293
|
+
// coverage gate can compare against the assigned inventory.
|
|
294
|
+
readFiles: ([] as string[]).concat(...filteredRounds.map((r) => r.readFiles ?? [])),
|
|
265
295
|
convergence: {
|
|
266
296
|
totalRounds: filteredRounds.length,
|
|
267
297
|
findingsByRound,
|
|
@@ -470,8 +500,8 @@ export interface RoundSchemaValidation {
|
|
|
470
500
|
*/
|
|
471
501
|
export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
|
|
472
502
|
return rounds.map((r) => {
|
|
473
|
-
const issues = validateFindingsSchema(r.findings)
|
|
474
|
-
return { round: r.round, valid: issues.length === 0, issues }
|
|
503
|
+
const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : [])
|
|
504
|
+
return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues }
|
|
475
505
|
})
|
|
476
506
|
}
|
|
477
507
|
|
|
@@ -491,21 +521,27 @@ export function sanitizeRounds(
|
|
|
491
521
|
schemaValidation: RoundSchemaValidation[] | null,
|
|
492
522
|
): ReviewRound[] {
|
|
493
523
|
return rounds.map((r, i) => {
|
|
524
|
+
// Defensive: malformed rounds must never crash the deterministic core.
|
|
525
|
+
const findings = Array.isArray(r?.findings) ? r.findings : []
|
|
526
|
+
const roundNo = typeof r?.round === 'number' ? r.round : 0
|
|
527
|
+
const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : []
|
|
494
528
|
if (schemaValidation) {
|
|
495
529
|
const issues = schemaValidation[i]?.issues ?? []
|
|
496
|
-
if (issues.some((iss) => iss.index === -1)) return { round:
|
|
530
|
+
if (issues.some((iss) => iss.index === -1)) return { round: roundNo, findings: [], readFiles }
|
|
497
531
|
const bad = new Set(issues.map((iss) => iss.index))
|
|
498
532
|
return {
|
|
499
|
-
round:
|
|
500
|
-
findings:
|
|
533
|
+
round: roundNo,
|
|
534
|
+
findings: findings.filter((_, fi) => !bad.has(fi)),
|
|
535
|
+
readFiles,
|
|
501
536
|
}
|
|
502
537
|
}
|
|
503
538
|
return {
|
|
504
|
-
round:
|
|
505
|
-
findings:
|
|
539
|
+
round: roundNo,
|
|
540
|
+
findings: findings.filter(
|
|
506
541
|
(f): f is ReviewFinding =>
|
|
507
542
|
Boolean(f) && typeof f === 'object' && !Array.isArray(f),
|
|
508
543
|
),
|
|
544
|
+
readFiles,
|
|
509
545
|
}
|
|
510
546
|
})
|
|
511
547
|
}
|
|
@@ -539,6 +575,12 @@ export function reviewerTaskPrompt(input: {
|
|
|
539
575
|
* `changedFiles`.
|
|
540
576
|
*/
|
|
541
577
|
scopeFiles?: string[]
|
|
578
|
+
/**
|
|
579
|
+
* Per-dimension focus guidance (from personalization.dimension_focus, or the
|
|
580
|
+
* skill's dimension definitions). Appended to the reviewer prompt so the
|
|
581
|
+
* review concentrates on the areas the user cares about.
|
|
582
|
+
*/
|
|
583
|
+
focus?: string
|
|
542
584
|
}): string {
|
|
543
585
|
const parts: string[] = []
|
|
544
586
|
parts.push(
|
|
@@ -546,6 +588,9 @@ export function reviewerTaskPrompt(input: {
|
|
|
546
588
|
`Goal: ${input.goal}`,
|
|
547
589
|
`Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
|
|
548
590
|
)
|
|
591
|
+
if (input.focus) {
|
|
592
|
+
parts.push(`FOCUS: ${input.focus}`)
|
|
593
|
+
}
|
|
549
594
|
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
550
595
|
parts.push(
|
|
551
596
|
'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
@@ -592,9 +637,9 @@ export function reviewerTaskPrompt(input: {
|
|
|
592
637
|
parts.push(
|
|
593
638
|
`Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
|
|
594
639
|
`Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
595
|
-
'line (
|
|
596
|
-
'
|
|
597
|
-
'
|
|
640
|
+
'line (optional; the exact line you READ for a line-targeted issue; ' +
|
|
641
|
+
'0 or omitted for whole-file/module-level issues), ' +
|
|
642
|
+
'severity (critical/high/medium/low), summary (one line), ' +
|
|
598
643
|
'failure_scenario (how/when it fails, backed by the code you actually ' +
|
|
599
644
|
'read), suggested_fix (the concrete fix), ' +
|
|
600
645
|
`is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
|
|
@@ -672,6 +717,17 @@ export function buildReviewPlan(input: {
|
|
|
672
717
|
reviewerPrompt: string
|
|
673
718
|
findingsSchema: Record<string, unknown>
|
|
674
719
|
}[] = []
|
|
720
|
+
// personalization.dimension_focus: [{dimension, focus}] — appended to the
|
|
721
|
+
// matching dimension's reviewer prompt.
|
|
722
|
+
const focusMap = new Map<string, string>()
|
|
723
|
+
const pf = input.config.personalization as { dimension_focus?: { dimension?: string; focus?: string }[] } | undefined
|
|
724
|
+
if (pf && Array.isArray(pf.dimension_focus)) {
|
|
725
|
+
for (const entry of pf.dimension_focus) {
|
|
726
|
+
if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
|
|
727
|
+
focusMap.set(entry.dimension, entry.focus)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
675
731
|
for (const d of dimensions) {
|
|
676
732
|
batches.forEach((batch, index) => {
|
|
677
733
|
const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
|
|
@@ -687,6 +743,7 @@ export function buildReviewPlan(input: {
|
|
|
687
743
|
maxLines,
|
|
688
744
|
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
689
745
|
scopeFiles: batch,
|
|
746
|
+
focus: focusMap.get(d),
|
|
690
747
|
}),
|
|
691
748
|
findingsSchema: findingsSchema(),
|
|
692
749
|
})
|
package/src/skill-prompt.ts
CHANGED
|
@@ -89,14 +89,28 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
89
89
|
const nudge = retries > 0
|
|
90
90
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
91
91
|
: ''
|
|
92
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
93
|
+
// Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
|
|
94
|
+
// with the assigned file inventory, EVIDENCE RULE, output language) and
|
|
95
|
+
// append the round-specific context — the reviewers must receive the
|
|
96
|
+
// file inventory or the coverage machinery has nothing to enforce.
|
|
97
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
98
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
99
|
+
? meta.reviewerPrompt
|
|
100
|
+
: 'Review dimension "' + dim + '".'
|
|
101
|
+
const extra =
|
|
102
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
103
|
+
'\\n Already-known findings (do NOT re-report): ' +
|
|
104
|
+
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
105
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
106
|
+
}))
|
|
107
|
+
const thisRound = {
|
|
108
|
+
round: r,
|
|
109
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
110
|
+
// readFiles are threaded through so the aggregate/meta-review coverage
|
|
111
|
+
// gate can compare self-reported reads against the assigned inventory.
|
|
112
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
113
|
+
}
|
|
100
114
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
101
115
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
102
116
|
agg = await agent(
|
|
@@ -229,6 +243,11 @@ let failedCommands = []
|
|
|
229
243
|
phase('loop')
|
|
230
244
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
231
245
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
246
|
+
// Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
|
|
247
|
+
await agent(
|
|
248
|
+
'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
|
|
249
|
+
Object.assign({ label: 'log:start:r' + r }, backend)
|
|
250
|
+
)
|
|
232
251
|
let agg = null
|
|
233
252
|
let schemaInvalid = false
|
|
234
253
|
let retries = 0
|
|
@@ -237,13 +256,24 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
237
256
|
const nudge = retries > 0
|
|
238
257
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
239
258
|
: ''
|
|
240
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
259
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
260
|
+
// Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
|
|
261
|
+
// the assigned file inventory, EVIDENCE RULE, output language) plus the
|
|
262
|
+
// round-specific context.
|
|
263
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
264
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
265
|
+
? meta.reviewerPrompt
|
|
266
|
+
: 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
|
|
267
|
+
const extra =
|
|
268
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
269
|
+
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
270
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
271
|
+
}))
|
|
272
|
+
const thisRound = {
|
|
273
|
+
round: r,
|
|
274
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
275
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
276
|
+
}
|
|
247
277
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
248
278
|
|
|
249
279
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Checkpoint layout: `.iterate/checkpoint.json`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
14
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
15
15
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -107,7 +107,9 @@ export function computeStatus(input: {
|
|
|
107
107
|
totalRounds,
|
|
108
108
|
fixedCount,
|
|
109
109
|
architecturalCount,
|
|
110
|
-
|
|
110
|
+
// A checkpoint may predate the `findings` field (or be hand-edited) — a
|
|
111
|
+
// missing findings must degrade to 0, never throw.
|
|
112
|
+
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
111
113
|
totalDecisionLogEntries: entries.length,
|
|
112
114
|
hasCheckpoint: checkpoint !== null,
|
|
113
115
|
// A checkpoint left on disk means the previous run was interrupted before
|
|
@@ -209,7 +211,12 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
209
211
|
}
|
|
210
212
|
try {
|
|
211
213
|
mkdirSync(iterateDir(projectRoot), { recursive: true })
|
|
212
|
-
|
|
214
|
+
// Atomic write (temp + rename): a crash mid-write must not corrupt
|
|
215
|
+
// the checkpoint and silently lose the interruption state.
|
|
216
|
+
const cpPath = checkpointPath(projectRoot)
|
|
217
|
+
const tmpPath = `${cpPath}.tmp-${Date.now()}`
|
|
218
|
+
writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8')
|
|
219
|
+
renameSync(tmpPath, cpPath)
|
|
213
220
|
} catch (err) {
|
|
214
221
|
return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
|
|
215
222
|
}
|
package/src/tools/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
@@ -263,7 +263,8 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
263
263
|
return { found: false, error: resolved.reason, searched: [] }
|
|
264
264
|
}
|
|
265
265
|
const projectRoot = resolved.root
|
|
266
|
-
|
|
266
|
+
// Guard: `files` must be a comma-separated string (model-controlled).
|
|
267
|
+
const requested = (typeof args.files === 'string' ? args.files : '')
|
|
267
268
|
.split(',')
|
|
268
269
|
.map((s) => s.trim().toLowerCase())
|
|
269
270
|
.filter(Boolean)
|
|
@@ -295,7 +296,17 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
295
296
|
// are all supported.
|
|
296
297
|
const skillRoot = findSkillRoot(PLUGIN_SRC_DIR)
|
|
297
298
|
const candidates: string[] = []
|
|
298
|
-
|
|
299
|
+
// skillDir is a model-controlled path; only honor it when it is an
|
|
300
|
+
// existing directory (resolve it first) — otherwise fall through to
|
|
301
|
+
// the auto-detected root / project root.
|
|
302
|
+
if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
|
|
303
|
+
try {
|
|
304
|
+
const dir = resolve(args.skillDir)
|
|
305
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
|
|
306
|
+
} catch {
|
|
307
|
+
// unreadable/invalid skillDir — skip it
|
|
308
|
+
}
|
|
309
|
+
}
|
|
299
310
|
if (skillRoot) candidates.push(skillRoot)
|
|
300
311
|
candidates.push(projectRoot)
|
|
301
312
|
result.searched = candidates
|
|
@@ -52,12 +52,19 @@ function logPath(projectRoot: string): string {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Append one entry to the decision log (JSONL format).
|
|
55
|
-
* Returns the entry count after appending.
|
|
55
|
+
* Returns the entry count after appending. Never throws — a disk failure is
|
|
56
|
+
* surfaced through `error` so callers (fix/prune) can report the audit-trail
|
|
57
|
+
* miss without failing the mutation they already performed.
|
|
56
58
|
*/
|
|
57
|
-
export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
|
|
60
|
+
let filePath: string
|
|
61
|
+
try {
|
|
62
|
+
filePath = logPath(projectRoot)
|
|
63
|
+
const line = JSON.stringify(entry) + '\n'
|
|
64
|
+
appendFileSync(filePath, line, 'utf-8')
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { count: -1, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
67
|
+
}
|
|
61
68
|
// Count entries
|
|
62
69
|
let count = 0
|
|
63
70
|
try {
|
|
@@ -71,19 +78,29 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
|
|
|
71
78
|
|
|
72
79
|
/**
|
|
73
80
|
* Read all entries from the decision log.
|
|
81
|
+
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
82
|
+
* one bad line must never empty the whole history for every reader.
|
|
74
83
|
*/
|
|
75
84
|
export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
|
|
76
85
|
const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
|
|
77
86
|
if (!existsSync(filePath)) return []
|
|
87
|
+
let content: string
|
|
78
88
|
try {
|
|
79
|
-
|
|
80
|
-
return content
|
|
81
|
-
.split('\n')
|
|
82
|
-
.filter((l) => l.trim().length > 0)
|
|
83
|
-
.map((l) => JSON.parse(l) as DecisionLogEntry)
|
|
89
|
+
content = readFileSync(filePath, 'utf-8')
|
|
84
90
|
} catch {
|
|
85
91
|
return []
|
|
86
92
|
}
|
|
93
|
+
const out: DecisionLogEntry[] = []
|
|
94
|
+
for (const line of content.split('\n')) {
|
|
95
|
+
const trimmed = line.trim()
|
|
96
|
+
if (trimmed.length === 0) continue
|
|
97
|
+
try {
|
|
98
|
+
out.push(JSON.parse(trimmed) as DecisionLogEntry)
|
|
99
|
+
} catch {
|
|
100
|
+
// skip the corrupt line, keep the rest
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/**
|
package/src/tools/fix.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
-
import { join } from 'node:path'
|
|
20
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { join, sep } from 'node:path'
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
24
24
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -123,6 +123,11 @@ export function readRegistry(projectRoot: string): FixRegistry {
|
|
|
123
123
|
try {
|
|
124
124
|
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
|
|
125
125
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
|
|
126
|
+
// Defensive: a hand-edited or partially-written registry may contain a
|
|
127
|
+
// round without a `records` array — normalize instead of crashing readers.
|
|
128
|
+
parsed.rounds = parsed.rounds.filter(
|
|
129
|
+
(r) => r && typeof r === 'object' && Array.isArray(r.records),
|
|
130
|
+
)
|
|
126
131
|
return parsed
|
|
127
132
|
} catch {
|
|
128
133
|
return emptyRegistry()
|
|
@@ -198,11 +203,56 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
|
|
|
198
203
|
if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
|
|
199
204
|
return { ok: false, reason: 'file resolves outside the project root' }
|
|
200
205
|
}
|
|
206
|
+
// Symlink containment: the lexical prefix check above does not resolve
|
|
207
|
+
// symlinks. If the target exists, verify its REAL path stays inside the REAL
|
|
208
|
+
// project root so a symlinked directory/file inside the repo can never route
|
|
209
|
+
// a fix (write/rollback/diff) outside the project.
|
|
210
|
+
if (existsSync(resolved)) {
|
|
211
|
+
let rootReal: string
|
|
212
|
+
let real: string
|
|
213
|
+
try {
|
|
214
|
+
rootReal = realpathSync(projectRoot)
|
|
215
|
+
real = realpathSync(resolved)
|
|
216
|
+
} catch {
|
|
217
|
+
return { ok: false, reason: 'failed to resolve real path for containment check' }
|
|
218
|
+
}
|
|
219
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
|
|
220
|
+
if (real !== rootReal && !real.startsWith(rootPrefix)) {
|
|
221
|
+
return { ok: false, reason: 'file resolves outside the project root (symlink escape)' }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
201
224
|
return { ok: true, resolved }
|
|
202
225
|
}
|
|
203
226
|
|
|
204
227
|
// ─── Shared execute helpers ──────────────────────────────────────────────────
|
|
205
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Minimal glob matcher for personalization.protected_paths.
|
|
231
|
+
* Supports `*` (any run of chars within one segment) and `**` (any chars,
|
|
232
|
+
* including separators). All other characters are literal. Pure, unit-testable.
|
|
233
|
+
*/
|
|
234
|
+
export function globMatch(path: string, pattern: string): boolean {
|
|
235
|
+
if (typeof path !== 'string' || typeof pattern !== 'string') return false
|
|
236
|
+
// Escape regex specials except our two wildcards.
|
|
237
|
+
let re = ''
|
|
238
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
239
|
+
const ch = pattern[i] as string
|
|
240
|
+
if (ch === '*') {
|
|
241
|
+
const isDouble = pattern[i + 1] === '*'
|
|
242
|
+
if (isDouble) { re += '[\\s\\S]*'; i++ } else { re += '[^/\\\\]*' }
|
|
243
|
+
} else if ('.[]{}()+-^$|?'.includes(ch)) {
|
|
244
|
+
re += '\\' + ch
|
|
245
|
+
} else {
|
|
246
|
+
re += ch
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return new RegExp('^' + re + '$').test(path)
|
|
251
|
+
} catch {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
206
256
|
/** Read the current content of a file under the project root. */
|
|
207
257
|
function readProjectFile(projectRoot: string, file: string): { ok: true; content: string } | { ok: false; reason: string } {
|
|
208
258
|
const resolved = resolveProjectFile(projectRoot, file)
|
|
@@ -314,6 +364,28 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
314
364
|
if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
|
|
315
365
|
return { ok: false, error: 'finding.dimension must be a non-empty string' }
|
|
316
366
|
}
|
|
367
|
+
// The finding must reference the file being fixed — the fix id and the
|
|
368
|
+
// rollback/diff target are derived from finding.file, so a mismatch
|
|
369
|
+
// would back up/restore the WRONG file.
|
|
370
|
+
if (finding.file !== file) {
|
|
371
|
+
return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` }
|
|
372
|
+
}
|
|
373
|
+
// Full finding validation, mirroring the review schema: malformed
|
|
374
|
+
// findings would produce lossy registry/log entries and a degraded id.
|
|
375
|
+
const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low'])
|
|
376
|
+
if (!SEVERITY_SET.has(finding.severity)) {
|
|
377
|
+
return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' }
|
|
378
|
+
}
|
|
379
|
+
if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
|
|
380
|
+
return { ok: false, error: 'finding.summary must be a non-empty string' }
|
|
381
|
+
}
|
|
382
|
+
if (typeof finding.is_atomic !== 'boolean') {
|
|
383
|
+
return { ok: false, error: 'finding.is_atomic must be a boolean' }
|
|
384
|
+
}
|
|
385
|
+
if (finding.line !== undefined && finding.line !== null &&
|
|
386
|
+
(typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
|
|
387
|
+
return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' }
|
|
388
|
+
}
|
|
317
389
|
|
|
318
390
|
const current = readProjectFile(projectRoot, file)
|
|
319
391
|
if (!current.ok) return { ok: false, error: current.reason }
|
|
@@ -346,6 +418,30 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
346
418
|
const target = resolveProjectFile(projectRoot, file)
|
|
347
419
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
348
420
|
|
|
421
|
+
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
422
|
+
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
423
|
+
// new content. Both are security-relevant, so they are enforced here
|
|
424
|
+
// in the tool, not left to the model.
|
|
425
|
+
const pers = config.personalization as
|
|
426
|
+
| { protected_paths?: unknown; forbidden_fixes?: unknown }
|
|
427
|
+
| undefined
|
|
428
|
+
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
429
|
+
? (pers.protected_paths as unknown[]).filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
430
|
+
: []
|
|
431
|
+
for (const pattern of protectedPaths) {
|
|
432
|
+
if (globMatch(file, pattern)) {
|
|
433
|
+
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
|
|
437
|
+
? (pers.forbidden_fixes as unknown[]).filter((f): f is string => typeof f === 'string' && f.length > 0)
|
|
438
|
+
: []
|
|
439
|
+
for (const forbidden of forbiddenFixes) {
|
|
440
|
+
if (args.content.includes(forbidden)) {
|
|
441
|
+
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
349
445
|
const timestamp = new Date().toISOString()
|
|
350
446
|
const backupPath = fixBackupPath(projectRoot, id, timestamp)
|
|
351
447
|
try {
|
|
@@ -376,7 +472,19 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
376
472
|
try {
|
|
377
473
|
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
|
|
378
474
|
} catch (err) {
|
|
379
|
-
|
|
475
|
+
// Registry write failed → the file was already modified but no record
|
|
476
|
+
// exists, so a later rollback/diff could never see it and a retry would
|
|
477
|
+
// back up the already-fixed content as "original". Restore the file
|
|
478
|
+
// from the backup to leave the tree exactly as it was.
|
|
479
|
+
try {
|
|
480
|
+
copyFileSync(backupPath, target.resolved)
|
|
481
|
+
} catch (restoreErr) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
|
|
380
488
|
}
|
|
381
489
|
|
|
382
490
|
appendDecisionEntry(projectRoot, {
|
|
@@ -485,6 +593,9 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
|
|
|
485
593
|
if (existing) {
|
|
486
594
|
existing.linesAdded += r.linesAdded
|
|
487
595
|
existing.linesRemoved += r.linesRemoved
|
|
596
|
+
// Recompute the summary from the summed counts so a multi-fix
|
|
597
|
+
// file's text does not contradict its accumulated numbers.
|
|
598
|
+
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`
|
|
488
599
|
} else {
|
|
489
600
|
files.push({
|
|
490
601
|
file: r.finding.file,
|