@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.
- package/dist/cli/index.js +8 -0
- package/dist/cli/tool.d.ts +11 -0
- package/dist/cli/tool.js +31 -0
- package/dist/cli/unity-ui-trace.test.d.ts +1 -0
- package/dist/cli/unity-ui-trace.test.js +24 -0
- package/dist/core/ingestion/call-processor.js +9 -1
- package/dist/core/ingestion/type-env.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/csharp.js +17 -2
- package/dist/core/ingestion/type-extractors/types.d.ts +4 -1
- package/dist/core/unity/csharp-selector-binding.d.ts +7 -0
- package/dist/core/unity/csharp-selector-binding.js +32 -0
- package/dist/core/unity/csharp-selector-binding.test.d.ts +1 -0
- package/dist/core/unity/csharp-selector-binding.test.js +14 -0
- package/dist/core/unity/scan-context.d.ts +2 -0
- package/dist/core/unity/scan-context.js +17 -0
- package/dist/core/unity/ui-asset-ref-scanner.d.ts +14 -0
- package/dist/core/unity/ui-asset-ref-scanner.js +213 -0
- package/dist/core/unity/ui-asset-ref-scanner.test.d.ts +1 -0
- package/dist/core/unity/ui-asset-ref-scanner.test.js +44 -0
- package/dist/core/unity/ui-meta-index.d.ts +8 -0
- package/dist/core/unity/ui-meta-index.js +60 -0
- package/dist/core/unity/ui-meta-index.test.d.ts +1 -0
- package/dist/core/unity/ui-meta-index.test.js +18 -0
- package/dist/core/unity/ui-trace-storage-guard.test.d.ts +1 -0
- package/dist/core/unity/ui-trace-storage-guard.test.js +10 -0
- package/dist/core/unity/ui-trace.acceptance.test.d.ts +1 -0
- package/dist/core/unity/ui-trace.acceptance.test.js +38 -0
- package/dist/core/unity/ui-trace.d.ts +31 -0
- package/dist/core/unity/ui-trace.js +363 -0
- package/dist/core/unity/ui-trace.test.d.ts +1 -0
- package/dist/core/unity/ui-trace.test.js +183 -0
- package/dist/core/unity/uss-selector-parser.d.ts +6 -0
- package/dist/core/unity/uss-selector-parser.js +21 -0
- package/dist/core/unity/uss-selector-parser.test.d.ts +1 -0
- package/dist/core/unity/uss-selector-parser.test.js +13 -0
- package/dist/core/unity/uxml-ref-parser.d.ts +10 -0
- package/dist/core/unity/uxml-ref-parser.js +22 -0
- package/dist/core/unity/uxml-ref-parser.test.d.ts +1 -0
- package/dist/core/unity/uxml-ref-parser.test.js +31 -0
- package/dist/mcp/local/local-backend.d.ts +13 -0
- package/dist/mcp/local/local-backend.js +298 -25
- package/dist/mcp/tools.js +41 -0
- package/package.json +2 -1
- package/skills/gitnexus-cli.md +29 -0
- 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')
|
package/dist/cli/tool.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,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>;
|