@veewo/gitnexus 1.3.10 → 1.3.11

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 (109) hide show
  1. package/README.md +3 -3
  2. package/dist/benchmark/analyze-memory-sampler.d.ts +10 -0
  3. package/dist/benchmark/analyze-memory-sampler.js +12 -0
  4. package/dist/benchmark/analyze-memory-sampler.test.d.ts +1 -0
  5. package/dist/benchmark/analyze-memory-sampler.test.js +12 -0
  6. package/dist/benchmark/io.test.js +48 -5
  7. package/dist/benchmark/u2-e2e/config.d.ts +1 -0
  8. package/dist/benchmark/u2-e2e/retrieval-runner.js +25 -3
  9. package/dist/benchmark/u2-e2e/retrieval-runner.test.js +44 -1
  10. package/dist/benchmark/unity-lazy-context-sampler.d.ts +58 -0
  11. package/dist/benchmark/unity-lazy-context-sampler.js +217 -0
  12. package/dist/benchmark/unity-lazy-context-sampler.test.d.ts +1 -0
  13. package/dist/benchmark/unity-lazy-context-sampler.test.js +32 -0
  14. package/dist/cli/analyze-close-policy.d.ts +5 -0
  15. package/dist/cli/analyze-close-policy.js +9 -0
  16. package/dist/cli/analyze-close-policy.test.d.ts +1 -0
  17. package/dist/cli/analyze-close-policy.test.js +12 -0
  18. package/dist/cli/analyze-runtime-summary.d.ts +2 -0
  19. package/dist/cli/analyze-runtime-summary.js +9 -0
  20. package/dist/cli/analyze-runtime-summary.test.d.ts +1 -0
  21. package/dist/cli/analyze-runtime-summary.test.js +14 -0
  22. package/dist/cli/analyze.js +42 -15
  23. package/dist/cli/eval-server.js +3 -0
  24. package/dist/cli/exit-code.d.ts +13 -0
  25. package/dist/cli/exit-code.js +25 -0
  26. package/dist/cli/exit-code.test.d.ts +1 -0
  27. package/dist/cli/exit-code.test.js +28 -0
  28. package/dist/cli/index.js +8 -2
  29. package/dist/cli/mcp.js +3 -0
  30. package/dist/cli/setup.js +3 -2
  31. package/dist/cli/setup.test.js +67 -0
  32. package/dist/cli/tool.d.ts +3 -1
  33. package/dist/cli/tool.js +2 -0
  34. package/dist/core/graph/types.d.ts +1 -1
  35. package/dist/core/ingestion/filesystem-walker.d.ts +6 -0
  36. package/dist/core/ingestion/filesystem-walker.js +17 -0
  37. package/dist/core/ingestion/filesystem-walker.test.d.ts +1 -0
  38. package/dist/core/ingestion/filesystem-walker.test.js +51 -0
  39. package/dist/core/ingestion/pipeline.js +4 -3
  40. package/dist/core/ingestion/unity-parity-seed.d.ts +9 -0
  41. package/dist/core/ingestion/unity-parity-seed.js +69 -0
  42. package/dist/core/ingestion/unity-parity-seed.test.d.ts +1 -0
  43. package/dist/core/ingestion/unity-parity-seed.test.js +35 -0
  44. package/dist/core/ingestion/unity-resource-processor.d.ts +2 -0
  45. package/dist/core/ingestion/unity-resource-processor.js +87 -53
  46. package/dist/core/ingestion/unity-resource-processor.test.js +37 -39
  47. package/dist/core/kuzu/csv-generator.d.ts +20 -1
  48. package/dist/core/kuzu/csv-generator.js +92 -25
  49. package/dist/core/kuzu/csv-generator.test.d.ts +1 -0
  50. package/dist/core/kuzu/csv-generator.test.js +28 -0
  51. package/dist/core/kuzu/kuzu-adapter.js +35 -54
  52. package/dist/core/kuzu/relationship-pair-buckets.d.ts +17 -0
  53. package/dist/core/kuzu/relationship-pair-buckets.js +79 -0
  54. package/dist/core/kuzu/relationship-pair-buckets.test.d.ts +1 -0
  55. package/dist/core/kuzu/relationship-pair-buckets.test.js +10 -0
  56. package/dist/core/kuzu/schema.d.ts +1 -1
  57. package/dist/core/kuzu/schema.js +1 -0
  58. package/dist/core/unity/options.d.ts +2 -0
  59. package/dist/core/unity/options.js +9 -0
  60. package/dist/core/unity/options.test.js +8 -1
  61. package/dist/core/unity/resolver.d.ts +3 -0
  62. package/dist/core/unity/resolver.js +56 -2
  63. package/dist/core/unity/resolver.test.js +46 -0
  64. package/dist/core/unity/scan-context.d.ts +5 -0
  65. package/dist/core/unity/scan-context.js +133 -44
  66. package/dist/core/unity/scan-context.test.js +41 -2
  67. package/dist/core/unity/serialized-type-index.d.ts +5 -0
  68. package/dist/core/unity/serialized-type-index.js +44 -13
  69. package/dist/core/unity/serialized-type-index.test.js +9 -1
  70. package/dist/mcp/local/local-backend.d.ts +16 -0
  71. package/dist/mcp/local/local-backend.js +320 -4
  72. package/dist/mcp/local/local-backend.unity-merge.test.d.ts +1 -0
  73. package/dist/mcp/local/local-backend.unity-merge.test.js +261 -0
  74. package/dist/mcp/local/unity-enrichment.d.ts +15 -0
  75. package/dist/mcp/local/unity-enrichment.js +69 -5
  76. package/dist/mcp/local/unity-enrichment.test.js +69 -1
  77. package/dist/mcp/local/unity-lazy-config.d.ts +6 -0
  78. package/dist/mcp/local/unity-lazy-config.js +7 -0
  79. package/dist/mcp/local/unity-lazy-config.test.d.ts +1 -0
  80. package/dist/mcp/local/unity-lazy-config.test.js +9 -0
  81. package/dist/mcp/local/unity-lazy-hydrator.d.ts +15 -0
  82. package/dist/mcp/local/unity-lazy-hydrator.js +43 -0
  83. package/dist/mcp/local/unity-lazy-hydrator.test.d.ts +1 -0
  84. package/dist/mcp/local/unity-lazy-hydrator.test.js +66 -0
  85. package/dist/mcp/local/unity-lazy-overlay.d.ts +3 -0
  86. package/dist/mcp/local/unity-lazy-overlay.js +89 -0
  87. package/dist/mcp/local/unity-lazy-overlay.test.d.ts +1 -0
  88. package/dist/mcp/local/unity-lazy-overlay.test.js +83 -0
  89. package/dist/mcp/local/unity-parity-cache.d.ts +7 -0
  90. package/dist/mcp/local/unity-parity-cache.js +88 -0
  91. package/dist/mcp/local/unity-parity-cache.test.d.ts +1 -0
  92. package/dist/mcp/local/unity-parity-cache.test.js +143 -0
  93. package/dist/mcp/local/unity-parity-seed-loader.d.ts +2 -0
  94. package/dist/mcp/local/unity-parity-seed-loader.js +30 -0
  95. package/dist/mcp/local/unity-parity-seed-loader.test.d.ts +1 -0
  96. package/dist/mcp/local/unity-parity-seed-loader.test.js +25 -0
  97. package/dist/mcp/local/unity-parity-warmup-queue.d.ts +6 -0
  98. package/dist/mcp/local/unity-parity-warmup-queue.js +28 -0
  99. package/dist/mcp/local/unity-parity-warmup-queue.test.d.ts +1 -0
  100. package/dist/mcp/local/unity-parity-warmup-queue.test.js +15 -0
  101. package/dist/mcp/tools.js +24 -2
  102. package/dist/types/pipeline.d.ts +7 -0
  103. package/package.json +4 -1
  104. package/skills/gitnexus-cli.md +18 -0
  105. package/skills/gitnexus-debugging.md +16 -2
  106. package/skills/gitnexus-exploring.md +15 -1
  107. package/skills/gitnexus-guide.md +15 -0
  108. package/skills/gitnexus-impact-analysis.md +2 -0
  109. package/skills/gitnexus-refactoring.md +5 -1
