@ngockhoale/ukit 3.0.8 → 3.0.10

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -23,6 +23,17 @@ function setBoundedCacheEntry(map, key, value, maxEntries = MAX_CACHE_ENTRIES) {
23
23
  }
24
24
  }
25
25
 
26
+ // Returns the artifact's item rows as plain objects, dropping poisoned rows
27
+ // (null, undefined, primitives). Hand-corrupted or truncated `.cache/index/*.json`
28
+ // files can carry `items: [null]` or `items: "corrupted"` — every consumer that
29
+ // dereferences `item.filePath` must see only object rows.
30
+ function safeItems(artifact) {
31
+ if (!artifact || typeof artifact !== 'object' || !Array.isArray(artifact.items)) {
32
+ return [];
33
+ }
34
+ return artifact.items.filter((entry) => entry && typeof entry === 'object');
35
+ }
36
+
26
37
  export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5 } = {}) {
27
38
  const normalizedQuery = String(query ?? '').trim();
28
39
  if (!normalizedQuery) {
@@ -54,7 +65,7 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
54
65
 
55
66
  const directScores = new Map();
56
67
 
57
- for (const file of files.items ?? []) {
68
+ for (const file of safeItems(files)) {
58
69
  const filePath = file.filePath;
59
70
  if (isLikelyTestFilePath(filePath) && !includeLikelyTestFiles) {
60
71
  continue;
@@ -161,7 +172,7 @@ export async function getFileOutline({ rootDir = process.cwd(), filePath, limit
161
172
  return Object.freeze([]);
162
173
  }
163
174
 
164
- const rows = (safeSymbolsArtifact.items ?? [])
175
+ const rows = safeItems(safeSymbolsArtifact)
165
176
  .filter((item) => item?.filePath === filePath && item?.line !== null && item?.line !== undefined)
166
177
  .filter((item) => item?.type !== 'callable' || isFunctionLikeSignature(item?.signature))
167
178
  .sort((a, b) => a.line - b.line);
@@ -226,10 +237,10 @@ async function loadQuerySearchBundle(rootDir) {
226
237
  .then(([files, symbols]) => {
227
238
  const safeFiles = ensureArtifact(files);
228
239
  const safeSymbols = ensureArtifact(symbols);
229
- const symbolMap = groupBy(safeSymbols.items ?? [], (item) => item.filePath);
240
+ const symbolMap = groupBy(safeItems(safeSymbols), (item) => item.filePath);
230
241
  return {
231
242
  files: safeFiles,
232
- searchDescriptorsByFile: buildFileSearchDescriptors(safeFiles.items ?? [], symbolMap),
243
+ searchDescriptorsByFile: buildFileSearchDescriptors(safeItems(safeFiles), symbolMap),
233
244
  };
234
245
  })
235
246
  .catch((error) => {
@@ -263,24 +274,24 @@ async function loadQuerySupportBundle(rootDir) {
263
274
  const safeTestsMap = ensureArtifact(testsMap);
264
275
  const safeHotspots = ensureArtifact(hotspots);
265
276
  const safeArchetypes = ensureArtifact(archetypes);
266
- const indexedFileSet = new Set((safeFiles.items ?? []).map((item) => item.filePath));
267
- const aliasContextPromise = importsNeedAliasContext(safeImports.items ?? [])
277
+ const indexedFileSet = new Set(safeItems(safeFiles).map((item) => item.filePath));
278
+ const aliasContextPromise = importsNeedAliasContext(safeItems(safeImports))
268
279
  ? loadImportAliasContext({ rootDir: absoluteRoot })
269
280
  : Promise.resolve(null);
270
281
  return aliasContextPromise.then((importAliasContext) => {
271
282
  const { resolvedImportsBySource, importersByTarget } = buildResolvedImportGraphs(
272
283
  absoluteRoot,
273
- safeImports.items ?? [],
284
+ safeItems(safeImports),
274
285
  indexedFileSet,
275
286
  importAliasContext,
276
287
  );
277
288
 
278
289
  return {
279
- hotspotScores: new Map((safeHotspots.items ?? []).map((item) => [item.filePath, item.count])),
280
- testsMapBySource: new Map((safeTestsMap.items ?? []).map((item) => [item.sourceFile, item.tests ?? []])),
290
+ hotspotScores: new Map(safeItems(safeHotspots).map((item) => [item.filePath, item.count])),
291
+ testsMapBySource: new Map(safeItems(safeTestsMap).map((item) => [item.sourceFile, item.tests ?? []])),
281
292
  resolvedImportsBySource,
282
293
  importersByTarget,
283
- archetypeByFile: new Map((safeArchetypes.items ?? []).map((item) => [item.filePath, item.archetype])),
294
+ archetypeByFile: new Map(safeItems(safeArchetypes).map((item) => [item.filePath, item.archetype])),
284
295
  };
285
296
  });
286
297
  })
@@ -607,11 +618,15 @@ export async function queryAnalog({ rootDir = process.cwd(), filePath, limit = 5
607
618
  const analogPromise = readArtifact(absoluteRoot, INDEX_ARTIFACTS.analogs)
608
619
  .then((analogs) => {
609
620
  const safeAnalogs = ensureArtifact(analogs);
610
- const entry = (safeAnalogs.items ?? []).find((item) => item.filePath === normalizedFilePath);
621
+ const entry = safeItems(safeAnalogs).find((item) => item.filePath === normalizedFilePath);
611
622
  if (!entry) {
612
623
  return Object.freeze([]);
613
624
  }
614
- return freezeAnalogResults(entry.analogs?.slice(0, limit) ?? []);
625
+ // `entry.analogs` is an item-row field that can be hand-corrupted into a
626
+ // truthy non-array (e.g. analogs: 42) — `?.slice` and `?? []` do not fire
627
+ // on those, so coerce before freezeAnalogResults' .map.
628
+ const entryAnalogs = Array.isArray(entry.analogs) ? entry.analogs : [];
629
+ return freezeAnalogResults(entryAnalogs.slice(0, limit));
615
630
  })
616
631
  .catch((error) => {
617
632
  ANALOG_RESULT_CACHE.delete(analogCacheKey);
@@ -675,8 +690,14 @@ function ensureArtifact(artifact) {
675
690
  }
676
691
  // A schema-current artifact whose `items` is not an array (truncated or
677
692
  // hand-corrupted JSON) must degrade to an empty item list — callers iterate
678
- // `items` unconditionally and a non-array value crashes the query path.
679
- return Array.isArray(artifact.items) ? artifact : { ...artifact, items: [] };
693
+ // `items` unconditionally and a non-array value crashes the query path. Rows
694
+ // that are not objects (null entries from partial writes) are dropped too.
695
+ return {
696
+ ...artifact,
697
+ items: Array.isArray(artifact.items)
698
+ ? artifact.items.filter((entry) => entry && typeof entry === 'object')
699
+ : [],
700
+ };
680
701
  }
681
702
 
682
703
  function escapeJsonString(value) {
@@ -123,8 +123,12 @@ export function buildRelatedTestLookups({
123
123
  relationsArtifact = { items: [] },
124
124
  } = {}) {
125
125
  return {
126
- analogsMap: new Map((analogsArtifact.items ?? []).map((item) => [item.filePath, item.analogs ?? []])),
127
- relationsMap: new Map((relationsArtifact.items ?? []).map((item) => [item.filePath, item.relations ?? {}])),
126
+ analogsMap: new Map(
127
+ safeItems(analogsArtifact).map((item) => [item.filePath, safeArray(item.analogs)]),
128
+ ),
129
+ relationsMap: new Map(
130
+ safeItems(relationsArtifact).map((item) => [item.filePath, normalizeRelations(item.relations)]),
131
+ ),
128
132
  };
129
133
  }
130
134
 
@@ -148,7 +152,7 @@ export function inferRelatedTestsFromArtifacts({
148
152
 
149
153
  for (const [candidateRank, candidateFile] of normalizedCandidates.entries()) {
150
154
  const rankPenalty = candidateRank * 5;
151
- const relations = relationsMap.get(candidateFile) ?? {};
155
+ const relations = normalizeRelations(relationsMap.get(candidateFile));
152
156
 
153
157
  addTestsFromSource({
154
158
  sourceFilePath: candidateFile,
@@ -158,7 +162,7 @@ export function inferRelatedTestsFromArtifacts({
158
162
  reason: `direct test for ${candidateFile}`,
159
163
  });
160
164
 
161
- for (const siblingFile of (relations.siblings ?? []).slice(0, 3)) {
165
+ for (const siblingFile of safeArray(relations.siblings).slice(0, 3)) {
162
166
  addTestsFromSource({
163
167
  sourceFilePath: siblingFile,
164
168
  relationsMap,
@@ -168,7 +172,9 @@ export function inferRelatedTestsFromArtifacts({
168
172
  });
169
173
  }
170
174
 
171
- for (const analog of (analogsMap.get(candidateFile) ?? []).slice(0, 3)) {
175
+ for (const analog of safeArray(analogsMap.get(candidateFile))
176
+ .filter((analogRow) => analogRow && typeof analogRow.filePath === 'string')
177
+ .slice(0, 3)) {
172
178
  addTestsFromSource({
173
179
  sourceFilePath: analog.filePath,
174
180
  relationsMap,
@@ -179,7 +185,7 @@ export function inferRelatedTestsFromArtifacts({
179
185
  }
180
186
 
181
187
  for (const { key, label } of RELATED_TEST_RELATION_TYPES) {
182
- for (const relatedFile of (relations[key] ?? []).slice(0, 2)) {
188
+ for (const relatedFile of safeArray(relations[key]).slice(0, 2)) {
183
189
  addTestsFromSource({
184
190
  sourceFilePath: relatedFile,
185
191
  relationsMap,
@@ -209,7 +215,7 @@ async function readArtifact(rootDir, artifactName) {
209
215
  RELATED_TEST_ARTIFACT_CACHE,
210
216
  artifactPath,
211
217
  fs.readFile(artifactPath, 'utf8')
212
- .then((content) => JSON.parse(content))
218
+ .then((content) => ensureArtifact(JSON.parse(content)))
213
219
  .catch(() => {
214
220
  RELATED_TEST_ARTIFACT_CACHE.delete(artifactPath);
215
221
  return { items: [] };
@@ -220,6 +226,42 @@ async function readArtifact(rootDir, artifactName) {
220
226
  return RELATED_TEST_ARTIFACT_CACHE.get(artifactPath);
221
227
  }
222
228
 
229
+ // Mirrors ensureArtifact in queryIndex.js: a shape-corrupt artifact (non-object
230
+ // root, non-array `items`, or null item rows) degrades to an empty item list so
231
+ // triage/context survive hand-edited or truncated `.cache/index/*.json` files.
232
+ function ensureArtifact(artifact) {
233
+ if (!artifact || typeof artifact !== 'object') {
234
+ return { items: [] };
235
+ }
236
+ return { ...artifact, items: safeItems(artifact) };
237
+ }
238
+
239
+ function safeItems(artifact) {
240
+ if (!artifact || typeof artifact !== 'object' || !Array.isArray(artifact.items)) {
241
+ return [];
242
+ }
243
+ return artifact.items.filter((entry) => entry && typeof entry === 'object');
244
+ }
245
+
246
+ // Item-row fields (`analogs`, `relations.*`) can be hand-edited into non-array
247
+ // shapes (e.g. analogs: "corrupted"). `?? []` does not fire on truthy
248
+ // non-arrays, so coerce when building the lookup maps and again at consumers —
249
+ // inferRelatedTestsFromArtifacts also accepts foreign maps.
250
+ function safeArray(value) {
251
+ return Array.isArray(value) ? value : [];
252
+ }
253
+
254
+ function normalizeRelations(relations) {
255
+ if (!relations || typeof relations !== 'object') {
256
+ return {};
257
+ }
258
+ const normalized = { ...relations };
259
+ for (const key of ['tests', 'siblings', 'styles', ...RELATED_TEST_RELATION_TYPES.map(({ key }) => key)]) {
260
+ normalized[key] = safeArray(normalized[key]);
261
+ }
262
+ return normalized;
263
+ }
264
+
223
265
  function addTestsFromSource({
224
266
  sourceFilePath,
225
267
  relationsMap,
@@ -231,7 +273,7 @@ function addTestsFromSource({
231
273
  return;
232
274
  }
233
275
 
234
- const tests = relationsMap.get(sourceFilePath)?.tests ?? [];
276
+ const tests = safeArray(relationsMap.get(sourceFilePath)?.tests);
235
277
  for (const testFilePath of tests) {
236
278
  if (!suggestions.has(testFilePath)) {
237
279
  suggestions.set(testFilePath, {
@@ -103,11 +103,13 @@ export async function resolveContext({
103
103
  const rels = relationsMap.get(primary) ?? {};
104
104
 
105
105
  // Related tests
106
- for (const testPath of rels.tests ?? []) {
106
+ for (const testPath of Array.isArray(rels.tests) ? rels.tests : []) {
107
107
  addExplainedFile(result, 'relatedTests', testPath, `test linked to ${primary}`);
108
108
  }
109
109
 
110
- for (const stylePath of rels.styles ?? []) {
110
+ // `styles` is a relations field that can carry a truthy non-array after
111
+ // hand-corruption (relations.styles: 42) — `?? []` does not fire on it.
112
+ for (const stylePath of Array.isArray(rels.styles) ? rels.styles : []) {
111
113
  addExplainedFile(result, 'styleFiles', stylePath, `style imported by ${primary}`);
112
114
  }
113
115
  }
@@ -160,8 +162,11 @@ export async function resolveContext({
160
162
 
161
163
  // Step 3: Find analog files for primary targets
162
164
  for (const primary of result.primaryTargets.slice(0, 2)) {
163
- const analogs = analogsMap.get(primary) ?? [];
164
- for (const analog of analogs.slice(0, 3)) {
165
+ const rawAnalogs = analogsMap.get(primary);
166
+ const analogs = Array.isArray(rawAnalogs) ? rawAnalogs : [];
167
+ for (const analog of analogs
168
+ .filter((analogRow) => analogRow && typeof analogRow.filePath === 'string')
169
+ .slice(0, 3)) {
165
170
  if (result.primaryTargets.includes(analog.filePath)) {
166
171
  continue;
167
172
  }
@@ -18,8 +18,12 @@ export const OPTIONAL_ADAPTER_ITEM_IDS = new Set([
18
18
  'multi-omp-agent-ukit-vision-analyst',
19
19
  ]);
20
20
 
21
- function resolveItemOrder(items) {
22
- const itemById = new Map(items.map((item) => [item.id, item]));
21
+ // Resolve installation order over the selected items, auto-including required
22
+ // dependencies that were filtered out of the selection (e.g. by pack or adapter
23
+ // filtering). `requires` is a hard dependency, so a dep that exists in the
24
+ // manifest is pulled in transitively; only deps absent from `allItems` throw.
25
+ function resolveItemOrder(items, allItems) {
26
+ const itemById = new Map((allItems ?? items).map((item) => [item.id, item]));
23
27
  const visiting = new Set();
24
28
  const visited = new Set();
25
29
  const ordered = [];
@@ -81,5 +85,5 @@ export function selectManifestItems({ items, detectedPacks, selectedAdapterItemI
81
85
  return true;
82
86
  });
83
87
 
84
- return resolveItemOrder(selected);
88
+ return resolveItemOrder(selected, items);
85
89
  }
@@ -183,19 +183,30 @@ const SOURCE_FILES = {
183
183
  /**
184
184
  * Read layout + sources + repo-vars + package.json version from disk, render
185
185
  * all outputs, and (write=true) write each target. Returns Map<target, content>.
186
+ * FR-015: every input read/parse failure is rethrown as a typed
187
+ * `Failed to read render input <rel>: <code|message>` naming the rel path.
188
+ * FR-016: target parent dirs are created before each write.
186
189
  */
187
190
  export async function renderInstructionsFromDisk({ repoRoot, write = true } = {}) {
188
191
  const abs = (p) => path.join(repoRoot, p);
189
- const layout = YAML.parse(
190
- fs.readFileSync(abs('template_project/instructions/layout.yaml'), 'utf8'),
192
+ const readInput = (rel, parse) => {
193
+ try {
194
+ return parse(fs.readFileSync(abs(rel), 'utf8'));
195
+ } catch (err) {
196
+ const reason = err && err.code ? err.code : err && err.message ? err.message : String(err);
197
+ throw new Error(`Failed to read render input ${rel}: ${reason}`);
198
+ }
199
+ };
200
+ const layout = readInput('template_project/instructions/layout.yaml', (text) =>
201
+ YAML.parse(text),
191
202
  );
192
203
  const repoVars =
193
- YAML.parse(fs.readFileSync(abs('template_project/instructions/repo-vars.yaml'), 'utf8')) || {};
194
- const pkg = JSON.parse(fs.readFileSync(abs('package.json'), 'utf8'));
204
+ readInput('template_project/instructions/repo-vars.yaml', (text) => YAML.parse(text)) || {};
205
+ const pkg = readInput('package.json', (text) => JSON.parse(text));
195
206
 
196
207
  const sourceTexts = {};
197
208
  for (const [name, rel] of Object.entries(SOURCE_FILES)) {
198
- sourceTexts[name] = fs.readFileSync(abs(rel), 'utf8');
209
+ sourceTexts[name] = readInput(rel, (text) => text);
199
210
  }
200
211
 
201
212
  const vars = { ...repoVars, ukit: { ...(repoVars.ukit || {}), version: pkg.version } };
@@ -203,6 +214,7 @@ export async function renderInstructionsFromDisk({ repoRoot, write = true } = {}
203
214
 
204
215
  if (write) {
205
216
  for (const [target, content] of rendered) {
217
+ fs.mkdirSync(path.dirname(abs(target)), { recursive: true });
206
218
  fs.writeFileSync(abs(target), content);
207
219
  }
208
220
  }
@@ -314,9 +314,9 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
314
314
  && Array.isArray(previousCallsArtifact.items);
315
315
 
316
316
  if (canReuseParsedArtifacts) {
317
- const previousSymbolsByPath = groupBy(previousSymbolsArtifact.items ?? [], (item) => item.filePath);
318
- const previousImportsByPath = groupBy(previousImportsArtifact.items ?? [], (item) => item.from);
319
- const previousCallsByPath = groupBy(previousCallsArtifact.items ?? [], (item) => item.filePath);
317
+ const previousSymbolsByPath = groupBy(previousSymbolsArtifact.items ?? [], (item) => item?.filePath);
318
+ const previousImportsByPath = groupBy(previousImportsArtifact.items ?? [], (item) => item?.from);
319
+ const previousCallsByPath = groupBy(previousCallsArtifact.items ?? [], (item) => item?.filePath);
320
320
 
321
321
  for (const filePath of reusableCodeFiles) {
322
322
  symbols.push(...(previousSymbolsByPath.get(filePath) ?? []));
@@ -888,9 +888,9 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
888
888
  }
889
889
  const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
890
890
 
891
- const previousSymbolsByPath = groupBy(symbolsArtifact.items ?? [], (item) => item.filePath);
892
- const previousImportsByPath = groupBy(importsArtifact.items ?? [], (item) => item.from);
893
- const previousCallsByPath = groupBy(callsArtifact.items ?? [], (item) => item.filePath);
891
+ const previousSymbolsByPath = groupBy(symbolsArtifact.items ?? [], (item) => item?.filePath);
892
+ const previousImportsByPath = groupBy(importsArtifact.items ?? [], (item) => item?.from);
893
+ const previousCallsByPath = groupBy(callsArtifact.items ?? [], (item) => item?.filePath);
894
894
 
895
895
  const parsedSet = new Set(parsedFiles);
896
896
  const codeFiles = mergedFileRecords.filter((record) => CODE_EXTENSIONS.has(record.ext));
@@ -1149,7 +1149,7 @@ export async function getFileOutline({ rootDir = process.cwd(), filePath, limit
1149
1149
  return Object.freeze([]);
1150
1150
  }
1151
1151
 
1152
- const rows = (safeSymbolsArtifact.items ?? [])
1152
+ const rows = safeItems(safeSymbolsArtifact)
1153
1153
  .filter((item) => item?.filePath === filePath && item?.line !== null && item?.line !== undefined)
1154
1154
  .filter((item) => item?.type !== 'callable' || isFunctionLikeSignature(item?.signature))
1155
1155
  .sort((a, b) => a.line - b.line);
@@ -1196,7 +1196,7 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
1196
1196
  const includeLikelyTestFiles = shouldIncludeLikelyTestFiles(queryDescriptor);
1197
1197
 
1198
1198
  const directScores = new Map();
1199
- for (const file of files.items ?? []) {
1199
+ for (const file of safeItems(files)) {
1200
1200
  const filePath = file.filePath;
1201
1201
  if (isLikelyTestFilePath(filePath) && !includeLikelyTestFiles) continue;
1202
1202
  const searchDescriptor = searchDescriptorsByFile.get(filePath) ?? createFileSearchDescriptor({ filePath, symbols: [] });
@@ -1275,9 +1275,13 @@ export async function queryAnalog({ rootDir = process.cwd(), filePath, limit = 5
1275
1275
  const analogPromise = readArtifact(absoluteRoot, INDEX_ARTIFACTS.analogs)
1276
1276
  .then((analogs) => {
1277
1277
  const safeAnalogs = ensureArtifact(analogs);
1278
- const entry = (safeAnalogs.items ?? []).find((item) => item.filePath === normalizedFilePath);
1278
+ const entry = safeItems(safeAnalogs).find((item) => item.filePath === normalizedFilePath);
1279
1279
  if (!entry) return Object.freeze([]);
1280
- return freezeAnalogResults(entry.analogs?.slice(0, limit) ?? []);
1280
+ // `entry.analogs` is an item-row field that can be hand-corrupted into a
1281
+ // truthy non-array (e.g. analogs: 42) — `?.slice` and `?? []` do not fire
1282
+ // on those, so coerce before freezeAnalogResults' .map.
1283
+ const entryAnalogs = Array.isArray(entry.analogs) ? entry.analogs : [];
1284
+ return freezeAnalogResults(entryAnalogs.slice(0, limit));
1281
1285
  })
1282
1286
  .catch((error) => {
1283
1287
  ANALOG_RESULT_CACHE.delete(analogCacheKey);
@@ -1336,14 +1340,31 @@ function freezeQueryResults(results) {
1336
1340
  function freezeAnalogResults(results) {
1337
1341
  return Object.freeze(results.map((item) => Object.freeze({ ...item })));
1338
1342
  }
1343
+ // Returns the artifact's item rows as plain objects, dropping poisoned rows
1344
+ // (null, undefined, primitives). Hand-corrupted or truncated `.cache/index/*.json`
1345
+ // files can carry `items: [null]` or `items: "corrupted"` — every consumer that
1346
+ // dereferences `item.filePath` must see only object rows.
1347
+ function safeItems(artifact) {
1348
+ if (!artifact || typeof artifact !== 'object' || !Array.isArray(artifact.items)) {
1349
+ return [];
1350
+ }
1351
+ return artifact.items.filter((entry) => entry && typeof entry === 'object');
1352
+ }
1353
+
1339
1354
  function ensureArtifact(artifact) {
1340
1355
  if (!artifact || typeof artifact !== 'object') {
1341
1356
  return { items: [] };
1342
1357
  }
1343
1358
  // A schema-current artifact whose `items` is not an array (truncated or
1344
1359
  // hand-corrupted JSON) must degrade to an empty item list — callers iterate
1345
- // `items` unconditionally and a non-array value crashes the query path.
1346
- return Array.isArray(artifact.items) ? artifact : { ...artifact, items: [] };
1360
+ // `items` unconditionally and a non-array value crashes the query path. Rows
1361
+ // that are not objects (null entries from partial writes) are dropped too.
1362
+ return {
1363
+ ...artifact,
1364
+ items: Array.isArray(artifact.items)
1365
+ ? artifact.items.filter((entry) => entry && typeof entry === 'object')
1366
+ : [],
1367
+ };
1347
1368
  }
1348
1369
 
1349
1370
  function escapeJsonString(value) {
@@ -1359,6 +1380,8 @@ const TASK_TYPE_BUDGETS = {
1359
1380
  'non-trivial': { minFiles: 4, maxFiles: 8 },
1360
1381
  };
1361
1382
 
1383
+ const TRIVIAL_SIGNALS = ['typo', 'label', 'text', 'rename', 'color', 'spacing', 'toggle', 'config', 'comment'];
1384
+
1362
1385
  const RISKY_SIGNALS = [
1363
1386
  'auth', 'authentication', 'authorization', 'security', 'migration', 'uninstall',
1364
1387
  'password', 'token', 'permission', 'delete all', 'drop table', 'race', 'flaky',
@@ -1449,10 +1472,12 @@ export async function resolveContext({
1449
1472
 
1450
1473
  for (const primary of result.primaryTargets) {
1451
1474
  const rels = relationsMap.get(primary) ?? {};
1452
- for (const testPath of rels.tests ?? []) {
1475
+ for (const testPath of Array.isArray(rels.tests) ? rels.tests : []) {
1453
1476
  addExplainedFile(result, 'relatedTests', testPath, `test linked to ${primary}`);
1454
1477
  }
1455
- for (const stylePath of rels.styles ?? []) {
1478
+ // `styles` is a relations field that can carry a truthy non-array after
1479
+ // hand-corruption (relations.styles: 42) — `?? []` does not fire on it.
1480
+ for (const stylePath of Array.isArray(rels.styles) ? rels.styles : []) {
1456
1481
  addExplainedFile(result, 'styleFiles', stylePath, `style imported by ${primary}`);
1457
1482
  }
1458
1483
  }
@@ -1501,8 +1526,11 @@ export async function resolveContext({
1501
1526
  });
1502
1527
 
1503
1528
  for (const primary of result.primaryTargets.slice(0, 2)) {
1504
- const analogs = analogsMap.get(primary) ?? [];
1505
- for (const analog of analogs.slice(0, 3)) {
1529
+ const rawAnalogs = analogsMap.get(primary);
1530
+ const analogs = Array.isArray(rawAnalogs) ? rawAnalogs : [];
1531
+ for (const analog of analogs
1532
+ .filter((analogRow) => analogRow && typeof analogRow.filePath === 'string')
1533
+ .slice(0, 3)) {
1506
1534
  if (result.primaryTargets.includes(analog.filePath)) continue;
1507
1535
  addExplainedFile(
1508
1536
  result,
@@ -1696,8 +1724,12 @@ function buildRelatedTestLookups({
1696
1724
  relationsArtifact = { items: [] },
1697
1725
  } = {}) {
1698
1726
  return {
1699
- analogsMap: new Map((analogsArtifact.items ?? []).map((item) => [item.filePath, item.analogs ?? []])),
1700
- relationsMap: new Map((relationsArtifact.items ?? []).map((item) => [item.filePath, item.relations ?? {}])),
1727
+ analogsMap: new Map(
1728
+ safeItems(analogsArtifact).map((item) => [item.filePath, safeArray(item.analogs)]),
1729
+ ),
1730
+ relationsMap: new Map(
1731
+ safeItems(relationsArtifact).map((item) => [item.filePath, normalizeRelations(item.relations)]),
1732
+ ),
1701
1733
  };
1702
1734
  }
1703
1735
 
@@ -1721,7 +1753,7 @@ function inferRelatedTestsFromArtifacts({
1721
1753
 
1722
1754
  for (const [candidateRank, candidateFile] of normalizedCandidates.entries()) {
1723
1755
  const rankPenalty = candidateRank * 5;
1724
- const relations = relationsMap.get(candidateFile) ?? {};
1756
+ const relations = normalizeRelations(relationsMap.get(candidateFile));
1725
1757
 
1726
1758
  addTestsFromSource({
1727
1759
  sourceFilePath: candidateFile,
@@ -1731,7 +1763,7 @@ function inferRelatedTestsFromArtifacts({
1731
1763
  reason: `direct test for ${candidateFile}`,
1732
1764
  });
1733
1765
 
1734
- for (const siblingFile of (relations.siblings ?? []).slice(0, 3)) {
1766
+ for (const siblingFile of safeArray(relations.siblings).slice(0, 3)) {
1735
1767
  addTestsFromSource({
1736
1768
  sourceFilePath: siblingFile,
1737
1769
  relationsMap,
@@ -1741,7 +1773,9 @@ function inferRelatedTestsFromArtifacts({
1741
1773
  });
1742
1774
  }
1743
1775
 
1744
- for (const analog of (analogsMap.get(candidateFile) ?? []).slice(0, 3)) {
1776
+ for (const analog of safeArray(analogsMap.get(candidateFile))
1777
+ .filter((analogRow) => analogRow && typeof analogRow.filePath === 'string')
1778
+ .slice(0, 3)) {
1745
1779
  addTestsFromSource({
1746
1780
  sourceFilePath: analog.filePath,
1747
1781
  relationsMap,
@@ -1752,7 +1786,7 @@ function inferRelatedTestsFromArtifacts({
1752
1786
  }
1753
1787
 
1754
1788
  for (const { key, label } of RELATED_TEST_RELATION_TYPES) {
1755
- for (const relatedFile of (relations[key] ?? []).slice(0, 2)) {
1789
+ for (const relatedFile of safeArray(relations[key]).slice(0, 2)) {
1756
1790
  addTestsFromSource({
1757
1791
  sourceFilePath: relatedFile,
1758
1792
  relationsMap,
@@ -1785,7 +1819,7 @@ function addTestsFromSource({
1785
1819
  return;
1786
1820
  }
1787
1821
 
1788
- const tests = relationsMap.get(sourceFilePath)?.tests ?? [];
1822
+ const tests = safeArray(relationsMap.get(sourceFilePath)?.tests);
1789
1823
  for (const testFilePath of tests) {
1790
1824
  if (!suggestions.has(testFilePath)) {
1791
1825
  suggestions.set(testFilePath, {
@@ -1803,6 +1837,25 @@ function addTestsFromSource({
1803
1837
  }
1804
1838
  }
1805
1839
 
1840
+ // Item-row fields (`analogs`, `relations.*`) can be hand-edited into non-array
1841
+ // shapes (e.g. analogs: "corrupted"). `?? []` does not fire on truthy
1842
+ // non-arrays, so coerce when building the lookup maps and again at consumers —
1843
+ // inferRelatedTestsFromArtifacts also accepts foreign maps.
1844
+ function safeArray(value) {
1845
+ return Array.isArray(value) ? value : [];
1846
+ }
1847
+
1848
+ function normalizeRelations(relations) {
1849
+ if (!relations || typeof relations !== 'object') {
1850
+ return {};
1851
+ }
1852
+ const normalized = { ...relations };
1853
+ for (const key of ['tests', 'siblings', 'styles', ...RELATED_TEST_RELATION_TYPES.map(({ key }) => key)]) {
1854
+ normalized[key] = safeArray(normalized[key]);
1855
+ }
1856
+ return normalized;
1857
+ }
1858
+
1806
1859
  // ── Bug Triage ──
1807
1860
 
1808
1861
  export async function triageBug({ rootDir = process.cwd(), signature } = {}) {
@@ -1837,9 +1890,10 @@ export async function triageBug({ rootDir = process.cwd(), signature } = {}) {
1837
1890
  limit: 3,
1838
1891
  });
1839
1892
 
1840
- const analogFiles = top
1841
- ? (relatedArtifacts?.analogsMap.get(top.filePath) ?? []).slice(0, 2)
1842
- : [];
1893
+ const topAnalogs = top ? relatedArtifacts?.analogsMap.get(top.filePath) : null;
1894
+ const analogFiles = (Array.isArray(topAnalogs) ? topAnalogs : [])
1895
+ .filter((analog) => analog && typeof analog.filePath === 'string')
1896
+ .slice(0, 2);
1843
1897
 
1844
1898
  return {
1845
1899
  signature,
@@ -1960,7 +2014,7 @@ const ARCHETYPE_RULES = [
1960
2014
  ];
1961
2015
 
1962
2016
  function classifyArchetypes(fileRecords, symbols) {
1963
- const symbolNamesByPath = groupBy(symbols, (s) => s.filePath);
2017
+ const symbolNamesByPath = groupBy(symbols, (s) => s?.filePath);
1964
2018
 
1965
2019
  return fileRecords
1966
2020
  .filter((f) => CODE_EXTENSIONS.has(f.ext))
@@ -1980,12 +2034,13 @@ function classifyArchetypes(fileRecords, symbols) {
1980
2034
 
1981
2035
  if (!archetype) {
1982
2036
  const fileSymbols = symbolNamesByPath.get(file.filePath) ?? [];
1983
- const hasDefaultExport = fileSymbols.some((s) => s.name === 'default');
2037
+ const hasDefaultExport = fileSymbols.some((s) => s?.name === 'default');
1984
2038
  const hasComponentSignal = file.ext === '.vue'
1985
2039
  || fileSymbols.some((s) =>
1986
- s.type === 'component-name'
1987
- || s.name.toLowerCase().includes('component')
1988
- || s.name.toLowerCase().includes('page'),
2040
+ s?.type === 'component-name'
2041
+ || (typeof s?.name === 'string'
2042
+ && (s.name.toLowerCase().includes('component')
2043
+ || s.name.toLowerCase().includes('page'))),
1989
2044
  );
1990
2045
 
1991
2046
  if ((hasDefaultExport || file.ext === '.vue') && hasComponentSignal) {
@@ -2875,10 +2930,10 @@ async function loadQuerySearchBundle(rootDir) {
2875
2930
  .then(([files, symbols]) => {
2876
2931
  const safeFiles = ensureArtifact(files);
2877
2932
  const safeSymbols = ensureArtifact(symbols);
2878
- const symbolMap = groupBy(safeSymbols.items ?? [], (item) => item.filePath);
2933
+ const symbolMap = groupBy(safeItems(safeSymbols), (item) => item.filePath);
2879
2934
  return {
2880
2935
  files: safeFiles,
2881
- searchDescriptorsByFile: buildFileSearchDescriptors(safeFiles.items ?? [], symbolMap),
2936
+ searchDescriptorsByFile: buildFileSearchDescriptors(safeItems(safeFiles), symbolMap),
2882
2937
  };
2883
2938
  })
2884
2939
  .catch((error) => {
@@ -2911,23 +2966,23 @@ async function loadQuerySupportBundle(rootDir) {
2911
2966
  const safeTestsMap = ensureArtifact(testsMap);
2912
2967
  const safeHotspots = ensureArtifact(hotspots);
2913
2968
  const safeArchetypes = ensureArtifact(archetypes);
2914
- const indexedFileSet = new Set((safeFiles.items ?? []).map((item) => item.filePath));
2915
- const importAliasContext = importsNeedAliasContext(safeImports.items ?? [])
2969
+ const indexedFileSet = new Set(safeItems(safeFiles).map((item) => item.filePath));
2970
+ const importAliasContext = importsNeedAliasContext(safeItems(safeImports))
2916
2971
  ? await loadImportAliasContext({ rootDir: absoluteRoot })
2917
2972
  : null;
2918
2973
  const { resolvedImportsBySource, importersByTarget } = buildResolvedImportGraphs(
2919
2974
  absoluteRoot,
2920
- safeImports.items ?? [],
2975
+ safeItems(safeImports),
2921
2976
  indexedFileSet,
2922
2977
  importAliasContext,
2923
2978
  );
2924
2979
 
2925
2980
  return {
2926
- hotspotScores: new Map((safeHotspots.items ?? []).map((item) => [item.filePath, item.count])),
2927
- testsMapBySource: new Map((safeTestsMap.items ?? []).map((item) => [item.sourceFile, item.tests ?? []])),
2981
+ hotspotScores: new Map(safeItems(safeHotspots).map((item) => [item.filePath, item.count])),
2982
+ testsMapBySource: new Map(safeItems(safeTestsMap).map((item) => [item.sourceFile, item.tests ?? []])),
2928
2983
  resolvedImportsBySource,
2929
2984
  importersByTarget,
2930
- archetypeByFile: new Map((safeArchetypes.items ?? []).map((item) => [item.filePath, item.archetype])),
2985
+ archetypeByFile: new Map(safeItems(safeArchetypes).map((item) => [item.filePath, item.archetype])),
2931
2986
  };
2932
2987
  })
2933
2988
  .catch((error) => {