@veewo/gitnexus 1.4.8 → 1.4.9

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 (49) hide show
  1. package/dist/cli/ai-context.js +1 -2
  2. package/dist/cli/analyze.js +2 -1
  3. package/dist/cli/index.js +8 -0
  4. package/dist/cli/repo-manager-alias.test.js +2 -0
  5. package/dist/cli/tool.d.ts +11 -0
  6. package/dist/cli/tool.js +59 -4
  7. package/dist/cli/unity-ui-trace.test.d.ts +1 -0
  8. package/dist/cli/unity-ui-trace.test.js +24 -0
  9. package/dist/core/ingestion/call-processor.js +9 -1
  10. package/dist/core/ingestion/type-env.d.ts +2 -0
  11. package/dist/core/ingestion/type-extractors/csharp.js +17 -2
  12. package/dist/core/ingestion/type-extractors/types.d.ts +4 -1
  13. package/dist/core/unity/csharp-selector-binding.d.ts +7 -0
  14. package/dist/core/unity/csharp-selector-binding.js +32 -0
  15. package/dist/core/unity/csharp-selector-binding.test.d.ts +1 -0
  16. package/dist/core/unity/csharp-selector-binding.test.js +14 -0
  17. package/dist/core/unity/scan-context.d.ts +2 -0
  18. package/dist/core/unity/scan-context.js +17 -0
  19. package/dist/core/unity/ui-asset-ref-scanner.d.ts +14 -0
  20. package/dist/core/unity/ui-asset-ref-scanner.js +213 -0
  21. package/dist/core/unity/ui-asset-ref-scanner.test.d.ts +1 -0
  22. package/dist/core/unity/ui-asset-ref-scanner.test.js +44 -0
  23. package/dist/core/unity/ui-meta-index.d.ts +8 -0
  24. package/dist/core/unity/ui-meta-index.js +60 -0
  25. package/dist/core/unity/ui-meta-index.test.d.ts +1 -0
  26. package/dist/core/unity/ui-meta-index.test.js +18 -0
  27. package/dist/core/unity/ui-trace-storage-guard.test.d.ts +1 -0
  28. package/dist/core/unity/ui-trace-storage-guard.test.js +10 -0
  29. package/dist/core/unity/ui-trace.acceptance.test.d.ts +1 -0
  30. package/dist/core/unity/ui-trace.acceptance.test.js +38 -0
  31. package/dist/core/unity/ui-trace.d.ts +31 -0
  32. package/dist/core/unity/ui-trace.js +363 -0
  33. package/dist/core/unity/ui-trace.test.d.ts +1 -0
  34. package/dist/core/unity/ui-trace.test.js +183 -0
  35. package/dist/core/unity/uss-selector-parser.d.ts +6 -0
  36. package/dist/core/unity/uss-selector-parser.js +21 -0
  37. package/dist/core/unity/uss-selector-parser.test.d.ts +1 -0
  38. package/dist/core/unity/uss-selector-parser.test.js +13 -0
  39. package/dist/core/unity/uxml-ref-parser.d.ts +10 -0
  40. package/dist/core/unity/uxml-ref-parser.js +22 -0
  41. package/dist/core/unity/uxml-ref-parser.test.d.ts +1 -0
  42. package/dist/core/unity/uxml-ref-parser.test.js +31 -0
  43. package/dist/mcp/local/local-backend.d.ts +13 -0
  44. package/dist/mcp/local/local-backend.js +298 -25
  45. package/dist/mcp/tools.js +41 -0
  46. package/dist/storage/repo-manager.d.ts +1 -0
  47. package/package.json +2 -1
  48. package/skills/gitnexus-cli.md +29 -0
  49. package/skills/gitnexus-guide.md +23 -0
@@ -13,6 +13,18 @@ import { type RegistryEntry } from '../../storage/repo-manager.js';
13
13
  * Matches common test file patterns across all supported languages.
14
14
  */
15
15
  export declare function isTestFilePath(filePath: string): boolean;
16
+ export interface ExpandedSymbolCandidate {
17
+ id: string;
18
+ name: string;
19
+ type: string;
20
+ filePath: string;
21
+ startLine?: number;
22
+ endLine?: number;
23
+ }
24
+ export declare function filterBm25ResultsByScopePreset<T extends {
25
+ filePath?: string;
26
+ }>(rows: T[], scopePreset?: string): T[];
27
+ export declare function rankExpandedSymbolsForQuery(symbols: ExpandedSymbolCandidate[], query: string, limit?: number, scopePreset?: string): ExpandedSymbolCandidate[];
16
28
  export declare function mergeUnityBindings(baseBindings: ResolvedUnityBinding[], resolvedByPath: Map<string, ResolvedUnityBinding[]>): ResolvedUnityBinding[];
