iterate-plugin 2.3.7 → 2.4.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/lib/client.js +635 -0
- package/lib/parse.js +459 -0
- package/package.json +11 -4
- package/src/index.ts +6 -4
- package/src/review.ts +13 -6
- package/src/tools/decision-log.ts +10 -0
- package/src/tools/triage.ts +370 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import yaml from 'js-yaml'
|
|
6
|
+
import { resolveProjectRoot } from '../config-loader.ts'
|
|
7
|
+
import type { KnownIntentional } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILE = 'iterate.config.yaml'
|
|
10
|
+
|
|
11
|
+
/** Personalization key that holds the known-intentional list. */
|
|
12
|
+
const PERSONALIZATION_KEY = 'personalization'
|
|
13
|
+
const KNOWN_INTENTIONAL_KEY = 'known_intentional'
|
|
14
|
+
|
|
15
|
+
/** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
|
|
16
|
+
const WHOLE_FILE_LINE = 0
|
|
17
|
+
|
|
18
|
+
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a caller-supplied `line` value.
|
|
22
|
+
* Returns a positive integer, or `undefined` when the value is absent,
|
|
23
|
+
* non-numeric, or non-positive (which is the "whole file" semantics).
|
|
24
|
+
*
|
|
25
|
+
* @param {unknown} line
|
|
26
|
+
* @returns {number | undefined}
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeEntryLine(line: unknown): number | undefined {
|
|
29
|
+
if (typeof line !== 'number' || !Number.isInteger(line)) return undefined
|
|
30
|
+
if (line <= 0) return undefined
|
|
31
|
+
return line
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate an array of triage entries. Each entry must be an object with
|
|
36
|
+
* non-empty string `file` / `dimension` / `reason`, and an optional positive
|
|
37
|
+
* integer `line`.
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} entries
|
|
40
|
+
* @returns {string[]} Validation error messages (empty when valid).
|
|
41
|
+
*/
|
|
42
|
+
export function validateTriageEntries(entries: unknown): string[] {
|
|
43
|
+
const errors: string[] = []
|
|
44
|
+
if (!Array.isArray(entries)) {
|
|
45
|
+
errors.push('entries must be an array')
|
|
46
|
+
return errors
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < entries.length; i++) {
|
|
49
|
+
const prefix = `entries[${i}]`
|
|
50
|
+
const e = entries[i]
|
|
51
|
+
if (!e || typeof e !== 'object') {
|
|
52
|
+
errors.push(`${prefix} must be an object`)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const entry = e as Record<string, unknown>
|
|
56
|
+
if (typeof entry.file !== 'string' || entry.file.trim().length === 0) {
|
|
57
|
+
errors.push(`${prefix}.file must be a non-empty string`)
|
|
58
|
+
}
|
|
59
|
+
if (typeof entry.dimension !== 'string' || entry.dimension.trim().length === 0) {
|
|
60
|
+
errors.push(`${prefix}.dimension must be a non-empty string`)
|
|
61
|
+
}
|
|
62
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim().length === 0) {
|
|
63
|
+
errors.push(`${prefix}.reason must be a non-empty string`)
|
|
64
|
+
}
|
|
65
|
+
if (entry.line !== undefined && normalizeEntryLine(entry.line) === undefined) {
|
|
66
|
+
errors.push(`${prefix}.line must be a positive integer when present`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return errors
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the dedupe key for a known-intentional entry.
|
|
74
|
+
* Semantics mirror review.ts filterKnownIntentional: a whole-file entry
|
|
75
|
+
* (`line` 0/undefined) is distinct from a line-specific one.
|
|
76
|
+
*
|
|
77
|
+
* @param {KnownIntentional} entry
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function entryKey(entry: KnownIntentional): string {
|
|
81
|
+
const line = normalizeEntryLine(entry.line) ?? WHOLE_FILE_LINE
|
|
82
|
+
return `${entry.file}|${entry.dimension}|${line}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Merge incoming entries into the existing known-intentional list.
|
|
87
|
+
* Existing entries are never mutated; incoming entries whose key already
|
|
88
|
+
* exists are skipped. Returns the merged list plus add/skip counts.
|
|
89
|
+
*
|
|
90
|
+
* @param {KnownIntentional[]} existing
|
|
91
|
+
* @param {KnownIntentional[]} incoming
|
|
92
|
+
* @returns {{ merged: KnownIntentional[], added: number, skipped: number }}
|
|
93
|
+
*/
|
|
94
|
+
export function mergeKnownIntentional(
|
|
95
|
+
existing: KnownIntentional[],
|
|
96
|
+
incoming: KnownIntentional[],
|
|
97
|
+
): { merged: KnownIntentional[]; added: number; skipped: number } {
|
|
98
|
+
const seen = new Set<string>()
|
|
99
|
+
const merged: KnownIntentional[] = []
|
|
100
|
+
for (const entry of existing) {
|
|
101
|
+
const key = entryKey(entry)
|
|
102
|
+
if (!seen.has(key)) {
|
|
103
|
+
seen.add(key)
|
|
104
|
+
merged.push(entry)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let added = 0
|
|
108
|
+
let skipped = 0
|
|
109
|
+
for (const entry of incoming) {
|
|
110
|
+
const key = entryKey(entry)
|
|
111
|
+
if (seen.has(key)) {
|
|
112
|
+
skipped++
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
seen.add(key)
|
|
116
|
+
merged.push(entry)
|
|
117
|
+
added++
|
|
118
|
+
}
|
|
119
|
+
return { merged, added, skipped }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build a NEW config object with `personalization.known_intentional` set to
|
|
124
|
+
* the merged entries. All other top-level fields are preserved unchanged.
|
|
125
|
+
* Returns a deep-enough copy so the caller can serialize it safely.
|
|
126
|
+
*
|
|
127
|
+
* @param {Record<string, unknown>} config
|
|
128
|
+
* @param {KnownIntentional[]} entries
|
|
129
|
+
* @returns {Record<string, unknown>}
|
|
130
|
+
*/
|
|
131
|
+
export function buildConfigWithKnownIntentional(
|
|
132
|
+
config: Record<string, unknown>,
|
|
133
|
+
entries: KnownIntentional[],
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const next: Record<string, unknown> = { ...config }
|
|
136
|
+
const personalization =
|
|
137
|
+
next[PERSONALIZATION_KEY] && typeof next[PERSONALIZATION_KEY] === 'object'
|
|
138
|
+
? { ...(next[PERSONALIZATION_KEY] as Record<string, unknown>) }
|
|
139
|
+
: {}
|
|
140
|
+
personalization[KNOWN_INTENTIONAL_KEY] = entries
|
|
141
|
+
next[PERSONALIZATION_KEY] = personalization
|
|
142
|
+
return next
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read the raw known-intentional list from a config object (may be absent). */
|
|
146
|
+
export function readKnownIntentional(
|
|
147
|
+
config: Record<string, unknown>,
|
|
148
|
+
): KnownIntentional[] {
|
|
149
|
+
const personalization = config[PERSONALIZATION_KEY]
|
|
150
|
+
if (!personalization || typeof personalization !== 'object') return []
|
|
151
|
+
const known = (personalization as Record<string, unknown>)[KNOWN_INTENTIONAL_KEY]
|
|
152
|
+
if (!Array.isArray(known)) return []
|
|
153
|
+
return known.filter(
|
|
154
|
+
(e): e is KnownIntentional =>
|
|
155
|
+
!!e &&
|
|
156
|
+
typeof e === 'object' &&
|
|
157
|
+
typeof (e as Record<string, unknown>).file === 'string',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Build a filesystem-safe backup suffix from the current time. */
|
|
162
|
+
export function backupSuffix(now = new Date()): string {
|
|
163
|
+
return now.toISOString().replace(/[:.]/g, '-')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── File I/O ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** Load the raw config object (empty when the file is missing). */
|
|
169
|
+
function readConfigFile(configPath: string): Record<string, unknown> {
|
|
170
|
+
if (!existsSync(configPath)) return {}
|
|
171
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
172
|
+
const parsed = yaml.load(content)
|
|
173
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
174
|
+
// A config that exists but is not a YAML mapping must NOT be silently
|
|
175
|
+
// treated as empty: writing over it would destroy user data. Callers
|
|
176
|
+
// surface this as an error and refuse to write.
|
|
177
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
178
|
+
}
|
|
179
|
+
return parsed as Record<string, unknown>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Apply the triage entries: backup, merge, write, rollback on failure. */
|
|
183
|
+
function applyEntries(
|
|
184
|
+
projectRoot: string,
|
|
185
|
+
incoming: KnownIntentional[],
|
|
186
|
+
): {
|
|
187
|
+
ok: true
|
|
188
|
+
added: number
|
|
189
|
+
skipped: number
|
|
190
|
+
count: number
|
|
191
|
+
configPath: string
|
|
192
|
+
backupPath: string | null
|
|
193
|
+
} | {
|
|
194
|
+
ok: false
|
|
195
|
+
error: string
|
|
196
|
+
} {
|
|
197
|
+
const configPath = join(projectRoot, CONFIG_FILE)
|
|
198
|
+
let config: Record<string, unknown>
|
|
199
|
+
try {
|
|
200
|
+
config = readConfigFile(configPath)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// The file exists but is malformed — refuse to overwrite user data.
|
|
203
|
+
return { ok: false, error: `Failed to read config: ${String(err)}` }
|
|
204
|
+
}
|
|
205
|
+
const existing = readKnownIntentional(config)
|
|
206
|
+
const { merged, added, skipped } = mergeKnownIntentional(existing, incoming)
|
|
207
|
+
const nextConfig = buildConfigWithKnownIntentional(config, merged)
|
|
208
|
+
|
|
209
|
+
const hadFile = existsSync(configPath)
|
|
210
|
+
const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
|
|
211
|
+
|
|
212
|
+
if (backupPath) {
|
|
213
|
+
try {
|
|
214
|
+
copyFileSync(configPath, backupPath)
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `Failed to create backup: ${String(err)}`,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const yamlText = yaml.dump(nextConfig, { noRefs: true })
|
|
224
|
+
try {
|
|
225
|
+
writeFileSync(configPath, yamlText, 'utf-8')
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Rollback: restore the backup (or delete the file we just created).
|
|
228
|
+
try {
|
|
229
|
+
if (backupPath) copyFileSync(backupPath, configPath)
|
|
230
|
+
else if (existsSync(configPath)) writeFileSync(configPath, '', 'utf-8')
|
|
231
|
+
} catch {
|
|
232
|
+
// Rollback failure is reported, not swallowed silently.
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
error: `Failed to write config: ${String(err)}`,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Register the `iterate_triage` tool.
|
|
245
|
+
*
|
|
246
|
+
* Completes the findings-triage closed loop: the client triage panel marks
|
|
247
|
+
* findings as "known intentional" (a), and this tool writes those entries
|
|
248
|
+
* into `iterate.config.yaml` under `personalization.known_intentional` so the
|
|
249
|
+
* next review round filters them out (review.ts filterKnownIntentional).
|
|
250
|
+
*
|
|
251
|
+
* Operations:
|
|
252
|
+
* - `apply`: merge validated entries into the config (dedupe by
|
|
253
|
+
* file|dimension|line), with an automatic timestamped backup and
|
|
254
|
+
* rollback if the write fails.
|
|
255
|
+
* - `list`: read back the current known_intentional entries.
|
|
256
|
+
*/
|
|
257
|
+
export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
258
|
+
ctx.tools.register(
|
|
259
|
+
defineTool({
|
|
260
|
+
name: 'iterate_triage',
|
|
261
|
+
description:
|
|
262
|
+
'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
|
|
263
|
+
'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
|
|
264
|
+
'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
|
|
265
|
+
'backed up before writing. Use `list` to read the current entries. ' +
|
|
266
|
+
'The client browser cannot write files, so this tool is the write-back channel for the triage panel.',
|
|
267
|
+
parameters: {
|
|
268
|
+
operation: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
required: true,
|
|
271
|
+
description: '"apply" to merge entries into the config, "list" to read them back.',
|
|
272
|
+
enum: ['apply', 'list'],
|
|
273
|
+
},
|
|
274
|
+
entries: {
|
|
275
|
+
type: 'json',
|
|
276
|
+
description:
|
|
277
|
+
'For `apply`: array of known-intentional entries, e.g. ' +
|
|
278
|
+
'[{"file":"src/a.ts","line":42,"dimension":"security","reason":"..."}]. ' +
|
|
279
|
+
'Each entry needs non-empty string file/dimension/reason; line is an optional positive integer ' +
|
|
280
|
+
'(omitted = whole file).',
|
|
281
|
+
},
|
|
282
|
+
path: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
description: 'Project root directory (default: current working directory).',
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
output: {
|
|
289
|
+
schema: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
additionalProperties: false,
|
|
292
|
+
properties: {
|
|
293
|
+
operation: { type: 'string', required: true },
|
|
294
|
+
added: { type: 'integer' },
|
|
295
|
+
skipped: { type: 'integer' },
|
|
296
|
+
count: { type: 'integer' },
|
|
297
|
+
path: { type: 'string' },
|
|
298
|
+
backupPath: { type: 'string' },
|
|
299
|
+
entries: { type: 'json' },
|
|
300
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
301
|
+
error: { type: 'string' },
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
render: (_args, value) => [
|
|
305
|
+
{ type: 'text', text: JSON.stringify(value, null, 2) },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async execute(args) {
|
|
310
|
+
const resolved = resolveProjectRoot(args.path)
|
|
311
|
+
if (!resolved.ok) {
|
|
312
|
+
return { operation: args.operation, error: resolved.reason }
|
|
313
|
+
}
|
|
314
|
+
const projectRoot = resolved.root
|
|
315
|
+
const configPath = join(projectRoot, CONFIG_FILE)
|
|
316
|
+
|
|
317
|
+
if (args.operation === 'list') {
|
|
318
|
+
let config: Record<string, unknown>
|
|
319
|
+
try {
|
|
320
|
+
config = readConfigFile(configPath)
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return { operation: 'list', error: `Failed to read config: ${String(err)}` }
|
|
323
|
+
}
|
|
324
|
+
const entries = readKnownIntentional(config)
|
|
325
|
+
return {
|
|
326
|
+
operation: 'list',
|
|
327
|
+
count: entries.length,
|
|
328
|
+
path: configPath,
|
|
329
|
+
entries: entries as unknown as JsonValue,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (args.operation === 'apply') {
|
|
334
|
+
const validation = validateTriageEntries(args.entries)
|
|
335
|
+
if (validation.length > 0) {
|
|
336
|
+
return { operation: 'apply', errors: validation, error: 'Invalid entries.' }
|
|
337
|
+
}
|
|
338
|
+
const incoming = (args.entries as unknown[]).map((e) => {
|
|
339
|
+
const raw = e as Record<string, unknown>
|
|
340
|
+
return {
|
|
341
|
+
file: String(raw.file),
|
|
342
|
+
...(normalizeEntryLine(raw.line) !== undefined
|
|
343
|
+
? { line: normalizeEntryLine(raw.line) as number }
|
|
344
|
+
: {}),
|
|
345
|
+
dimension: String(raw.dimension),
|
|
346
|
+
reason: String(raw.reason),
|
|
347
|
+
} as KnownIntentional
|
|
348
|
+
})
|
|
349
|
+
const result = applyEntries(projectRoot, incoming)
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
return { operation: 'apply', error: result.error }
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
operation: 'apply',
|
|
355
|
+
added: result.added,
|
|
356
|
+
skipped: result.skipped,
|
|
357
|
+
count: result.count,
|
|
358
|
+
path: result.configPath,
|
|
359
|
+
backupPath: result.backupPath ?? undefined,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
operation: args.operation,
|
|
365
|
+
error: 'Unknown operation. Use "apply" or "list".',
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
)
|
|
370
|
+
}
|