@@ -0,0 +1,35 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildUnityParitySeed } from './unity-parity-seed.js';
4
+ test('buildUnityParitySeed extracts canonical script/guid/resource indexes', () => {
5
+ const seed = buildUnityParitySeed({
6
+ symbolToScriptPaths: new Map([
7
+ ['DoorObj', ['Assets/Code/DoorObj.generated.cs', 'Assets/Code/DoorObj.cs']],
8
+ ]),
9
+ symbolToCanonicalScriptPath: new Map([
10
+ ['DoorObj', 'Assets/Code/DoorObj.cs'],
11
+ ]),
12
+ symbolToScriptPath: new Map([
13
+ ['DoorObj', 'Assets/Code/DoorObj.cs'],
14
+ ]),
15
+ scriptPathToGuid: new Map([
16
+ ['Assets/Code/DoorObj.cs', 'abc123abc123abc123abc123abc123ab'],
17
+ ]),
18
+ guidToResourceHits: new Map([
19
+ ['abc123abc123abc123abc123abc123ab', [
20
+ { resourcePath: 'Assets/Prefabs/Door.prefab', resourceType: 'prefab', line: 12, lineText: 'guid: abc123' },
21
+ ]],
22
+ ]),
23
+ assetGuidToPath: new Map([
24
+ ['asset0000000000000000000000000001', 'Assets/Config/Ref.asset'],
25
+ ]),
26
+ serializableSymbols: new Set(),
27
+ hostFieldTypeHints: new Map(),
28
+ resourceDocCache: new Map(),
29
+ });
30
+ assert.equal(seed.version, 1);
31
+ assert.equal(seed.symbolToScriptPath.DoorObj, 'Assets/Code/DoorObj.cs');
32
+ assert.equal(seed.scriptPathToGuid['Assets/Code/DoorObj.cs'], 'abc123abc123abc123abc123abc123ab');
33
+ assert.deepEqual(seed.guidToResourcePaths['abc123abc123abc123abc123abc123ab'], ['Assets/Prefabs/Door.prefab']);
34
+ assert.equal(seed.assetGuidToPath?.asset0000000000000000000000000001, 'Assets/Config/Ref.asset');
35
+ });
@@ -1,11 +1,13 @@
1
1
  import type { KnowledgeGraph } from '../graph/types.js';