17
29
  export declare function mergeParityUnityBindings(baseNonLightweightBindings: ResolvedUnityBinding[], resolvedBindings: ResolvedUnityBinding[]): ResolvedUnityBinding[];
18
30
  export declare function attachUnityHydrationMeta(payload: UnityContextPayload, input: Pick<UnityHydrationMeta, 'requestedMode' | 'effectiveMode' | 'elapsedMs' | 'fallbackToCompact'> & {
@@ -97,6 +109,7 @@ export declare class LocalBackend {
97
109
  stats?: any;
98
110
  }>>;
99
111
  callTool(method: string, params: any): Promise<any>;
112
+ private unityUiTrace;
100
113
  /**
101
114
  * Query tool — process-grouped search.
102
115
  *
@@ -8,6 +8,7 @@
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady } from '../core/lbug-adapter.js';
11
+ import { runUnityUiTrace } from '../../core/unity/ui-trace.js';
11
12
  // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
12
13
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
13
14
  // git utilities available if needed
@@ -32,6 +33,120 @@ export function isTestFilePath(filePath) {
32
33
  function normalizePath(filePath) {
33
34
  return String(filePath || '').replace(/\\/g, '/');
34
35
  }
36
+ const UNITY_GAMEPLAY_INCLUDE_PREFIXES = ['assets/'];
37
+ const UNITY_GAMEPLAY_EXCLUDE_PREFIXES = [
38
+ 'assets/plugins/',
39
+ 'packages/',
40
+ 'library/',
41
+ 'projectsettings/',
42
+ 'usersettings/',
43
+ 'temp/',
44
+ ];
45
+ const UNITY_PLUGIN_INTENT_TOKENS = new Set(['plugin', 'plugins', 'fmod', 'steam', 'crash', 'sdk', 'package']);
46
+ const QUERY_STOP_WORDS = new Set([
47
+ 'the', 'and', 'for', 'from', 'with', 'that', 'this', 'into',
48
+ 'using', 'use', 'in', 'on', 'of', 'to', 'a', 'an',
49
+ ]);
50
+ function tokenizeQuery(query) {
51
+ const normalized = String(query || '').toLowerCase();
52
+ return normalized
53
+ .split(/[^a-z0-9_]+/)
54
+ .map((token) => token.trim())
55
+ .filter((token) => token.length >= 2 && !QUERY_STOP_WORDS.has(token));
56
+ }
57
+ function matchesAnyPrefix(pathLower, prefixes) {
58
+ return prefixes.some((prefix) => pathLower.startsWith(prefix));
59
+ }
60
+ function isUnityPluginPath(filePath) {
61
+ const p = normalizePath(filePath).toLowerCase();
62
+ return p.startsWith('assets/plugins/') || p.startsWith('packages/') || p.startsWith('library/packagecache/');
63
+ }
64
+ function isUnityGameplayPath(filePath) {
65
+ const p = normalizePath(filePath).toLowerCase();
66
+ return p.startsWith('assets/') && !isUnityPluginPath(p);
67
+ }
68
+ function resolveQueryScopePreset(scopePreset) {
69
+ if (!scopePreset)
70
+ return undefined;
71
+ const normalized = String(scopePreset).trim().toLowerCase();
72
+ if (normalized === 'unity-gameplay' || normalized === 'unity-all') {
73
+ return normalized;
74
+ }
75
+ return undefined;
76
+ }
77
+ export function filterBm25ResultsByScopePreset(rows, scopePreset) {
78
+ const preset = resolveQueryScopePreset(scopePreset);
79
+ if (!preset || preset === 'unity-all')
80
+ return rows;
81
+ if (preset === 'unity-gameplay') {
82
+ return rows.filter((row) => {
83
+ const p = normalizePath(row.filePath || '').toLowerCase();
84
+ if (!p)
85
+ return false;
86
+ if (!matchesAnyPrefix(p, UNITY_GAMEPLAY_INCLUDE_PREFIXES))
87
+ return false;
88
+ if (matchesAnyPrefix(p, UNITY_GAMEPLAY_EXCLUDE_PREFIXES))
89
+ return false;
90
+ return true;
91
+ });
92
+ }
93
+ return rows;
94
+ }
95
+ function scoreExpandedSymbolForQuery(symbol, queryTokens, scopePreset) {
96
+ const name = String(symbol.name || '').toLowerCase();
97
+ const filePath = normalizePath(symbol.filePath || '').toLowerCase();
98
+ const pathText = filePath.replace(/[^a-z0-9_]+/g, ' ');
99
+ const hasPluginIntent = queryTokens.some((token) => UNITY_PLUGIN_INTENT_TOKENS.has(token));
100
+ let score = 0;
101
+ for (const token of queryTokens) {
102
+ if (name === token)
103
+ score += 8;
104
+ else if (name.includes(token))
105
+ score += 3;
106
+ if (pathText.includes(token))
107
+ score += 1;
108
+ }
109
+ if (queryTokens.length > 0) {
110
+ const fileName = filePath.split('/').pop() || '';
111
+ const fileNameNoExt = fileName.replace(/\.[a-z0-9]+$/i, '');
112
+ if (queryTokens.includes(fileNameNoExt)) {
113
+ score += 2;
114
+ }
115
+ }
116
+ if (isUnityPluginPath(filePath) && !hasPluginIntent) {
117
+ score -= scopePreset === 'unity-gameplay' ? 10 : 4;
118
+ }
119
+ else if (scopePreset === 'unity-gameplay' && isUnityGameplayPath(filePath)) {
120
+ score += 2;
121
+ }
122
+ return score;
123
+ }
124
+ export function rankExpandedSymbolsForQuery(symbols, query, limit = 3, scopePreset) {
125
+ const queryTokens = tokenizeQuery(query);
126
+ return [...symbols]
127
+ .sort((a, b) => {
128
+ const scoreDelta = scoreExpandedSymbolForQuery(b, queryTokens, scopePreset)
129
+ - scoreExpandedSymbolForQuery(a, queryTokens, scopePreset);
130
+ if (scoreDelta !== 0)
131
+ return scoreDelta;
132
+ const aLine = a.startLine ?? Number.MAX_SAFE_INTEGER;
133
+ const bLine = b.startLine ?? Number.MAX_SAFE_INTEGER;
134
+ if (aLine !== bLine)
135
+ return aLine - bLine;
136
+ return String(a.name || '').localeCompare(String(b.name || ''));
137
+ })
138
+ .slice(0, Math.max(1, limit));
139
+ }
140
+ function getUnityPathScoreMultiplier(filePath, queryTokens, scopePreset) {
141
+ const hasPluginIntent = queryTokens.some((token) => UNITY_PLUGIN_INTENT_TOKENS.has(token));
142
+ if (isUnityPluginPath(filePath) && !hasPluginIntent) {
143
+ return scopePreset === 'unity-gameplay' ? 0.1 : 0.45;
144
+ }
145
+ if (scopePreset === 'unity-gameplay' && isUnityGameplayPath(filePath)) {
146
+ return 1.15;
147
+ }
148
+ return 1;
149
+ }
35
150
  function bindingIdentity(binding) {
36
151
  return [
37
152
  normalizePath(binding.resourcePath),
@@ -330,6 +445,8 @@ export class LocalBackend {
330
445
  return this.context(repo, params);
331
446
  case 'impact':
332
447
  return this.impact(repo, params);
448
+ case 'unity_ui_trace':
449
+ return this.unityUiTrace(repo, params);
333
450
  case 'detect_changes':
334
451
  return this.detectChanges(repo, params);
335
452
  case 'rename':
@@ -345,6 +462,31 @@ export class LocalBackend {
345
462
  throw new Error(`Unknown tool: ${method}`);
346
463
  }
347
464
  }
465
+ async unityUiTrace(repo, params) {
466
+ const target = String(params?.target || '').trim();
467
+ const goal = params?.goal;
468
+ const selectorMode = params?.selector_mode || 'balanced';
469
+ if (!target) {
470
+ return { error: 'target parameter is required and cannot be empty.' };
471
+ }
472
+ if (goal !== 'asset_refs' && goal !== 'template_refs' && goal !== 'selector_bindings') {
473
+ return { error: 'goal must be one of: asset_refs, template_refs, selector_bindings.' };
474
+ }
475
+ if (selectorMode !== 'strict' && selectorMode !== 'balanced') {
476
+ return { error: 'selector_mode must be one of: strict, balanced.' };
477
+ }
478
+ try {
479
+ return await runUnityUiTrace({
480
+ repoRoot: repo.repoPath,
481
+ target,
482
+ goal,
483
+ selectorMode,
484
+ });
485
+ }
486
+ catch (err) {
487
+ return { error: err?.message || 'unity_ui_trace failed' };
488
+ }
489
+ }
348
490
  // ─── Tool Implementations ────────────────────────────────────────
349
491
  /**
350
492
  * Query tool — process-grouped search.
@@ -366,7 +508,7 @@ export class LocalBackend {
366
508
  // Step 1: Run hybrid search to get matching symbols
367
509
  const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
368
510
  const [bm25Results, semanticResults] = await Promise.all([
369
- this.bm25Search(repo, searchQuery, searchLimit),
511
+ this.bm25Search(repo, searchQuery, searchLimit, params.scope_preset),
370
512
  this.semanticSearch(repo, searchQuery, searchLimit),
371
513
  ]);
372
514
  // Merge via reciprocal rank fusion
@@ -540,8 +682,9 @@ export class LocalBackend {
540
682
  /**
541
683
  * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
542
684
  */
543
- async bm25Search(repo, query, limit) {
685
+ async bm25Search(repo, query, limit, scopePreset) {
544
686
  const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
687
+ const queryTokens = tokenizeQuery(query);
545
688
  let bm25Results;
546
689
  try {
547
690
  bm25Results = await searchFTSFromLbug(query, limit, repo.id);
@@ -550,26 +693,36 @@ export class LocalBackend {
550
693
  console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
551
694
  return [];
552
695
  }
696
+ bm25Results = filterBm25ResultsByScopePreset(bm25Results, scopePreset);
553
697
  const results = [];
554
698
  for (const bm25Result of bm25Results) {
555
699
  const fullPath = bm25Result.filePath;
700
+ const adjustedScore = Number(bm25Result.score || 0) * getUnityPathScoreMultiplier(fullPath, queryTokens, scopePreset);
556
701
  try {
557
702
  const symbols = await executeParameterized(repo.id, `
558
703
  MATCH (n)
559
704
  WHERE n.filePath = $filePath
560
705
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
561
- LIMIT 3
706
+ LIMIT 50
562
707
  `, { filePath: fullPath });
563
708
  if (symbols.length > 0) {
564
- for (const sym of symbols) {
709
+ const rankedSymbols = rankExpandedSymbolsForQuery(symbols.map((sym) => ({
710
+ id: sym.id || sym[0],
711
+ name: sym.name || sym[1],
712
+ type: sym.type || sym[2],
713
+ filePath: sym.filePath || sym[3],
714
+ startLine: sym.startLine || sym[4],
715
+ endLine: sym.endLine || sym[5],
716
+ })), query, 3, scopePreset);
717
+ for (const sym of rankedSymbols) {
565
718
  results.push({
566
- nodeId: sym.id || sym[0],
567
- name: sym.name || sym[1],
568
- type: sym.type || sym[2],
569
- filePath: sym.filePath || sym[3],
570
- startLine: sym.startLine || sym[4],
571
- endLine: sym.endLine || sym[5],
572
- bm25Score: bm25Result.score,
719
+ nodeId: sym.id,
720
+ name: sym.name,
721
+ type: sym.type,
722
+ filePath: sym.filePath,
723
+ startLine: sym.startLine,
724
+ endLine: sym.endLine,
725
+ bm25Score: adjustedScore,
573
726
  });
574
727
  }
575
728
  }
@@ -579,7 +732,7 @@ export class LocalBackend {
579
732
  name: fileName,
580
733
  type: 'File',
581
734
  filePath: bm25Result.filePath,
582
- bm25Score: bm25Result.score,
735
+ bm25Score: adjustedScore,
583
736
  });
584
737
  }
585
738
  }
@@ -589,11 +742,11 @@ export class LocalBackend {
589
742
  name: fileName,
590
743
  type: 'File',
591
744
  filePath: bm25Result.filePath,
592
- bm25Score: bm25Result.score,
745
+ bm25Score: adjustedScore,
593
746
  });
594
747
  }
595
748
  }
596
- return results;
749
+ return results.sort((a, b) => (Number(b.bm25Score || 0) - Number(a.bm25Score || 0)));
597
750
  }
598
751
  /**
599
752
  * Semantic vector search helper
@@ -858,20 +1011,63 @@ export class LocalBackend {
858
1011
  // Step 3: Build full context
859
1012
  const sym = symbols[0];
860
1013
  const symId = sym.id || sym[0];
861
- // Categorized incoming refs
862
- const incomingRows = await executeParameterized(repo.id, `
1014
+ // Direct incoming refs for the selected symbol.
1015
+ const directIncomingRows = await executeParameterized(repo.id, `
863
1016
  MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
864
1017
  WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
865
1018
  RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
866
1019
  LIMIT 30
867
1020
  `, { symId });
868
- // Categorized outgoing refs
869
- const outgoingRows = await executeParameterized(repo.id, `
1021
+ // Direct outgoing refs for the selected symbol.
1022
+ const directOutgoingRows = await executeParameterized(repo.id, `
870
1023
  MATCH (n {id: $symId})-[r:CodeRelation]->(target)
871
1024
  WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
872
1025
  RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
873
1026
  LIMIT 30
874
1027
  `, { symId });
1028
+ const kind = sym.type || sym[2];
1029
+ const symIdLower = String(symId).toLowerCase();
1030
+ const isMethodContainer = new Set(['Class', 'Interface', 'Struct', 'Trait', 'Impl', 'Record']).has(kind)
1031
+ || symIdLower.startsWith('class:')
1032
+ || symIdLower.startsWith('interface:')
1033
+ || symIdLower.startsWith('struct:')
1034
+ || symIdLower.startsWith('trait:')
1035
+ || symIdLower.startsWith('impl:')
1036
+ || symIdLower.startsWith('record:');
1037
+ let incomingRows = [...directIncomingRows];
1038
+ let outgoingRows = [...directOutgoingRows];
1039
+ if (isMethodContainer) {
1040
+ const methodIncomingRows = await executeParameterized(repo.id, `
1041
+ MATCH (n {id: $symId})-[:CodeRelation {type: 'HAS_METHOD'}]->(m)
1042
+ MATCH (caller)-[r:CodeRelation]->(m)
1043
+ WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
1044
+ RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
1045
+ LIMIT 60
1046
+ `, { symId });
1047
+ const methodOutgoingRows = await executeParameterized(repo.id, `
1048
+ MATCH (n {id: $symId})-[:CodeRelation {type: 'HAS_METHOD'}]->(m)
1049
+ MATCH (m)-[r:CodeRelation]->(target)
1050
+ WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
1051
+ RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
1052
+ LIMIT 60
1053
+ `, { symId });
1054
+ const dedupe = (rows) => {
1055
+ const seen = new Set();
1056
+ const out = [];
1057
+ for (const row of rows) {
1058
+ const relType = row.relType || row[0] || '';
1059
+ const uidVal = row.uid || row[1] || '';
1060
+ const key = `${relType}:${uidVal}`;
1061
+ if (seen.has(key))
1062
+ continue;
1063
+ seen.add(key);
1064
+ out.push(row);
1065
+ }
1066
+ return out;
1067
+ };
1068
+ incomingRows = dedupe([...directIncomingRows, ...methodIncomingRows]);
1069
+ outgoingRows = dedupe([...directOutgoingRows, ...methodOutgoingRows]);
1070
+ }
875
1071
  // Process participation
876
1072
  let processRows = [];
877
1073
  try {
@@ -905,7 +1101,7 @@ export class LocalBackend {
905
1101
  symbol: {
906
1102
  uid: sym.id || sym[0],
907
1103
  name: sym.name || sym[1],
908
- kind: sym.type || sym[2],
1104
+ kind,
909
1105
  filePath: sym.filePath || sym[3],
910
1106
  startLine: sym.startLine || sym[4],
911
1107
  endLine: sym.endLine || sym[5],
@@ -913,6 +1109,8 @@ export class LocalBackend {
913
1109
  },
914
1110
  incoming: categorize(incomingRows),
915
1111
  outgoing: categorize(outgoingRows),
1112
+ directIncoming: categorize(directIncomingRows),
1113
+ directOutgoing: categorize(directOutgoingRows),
916
1114
  processes: processRows.map((r) => ({
917
1115
  id: r.pid || r[0],
918
1116
  name: r.label || r[1],
@@ -1279,22 +1477,41 @@ export class LocalBackend {
1279
1477
  }
1280
1478
  async _impactImpl(repo, params) {
1281
1479
  await this.ensureInitialized(repo.id);
1282
- const { target, direction } = params;
1480
+ const { target, target_uid, file_path, direction } = params;
1283
1481
  const maxDepth = params.maxDepth || 3;
1482
+ const usesDefaultRelationTypes = !params.relationTypes || params.relationTypes.length === 0;
1284
1483
  const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
1285
1484
  ? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
1286
1485
  : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
1287
1486
  const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
1288
1487
  const includeTests = params.includeTests ?? false;
1289
1488
  const minConfidence = params.minConfidence ?? 0;
1489
+ const shouldBridgeClassMethods = usesDefaultRelationTypes;
1290
1490
  const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
1291
1491
  const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
1492
+ const targetQueryParts = [];
1493
+ const targetParams = { targetName: target };
1494
+ if (target_uid) {
1495
+ targetQueryParts.push('n.id = $targetUid');
1496
+ targetParams.targetUid = target_uid;
1497
+ }
1498
+ else if (file_path) {
1499
+ targetQueryParts.push('n.name = $targetName');
1500
+ targetQueryParts.push('n.filePath CONTAINS $filePath');
1501
+ targetParams.filePath = file_path;
1502
+ }
1503
+ else if (target.includes('/') || target.includes(':')) {
1504
+ targetQueryParts.push('(n.id = $targetName OR n.name = $targetName)');
1505
+ }
1506
+ else {
1507
+ targetQueryParts.push('n.name = $targetName');
1508
+ }
1292
1509
  const targets = await executeParameterized(repo.id, `
1293
1510
  MATCH (n)
1294
- WHERE n.name = $targetName
1511
+ WHERE ${targetQueryParts.join(' AND ')}
1295
1512
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
1296
- LIMIT 1
1297
- `, { targetName: target });
1513
+ LIMIT 10
1514
+ `, targetParams);
1298
1515
  if (targets.length === 0)
1299
1516
  return { error: `Target '${target}' not found` };
1300
1517
  const sym = targets[0];
@@ -1302,11 +1519,63 @@ export class LocalBackend {
1302
1519
  const impacted = [];
1303
1520
  const visited = new Set([symId]);
1304
1521
  let frontier = [symId];
1522
+ let frontierKindById = new Map([[symId, sym.type || sym[2] || '']]);
1305
1523
  let traversalComplete = true;
1306
1524
  for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
1307
1525
  const nextFrontier = [];
1526
+ let traversalFrontier = [...frontier];
1527
+ // Bridge class-like symbols through HAS_METHOD so class-level impact includes method-level dependencies.
1528
+ if (shouldBridgeClassMethods) {
1529
+ if (frontier.length > 0) {
1530
+ const bridgeIdList = frontier.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
1531
+ try {
1532
+ const bridgeRows = await executeQuery(repo.id, `
1533
+ MATCH (container)-[r:CodeRelation {type: 'HAS_METHOD'}]->(method)
1534
+ WHERE container.id IN [${bridgeIdList}]${confidenceFilter}
1535
+ RETURN container.id AS containerId, method.id AS methodId, method.name AS methodName, labels(method)[0] AS methodType, method.filePath AS methodFilePath
1536
+ `);
1537
+ for (const bridge of bridgeRows) {
1538
+ const methodId = bridge.methodId || bridge[1];
1539
+ const methodType = bridge.methodType || bridge[3];
1540
+ if (!methodId)
1541
+ continue;
1542
+ if (!frontierKindById.has(methodId)) {
1543
+ frontierKindById.set(methodId, methodType || '');
1544
+ }
1545
+ if (direction === 'upstream') {
1546
+ traversalFrontier.push(methodId);
1547
+ }
1548
+ else {
1549
+ const methodFilePath = bridge.methodFilePath || bridge[4] || '';
1550
+ if (!includeTests && isTestFilePath(methodFilePath))
1551
+ continue;
1552
+ if (!visited.has(methodId)) {
1553
+ visited.add(methodId);
1554
+ nextFrontier.push(methodId);
1555
+ impacted.push({
1556
+ depth,
1557
+ id: methodId,
1558
+ name: bridge.methodName || bridge[2],
1559
+ type: methodType,
1560
+ filePath: methodFilePath,
1561
+ relationType: 'HAS_METHOD',
1562
+ confidence: 1.0,
1563
+ });
1564
+ }
1565
+ }
1566
+ }
1567
+ }
1568
+ catch (e) {
1569
+ logQueryError('impact:class-method-bridge', e);
1570
+ traversalComplete = false;
1571
+ break;
1572
+ }
1573
+ }
1574
+ }
1575
+ // de-dupe traversal frontier (class + bridged methods)
1576
+ traversalFrontier = [...new Set(traversalFrontier)];
1308
1577
  // Batch frontier nodes into a single Cypher query per depth level
1309
- const idList = frontier.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
1578
+ const idList = traversalFrontier.map(id => `'${id.replace(/'/g, "''")}'`).join(', ');
1310
1579
  const query = direction === 'upstream'
1311
1580
  ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
1312
1581
  : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
@@ -1320,11 +1589,15 @@ export class LocalBackend {
1320
1589
  if (!visited.has(relId)) {
1321
1590
  visited.add(relId);
1322
1591
  nextFrontier.push(relId);
1592
+ const relType = rel.type || rel[3];
1593
+ if (!frontierKindById.has(relId)) {
1594
+ frontierKindById.set(relId, relType || '');
1595
+ }
1323
1596
  impacted.push({
1324
1597
  depth,
1325
1598
  id: relId,
1326
1599
  name: rel.name || rel[2],
1327
- type: rel.type || rel[3],
1600
+ type: relType,
1328
1601
  filePath,
1329
1602
  relationType: rel.relType || rel[5],
1330
1603
  confidence: rel.confidence || rel[6] || 1.0,
package/dist/mcp/tools.js CHANGED
@@ -36,6 +36,9 @@ Returns results grouped by process (execution flow):
36
36
  - definitions: standalone types/interfaces not in any process
37
37
 
38
38
  Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion.
39
+ Supports optional scope controls for noisy codebases:
40
+ - scope_preset=unity-gameplay to prioritize project gameplay code and suppress plugin-heavy paths.
41
+ - scope_preset=unity-all (default behavior) to keep full Unity search scope.
39
42
 
40
43
  Includes optional Unity retrieval contract:
41
44
  - Set unity_resources=on|auto to include Unity resource evidence.
@@ -50,6 +53,11 @@ Includes optional Unity retrieval contract:
50
53
  limit: { type: 'number', description: 'Max processes to return (default: 5)', default: 5 },
51
54
  max_symbols: { type: 'number', description: 'Max symbols per process (default: 10)', default: 10 },
52
55
  include_content: { type: 'boolean', description: 'Include full symbol source code (default: false)', default: false },
56
+ scope_preset: {
57
+ type: 'string',
58
+ enum: ['unity-gameplay', 'unity-all'],
59
+ description: 'Optional retrieval preset. unity-gameplay reduces plugin/package noise in Unity projects.',
60
+ },
53
61
  unity_resources: {
54
62
  type: 'string',
55
63
  enum: ['off', 'on', 'auto'],
@@ -197,6 +205,39 @@ Each edit is tagged with confidence:
197
205
  required: ['new_name'],
198
206
  },
199
207
  },
208
+ {
209
+ name: 'unity_ui_trace',
210
+ description: `Resolve Unity UI evidence chains (query-time only, no graph writes).
211
+
212
+ Supports three goals:
213
+ - asset_refs: which prefab/asset points to a target UXML
214
+ - template_refs: which UXML templates are referenced by a target UXML
215
+ - selector_bindings: static C# selector bindings traced to USS selectors
216
+
217
+ Selector matching modes for selector_bindings:
218
+ - balanced (default): match class tokens inside composite selectors (higher recall)
219
+ - strict: only exact \`.className\` selectors (higher precision)
220
+
221
+ Output enforces unique-result policy and includes path+line evidence hops.`,
222
+ inputSchema: {
223
+ type: 'object',
224
+ properties: {
225
+ target: { type: 'string', description: 'Target C# class or UXML path' },
226
+ goal: {
227
+ type: 'string',
228
+ enum: ['asset_refs', 'template_refs', 'selector_bindings'],
229
+ description: 'Trace goal',
230
+ },
231
+ selector_mode: {
232
+ type: 'string',
233
+ enum: ['strict', 'balanced'],
234
+ description: 'Selector matching mode for selector_bindings (default: balanced)',
235
+ },
236
+ repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' },
237
+ },
238
+ required: ['target', 'goal'],
239
+ },
240
+ },
200
241
  {
201
242
  name: 'impact',
202
243
  description: `Analyze the blast radius of changing a code symbol.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export interface RepoMeta {
9
9
  repoPath: string;
10
+ repoId?: string;
10
11
  lastCommit: string;
11
12
  indexedAt: string;
12
13
  analyzeOptions?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veewo/gitnexus",
3
- "version": "1.4.8",
3
+ "version": "1.4.9",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -51,6 +51,7 @@
51
51
  "check:neonspark-target": "node -e \"const p=(process.env.GITNEXUS_NEONSPARK_TARGET_PATH||'').trim();if(!p){console.error('Missing env: GITNEXUS_NEONSPARK_TARGET_PATH');process.exit(1);}\"",
52
52
  "check:u2-e2e-target": "node -e \"const p=(process.env.GITNEXUS_U2_E2E_TARGET_PATH||'').trim();if(!p){console.error('Missing env: GITNEXUS_U2_E2E_TARGET_PATH');process.exit(1);}\"",
53
53
  "test:unity": "npm run build && node --test dist/core/unity/*.test.js",
54
+ "test:unity-ui-trace:smoke": "npm run build && node --test dist/core/unity/ui-trace.acceptance.test.js dist/core/unity/ui-trace.test.js dist/cli/unity-ui-trace.test.js",
54
55
  "test:u3:gates": "npm run check:release-paths && npm run build && node --test dist/benchmark/u2-e2e/*.test.js dist/mcp/local/unity-enrichment.test.js dist/core/ingestion/unity-resource-processor.test.js",
55
56
  "test:benchmark": "npm run build && node --test dist/benchmark/*.test.js && node --test dist/cli/*.test.js",
56
57
  "benchmark:quick": "npm run build && node dist/cli/index.js benchmark-unity ../benchmarks/unity-baseline/v1 --profile quick --target-path ../benchmarks/fixtures/unity-mini",
@@ -113,6 +113,35 @@ Rules:
113
113
  - If response `hydrationMeta.needsParityRetry=true`, rerun with `--unity-hydration parity`.
114
114
  - `--unity-hydration parity` is completeness-first mode for advanced verification.
115
115
 
116
+ ### unity-ui-trace — Unity UI evidence tracing workflow
117
+
118
+ ```bash
119
+ $GN unity-ui-trace "Assets/NEON/VeewoUI/Uxml/BarScreen/Patch/PatchItemPreview.uxml" --goal asset_refs --repo neonspark
120
+ $GN unity-ui-trace "Assets/NEON/VeewoUI/Uxml/BarScreen/CoreScreen.uxml" --goal template_refs --repo neonspark
121
+ $GN unity-ui-trace "Assets/NEON/VeewoUI/Uxml/BarScreen/Patch/PatchItemPreview.uxml" --goal selector_bindings --selector-mode balanced --repo neonspark
122
+ ```
123
+
124
+ Supported goals:
125
+ - `asset_refs`: 哪些 prefab/asset 引用了目标 UXML
126
+ - `template_refs`: 目标 UXML 里引用了哪些模板 UXML
127
+ - `selector_bindings`: C# `AddToClassList/Q(className)` 到 USS 选择器证据链
128
+
129
+ Selector mode(仅 `selector_bindings` 生效):
130
+ - `--selector-mode balanced`(默认): 复合选择器 token 匹配,召回更高
131
+ - `--selector-mode strict`: 仅匹配精确 `.className` 选择器,精度更高
132
+
133
+ 输出字段解读:
134
+ - `results[].evidence_chain`: 每跳都有 `path + line + snippet`
135
+ - `results[].score`: 排序分数(越高越优先)
136
+ - `results[].confidence`: `high|medium|low`(基于 score)
137
+ - `diagnostics`: `not_found|ambiguous` 诊断
138
+
139
+ 推荐排查顺序(实仓):
140
+ 1. 先跑 `asset_refs`,确认资源链是否存在
141
+ 2. 再跑 `template_refs`,确认模板链是否存在
142
+ 3. 最后跑 `selector_bindings`,默认 `balanced`
143
+ 4. 若怀疑误报,复跑 `--selector-mode strict` 对比
144
+
116
145
  ## After Indexing
117
146
 
118
147
  1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
@@ -37,6 +37,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
37
37
  | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
38
38
  | `detect_changes` | Git-diff impact — what do your current changes affect |
39
39
  | `rename` | Multi-file coordinated rename with confidence-tagged edits |
40
+ | `unity_ui_trace` | Unity UI query-time evidence chains (`asset_refs/template_refs/selector_bindings`) |
40
41
  | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
41
42
  | `list_repos` | Discover indexed repos |
42
43
 
@@ -55,6 +56,28 @@ Recommended default workflow:
55
56
  - `isComplete: true` → keep compact result
56
57
  3. Treat parity as the completeness path for advanced verification.
57
58
 
59
+ ### Unity UI Trace Contract (`unity_ui_trace` / `gitnexus unity-ui-trace`)
60
+
61
+ Input:
62
+ - `target`: C# class 名或 UXML 路径
63
+ - `goal`: `asset_refs | template_refs | selector_bindings`
64
+ - `selector_mode`(可选): `balanced`(默认)或 `strict`
65
+
66
+ Modes:
67
+ - `balanced`: 复合选择器 token 匹配,召回优先
68
+ - `strict`: 仅精确 `.className` 选择器,精度优先
69
+
70
+ Output:
71
+ - `results[].evidence_chain`: 严格 `path + line + snippet` 证据跳
72
+ - `results[].score`: 排序分数(高分优先)
73
+ - `results[].confidence`: `high|medium|low`
74
+ - `diagnostics`: `not_found|ambiguous`
75
+
76
+ Recommended workflow:
77
+ 1. 先跑 `asset_refs`(确认资源引用链存在)
78
+ 2. 再跑 `template_refs`(确认模板引用链存在)
79
+ 3. 最后跑 `selector_bindings`(先 `balanced`,必要时切 `strict` 验证)
80
+
58
81
  ## Resources Reference
59
82
 
60
83
  Lightweight reads (~100-500 tokens) for navigation: