@veewo/gitnexus 1.4.8-rc.2 → 1.4.9-rc

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 (45) hide show
  1. package/dist/cli/index.js +8 -0
  2. package/dist/cli/tool.d.ts +11 -0
  3. package/dist/cli/tool.js +31 -0
  4. package/dist/cli/unity-ui-trace.test.d.ts +1 -0
  5. package/dist/cli/unity-ui-trace.test.js +24 -0
  6. package/dist/core/ingestion/call-processor.js +9 -1
  7. package/dist/core/ingestion/type-env.d.ts +2 -0
  8. package/dist/core/ingestion/type-extractors/csharp.js +17 -2
  9. package/dist/core/ingestion/type-extractors/types.d.ts +4 -1
  10. package/dist/core/unity/csharp-selector-binding.d.ts +7 -0
  11. package/dist/core/unity/csharp-selector-binding.js +32 -0
  12. package/dist/core/unity/csharp-selector-binding.test.d.ts +1 -0
  13. package/dist/core/unity/csharp-selector-binding.test.js +14 -0
  14. package/dist/core/unity/scan-context.d.ts +2 -0
  15. package/dist/core/unity/scan-context.js +17 -0
  16. package/dist/core/unity/ui-asset-ref-scanner.d.ts +14 -0
  17. package/dist/core/unity/ui-asset-ref-scanner.js +213 -0
  18. package/dist/core/unity/ui-asset-ref-scanner.test.d.ts +1 -0
  19. package/dist/core/unity/ui-asset-ref-scanner.test.js +44 -0
  20. package/dist/core/unity/ui-meta-index.d.ts +8 -0
  21. package/dist/core/unity/ui-meta-index.js +60 -0
  22. package/dist/core/unity/ui-meta-index.test.d.ts +1 -0
  23. package/dist/core/unity/ui-meta-index.test.js +18 -0
  24. package/dist/core/unity/ui-trace-storage-guard.test.d.ts +1 -0
  25. package/dist/core/unity/ui-trace-storage-guard.test.js +10 -0
  26. package/dist/core/unity/ui-trace.acceptance.test.d.ts +1 -0
  27. package/dist/core/unity/ui-trace.acceptance.test.js +38 -0
  28. package/dist/core/unity/ui-trace.d.ts +31 -0
  29. package/dist/core/unity/ui-trace.js +363 -0
  30. package/dist/core/unity/ui-trace.test.d.ts +1 -0
  31. package/dist/core/unity/ui-trace.test.js +183 -0
  32. package/dist/core/unity/uss-selector-parser.d.ts +6 -0
  33. package/dist/core/unity/uss-selector-parser.js +21 -0
  34. package/dist/core/unity/uss-selector-parser.test.d.ts +1 -0
  35. package/dist/core/unity/uss-selector-parser.test.js +13 -0
  36. package/dist/core/unity/uxml-ref-parser.d.ts +10 -0
  37. package/dist/core/unity/uxml-ref-parser.js +22 -0
  38. package/dist/core/unity/uxml-ref-parser.test.d.ts +1 -0
  39. package/dist/core/unity/uxml-ref-parser.test.js +31 -0
  40. package/dist/mcp/local/local-backend.d.ts +13 -0
  41. package/dist/mcp/local/local-backend.js +298 -25
  42. package/dist/mcp/tools.js +41 -0
  43. package/package.json +2 -1
  44. package/skills/gitnexus-cli.md +29 -0
  45. package/skills/gitnexus-guide.md +23 -0
package/dist/cli/index.js CHANGED
@@ -82,6 +82,7 @@ program
82
82
  .option('-g, --goal <text>', 'What you want to find')
83
83
  .option('-l, --limit <n>', 'Max processes to return (default: 5)')
84
84
  .option('--content', 'Include full symbol source code')
85
+ .option('--scope-preset <preset>', 'Scope preset for retrieval: unity-gameplay|unity-all')
85
86
  .option('--unity-resources <mode>', 'Unity resource retrieval mode: off|on|auto', 'off')
86
87
  .option('--unity-hydration <mode>', 'Unity hydration mode when resources are enabled: parity|compact', 'compact')
