iterate-plugin 3.5.1 → 3.5.3

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.
@@ -24,16 +24,41 @@ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGat
24
24
 
25
25
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
26
26
 
27
+ /** Max findings persisted in a checkpoint (a model-authored findings payload is
28
+ * bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
29
+ export const MAX_CHECKPOINT_FINDINGS = 1000
30
+
27
31
  /** Read the current checkpoint from disk (missing/corrupt → null). */
28
32
  export function readCheckpoint(projectRoot: string): IterationCheckpoint | null {
29
33
  const file = checkpointPath(projectRoot)
30
34
  if (!existsSync(file)) return null
31
35
  try {
32
- const parsed = JSON.parse(readFileSync(file, 'utf-8')) as IterationCheckpoint
36
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<IterationCheckpoint>
33
37
  if (!parsed || typeof parsed !== 'object') return null
34
38
  if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal') return null
35
- if (typeof parsed.round !== 'number') return null
36
- return parsed
39
+ // Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
40
+ // `NaN < 0` also false) round values must not survive into the status.
41
+ const round =
42
+ typeof parsed.round === 'number' && Number.isFinite(parsed.round)
43
+ ? Math.floor(parsed.round)
44
+ : null
45
+ if (round === null || round < 0) return null
46
+ const num = (v: unknown, fallback: number): number =>
47
+ typeof v === 'number' && Number.isFinite(v) ? v : fallback
48
+ // Normalize every field so a hand-edited/corrupt checkpoint (string
49
+ // maxRounds, missing fixedCount, …) can never leak a non-numeric value
50
+ // into the status output or crash a consumer.
51
+ return {
52
+ mode: parsed.mode,
53
+ round,
54
+ maxRounds: num(parsed.maxRounds, round),
55
+ fixedCount: num(parsed.fixedCount, 0),
56
+ architecturalCount: num(parsed.architecturalCount, 0),
57
+ resumeCount: num(parsed.resumeCount, 0),
58
+ findings: Array.isArray(parsed.findings) ? (parsed.findings as IterationCheckpoint['findings']) : [],
59
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
60
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
61
+ }
37
62
  } catch {
38
63
  return null
39
64
  }
@@ -82,6 +107,17 @@ export function validateCheckpoint(input: {
82
107
  ) {
83
108
  return 'resumeCount must be a non-negative integer'
84
109
  }
110
+ // A round that claims to be past the configured cap is an inconsistent save —
111
+ // the status would render `Round X / Y` with X > Y, which is never legitimate
112
+ // for a resumed run (resume continues AT the checkpoint, it does not exceed
113
+ // the cap).
114
+ if (
115
+ typeof input.round === 'number' &&
116
+ typeof input.maxRounds === 'number' &&
117
+ input.round > input.maxRounds
118
+ ) {
119
+ return 'round must not exceed maxRounds'
120
+ }
85
121
  return null
86
122
  }
87
123
 
@@ -118,8 +154,11 @@ export function computeStatus(input: {
118
154
  }
119
155
 
120
156
  const totalRounds = checkpoint?.maxRounds ?? currentRound
121
- const registryFixed = registry.rounds.reduce((sum, r) => sum + r.fixedCount, 0)
122
- const failedCount = registry.rounds.reduce((sum, r) => sum + r.failedCount, 0)
157
+ // Coerce the registry's per-round counts defensively: a hand-edited registry
158
+ // round missing fixedCount/failedCount would otherwise feed NaN into the
159
+ // status integer fields.
160
+ const registryFixed = registry.rounds.reduce((sum, r) => sum + (Number(r.fixedCount) || 0), 0)
161
+ const failedCount = registry.rounds.reduce((sum, r) => sum + (Number(r.failedCount) || 0), 0)
123
162
  // When a checkpoint exists, its snapshot fields are authoritative for resume
124
163
  // (fixedCount / architecturalCount / findings); otherwise derive from the
125
164
  // live fix registry and decision log.
@@ -258,7 +297,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
258
297
  fixedCount: args.fixedCount as number,
259
298
  architecturalCount: args.architecturalCount as number,
260
299
  resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
261
- findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
300
+ findings: (Array.isArray(args.findings) ? args.findings.slice(0, MAX_CHECKPOINT_FINDINGS) : []) as unknown as IterationCheckpoint['findings'],
262
301
  startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
263
302
  updatedAt: new Date().toISOString(),
264
303
  }
@@ -1,7 +1,8 @@
1
1
  import { join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
4
- import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
+ import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
6
  import {
6
7
  applyConfigUpdates,
7
8
  readRawConfig,
@@ -106,7 +107,15 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
106
107
  }
107
108
  let base: Record<string, unknown>
108
109
  try {
109
- base = readRawConfig(join(projectRoot, 'iterate.config.yaml'))
110
+ // Partial updates must be mergeable on a FRESH project (no config
111
+ // file yet): otherwise `{max_rounds: 5}` alone fails schema
112
+ // validation with a misleading "missing goal". Merge against the
113
+ // built-in defaults when there is no file to read, so the written
114
+ // config captures the effective config (defaults + update) and the
115
+ // documented "write a partial update" contract works out of the box.
116
+ base = existsSync(join(projectRoot, 'iterate.config.yaml'))
117
+ ? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
118
+ : (defaultConfig() as unknown as Record<string, unknown>)
110
119
  } catch (err) {
111
120
  return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
112
121
  }
@@ -1,5 +1,5 @@
1
- import { readFileSync, existsSync, statSync } from 'node:fs'
2
- import { join, dirname, resolve } from 'node:path'
1
+ import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs'
2
+ import { join, dirname, resolve, sep } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
5
  import { resolveProjectRootForExec } from '../config-loader.ts'
@@ -7,6 +7,33 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
7
7
  /** How many ancestor directories we walk up looking for a SKILL.md. */
8
8
  const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
9
9
 
10
+ /**
11
+ * Whether a model-supplied `skillDir` may be used as a SKILL.md search root.
12
+ *
13
+ * `skillDir` is model-controlled, so it is only honored when it is an existing
14
+ * directory whose REAL path (symlinks resolved) sits inside one of the allowed
15
+ * roots (the project root or the plugin directory). Anything else — a path
16
+ * outside the allowed roots, a nonexistent dir, or a symlink pointing outside
17
+ * (e.g. at `/etc`) — is rejected so arbitrary file contents can never be
18
+ * injected wholesale into the review context.
19
+ */
20
+ export function isAllowedSkillDir(skillDir: string, allowedRoots: readonly string[]): boolean {
21
+ if (typeof skillDir !== 'string' || skillDir.trim().length === 0) return false
22
+ try {
23
+ const dir = resolve(skillDir)
24
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return false
25
+ return allowedRoots.some((root) => {
26
+ if (typeof root !== 'string' || root.length === 0) return false
27
+ const rootReal = realpathSync(root)
28
+ const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
29
+ const real = realpathSync(dir)
30
+ return real === rootReal || real.startsWith(rootPrefix)
31
+ })
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
10
37
  /** Maximum number of image attachments relayed into the context in one call. */
11
38
  const MAX_ATTACHMENTS = 8
12
39
 
@@ -304,8 +331,13 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
304
331
  // the auto-detected root / project root.
305
332
  if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
306
333
  try {
307
- const dir = resolve(args.skillDir)
308
- if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
334
+ // Model-controlled path: only honor it when its REAL path stays
335
+ // inside the project root or the plugin directory — never let a
336
+ // skillDir point at arbitrary filesystem locations (e.g. /etc)
337
+ // whose contents would be injected wholesale into the context.
338
+ if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
339
+ candidates.push(resolve(args.skillDir))
340
+ }
309
341
  } catch {
310
342
  // unreadable/invalid skillDir — skip it
311
343
  }
@@ -106,7 +106,16 @@ export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
106
106
  const trimmed = line.trim()
107
107
  if (trimmed.length === 0) continue
108
108
  try {
109
- entries.push(JSON.parse(trimmed) as DecisionLogEntry)
109
+ const parsed: unknown = JSON.parse(trimmed)
110
+ if (
111
+ parsed === null || typeof parsed !== 'object' ||
112
+ typeof (parsed as { timestamp?: unknown }).timestamp !== 'string' ||
113
+ typeof (parsed as { type?: unknown }).type !== 'string'
114
+ ) {
115
+ invalidLines += 1
116
+ continue
117
+ }
118
+ entries.push(parsed as DecisionLogEntry)
110
119
  } catch {
111
120
  invalidLines += 1
112
121
  }
@@ -12,7 +12,7 @@
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools'
13
13
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
14
14
  import { resolveProjectRootForExec, loadEffectiveConfig } from '../config-loader.ts'
15
- import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from './defense-store.ts'
15
+ import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from './defense-store.ts'
16
16
  import type { DefenseEvent, DefenseEventType } from '../types.ts'
17
17
 
18
18
  const DEFAULT_LIMIT = 50
@@ -95,16 +95,18 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
95
95
  // join a parallel dispatch group.
96
96
  isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'record',
97
97
  description:
98
- 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
98
+ 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
99
99
  'and assumption falsifications. ' +
100
100
  'List/counts return events with descriptions, outcomes, and summary counts; ' +
101
- '"record" persists a new event to .iterate/defense-events.json. ' +
101
+ '"record" persists a new event to .iterate/defense-events.json; ' +
102
+ '"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
103
+ 'defense data). ' +
102
104
  'Use it to review defensive actions taken, or to log one when a defense fires.',
103
105
  parameters: {
104
106
  operation: {
105
107
  type: 'string',
106
- description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
107
- enum: ['list', 'counts', 'record'],
108
+ description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
109
+ enum: ['list', 'counts', 'record', 'clear'],
108
110
  },
109
111
  type: {
110
112
  type: 'string',
@@ -162,6 +164,7 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
162
164
  kind: { type: 'string' },
163
165
  operation: { type: 'string' },
164
166
  count: { type: 'integer' },
167
+ counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
165
168
  events: { type: 'json' },
166
169
  counts: { type: 'json' },
167
170
  event: { type: 'json' },
@@ -174,6 +177,17 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
174
177
  if (!value.ok) return [{ type: 'text', text: `defense events query failed: ${value.error}` }]
175
178
  const language: 'zh' | 'en' = value.language === 'zh' ? 'zh' : 'en'
176
179
 
180
+ if (value.operation === 'clear') {
181
+ // Fresh stream: counts are all zero and the event list is empty.
182
+ return [{ type: 'text', text: [
183
+ value.counted === true
184
+ ? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
185
+ : 'No persisted defense event stream to clear.',
186
+ 'Defense Event Summary:',
187
+ ...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
188
+ ].join('\n') }]
189
+ }
190
+
177
191
  if (value.operation === 'counts' && value.counts) {
178
192
  const counts = value.counts as Record<DefenseEventType, number>
179
193
  const lines = [
@@ -220,12 +234,35 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
220
234
  if (!resolved.ok) return { ok: false, kind: 'defense_events', error: resolved.reason }
221
235
  const projectRoot = resolved.root
222
236
 
223
- const configLang = loadEffectiveConfig(projectRoot).config.language
237
+ // A hand-edited `language` value outside zh/en must not leak out of the
238
+ // declared 'zh' | 'en' enum (labelFor would return undefined for it) —
239
+ // normalize any non-'zh' config value to 'en'.
240
+ const rawConfigLang = loadEffectiveConfig(projectRoot).config.language
241
+ const configLang: 'zh' | 'en' = rawConfigLang === 'zh' ? 'zh' : 'en'
224
242
  const language: 'zh' | 'en' = args.language === 'zh' || args.language === 'en' ? args.language : configLang
225
243
 
226
244
  const operation = typeof args.operation === 'string' ? args.operation : 'list'
227
245
  const limit = clampLimit(args.limit as number | undefined)
228
246
 
247
+ if (operation === 'clear') {
248
+ // Reset the persisted event stream so a fresh iteration does not carry
249
+ // stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
250
+ const result = clearDefenseEvents(projectRoot)
251
+ if (!result.ok) {
252
+ return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error }
253
+ }
254
+ const empty = readDefenseEvents(projectRoot)
255
+ return {
256
+ ok: true,
257
+ kind: 'defense_events',
258
+ operation: 'clear',
259
+ counted: result.existed,
260
+ language,
261
+ counts: empty.counts as unknown as JsonValue,
262
+ events: empty.events as unknown as JsonValue,
263
+ }
264
+ }
265
+
229
266
  if (operation === 'record') {
230
267
  const errors = validateRecordInput(args)
231
268
  if (errors.length > 0) {
@@ -46,11 +46,41 @@ function emptyStream(): DefenseEventStream {
46
46
  }
47
47
  }
48
48
 
49
+ /** Valid severity values (kept in sync with DefenseEvent). */
50
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
51
+
52
+ /**
53
+ * Normalize one persisted event. Hand-edited files can carry events missing
54
+ * `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
55
+ * (list sort by timestamp, render label selection) must never crash or emit
56
+ * NaN for those. Returns null when the entry is not an object or has no usable
57
+ * `type`; otherwise fills every required field with a safe default.
58
+ */
59
+ function normalizeEvent(raw: unknown): DefenseEvent | null {
60
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
61
+ const e = raw as Record<string, unknown>
62
+ if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type as DefenseEventType)) return null
63
+ return {
64
+ id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
65
+ timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
66
+ round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
67
+ type: e.type as DefenseEventType,
68
+ description: typeof e.description === 'string' ? e.description : '',
69
+ defense: typeof e.defense === 'string' ? e.defense : '',
70
+ outcome: typeof e.outcome === 'string' ? e.outcome : '',
71
+ ...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
72
+ ...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
73
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as DefenseEvent['severity']) : 'low',
74
+ }
75
+ }
76
+
49
77
  /**
50
78
  * Read the defense events stream from disk.
51
79
  * Normalizes the persisted stream so a hand-edited / partial file can never
52
80
  * produce NaN counts: `counts` is recomputed from the events when missing or
53
- * malformed, and every type key is guaranteed present.
81
+ * malformed, every type key is guaranteed present, and every surviving event is
82
+ * shape-normalized so consumers (timestamp sort, render label selection) cannot
83
+ * throw on missing fields.
54
84
  */
55
85
  export function readDefenseEvents(projectRoot: string): DefenseEventStream {
56
86
  const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
@@ -58,10 +88,9 @@ export function readDefenseEvents(projectRoot: string): DefenseEventStream {
58
88
  const content = fs.readFileSync(filePath, 'utf-8')
59
89
  const parsed = JSON.parse(content) as Partial<DefenseEventStream>
60
90
  if (parsed && Array.isArray(parsed.events)) {
61
- const events = parsed.events.filter(
62
- (e): e is DefenseEvent =>
63
- !!e && typeof e === 'object' && typeof (e as DefenseEvent).type === 'string',
64
- )
91
+ const events = parsed.events
92
+ .map(normalizeEvent)
93
+ .filter((e): e is DefenseEvent => e !== null)
65
94
  const counts = computeCounts(events)
66
95
  return {
67
96
  events,
@@ -101,6 +130,26 @@ export function writeDefenseEvents(
101
130
  return { ok: true }
102
131
  }
103
132
 
133
+ /**
134
+ * Clear the persisted defense-event stream (`.iterate/defense-events.json`).
135
+ * Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
136
+ * previous iteration must be resettable before a fresh run. Returns whether a
137
+ * file existed and was removed, or a structured error when removal fails.
138
+ */
139
+ export function clearDefenseEvents(
140
+ projectRoot: string,
141
+ ): { ok: true; existed: boolean } | { ok: false; error: string } {
142
+ const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE)
143
+ const existed = fs.existsSync(filePath)
144
+ if (!existed) return { ok: true, existed: false }
145
+ try {
146
+ fs.rmSync(filePath, { force: true })
147
+ } catch (err) {
148
+ return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` }
149
+ }
150
+ return { ok: true, existed: true }
151
+ }
152
+
104
153
  /** Add a defense event to the stream. */
105
154
  export function addDefenseEvent(
106
155
  stream: DefenseEventStream,
@@ -151,8 +151,8 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
151
151
  `Dimension: ${entry.dimension}`,
152
152
  `Description: ${entry.description}`,
153
153
  `Fix: ${entry.verifiedFix}`,
154
- `Files: ${entry.files.join(', ')}`,
155
- `Tags: ${entry.tags.join(', ')}`,
154
+ `Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
155
+ `Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
156
156
  ].join('\n') }]
157
157
  }
158
158
  if (value.operation === 'remove') {
@@ -165,9 +165,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
165
165
  `Pattern: ${entry.pattern}`,
166
166
  `Description: ${entry.description}`,
167
167
  `Fix: ${entry.verifiedFix}`,
168
- `Files: ${entry.files.join(', ')}`,
168
+ `Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
169
169
  `Hits: ${entry.hitCount}`,
170
- `Tags: ${entry.tags.join(', ')}`,
170
+ `Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
171
171
  ].join('\n') }]
172
172
  }
173
173
  const entries = (value.entries as unknown as ExperienceEntry[] | undefined) ?? []
@@ -264,7 +264,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
264
264
  operation: 'get',
265
265
  count: 1,
266
266
  entry: entry as unknown as JsonValue,
267
- totalHits: bank.totalHits,
267
+ totalHits: bank.totalHits ?? 0,
268
268
  }
269
269
  }
270
270
 
@@ -280,7 +280,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
280
280
  operation: 'search',
281
281
  count: entries.length,
282
282
  entries: entries as unknown as JsonValue,
283
- totalHits: bank.totalHits,
283
+ totalHits: bank.totalHits ?? 0,
284
284
  }
285
285
  }
286
286
 
@@ -290,7 +290,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
290
290
  entries = entries.filter((e) => e.dimension === args.dimension)
291
291
  }
292
292
  if (Array.isArray(args.tags) && args.tags.length > 0) {
293
- entries = entries.filter((e) => args.tags!.every((t: string) => e.tags.includes(t)))
293
+ entries = entries.filter((e) => args.tags!.every((t: string) => Array.isArray(e.tags) && e.tags.includes(t)))
294
294
  }
295
295
 
296
296
  return {
@@ -299,7 +299,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
299
299
  operation: 'list',
300
300
  count: Math.min(entries.length, limit),
301
301
  entries: entries.slice(0, limit) as unknown as JsonValue,
302
- totalHits: bank.totalHits,
302
+ totalHits: bank.totalHits ?? 0,
303
303
  }
304
304
  },
305
305
  }),
@@ -22,14 +22,62 @@ function emptyBank(): ExperienceBank {
22
22
  }
23
23
  }
24
24
 
25
+ /** Valid severity values (kept in sync with ExperienceEntry). */
26
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
27
+
28
+ /** String array guard for fields that must be arrays (`files`, `tags`). */
29
+ function stringArray(v: unknown): string[] {
30
+ if (!Array.isArray(v)) return []
31
+ return v.filter((x): x is string => typeof x === 'string')
32
+ }
33
+
34
+ /**
35
+ * Normalize one persisted experience entry. A hand-edited bank entry can be
36
+ * missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
37
+ * entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
38
+ * throw or emit NaN. Returns null for non-object entries; every required field
39
+ * gets a safe default.
40
+ */
41
+ function normalizeEntry(raw: unknown): ExperienceEntry | null {
42
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
43
+ const e = raw as Record<string, unknown>
44
+ const pattern = typeof e.pattern === 'string' ? e.pattern : ''
45
+ const dimension = typeof e.dimension === 'string' ? e.dimension : ''
46
+ const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`
47
+ if (!pattern && !dimension) return null
48
+ const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
49
+ return {
50
+ id,
51
+ timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
52
+ dimension,
53
+ pattern,
54
+ description: typeof e.description === 'string' ? e.description : '',
55
+ verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
56
+ findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
57
+ files: stringArray(e.files),
58
+ hitCount,
59
+ ...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
60
+ tags: stringArray(e.tags),
61
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as ExperienceEntry['severity']) : 'low',
62
+ }
63
+ }
64
+
25
65
  /** Read the experience bank from disk. Returns empty bank if not found. */
26
66
  export function readExperienceBank(projectRoot: string): ExperienceBank {
27
67
  const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE)
28
68
  try {
29
69
  const content = fs.readFileSync(filePath, 'utf-8')
30
- const parsed = JSON.parse(content) as ExperienceBank
70
+ const parsed = JSON.parse(content) as Partial<ExperienceBank>
31
71
  if (parsed && Array.isArray(parsed.entries)) {
32
- return parsed
72
+ const entries = parsed.entries
73
+ .map(normalizeEntry)
74
+ .filter((e): e is ExperienceEntry => e !== null)
75
+ return {
76
+ entries,
77
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
78
+ totalHits:
79
+ typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
80
+ }
33
81
  }
34
82
  } catch {
35
83
  // File not found or invalid JSON
@@ -72,6 +120,9 @@ export function searchExperienceEntries(
72
120
  const lowerQuery = query.toLowerCase()
73
121
 
74
122
  return entries.filter((entry) => {
123
+ if (!entry || typeof entry !== 'object') return false
124
+ const rawEntry = entry as unknown as Record<string, unknown>
125
+ const tags = stringArray(rawEntry.tags)
75
126
  // Dimension filter
76
127
  if (opts.dimension && entry.dimension !== opts.dimension) {
77
128
  return false
@@ -79,22 +130,24 @@ export function searchExperienceEntries(
79
130
 
80
131
  // Tags filter (AND logic)
81
132
  if (opts.tags && opts.tags.length > 0) {
82
- if (!opts.tags.every((t) => entry.tags.includes(t))) {
133
+ if (!opts.tags.every((t) => tags.includes(t))) {
83
134
  return false
84
135
  }
85
136
  }
86
137
 
87
- // Text search across multiple fields
88
- if (query) {
89
- const searchableText = [
90
- entry.pattern,
91
- entry.description,
92
- entry.verifiedFix,
93
- entry.findingSummary,
94
- entry.dimension,
95
- ...entry.files,
96
- ...entry.tags,
97
- ].join(' ').toLowerCase()
138
+ // Text search across multiple fields. Guards against a hand-edited bank entry
139
+ // whose `files`/`tags` are missing or non-array (the spread below would
140
+ // otherwise throw a TypeError on a non-iterable).
141
+ if (query) {
142
+ const searchableText = [
143
+ entry.pattern,
144
+ entry.description,
145
+ entry.verifiedFix,
146
+ entry.findingSummary,
147
+ entry.dimension,
148
+ ...stringArray(entry.files),
149
+ ...stringArray(entry.tags),
150
+ ].join(' ').toLowerCase()
98
151
 
99
152
  if (!searchableText.includes(lowerQuery)) {
100
153
  return false