2
2
  import { buildUnityScanContext } from '../unity/scan-context.js';
3
3
  import { resolveUnityBindings } from '../unity/resolver.js';
4
+ import { type UnityParitySeed } from './unity-parity-seed.js';
4
5
  export interface UnityResourceProcessingResult {
5
6
  processedSymbols: number;
6
7
  bindingCount: number;
7
8
  componentCount: number;
8
9
  diagnostics: string[];
10
+ paritySeed?: UnityParitySeed;
9
11
  timingsMs: {
10
12
  scanContext: number;
11
13
  resolve: number;
@@ -1,8 +1,8 @@
1
- import path from 'node:path';
2
1
  import { performance } from 'node:perf_hooks';
3
2
  import { generateId } from '../../lib/utils.js';
4
3
  import { buildUnityScanContext } from '../unity/scan-context.js';
5
4
  import { resolveUnityBindings } from '../unity/resolver.js';
5
+ import { buildUnityParitySeed } from './unity-parity-seed.js';
6
6
  const UNITY_DIAGNOSTIC_SAMPLE_LIMIT = 3;
7
7
  export async function processUnityResources(graph, options, deps) {
8
8
  const tStart = performance.now();
@@ -98,37 +98,53 @@ export async function processUnityResources(graph, options, deps) {
98
98
  continue;
99
99
  }
100
100
  processedSymbols += 1;
101
+ const tWriteStart = performance.now();
102
+ const summaryBySource = new Map();
103
+ const appendSummary = (sourceNodeId, binding) => {
104
+ const normalizedPath = normalizePath(binding.resourcePath);
105
+ const perPath = summaryBySource.get(sourceNodeId) || new Map();
106
+ const existing = perPath.get(normalizedPath) || {
107
+ resourceType: binding.resourceType,
108
+ bindingKinds: new Set(),
109
+ lightweight: true,
110
+ };
111
+ existing.resourceType = binding.resourceType || existing.resourceType;
112
+ existing.bindingKinds.add(binding.bindingKind);
113
+ existing.lightweight = existing.lightweight && Boolean(binding.lightweight);
114
+ perPath.set(normalizedPath, existing);
115
+ summaryBySource.set(sourceNodeId, perPath);
116
+ };
101
117
  for (const binding of resolved.resourceBindings) {
102
- const tWriteStart = performance.now();
103
118
  bindingCount += 1;
104
- componentCount += 1;
105
- const resourceFileNode = ensureResourceFileNode(graph, binding.resourcePath);
106
- const componentNode = createComponentNode(symbol, binding, payloadMode);
107
- graph.addNode(componentNode);
108
- graph.addRelationship({
109
- id: generateId('UNITY_COMPONENT_IN', `${classNode.id}:${binding.componentObjectId}->${resourceFileNode.id}`),
110
- type: 'UNITY_COMPONENT_IN',
111
- sourceId: classNode.id,
112
- targetId: resourceFileNode.id,
113
- confidence: 1.0,
114
- reason: binding.bindingKind,
115
- });
116
- graph.addRelationship({
117
- id: generateId('UNITY_COMPONENT_INSTANCE', `${classNode.id}->${componentNode.id}`),
118
- type: 'UNITY_COMPONENT_INSTANCE',
119
- sourceId: classNode.id,
120
- targetId: componentNode.id,
121
- confidence: 1.0,
122
- reason: binding.bindingKind,
123
- });
124
- const serializableTypeLinking = linkSerializableTypeEdges(graph, componentNode, symbol, binding, scanContext, canonicalClassNodeBySymbol);
119
+ appendSummary(classNode.id, binding);
120
+ const serializableTypeLinking = collectSerializableTypeTargetsForBinding(symbol, binding, scanContext, canonicalClassNodeBySymbol);
125
121
  serializedTypeEdgeCount += serializableTypeLinking.edgeCount;
126
122
  serializedTypeMissCount += serializableTypeLinking.missCount;
127
123
  for (const hitSymbol of serializableTypeLinking.symbols) {
128
124
  serializedTypeSymbols.add(hitSymbol);
129
125
  }
130
- graphWriteMs += performance.now() - tWriteStart;
126
+ for (const targetClassId of serializableTypeLinking.targetClassIds) {
127
+ appendSummary(targetClassId, binding);
128
+ }
129
+ }
130
+ for (const [sourceNodeId, perPath] of summaryBySource.entries()) {
131
+ for (const [resourcePath, summary] of perPath.entries()) {
132
+ const resourceFileId = generateId('File', resourcePath);
133
+ graph.addRelationship({
134
+ id: generateId('UNITY_RESOURCE_SUMMARY', `${sourceNodeId}->${resourceFileId}`),
135
+ type: 'UNITY_RESOURCE_SUMMARY',
136
+ sourceId: sourceNodeId,
137
+ targetId: resourceFileId,
138
+ confidence: 1.0,
139
+ reason: JSON.stringify({
140
+ resourceType: summary.resourceType,
141
+ bindingKinds: [...summary.bindingKinds.values()].sort(),
142
+ lightweight: true,
143
+ }),
144
+ });
145
+ }
131
146
  }
147
+ graphWriteMs += performance.now() - tWriteStart;
132
148
  }
133
149
  catch (error) {
134
150
  const message = error instanceof Error ? error.message : String(error);
@@ -150,6 +166,7 @@ export async function processUnityResources(graph, options, deps) {
150
166
  bindingCount,
151
167
  componentCount,
152
168
  diagnostics,
169
+ paritySeed: scanContext ? buildUnityParitySeed(scanContext) : undefined,
153
170
  timingsMs: {
154
171
  scanContext: roundMs(scanContextMs),
155
172
  resolve: roundMs(resolveMs),
@@ -193,24 +210,6 @@ function resolveUnityPayloadMode(explicit) {
193
210
  return 'full';
194
211
  return 'compact';
195
212
  }
196
- function ensureResourceFileNode(graph, resourcePath) {
197
- const normalizedResourcePath = resourcePath.replace(/\\/g, '/');
198
- const fileId = generateId('File', normalizedResourcePath);
199
- const existing = graph.getNode(fileId);
200
- if (existing) {
201
- return existing;
202
- }
203
- const node = {
204
- id: fileId,
205
- label: 'File',
206
- properties: {
207
- name: path.basename(normalizedResourcePath),
208
- filePath: normalizedResourcePath,
209
- },
210
- };
211
- graph.addNode(node);
212
- return node;
213
- }
214
213
  function createComponentNode(symbol, binding, payloadMode) {
215
214
  const payload = buildUnityPayload(binding, payloadMode);
216
215
  return {
@@ -230,8 +229,12 @@ function buildUnityPayload(binding, mode) {
230
229
  bindingKind: binding.bindingKind,
231
230
  componentObjectId: binding.componentObjectId,
232
231
  };
233
- if (binding.serializedFields.scalarFields.length > 0 || binding.serializedFields.referenceFields.length > 0) {
234
- payload.serializedFields = binding.serializedFields;
232
+ if (binding.lightweight) {
233
+ payload.lightweight = true;
234
+ }
235
+ const serializedFields = compactSerializedFieldsForStorage(binding.serializedFields);
236
+ if (serializedFields.scalarFields.length > 0 || serializedFields.referenceFields.length > 0) {
237
+ payload.serializedFields = serializedFields;
235
238
  }
236
239
  if (binding.resolvedReferences && binding.resolvedReferences.length > 0) {
237
240
  payload.resolvedReferences = binding.resolvedReferences;
@@ -246,6 +249,22 @@ function buildUnityPayload(binding, mode) {
246
249
  }
247
250
  return payload;
248
251
  }
252
+ function compactSerializedFieldsForStorage(input) {
253
+ return {
254
+ scalarFields: input.scalarFields.map((field) => ({
255
+ name: field.name,
256
+ sourceLayer: field.sourceLayer,
257
+ value: field.value,
258
+ valueType: field.valueType,
259
+ })),
260
+ referenceFields: input.referenceFields.map((field) => ({
261
+ name: field.name,
262
+ guid: field.guid,
263
+ fileId: field.fileId,
264
+ sourceLayer: field.sourceLayer,
265
+ })),
266
+ };
267
+ }
249
268
  function buildCanonicalClassNodeIndex(classNodes, scanContext) {
250
269
  const index = new Map();
251
270
  for (const classNode of classNodes) {
@@ -261,11 +280,12 @@ function buildCanonicalClassNodeIndex(classNodes, scanContext) {
261
280
  }
262
281
  return index;
263
282
  }
264
- function linkSerializableTypeEdges(graph, componentNode, hostSymbol, binding, scanContext, canonicalClassNodeBySymbol) {
283
+ function collectSerializableTypeTargetsForBinding(hostSymbol, binding, scanContext, canonicalClassNodeBySymbol) {
265
284
  const stats = {
266
285
  edgeCount: 0,
267
286
  missCount: 0,
268
287
  symbols: new Set(),
288
+ targetClassIds: new Set(),
269
289
  };
270
290
  if (!scanContext)
271
291
  return stats;
@@ -290,14 +310,7 @@ function linkSerializableTypeEdges(graph, componentNode, hostSymbol, binding, sc
290
310
  stats.missCount += 1;
291
311
  continue;
292
312
  }
293
- graph.addRelationship({
294
- id: generateId('UNITY_SERIALIZED_TYPE_IN', `${serializableNode.id}->${componentNode.id}:${fieldName}`),
295
- type: 'UNITY_SERIALIZED_TYPE_IN',
296
- sourceId: serializableNode.id,
297
- targetId: componentNode.id,
298
- confidence: 1.0,
299
- reason: JSON.stringify({ hostSymbol, fieldName, declaredType, sourceLayer }),
300
- });
313
+ stats.targetClassIds.add(serializableNode.id);
301
314
  stats.edgeCount += 1;
302
315
  stats.symbols.add(declaredType);
303
316
  }
@@ -317,6 +330,27 @@ function collectBindingFieldSources(binding) {
317
330
  }
318
331
  return fieldSources;
319
332
  }
333
+ function collectResourceSummaryRows(bindings) {
334
+ const summaryByPath = new Map();
335
+ for (const binding of bindings) {
336
+ const resourcePath = normalizePath(binding.resourcePath);
337
+ const row = summaryByPath.get(resourcePath) || {
338
+ resourceType: binding.resourceType,
339
+ bindingKinds: new Set(),
340
+ lightweight: true,
341
+ };
342
+ row.resourceType = binding.resourceType || row.resourceType;
343
+ row.bindingKinds.add(binding.bindingKind);
344
+ row.lightweight = row.lightweight && Boolean(binding.lightweight);
345
+ summaryByPath.set(resourcePath, row);
346
+ }
347
+ return [...summaryByPath.entries()].map(([resourcePath, value]) => ({
348
+ resourcePath,
349
+ resourceType: value.resourceType,
350
+ bindingKinds: [...value.bindingKinds.values()].sort(),
351
+ lightweight: value.lightweight,
352
+ }));
353
+ }
320
354
  function roundMs(value) {
321
355
  return Number(value.toFixed(1));
322
356
  }
@@ -8,7 +8,7 @@ import { processUnityResources } from './unity-resource-processor.js';
8
8
  const here = path.dirname(fileURLToPath(import.meta.url));
9
9
  const fixtureRoot = path.resolve(here, '../../../src/core/unity/__fixtures__/mini-unity');
10
10
  const symbols = ['Global', 'BattleMode', 'PlayerActor', 'MainUIManager'];
11
- test('processUnityResources adds Unity resource relationships and component payload nodes', async () => {
11
+ test('processUnityResources does not emit UNITY_COMPONENT_IN or synthetic resource File nodes', async () => {
12
12
  const graph = createKnowledgeGraph();
13
13
  for (const symbol of symbols) {
14
14
  const filePath = `Assets/Scripts/${symbol}.cs`;
@@ -41,13 +41,17 @@ test('processUnityResources adds Unity resource relationships and component payl
41
41
  }
42
42
  const result = await processUnityResources(graph, { repoPath: fixtureRoot });
43
43
  const unityFileRelations = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_COMPONENT_IN');
44
- const unityInstanceRelations = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_COMPONENT_INSTANCE');
45
- const componentNodes = [...graph.iterNodes()].filter((node) => node.label === 'CodeElement' && /\.(unity|prefab)$/.test(String(node.properties.filePath)));
46
- assert.ok(unityFileRelations.length > 0);
47
- assert.ok(unityInstanceRelations.length > 0);
48
- assert.ok(componentNodes.length > 0);
49
- assert.ok(componentNodes.some((node) => String(node.properties.description).includes('mainUIDocument')));
44
+ const unitySummaryRelations = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_RESOURCE_SUMMARY');
45
+ const syntheticResourceFiles = [...graph.iterNodes()].filter((node) => node.label === 'File' && /\.(prefab|unity|asset)$/.test(String(node.properties.filePath)));
46
+ const componentNodes = [...graph.iterNodes()].filter((node) => node.label === 'CodeElement');
47
+ assert.equal(result.bindingCount > 0, true);
48
+ assert.equal(unityFileRelations.length, 0);
49
+ assert.equal(syntheticResourceFiles.length, 0);
50
+ assert.ok(unitySummaryRelations.length > 0);
51
+ assert.equal(componentNodes.length, 0);
50
52
  assert.ok(result.bindingCount >= symbols.length);
53
+ assert.equal(result.paritySeed?.version, 1);
54
+ assert.ok((result.paritySeed?.scriptPathToGuid || {})['Assets/Scripts/MainUIManager.cs']);
51
55
  assert.ok(result.timingsMs.scanContext >= 0);
52
56
  assert.ok(result.timingsMs.resolve >= 0);
53
57
  assert.ok(result.timingsMs.graphWrite > 0);
@@ -248,7 +252,7 @@ test('processUnityResources memoizes resolve results by symbol within one run',
248
252
  assert.equal(result.bindingCount, 1);
249
253
  assert.ok(result.diagnostics.some((line) => line.includes('skip-non-canonical=1')));
250
254
  });
251
- test('processUnityResources writes UNITY_COMPONENT_INSTANCE only for canonical class node', async () => {
255
+ test('processUnityResources writes UNITY_RESOURCE_SUMMARY only for canonical class node', async () => {
252
256
  const graph = createKnowledgeGraph();
253
257
  const canonicalPath = 'Assets/Scripts/PlayerActor.cs';
254
258
  const partialPath = 'Assets/Scripts/PlayerActor.Visual.cs';
@@ -294,15 +298,15 @@ test('processUnityResources writes UNITY_COMPONENT_INSTANCE only for canonical c
294
298
  unityDiagnostics: [],
295
299
  }),
296
300
  });
297
- const instanceRelations = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_COMPONENT_INSTANCE');
298
- assert.equal(instanceRelations.length, 1);
299
- assert.equal(instanceRelations[0]?.sourceId, canonicalClassId);
301
+ const summaryRelations = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_RESOURCE_SUMMARY');
302
+ assert.equal(summaryRelations.length, 1);
303
+ assert.equal(summaryRelations[0]?.sourceId, canonicalClassId);
300
304
  assert.equal(result.processedSymbols, 1);
301
305
  assert.equal(result.bindingCount, 1);
302
306
  assert.ok(result.diagnostics.some((line) => line.includes('selected=1')));
303
307
  assert.ok(result.diagnostics.some((line) => line.includes('skip-non-canonical=1')));
304
308
  });
305
- test('processUnityResources writes UNITY_SERIALIZED_TYPE_IN for serializable class field matches', async () => {
309
+ test('processUnityResources writes UNITY_RESOURCE_SUMMARY for serializable class field matches', async () => {
306
310
  const graph = createKnowledgeGraph();
307
311
  const hostPath = 'Assets/Scripts/HostClass.cs';
308
312
  const serializablePath = 'Assets/Scripts/AssetRef.cs';
@@ -364,11 +368,11 @@ test('processUnityResources writes UNITY_SERIALIZED_TYPE_IN for serializable cla
364
368
  unityDiagnostics: [],
365
369
  }),
366
370
  });
367
- const serializedTypeEdges = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_SERIALIZED_TYPE_IN');
368
- assert.equal(serializedTypeEdges.length, 1);
369
- assert.equal(serializedTypeEdges[0]?.sourceId, serializableClassId);
371
+ const summaryRelations = [...graph.iterRelationships()].filter((rel) => rel.type === 'UNITY_RESOURCE_SUMMARY');
372
+ const serializableSummary = summaryRelations.filter((rel) => rel.sourceId === serializableClassId);
373
+ assert.equal(serializableSummary.length, 1);
370
374
  });
371
- test('processUnityResources writes compact unity payload by default', async () => {
375
+ test('processUnityResources writes compact UNITY_RESOURCE_SUMMARY reason by default', async () => {
372
376
  const graph = createKnowledgeGraph();
373
377
  const classId = generateId('Class', 'Assets/Scripts/Compact.cs:CompactSymbol');
374
378
  graph.addNode({
@@ -407,17 +411,14 @@ test('processUnityResources writes compact unity payload by default', async () =
407
411
  unityDiagnostics: [],
408
412
  }),
409
413
  });
410
- const component = [...graph.iterNodes()].find((node) => node.label === 'CodeElement');
411
- assert.ok(component);
412
- const payload = JSON.parse(String(component.properties.description));
413
- assert.equal(payload.bindingKind, 'scene-override');
414
- assert.equal(payload.componentObjectId, '11400000');
415
- assert.ok(Array.isArray(payload.serializedFields.scalarFields));
416
- assert.equal(payload.resourcePath, undefined);
417
- assert.equal(payload.resourceType, undefined);
418
- assert.equal(payload.evidence, undefined);
414
+ const summary = [...graph.iterRelationships()].find((rel) => rel.type === 'UNITY_RESOURCE_SUMMARY');
415
+ assert.ok(summary);
416
+ const reason = JSON.parse(String(summary.reason || '{}'));
417
+ assert.deepEqual(reason.bindingKinds, ['scene-override']);
418
+ assert.equal(reason.lightweight, true);
419
+ assert.equal(reason.resourceType, 'scene');
419
420
  });
420
- test('processUnityResources includes structured assetRefPaths in component payload', async () => {
421
+ test('processUnityResources keeps UNITY_RESOURCE_SUMMARY reason compact when bindings include assetRefPaths', async () => {
421
422
  const graph = createKnowledgeGraph();
422
423
  const classId = generateId('Class', 'Assets/Scripts/CharacterList.cs:CharacterList');
423
424
  graph.addNode({
@@ -464,14 +465,11 @@ test('processUnityResources includes structured assetRefPaths in component paylo
464
465
  unityDiagnostics: [],
465
466
  }),
466
467
  });
467
- const component = [...graph.iterNodes()].find((node) => node.label === 'CodeElement');
468
- assert.ok(component);
469
- const payload = JSON.parse(String(component.properties.description));
470
- assert.equal(payload.assetRefPaths?.length, 1);
471
- assert.equal(payload.assetRefPaths?.[0]?.fieldName, '_Head_Ref');
472
- assert.equal(payload.assetRefPaths?.[0]?.isSprite, true);
468
+ const summary = [...graph.iterRelationships()].find((rel) => rel.type === 'UNITY_RESOURCE_SUMMARY');
469
+ assert.ok(summary);
470
+ assert.equal(String(summary.reason).includes('assetRefPaths'), false);
473
471
  });
474
- test('processUnityResources writes full unity payload when payloadMode=full', async () => {
472
+ test('processUnityResources summary-only persistence ignores full payload mode and keeps summary reason', async () => {
475
473
  const graph = createKnowledgeGraph();
476
474
  const classId = generateId('Class', 'Assets/Scripts/Full.cs:FullSymbol');
477
475
  graph.addNode({
@@ -507,12 +505,12 @@ test('processUnityResources writes full unity payload when payloadMode=full', as
507
505
  unityDiagnostics: [],
508
506
  }),
509
507
  });
510
- const component = [...graph.iterNodes()].find((node) => node.label === 'CodeElement');
511
- assert.ok(component);
512
- const payload = JSON.parse(String(component.properties.description));
513
- assert.equal(payload.resourcePath, 'Assets/Scene/Test.unity');
514
- assert.equal(payload.resourceType, 'scene');
515
- assert.equal(payload.evidence?.line, 9);
508
+ const summary = [...graph.iterRelationships()].find((rel) => rel.type === 'UNITY_RESOURCE_SUMMARY');
509
+ assert.ok(summary);
510
+ const reason = JSON.parse(String(summary.reason || '{}'));
511
+ assert.equal(reason.resourceType, 'scene');
512
+ assert.deepEqual(reason.bindingKinds, ['scene-override']);
513
+ assert.equal(String(summary.reason).includes('evidence'), false);
516
514
  });
517
515
  test('processUnityResources aggregates repetitive diagnostics with capped samples', async () => {
518
516
  const graph = createKnowledgeGraph();
@@ -11,8 +11,27 @@
11
11
  * - Double quotes within fields are escaped by doubling them ("")
12
12
  * - All fields are consistently quoted for safety with code content
13
13
  */
14
- import { KnowledgeGraph } from '../graph/types.js';
14
+ import { KnowledgeGraph, GraphNode } from '../graph/types.js';
15
15
  import { NodeTableName } from './schema.js';
16
+ /**
17
+ * LRU content cache — avoids re-reading the same source file for every
18
+ * symbol defined in it. Sized generously so most files stay cached during
19
+ * the single-pass node iteration.
20
+ */
21
+ export declare class FileContentCache {
22
+ private repoPath;
23
+ private maxBytes;
24
+ private cache;
25
+ private currentBytes;
26
+ constructor(repoPath: string, maxBytes?: number);
27
+ get(relativePath: string): Promise<string>;
28
+ setForTest(key: string, value: string): void;
29
+ hasForTest(key: string): boolean;
30
+ private touch;
31
+ private set;
32
+ private evictIfNeeded;
33
+ }
34
+ export declare function toCodeElementCsvRow(node: GraphNode, contentCache?: FileContentCache): Promise<string>;
16
35
  export interface StreamedCSVResult {
17
36
  nodeFiles: Map<NodeTableName, {
18
37
  csvPath: string;
@@ -39,6 +39,7 @@ const escapeCSVNumber = (value, defaultValue = -1) => {
39
39
  return String(defaultValue);
40
40
  return String(value);
41
41
  };
42
+ const UNITY_RESOURCE_PATH_PATTERN = /\.(prefab|unity|asset)$/i;
42
43
  // ============================================================================
43
44
  // CONTENT EXTRACTION (lazy — reads from disk on demand)
44
45
  // ============================================================================
@@ -59,21 +60,23 @@ const isBinaryContent = (content) => {
59
60
  * symbol defined in it. Sized generously so most files stay cached during
60
61
  * the single-pass node iteration.
61
62
  */
62
- class FileContentCache {
63
- cache = new Map();
64
- accessOrder = [];
65
- maxSize;
63
+ export class FileContentCache {
66
64
  repoPath;
67
- constructor(repoPath, maxSize = 3000) {
65
+ maxBytes;
66
+ cache = new Map();
67
+ currentBytes = 0;
68
+ constructor(repoPath, maxBytes = 128 * 1024 * 1024) {
68
69
  this.repoPath = repoPath;
69
- this.maxSize = maxSize;
70
+ this.maxBytes = maxBytes;
70
71
  }
71
72
  async get(relativePath) {
72
73
  if (!relativePath)
73
74
  return '';
74
75
  const cached = this.cache.get(relativePath);
75
- if (cached !== undefined)
76
- return cached;
76
+ if (cached !== undefined) {
77
+ this.touch(relativePath, cached);
78
+ return cached.content;
79
+ }
77
80
  try {
78
81
  const fullPath = path.join(this.repoPath, relativePath);
79
82
  const content = await fs.readFile(fullPath, 'utf-8');
@@ -85,14 +88,38 @@ class FileContentCache {
85
88
  return '';
86
89
  }
87
90
  }
91
+ setForTest(key, value) {
92
+ this.set(key, value);
93
+ }
94
+ hasForTest(key) {
95
+ return this.cache.has(key);
96
+ }
97
+ touch(key, value) {
98
+ this.cache.delete(key);
99
+ this.cache.set(key, value);
100
+ }
88
101
  set(key, value) {
89
- if (this.cache.size >= this.maxSize) {
90
- const oldest = this.accessOrder.shift();
91
- if (oldest)
92
- this.cache.delete(oldest);
102
+ const prev = this.cache.get(key);
103
+ if (prev) {
104
+ this.currentBytes -= prev.sizeBytes;
105
+ this.cache.delete(key);
106
+ }
107
+ const sizeBytes = Buffer.byteLength(value, 'utf-8');
108
+ this.cache.set(key, { content: value, sizeBytes });
109
+ this.currentBytes += sizeBytes;
110
+ this.evictIfNeeded();
111
+ }
112
+ evictIfNeeded() {
113
+ while (this.currentBytes > this.maxBytes && this.cache.size > 0) {
114
+ const oldestKey = this.cache.keys().next().value;
115
+ if (!oldestKey)
116
+ break;
117
+ const oldest = this.cache.get(oldestKey);
118
+ this.cache.delete(oldestKey);
119
+ if (oldest) {
120
+ this.currentBytes -= oldest.sizeBytes;
121
+ }
93
122
  }
94
- this.cache.set(key, value);
95
- this.accessOrder.push(key);
96
123
  }
97
124
  }
98
125
  const extractContent = async (node, contentCache) => {
@@ -123,6 +150,56 @@ const extractContent = async (node, contentCache) => {
123
150
  ? snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]'
124
151
  : snippet;
125
152
  };
153
+ function compactUnityComponentDescription(description) {
154
+ const raw = typeof description === 'string' ? description : '';
155
+ if (!raw)
156
+ return '';
157
+ try {
158
+ const parsed = JSON.parse(raw);
159
+ const compact = {};
160
+ if (parsed.bindingKind)
161
+ compact.bindingKind = parsed.bindingKind;
162
+ if (parsed.componentObjectId)
163
+ compact.componentObjectId = parsed.componentObjectId;
164
+ if (parsed.lightweight)
165
+ compact.lightweight = true;
166
+ const scalarFields = parsed.serializedFields?.scalarFields || [];
167
+ const referenceFields = parsed.serializedFields?.referenceFields || [];
168
+ if (scalarFields.length > 0 || referenceFields.length > 0) {
169
+ compact.serializedFields = { scalarFields, referenceFields };
170
+ }
171
+ if (Array.isArray(parsed.resolvedReferences) && parsed.resolvedReferences.length > 0) {
172
+ compact.resolvedReferences = parsed.resolvedReferences;
173
+ }
174
+ if (Array.isArray(parsed.assetRefPaths) && parsed.assetRefPaths.length > 0) {
175
+ compact.assetRefPaths = parsed.assetRefPaths;
176
+ }
177
+ return JSON.stringify(Object.keys(compact).length > 0 ? compact : parsed);
178
+ }
179
+ catch {
180
+ return raw;
181
+ }
182
+ }
183
+ export async function toCodeElementCsvRow(node, contentCache) {
184
+ const filePath = String(node.properties.filePath || '');
185
+ const isUnityComponentRow = node.label === 'CodeElement' && UNITY_RESOURCE_PATH_PATTERN.test(filePath);
186
+ const content = isUnityComponentRow
187
+ ? ''
188
+ : (contentCache ? await extractContent(node, contentCache) : '');
189
+ const description = isUnityComponentRow
190
+ ? compactUnityComponentDescription(node.properties.description || '')
191
+ : String(node.properties.description || '');
192
+ return [
193
+ escapeCSVField(node.id),
194
+ escapeCSVField(node.properties.name || ''),
195
+ escapeCSVField(filePath),
196
+ escapeCSVNumber(node.properties.startLine, -1),
197
+ escapeCSVNumber(node.properties.endLine, -1),
198
+ node.properties.isExported ? 'true' : 'false',
199
+ escapeCSVField(content),
200
+ escapeCSVField(description),
201
+ ].join(',');
202
+ }
126
203
  // ============================================================================
127
204
  // BUFFERED CSV WRITER
128
205
  // ============================================================================
@@ -266,17 +343,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir) => {
266
343
  // Code element nodes (Function, Class, Interface, Method, CodeElement)
267
344
  const writer = codeWriterMap[node.label];
268
345
  if (writer) {
269
- const content = await extractContent(node, contentCache);
270
- await writer.addRow([
271
- escapeCSVField(node.id),
272
- escapeCSVField(node.properties.name || ''),
273
- escapeCSVField(node.properties.filePath || ''),
274
- escapeCSVNumber(node.properties.startLine, -1),
275
- escapeCSVNumber(node.properties.endLine, -1),
276
- node.properties.isExported ? 'true' : 'false',
277
- escapeCSVField(content),
278
- escapeCSVField(node.properties.description || ''),
279
- ].join(','));
346
+ await writer.addRow(await toCodeElementCsvRow(node, contentCache));
280
347
  }
281
348
  else {
282
349
  // Multi-language node types (Struct, Impl, Trait, Macro, etc.)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { FileContentCache, toCodeElementCsvRow } from './csv-generator.js';
4
+ test('FileContentCache evicts oldest entries when byte budget is exceeded', async () => {
5
+ const cache = new FileContentCache('/tmp/repo', 10);
6
+ cache.setForTest('a.cs', '123456');
7
+ cache.setForTest('b.cs', '123456');
8
+ assert.equal(cache.hasForTest('a.cs'), false);
9
+ assert.equal(cache.hasForTest('b.cs'), true);
10
+ });
11
+ test('Unity component CodeElement rows store compact description and empty content', async () => {
12
+ const row = await toCodeElementCsvRow({
13
+ id: 'CodeElement:Assets/A.prefab:114',
14
+ label: 'CodeElement',
15
+ properties: {
16
+ name: 'DoorObj@114',
17
+ filePath: 'Assets/A.prefab',
18
+ startLine: 12,
19
+ endLine: 12,
20
+ description: JSON.stringify({
21
+ bindingKind: 'direct',
22
+ componentObjectId: '114',
23
+ serializedFields: { scalarFields: [], referenceFields: [] },
24
+ }),
25
+ },
26
+ });
27
+ assert.match(row, /,\"\"\,\"\{/);
28
+ });