87
88
  .action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
@@ -101,6 +102,13 @@ program
101
102
  .option('--target-path <path>', 'Unity project root (default: cwd)')
102
103
  .option('--json', 'Output JSON')
103
104
  .action(createLazyAction(() => import('./unity-bindings.js'), 'unityBindingsCommand'));
105
+ program
106
+ .command('unity-ui-trace <target>')
107
+ .description('Query-time Unity UI evidence tracing (asset_refs|template_refs|selector_bindings)')
108
+ .option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
109
+ .option('--goal <goal>', 'Trace goal: asset_refs|template_refs|selector_bindings', 'asset_refs')
110
+ .option('--selector-mode <mode>', 'Selector matching mode for selector_bindings: strict|balanced', 'balanced')
111
+ .action(createLazyAction(() => import('./tool.js'), 'unityUiTraceCommand'));
104
112
  program
105
113
  .command('impact <target>')
106
114
  .description('Blast radius analysis: what breaks if you change a symbol')
@@ -21,6 +21,7 @@ export declare function queryCommand(queryText: string, options?: {
21
21
  goal?: string;
22
22
  limit?: string;
23
23
  content?: boolean;
24
+ scopePreset?: 'unity-gameplay' | 'unity-all';
24
25
  unityResources?: UnityResourcesMode;
25
26
  unityHydration?: UnityHydrationMode;
26
27
  }): Promise<void>;
@@ -44,3 +45,13 @@ export declare function impactCommand(target: string, options?: {
44
45
  export declare function cypherCommand(query: string, options?: {
45
46
  repo?: string;
46
47
  }): Promise<void>;
48
+ export declare function unityUiTraceCommand(target: string, options?: {
49
+ repo?: string;
50
+ goal?: string;
51
+ selectorMode?: string;
52
+ }, deps?: {
53
+ backend?: {
54
+ callTool: (method: string, params: any) => Promise<any>;
55
+ };
56
+ output?: (data: any) => void;
57
+ }): Promise<void>;
package/dist/cli/tool.js CHANGED
@@ -53,6 +53,12 @@ function output(data) {
53
53
  process.stderr.write(text + '\n');
54
54
  }
55
55
  }
56
+ function isUnityUiTraceGoal(value) {
57
+ return value === 'asset_refs' || value === 'template_refs' || value === 'selector_bindings';
58
+ }
59
+ function isUnityUiSelectorMode(value) {
60
+ return value === 'strict' || value === 'balanced';
61
+ }
56
62
  export async function queryCommand(queryText, options) {
57
63
  if (!queryText?.trim()) {
58
64
  console.error('Usage: gitnexus query <search_query>');
@@ -65,6 +71,7 @@ export async function queryCommand(queryText, options) {
65
71
  goal: options?.goal,
66
72
  limit: options?.limit ? parseInt(options.limit) : undefined,
67
73
  include_content: options?.content ?? false,
74
+ scope_preset: options?.scopePreset,
68
75
  unity_resources: options?.unityResources,
69
76
  unity_hydration_mode: options?.unityHydration,
70
77
  repo: options?.repo,
@@ -130,3 +137,27 @@ export async function cypherCommand(query, options) {
130
137
  });
131
138
  output(result);
132
139
  }
140
+ export async function unityUiTraceCommand(target, options, deps) {
141
+ if (!target?.trim()) {
142
+ console.error('Usage: gitnexus unity-ui-trace <target> --goal asset_refs|template_refs|selector_bindings');
143
+ process.exit(1);
144
+ }
145
+ const goal = String(options?.goal || 'asset_refs').trim();
146
+ if (!isUnityUiTraceGoal(goal)) {
147
+ console.error('Invalid --goal. Use one of: asset_refs, template_refs, selector_bindings');
148
+ process.exit(1);
149
+ }
150
+ const selectorMode = String(options?.selectorMode || 'balanced').trim();
151
+ if (!isUnityUiSelectorMode(selectorMode)) {
152
+ console.error('Invalid --selector-mode. Use one of: strict, balanced');
153
+ process.exit(1);
154
+ }
155
+ const backend = deps?.backend || await getBackend();
156
+ const result = await backend.callTool('unity_ui_trace', {
157
+ target,
158
+ goal,
159
+ selector_mode: selectorMode,
160
+ repo: options?.repo,
161
+ });
162
+ (deps?.output || output)(result);
163
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { unityUiTraceCommand } from './tool.js';
4
+ test('unity-ui-trace command forwards params and prints result', async () => {
5
+ const calls = [];
6
+ let printed = null;
7
+ await unityUiTraceCommand('EliteBossScreenController', { goal: 'selector_bindings', selectorMode: 'strict', repo: 'mini-unity-ui' }, {
8
+ backend: {
9
+ async callTool(method, params) {
10
+ calls.push({ method, params });
11
+ return { goal: 'selector_bindings', results: [{ evidence_chain: [{ path: 'a', line: 1 }] }], diagnostics: [] };
12
+ },
13
+ },
14
+ output: (value) => {
15
+ printed = value;
16
+ },
17
+ });
18
+ assert.equal(calls.length, 1);
19
+ assert.equal(calls[0].method, 'unity_ui_trace');
20
+ assert.equal(calls[0].params.target, 'EliteBossScreenController');
21
+ assert.equal(calls[0].params.goal, 'selector_bindings');
22
+ assert.equal(calls[0].params.selector_mode, 'strict');
23
+ assert.equal(printed.goal, 'selector_bindings');
24
+ });
@@ -35,7 +35,15 @@ const findEnclosingFunction = (node, filePath, ctx) => {
35
35
  */
36
36
  const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
37
37
  const verified = new Map();
38
- for (const { scope, varName, calleeName, receiverClassName } of bindings) {
38
+ for (const { scope, varName, calleeName, receiverClassName, inferredTypeName } of bindings) {
39
+ if (inferredTypeName) {
40
+ const inferred = ctx.resolve(inferredTypeName, filePath);
41
+ const isReceivableType = inferred?.candidates.some(def => def.type === 'Class' || def.type === 'Interface' || def.type === 'Struct' || def.type === 'Enum') ?? false;
42
+ if (isReceivableType) {
43
+ verified.set(receiverKey(scope, varName), inferredTypeName);
44
+ continue;
45
+ }
46
+ }
39
47
  const tiered = ctx.resolve(calleeName, filePath);
40
48
  const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false;
41
49
  if (isClass) {
@@ -46,4 +46,6 @@ export interface ConstructorBinding {
46
46
  calleeName: string;
47
47
  /** Enclosing class name when callee is a method on a known receiver (e.g. $this) */
48
48
  receiverClassName?: string;
49
+ /** Optional direct type hint extracted from safe generic invocation patterns. */
50
+ inferredTypeName?: string;
49
51
  }
@@ -1,4 +1,4 @@
1
- import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString } from './shared.js';
1
+ import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString } from './shared.js';
2
2
  /** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */
3
3
  const KNOWN_CONTAINER_PROPS = new Set(['Keys', 'Values']);
4
4
  const DECLARATION_NODE_TYPES = new Set([
@@ -145,7 +145,22 @@ const scanConstructorBinding = (node) => {
145
145
  const calleeName = extractSimpleTypeName(func);
146
146
  if (!calleeName)
147
147
  return undefined;
148
- return { varName: nameNode.text, calleeName };
148
+ // Safe generic-invocation receiver hint:
149
+ // var user = GetComponentInParent<User>()
150
+ // var user = svc.GetComponentInParent<User>()
151
+ // infer user -> User directly from the single type argument.
152
+ let inferredTypeName;
153
+ const genericCallee = func.type === 'generic_name'
154
+ ? func
155
+ : func.type === 'member_access_expression'
156
+ ? func.childForFieldName('name')
157
+ : null;
158
+ if (genericCallee?.type === 'generic_name') {
159
+ const typeArgs = extractGenericTypeArgs(genericCallee);
160
+ if (typeArgs.length === 1)
161
+ inferredTypeName = typeArgs[0];
162
+ }
163
+ return { varName: nameNode.text, calleeName, inferredTypeName };
149
164
  };
150
165
  const FOR_LOOP_NODE_TYPES = new Set([
151
166
  'foreach_statement',
@@ -13,11 +13,14 @@ export type InitializerExtractor = (node: SyntaxNode, env: Map<string, string>,
13
13
  /** Scans an AST node for untyped `var = callee()` patterns for return-type inference.
14
14
  * Returns { varName, calleeName } if the node matches, undefined otherwise.
15
15
  * `receiverClassName` — optional hint for method calls on known receivers
16
- * (e.g. $this->getUser() in PHP provides the enclosing class name). */
16
+ * (e.g. $this->getUser() in PHP provides the enclosing class name).
17
+ * `inferredTypeName` — optional direct type hint from safe generic invocations
18
+ * (e.g. `var x = GetComponentInParent<User>()` gives `User`). */
17
19
  export type ConstructorBindingScanner = (node: SyntaxNode) => {
18
20
  varName: string;
19
21
  calleeName: string;
20
22
  receiverClassName?: string;
23
+ inferredTypeName?: string;
21
24
  } | undefined;
22
25
  /** Extracts a return type string from a method/function definition node.
23
26
  * Used for languages where return types are expressed in comments (e.g. YARD @return [Type])
@@ -0,0 +1,7 @@
1
+ export interface CsharpSelectorBinding {
2
+ className: string;
3
+ line: number;
4
+ snippet: string;
5
+ isDynamic: boolean;
6
+ }
7
+ export declare function extractCsharpSelectorBindings(content: string): CsharpSelectorBinding[];
@@ -0,0 +1,32 @@
1
+ const ADD_TO_CLASS_LIST_LITERAL = /\bAddToClassList\s*\(\s*"([^"]+)"\s*\)/g;
2
+ const Q_CLASS_NAME_LITERAL = /\bQ\s*<[^>]*>\s*\([^)]*\bclassName\s*:\s*"([^"]+)"[^)]*\)/g;
3
+ export function extractCsharpSelectorBindings(content) {
4
+ const out = [];
5
+ const lines = content.split(/\r?\n/);
6
+ for (let i = 0; i < lines.length; i += 1) {
7
+ const line = lines[i];
8
+ ADD_TO_CLASS_LIST_LITERAL.lastIndex = 0;
9
+ let addMatch = ADD_TO_CLASS_LIST_LITERAL.exec(line);
10
+ while (addMatch) {
11
+ out.push({
12
+ className: addMatch[1],
13
+ line: i + 1,
14
+ snippet: line.trim(),
15
+ isDynamic: false,
16
+ });
17
+ addMatch = ADD_TO_CLASS_LIST_LITERAL.exec(line);
18
+ }
19
+ Q_CLASS_NAME_LITERAL.lastIndex = 0;
20
+ let qMatch = Q_CLASS_NAME_LITERAL.exec(line);
21
+ while (qMatch) {
22
+ out.push({
23
+ className: qMatch[1],
24
+ line: i + 1,
25
+ snippet: line.trim(),
26
+ isDynamic: false,
27
+ });
28
+ qMatch = Q_CLASS_NAME_LITERAL.exec(line);
29
+ }
30
+ }
31
+ return out;
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { extractCsharpSelectorBindings } from './csharp-selector-binding.js';
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const fixtureRoot = path.resolve(here, '../../../src/core/unity/__fixtures__/mini-unity-ui');
9
+ test('extracts static-only selector bindings from csharp', async () => {
10
+ const source = await fs.readFile(path.join(fixtureRoot, 'Assets/Scripts/EliteBossScreenController.cs'), 'utf-8');
11
+ const bindings = extractCsharpSelectorBindings(source);
12
+ assert.equal(bindings.some((entry) => entry.className === 'tooltip-box'), true);
13
+ assert.equal(bindings.some((entry) => entry.isDynamic), false);
14
+ });
@@ -19,6 +19,8 @@ export interface UnityScanContext {
19
19
  serializableSymbols: Set<string>;
20
20
  hostFieldTypeHints: Map<string, Map<string, string>>;
21
21
  assetGuidToPath?: Map<string, string>;
22
+ uxmlGuidToPath?: Map<string, string>;
23
+ ussGuidToPath?: Map<string, string>;
22
24
  resourceDocCache: Map<string, UnityObjectBlock[]>;
23
25
  }
24
26
  export declare function buildUnityScanContext(input: BuildScanContextInput): Promise<UnityScanContext>;
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { glob } from 'glob';
6
6
  import { buildAssetMetaIndex, buildMetaIndex } from './meta-index.js';
7
+ import { buildUnityUiMetaIndex } from './ui-meta-index.js';
7
8
  import { buildSerializableTypeIndexFromFiles } from './serialized-type-index.js';
8
9
  const DECLARATION_PATTERN = /\b(?:class|struct|interface)\s+([A-Za-z_][A-Za-z0-9_]*)\b/g;
9
10
  const SCRIPT_GUID_IN_LINE_PATTERN = /\bm_Script\s*:\s*\{[^}]*\bguid\s*:\s*([0-9a-f]{32})\b/gi;
@@ -32,6 +33,7 @@ export async function buildUnityScanContext(input) {
32
33
  const guidToResourceHits = await buildGuidHitIndex(input.repoRoot, scriptPathToGuid, resourceFiles);
33
34
  const assetMetaFiles = resolveAssetMetaFiles(input.repoRoot, input.scopedPaths, scriptFiles, resourceFiles);
34
35
  const assetGuidToPath = await buildAssetMetaIndex(input.repoRoot, { metaFiles: assetMetaFiles });
36
+ const uiMetaIndex = await buildUnityUiMetaIndex(input.repoRoot, { scopedPaths: input.scopedPaths });
35
37
  const symbolToCanonicalScriptPath = buildCanonicalScriptPathIndex(symbolToScriptPaths, scriptPathToGuid, guidToResourceHits);
36
38
  const symbolToScriptPath = new Map(symbolToCanonicalScriptPath);
37
39
  return {
@@ -43,6 +45,8 @@ export async function buildUnityScanContext(input) {
43
45
  serializableSymbols: serializableTypeIndex.serializableSymbols,
44
46
  hostFieldTypeHints: serializableTypeIndex.hostFieldTypeHints,
45
47
  assetGuidToPath,
48
+ uxmlGuidToPath: uiMetaIndex.uxmlGuidToPath,
49
+ ussGuidToPath: uiMetaIndex.ussGuidToPath,
46
50
  resourceDocCache: new Map(),
47
51
  };
48
52
  }
@@ -114,11 +118,22 @@ export function buildUnityScanContextFromSeed(input) {
114
118
  }
115
119
  }
116
120
  const assetGuidToPath = new Map();
121
+ const uxmlGuidToPath = new Map();
122
+ const ussGuidToPath = new Map();
117
123
  for (const [guid, assetPath] of Object.entries(seed.assetGuidToPath || {})) {
118
124
  const normalizedPath = normalizeSlashes(String(assetPath || '').trim());
119
125
  if (!guid || !normalizedPath)
120
126
  continue;
121
127
  assetGuidToPath.set(guid, normalizedPath);
128
+ assetGuidToPath.set(guid.toLowerCase(), normalizedPath);
129
+ if (normalizedPath.endsWith('.uxml')) {
130
+ uxmlGuidToPath.set(guid, normalizedPath);
131
+ uxmlGuidToPath.set(guid.toLowerCase(), normalizedPath);
132
+ }
133
+ if (normalizedPath.endsWith('.uss')) {
134
+ ussGuidToPath.set(guid, normalizedPath);
135
+ ussGuidToPath.set(guid.toLowerCase(), normalizedPath);
136
+ }
122
137
  }
123
138
  return {
124
139
  symbolToScriptPaths,
@@ -129,6 +144,8 @@ export function buildUnityScanContextFromSeed(input) {
129
144
  serializableSymbols: new Set(),
130
145
  hostFieldTypeHints: new Map(),
131
146
  assetGuidToPath,
147
+ uxmlGuidToPath,
148
+ ussGuidToPath,
132
149
  resourceDocCache: new Map(),
133
150
  };
134
151
  }
@@ -0,0 +1,14 @@
1
+ export interface UiAssetRefEvidence {
2
+ sourceType: 'prefab' | 'asset';
3
+ sourcePath: string;
4
+ line: number;
5
+ fieldName: string;
6
+ guid: string;
7
+ snippet: string;
8
+ }
9
+ export interface ScanUiAssetRefInput {
10
+ repoRoot: string;
11
+ scopedPaths?: string[];
12
+ targetGuids?: string[];
13
+ }
14
+ export declare function scanUiAssetRefs(input: ScanUiAssetRefInput): Promise<UiAssetRefEvidence[]>;
@@ -0,0 +1,213 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { glob } from 'glob';
5
+ const YAML_INLINE_GUID_PATTERN = /\bguid\s*:\s*([0-9a-f]{32})\b/i;
6
+ const YAML_FIELD_OPEN_PATTERN = /^\s*([A-Za-z0-9_]+)\s*:\s*\{/;
7
+ const YAML_INLINE_FIELD_PATTERN = /^\s*([A-Za-z0-9_]+)\s*:\s*\{[^}]*\bguid\s*:\s*([0-9a-f]{32})\b[^}]*\}/i;
8
+ const SCAN_CONCURRENCY = 8;
9
+ export async function scanUiAssetRefs(input) {
10
+ const resourceFiles = await resolveFiles(input.repoRoot, input.scopedPaths);
11
+ const guidFilter = toGuidFilter(input.targetGuids);
12
+ const candidateFiles = guidFilter && guidFilter.size > 0
13
+ ? await findCandidateFilesByGuid(input.repoRoot, resourceFiles, guidFilter)
14
+ : resourceFiles;
15
+ const perFile = await mapWithConcurrency(candidateFiles, SCAN_CONCURRENCY, async (resourcePath) => {
16
+ const sourceType = resourcePath.endsWith('.prefab') ? 'prefab' : 'asset';
17
+ return scanResourceFileForGuidRefs(input.repoRoot, resourcePath, sourceType, guidFilter);
18
+ });
19
+ const all = [];
20
+ for (const entries of perFile) {
21
+ all.push(...entries);
22
+ }
23
+ return all;
24
+ }
25
+ async function findCandidateFilesByGuid(repoRoot, resourceFiles, guidFilter) {
26
+ const candidates = await mapWithConcurrency(resourceFiles, SCAN_CONCURRENCY, async (resourcePath) => {
27
+ const absolutePath = path.join(repoRoot, resourcePath);
28
+ const stream = createReadStream(absolutePath, { encoding: 'utf-8' });
29
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
30
+ try {
31
+ for await (const line of reader) {
32
+ const lower = line.toLowerCase();
33
+ for (const guid of guidFilter) {
34
+ if (lower.includes(guid))
35
+ return resourcePath;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ catch (error) {
41
+ const code = error.code;
42
+ if (code === 'ENOENT' || code === 'EISDIR')
43
+ return null;
44
+ throw error;
45
+ }
46
+ finally {
47
+ reader.close();
48
+ stream.destroy();
49
+ }
50
+ });
51
+ return candidates
52
+ .filter((value) => Boolean(value))
53
+ .sort((left, right) => left.localeCompare(right));
54
+ }
55
+ async function scanResourceFileForGuidRefs(repoRoot, resourcePath, sourceType, guidFilter) {
56
+ const absolutePath = path.join(repoRoot, resourcePath);
57
+ const stream = createReadStream(absolutePath, { encoding: 'utf-8' });
58
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
59
+ const out = [];
60
+ let lineNumber = 0;
61
+ let currentField = '';
62
+ let currentStartLine = 0;
63
+ let currentLines = [];
64
+ let currentGuidLine = 0;
65
+ let braceDepth = 0;
66
+ const flushCurrentBlock = () => {
67
+ if (!currentField || currentLines.length === 0) {
68
+ resetCurrentBlock();
69
+ return;
70
+ }
71
+ const combined = currentLines.join('\n');
72
+ const guidMatch = combined.match(YAML_INLINE_GUID_PATTERN);
73
+ if (!guidMatch) {
74
+ resetCurrentBlock();
75
+ return;
76
+ }
77
+ const guid = guidMatch[1].toLowerCase();
78
+ if (guidFilter && !guidFilter.has(guid)) {
79
+ resetCurrentBlock();
80
+ return;
81
+ }
82
+ out.push({
83
+ sourceType,
84
+ sourcePath: resourcePath,
85
+ line: currentGuidLine || currentStartLine || 1,
86
+ fieldName: currentField,
87
+ guid,
88
+ snippet: currentLines[0].trim(),
89
+ });
90
+ resetCurrentBlock();
91
+ };
92
+ const resetCurrentBlock = () => {
93
+ currentField = '';
94
+ currentStartLine = 0;
95
+ currentLines = [];
96
+ currentGuidLine = 0;
97
+ braceDepth = 0;
98
+ };
99
+ try {
100
+ for await (const line of reader) {
101
+ lineNumber += 1;
102
+ const inlineMatch = line.match(YAML_INLINE_FIELD_PATTERN);
103
+ if (inlineMatch) {
104
+ const guid = inlineMatch[2].toLowerCase();
105
+ if (!guidFilter || guidFilter.has(guid)) {
106
+ out.push({
107
+ sourceType,
108
+ sourcePath: resourcePath,
109
+ line: lineNumber,
110
+ fieldName: inlineMatch[1],
111
+ guid,
112
+ snippet: line.trim(),
113
+ });
114
+ }
115
+ continue;
116
+ }
117
+ if (currentField) {
118
+ currentLines.push(line);
119
+ if (!currentGuidLine && YAML_INLINE_GUID_PATTERN.test(line)) {
120
+ currentGuidLine = lineNumber;
121
+ }
122
+ braceDepth += countChar(line, '{');
123
+ braceDepth -= countChar(line, '}');
124
+ if (braceDepth <= 0 || line.includes('}')) {
125
+ flushCurrentBlock();
126
+ }
127
+ continue;
128
+ }
129
+ const openMatch = line.match(YAML_FIELD_OPEN_PATTERN);
130
+ if (!openMatch)
131
+ continue;
132
+ currentField = openMatch[1];
133
+ currentStartLine = lineNumber;
134
+ currentLines = [line];
135
+ currentGuidLine = YAML_INLINE_GUID_PATTERN.test(line) ? lineNumber : 0;
136
+ braceDepth = Math.max(1, countChar(line, '{') - countChar(line, '}'));
137
+ if (line.includes('}') || braceDepth <= 0) {
138
+ flushCurrentBlock();
139
+ }
140
+ }
141
+ }
142
+ catch (error) {
143
+ const code = error.code;
144
+ if (code === 'ENOENT' || code === 'EISDIR') {
145
+ return [];
146
+ }
147
+ throw error;
148
+ }
149
+ finally {
150
+ reader.close();
151
+ stream.destroy();
152
+ }
153
+ if (currentField) {
154
+ flushCurrentBlock();
155
+ }
156
+ return out;
157
+ }
158
+ async function resolveFiles(repoRoot, scopedPaths) {
159
+ if (!scopedPaths || scopedPaths.length === 0) {
160
+ return (await glob(['**/*.prefab', '**/*.asset'], {
161
+ cwd: repoRoot,
162
+ nodir: true,
163
+ dot: false,
164
+ })).sort((left, right) => left.localeCompare(right));
165
+ }
166
+ const normalized = scopedPaths
167
+ .filter((value) => value.endsWith('.prefab') || value.endsWith('.asset'))
168
+ .map((value) => normalizeRelativePath(repoRoot, value))
169
+ .filter((value) => value !== null)
170
+ .sort((left, right) => left.localeCompare(right));
171
+ return [...new Set(normalized)];
172
+ }
173
+ function normalizeRelativePath(repoRoot, filePath) {
174
+ const relativePath = path.isAbsolute(filePath) ? path.relative(repoRoot, filePath) : filePath;
175
+ const normalized = relativePath.replace(/\\/g, '/');
176
+ if (normalized.startsWith('../'))
177
+ return null;
178
+ return normalized;
179
+ }
180
+ function toGuidFilter(guidInputs) {
181
+ if (!guidInputs || guidInputs.length === 0)
182
+ return null;
183
+ const normalized = guidInputs
184
+ .map((value) => String(value || '').trim().toLowerCase())
185
+ .filter((value) => /^[0-9a-f]{32}$/.test(value));
186
+ if (normalized.length === 0)
187
+ return null;
188
+ return new Set(normalized);
189
+ }
190
+ function countChar(input, char) {
191
+ let count = 0;
192
+ for (let i = 0; i < input.length; i += 1) {
193
+ if (input[i] === char)
194
+ count += 1;
195
+ }
196
+ return count;
197
+ }
198
+ async function mapWithConcurrency(items, concurrency, mapper) {
199
+ const safeConcurrency = Math.max(1, Math.min(concurrency, items.length || 1));
200
+ const results = new Array(items.length);
201
+ let cursor = 0;
202
+ const workers = Array.from({ length: safeConcurrency }, async () => {
203
+ while (true) {
204
+ const index = cursor;
205
+ cursor += 1;
206
+ if (index >= items.length)
207
+ break;
208
+ results[index] = await mapper(items[index], index);
209
+ }
210
+ });
211
+ await Promise.all(workers);
212
+ return results;
213
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { scanUiAssetRefs } from './ui-asset-ref-scanner.js';
8
+ const here = path.dirname(fileURLToPath(import.meta.url));
9
+ const fixtureRoot = path.resolve(here, '../../../src/core/unity/__fixtures__/mini-unity-ui');
10
+ test('scans prefab and asset VisualTreeAsset refs with evidence lines', async () => {
11
+ const refs = await scanUiAssetRefs({ repoRoot: fixtureRoot });
12
+ assert.ok(refs.some((entry) => entry.sourceType === 'prefab'
13
+ && entry.sourcePath === 'Assets/Prefabs/EliteBossScreen.prefab'
14
+ && entry.guid === 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
15
+ && entry.line > 0));
16
+ assert.ok(refs.some((entry) => entry.sourceType === 'asset'
17
+ && entry.sourcePath === 'Assets/Config/DressUpScreenConfig.asset'
18
+ && entry.guid === 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
19
+ && entry.line > 0));
20
+ });
21
+ test('parses multiline YAML object refs and supports target guid prefilter', async () => {
22
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ui-asset-scan-'));
23
+ await fs.mkdir(path.join(tempRoot, 'Assets/Prefabs'), { recursive: true });
24
+ await fs.writeFile(path.join(tempRoot, 'Assets/Prefabs/MultiLine.prefab'), [
25
+ '%YAML 1.1',
26
+ '--- !u!114 &11400000',
27
+ 'MonoBehaviour:',
28
+ ' m_ObjectHideFlags: 0',
29
+ ' m_VisualTreeAsset: {',
30
+ ' fileID: 9197481965408888,',
31
+ ' guid: abcdefabcdefabcdefabcdefabcdefab,',
32
+ ' type: 3',
33
+ ' }',
34
+ ].join('\n'), 'utf-8');
35
+ const refs = await scanUiAssetRefs({
36
+ repoRoot: tempRoot,
37
+ targetGuids: ['abcdefabcdefabcdefabcdefabcdefab'],
38
+ });
39
+ assert.equal(refs.length, 1);
40
+ assert.equal(refs[0].sourcePath, 'Assets/Prefabs/MultiLine.prefab');
41
+ assert.equal(refs[0].fieldName, 'm_VisualTreeAsset');
42
+ assert.equal(refs[0].guid, 'abcdefabcdefabcdefabcdefabcdefab');
43
+ await fs.rm(tempRoot, { recursive: true, force: true });
44
+ });
@@ -0,0 +1,8 @@
1
+ export interface UnityUiMetaIndex {
2
+ uxmlGuidToPath: Map<string, string>;
3
+ ussGuidToPath: Map<string, string>;
4
+ }
5
+ export interface BuildUnityUiMetaIndexOptions {
6
+ scopedPaths?: string[];
7
+ }
8
+ export declare function buildUnityUiMetaIndex(repoRoot: string, options?: BuildUnityUiMetaIndexOptions): Promise<UnityUiMetaIndex>;