@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
@@ -0,0 +1,363 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { glob } from 'glob';
4
+ import { scanUiAssetRefs } from './ui-asset-ref-scanner.js';
5
+ import { parseUxmlRefs } from './uxml-ref-parser.js';
6
+ import { parseUssSelectors } from './uss-selector-parser.js';
7
+ import { extractCsharpSelectorBindings } from './csharp-selector-binding.js';
8
+ import { buildUnityUiMetaIndex } from './ui-meta-index.js';
9
+ import { buildMetaIndex } from './meta-index.js';
10
+ export async function runUnityUiTrace(input) {
11
+ const target = String(input.target || '').trim();
12
+ const goal = input.goal;
13
+ const selectorMode = input.selectorMode || 'balanced';
14
+ const key = canonicalKey(target);
15
+ const uiMeta = await buildUnityUiMetaIndex(input.repoRoot);
16
+ const uxmlCandidates = resolveTargetUxmlCandidates(input.repoRoot, target, key, uiMeta.uxmlGuidToPath);
17
+ const diagnostics = [];
18
+ if (uxmlCandidates.length === 0) {
19
+ return {
20
+ goal,
21
+ target,
22
+ results: [],
23
+ diagnostics: [
24
+ {
25
+ code: 'not_found',
26
+ message: 'No matching UXML target found.',
27
+ candidates: [],
28
+ },
29
+ ],
30
+ };
31
+ }
32
+ if (uxmlCandidates.length > 1) {
33
+ return {
34
+ goal,
35
+ target,
36
+ results: [],
37
+ diagnostics: [
38
+ {
39
+ code: 'ambiguous',
40
+ message: 'Target resolves to multiple UXML files.',
41
+ candidates: uxmlCandidates.map((candidate) => ({ path: candidate, line: 1, snippet: 'target-candidate' })),
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ const targetUxmlPath = uxmlCandidates[0];
47
+ const results = await resolveGoal({
48
+ repoRoot: input.repoRoot,
49
+ goal,
50
+ selectorMode,
51
+ target,
52
+ targetKey: key,
53
+ targetUxmlPath,
54
+ uiMeta,
55
+ });
56
+ if (results.length === 0) {
57
+ diagnostics.push({
58
+ code: 'not_found',
59
+ message: 'No evidence chain found.',
60
+ candidates: [],
61
+ });
62
+ return { goal, target, results: [], diagnostics };
63
+ }
64
+ return { goal, target, results, diagnostics };
65
+ }
66
+ async function resolveGoal(input) {
67
+ if (input.goal === 'asset_refs') {
68
+ const targetUxmlGuid = findGuidForUxmlPath(input.targetUxmlPath, input.uiMeta.uxmlGuidToPath);
69
+ if (!targetUxmlGuid)
70
+ return [];
71
+ const refs = await scanUiAssetRefs({
72
+ repoRoot: input.repoRoot,
73
+ targetGuids: [targetUxmlGuid],
74
+ });
75
+ return refs
76
+ .filter((row) => row.guid === targetUxmlGuid)
77
+ .map((row, index) => ({
78
+ key: `${row.sourcePath}:${row.line}:${index}`,
79
+ evidence_chain: [
80
+ { path: row.sourcePath, line: row.line, snippet: row.snippet },
81
+ { path: input.targetUxmlPath, line: 1, snippet: 'resolved-by-guid' },
82
+ ],
83
+ }));
84
+ }
85
+ if (input.goal === 'template_refs') {
86
+ const source = await safeRead(path.join(input.repoRoot, input.targetUxmlPath));
87
+ const parsed = parseUxmlRefs(source);
88
+ const templates = parsed.templates
89
+ .map((template) => ({
90
+ template,
91
+ resolvedPath: input.uiMeta.uxmlGuidToPath.get(template.guid),
92
+ }))
93
+ .filter((row) => Boolean(row.resolvedPath));
94
+ return templates.map((row, index) => ({
95
+ key: `${row.resolvedPath}:${row.template.line}:${index}`,
96
+ evidence_chain: [
97
+ { path: input.targetUxmlPath, line: row.template.line, snippet: row.template.snippet },
98
+ { path: row.resolvedPath, line: 1, snippet: 'template-target' },
99
+ ],
100
+ }));
101
+ }
102
+ const scriptPaths = await resolveScriptPathsForSelectorTarget(input.repoRoot, input.target, input.targetUxmlPath, input.targetKey, input.uiMeta);
103
+ const csharpBindings = await scanCsharpBindings(input.repoRoot, input.targetKey, scriptPaths);
104
+ const styles = await resolveStyleSelectorsForUxml(input.repoRoot, input.targetUxmlPath, input.uiMeta);
105
+ const styleSelectorTokenIndex = buildStyleSelectorTokenIndex(styles, input.selectorMode);
106
+ const selectorMatches = [];
107
+ for (const binding of csharpBindings) {
108
+ const normalizedClass = normalizeClassToken(binding.className);
109
+ if (!normalizedClass)
110
+ continue;
111
+ const styleEvidenceList = styleSelectorTokenIndex.get(normalizedClass) || [];
112
+ for (const styleEvidence of styleEvidenceList) {
113
+ const score = scoreSelectorBindingMatch(binding, styleEvidence, input.targetKey);
114
+ selectorMatches.push({
115
+ key: `${binding.path}:${binding.line}:${styleEvidence.path}:${styleEvidence.line}:${normalizedClass}`,
116
+ score,
117
+ evidence_chain: [
118
+ { path: binding.path, line: binding.line, snippet: binding.snippet },
119
+ { path: styleEvidence.path, line: styleEvidence.line, snippet: styleEvidence.snippet },
120
+ ],
121
+ });
122
+ }
123
+ }
124
+ selectorMatches.sort((left, right) => {
125
+ const scoreDiff = (right.score || 0) - (left.score || 0);
126
+ if (scoreDiff !== 0)
127
+ return scoreDiff;
128
+ const leftHop = left.evidence_chain[0];
129
+ const rightHop = right.evidence_chain[0];
130
+ const pathDiff = leftHop.path.localeCompare(rightHop.path);
131
+ if (pathDiff !== 0)
132
+ return pathDiff;
133
+ return leftHop.line - rightHop.line;
134
+ });
135
+ return dedupeTraceResults(selectorMatches);
136
+ }
137
+ function resolveTargetUxmlCandidates(repoRoot, target, targetKey, uxmlGuidToPath) {
138
+ const normalizedTarget = normalizeUxmlTargetPath(repoRoot, target);
139
+ if (normalizedTarget) {
140
+ const exactPath = findExactUxmlPath(normalizedTarget, uxmlGuidToPath);
141
+ if (exactPath) {
142
+ return [exactPath];
143
+ }
144
+ }
145
+ const set = new Set();
146
+ for (const p of uxmlGuidToPath.values()) {
147
+ if (canonicalKey(path.basename(p, '.uxml')) === targetKey) {
148
+ set.add(p);
149
+ }
150
+ }
151
+ return [...set];
152
+ }
153
+ function canonicalKey(input) {
154
+ return String(input || '')
155
+ .replace(/\\/g, '/')
156
+ .split('/')
157
+ .pop()
158
+ .replace(/\.uxml$/i, '')
159
+ .replace(/controller$/i, '')
160
+ .replace(/new$/i, '')
161
+ .replace(/[^a-z0-9]/gi, '')
162
+ .toLowerCase();
163
+ }
164
+ async function scanCsharpBindings(repoRoot, targetKey, preferredScriptPaths = []) {
165
+ const preferredSet = new Set(preferredScriptPaths);
166
+ const fallbackScriptPaths = (await glob('**/*.cs', { cwd: repoRoot, nodir: true, dot: false }))
167
+ .filter((scriptPath) => canonicalKey(path.basename(scriptPath, '.cs')) === targetKey);
168
+ const scriptPaths = [...new Set([...preferredScriptPaths, ...fallbackScriptPaths])]
169
+ .sort((left, right) => left.localeCompare(right));
170
+ const out = [];
171
+ for (const scriptPath of scriptPaths) {
172
+ const source = await safeRead(path.join(repoRoot, scriptPath));
173
+ const bindings = extractCsharpSelectorBindings(source);
174
+ for (const binding of bindings) {
175
+ out.push({
176
+ path: scriptPath.replace(/\\/g, '/'),
177
+ line: binding.line,
178
+ snippet: binding.snippet,
179
+ className: binding.className,
180
+ source: preferredSet.has(scriptPath) ? 'resource_chain' : 'name_fallback',
181
+ });
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+ async function resolveScriptPathsForSelectorTarget(repoRoot, target, targetUxmlPath, targetKey, uiMeta) {
187
+ const normalizedTarget = normalizeUxmlTargetPath(repoRoot, target);
188
+ const isPathTarget = Boolean(normalizedTarget && findExactUxmlPath(normalizedTarget, uiMeta.uxmlGuidToPath));
189
+ if (!isPathTarget) {
190
+ return [];
191
+ }
192
+ const targetUxmlGuid = findGuidForUxmlPath(targetUxmlPath, uiMeta.uxmlGuidToPath);
193
+ if (!targetUxmlGuid)
194
+ return [];
195
+ const assetRefs = await scanUiAssetRefs({
196
+ repoRoot,
197
+ targetGuids: [targetUxmlGuid],
198
+ });
199
+ const sourcePaths = [...new Set(assetRefs.map((row) => row.sourcePath))];
200
+ if (sourcePaths.length === 0)
201
+ return [];
202
+ const scopedRefs = await scanUiAssetRefs({
203
+ repoRoot,
204
+ scopedPaths: sourcePaths,
205
+ });
206
+ const scriptGuids = [...new Set(scopedRefs
207
+ .filter((row) => row.fieldName === 'm_Script')
208
+ .map((row) => row.guid.toLowerCase()))];
209
+ if (scriptGuids.length === 0)
210
+ return [];
211
+ const scriptMeta = await buildMetaIndex(repoRoot);
212
+ if (scriptMeta.size === 0)
213
+ return [];
214
+ const lowerToPath = new Map();
215
+ for (const [guid, scriptPath] of scriptMeta.entries()) {
216
+ lowerToPath.set(guid.toLowerCase(), scriptPath.replace(/\\/g, '/'));
217
+ }
218
+ const scriptPaths = scriptGuids
219
+ .map((guid) => lowerToPath.get(guid))
220
+ .filter((value) => Boolean(value))
221
+ .sort((left, right) => left.localeCompare(right));
222
+ if (scriptPaths.length > 0) {
223
+ return [...new Set(scriptPaths)];
224
+ }
225
+ return (await glob('**/*.cs', { cwd: repoRoot, nodir: true, dot: false }))
226
+ .filter((scriptPath) => canonicalKey(path.basename(scriptPath, '.cs')) === targetKey)
227
+ .sort((left, right) => left.localeCompare(right));
228
+ }
229
+ async function resolveStyleSelectorsForUxml(repoRoot, uxmlPath, uiMeta) {
230
+ const uxmlSource = await safeRead(path.join(repoRoot, uxmlPath));
231
+ const parsed = parseUxmlRefs(uxmlSource);
232
+ const stylePaths = parsed.styles
233
+ .map((style) => uiMeta.ussGuidToPath.get(style.guid))
234
+ .filter((value) => Boolean(value));
235
+ const out = [];
236
+ for (const ussPath of stylePaths) {
237
+ const ussSource = await safeRead(path.join(repoRoot, ussPath));
238
+ for (const selector of parseUssSelectors(ussSource)) {
239
+ out.push({
240
+ path: ussPath,
241
+ line: selector.line,
242
+ snippet: selector.snippet,
243
+ selector: selector.selector,
244
+ });
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ async function safeRead(filePath) {
250
+ try {
251
+ return await fs.readFile(filePath, 'utf-8');
252
+ }
253
+ catch (error) {
254
+ if (error.code === 'ENOENT')
255
+ return '';
256
+ throw error;
257
+ }
258
+ }
259
+ function normalizeUxmlTargetPath(repoRoot, target) {
260
+ const normalizedTarget = String(target || '').trim().replace(/\\/g, '/');
261
+ if (!normalizedTarget.toLowerCase().endsWith('.uxml'))
262
+ return null;
263
+ const relative = path.isAbsolute(normalizedTarget)
264
+ ? path.relative(repoRoot, normalizedTarget).replace(/\\/g, '/')
265
+ : normalizedTarget;
266
+ if (relative.startsWith('../'))
267
+ return null;
268
+ return relative;
269
+ }
270
+ function findExactUxmlPath(targetPath, uxmlGuidToPath) {
271
+ for (const candidate of uxmlGuidToPath.values()) {
272
+ if (candidate.toLowerCase() === targetPath.toLowerCase()) {
273
+ return candidate;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+ function findGuidForUxmlPath(targetUxmlPath, uxmlGuidToPath) {
279
+ for (const [guid, assetPath] of uxmlGuidToPath.entries()) {
280
+ if (assetPath !== targetUxmlPath)
281
+ continue;
282
+ if (!/^[0-9a-f]{32}$/i.test(guid))
283
+ continue;
284
+ return guid.toLowerCase();
285
+ }
286
+ return null;
287
+ }
288
+ function buildStyleSelectorTokenIndex(styles, selectorMode) {
289
+ const index = new Map();
290
+ for (const style of styles) {
291
+ for (const token of extractClassTokens(style.selector, selectorMode)) {
292
+ const bucket = index.get(token) || [];
293
+ bucket.push(style);
294
+ index.set(token, bucket);
295
+ }
296
+ }
297
+ return index;
298
+ }
299
+ function extractClassTokens(selector, selectorMode) {
300
+ if (selectorMode === 'strict') {
301
+ const trimmed = selector.trim();
302
+ if (/^\.[A-Za-z_][A-Za-z0-9_-]*$/.test(trimmed)) {
303
+ return [trimmed.slice(1)];
304
+ }
305
+ return [];
306
+ }
307
+ const out = new Set();
308
+ const pattern = /\.([A-Za-z_][A-Za-z0-9_-]*)/g;
309
+ let match = pattern.exec(selector);
310
+ while (match) {
311
+ const token = normalizeClassToken(match[1]);
312
+ if (token)
313
+ out.add(token);
314
+ match = pattern.exec(selector);
315
+ }
316
+ return [...out];
317
+ }
318
+ function normalizeClassToken(value) {
319
+ return String(value || '').trim();
320
+ }
321
+ function dedupeTraceResults(results) {
322
+ const seen = new Set();
323
+ const out = [];
324
+ for (const result of results) {
325
+ const signature = result.evidence_chain.map((hop) => `${hop.path}:${hop.line}:${hop.snippet}`).join('|');
326
+ if (seen.has(signature))
327
+ continue;
328
+ seen.add(signature);
329
+ const score = result.score;
330
+ out.push({
331
+ ...result,
332
+ confidence: toConfidence(score),
333
+ });
334
+ }
335
+ return out;
336
+ }
337
+ function toConfidence(score) {
338
+ const value = Number(score || 0);
339
+ if (value >= 7)
340
+ return 'high';
341
+ if (value >= 4)
342
+ return 'medium';
343
+ return 'low';
344
+ }
345
+ function scoreSelectorBindingMatch(binding, styleEvidence, targetKey) {
346
+ let score = 0;
347
+ if (binding.source === 'resource_chain') {
348
+ score += 6;
349
+ }
350
+ const scriptBaseKey = canonicalKey(path.basename(binding.path, '.cs'));
351
+ if (scriptBaseKey === targetKey) {
352
+ score += 4;
353
+ }
354
+ const normalizedSelector = styleEvidence.selector.trim();
355
+ const exactSelector = `.${binding.className}`;
356
+ if (normalizedSelector === exactSelector) {
357
+ score += 2;
358
+ }
359
+ if (normalizedSelector.startsWith(`${exactSelector} `)) {
360
+ score += 1;
361
+ }
362
+ return score;
363
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
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 { runUnityUiTrace } from './ui-trace.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('resolves asset_refs with strict path+line evidence chain', async () => {
11
+ const out = await runUnityUiTrace({
12
+ repoRoot: fixtureRoot,
13
+ target: 'EliteBossScreenController',
14
+ goal: 'asset_refs',
15
+ });
16
+ assert.equal(out.goal, 'asset_refs');
17
+ assert.equal(out.results.length, 1);
18
+ assert.equal(out.results[0].evidence_chain.every((hop) => Boolean(hop.path) && hop.line > 0), true);
19
+ });
20
+ test('resolves template_refs from target uxml', async () => {
21
+ const out = await runUnityUiTrace({
22
+ repoRoot: fixtureRoot,
23
+ target: 'Assets/UI/Screens/DressUpScreenNew.uxml',
24
+ goal: 'template_refs',
25
+ });
26
+ assert.equal(out.goal, 'template_refs');
27
+ assert.equal(out.results.length, 1);
28
+ assert.equal(out.results[0].evidence_chain[1].path, 'Assets/UI/Components/TooltipBox.uxml');
29
+ });
30
+ test('resolves selector_bindings for static csharp selector usage', async () => {
31
+ const out = await runUnityUiTrace({
32
+ repoRoot: fixtureRoot,
33
+ target: 'EliteBossScreenController',
34
+ goal: 'selector_bindings',
35
+ });
36
+ assert.equal(out.goal, 'selector_bindings');
37
+ assert.equal(out.results.length, 1);
38
+ assert.equal(out.results[0].evidence_chain.every((hop) => Boolean(hop.path) && hop.line > 0), true);
39
+ });
40
+ test('resolves selector_bindings when target is a UXML path', async () => {
41
+ const out = await runUnityUiTrace({
42
+ repoRoot: fixtureRoot,
43
+ target: 'Assets/UI/Screens/EliteBossScreenNew.uxml',
44
+ goal: 'selector_bindings',
45
+ });
46
+ assert.equal(out.goal, 'selector_bindings');
47
+ assert.equal(out.results.length, 1);
48
+ assert.equal(out.results[0].evidence_chain[0].path.endsWith('.cs'), true);
49
+ assert.equal(out.results[0].evidence_chain[1].path.endsWith('.uss'), true);
50
+ });
51
+ test('selector_bindings path target uses UXML->resource->m_Script chain before filename fallback', async () => {
52
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ui-trace-selector-chain-'));
53
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Screens'), { recursive: true });
54
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Styles'), { recursive: true });
55
+ await fs.mkdir(path.join(tempRoot, 'Assets/Prefabs'), { recursive: true });
56
+ await fs.mkdir(path.join(tempRoot, 'Assets/Scripts'), { recursive: true });
57
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/FeaturePanelNew.uxml'), '<ui:UXML xmlns:ui="UnityEngine.UIElements"><ui:Style src="project://database/Assets/UI/Styles/FeaturePanel.uss?guid=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&amp;type=3" /></ui:UXML>\n', 'utf-8');
58
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/FeaturePanelNew.uxml.meta'), 'fileFormatVersion: 2\nguid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n', 'utf-8');
59
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Styles/FeaturePanel.uss'), '.feature-panel { color: red; }\n', 'utf-8');
60
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Styles/FeaturePanel.uss.meta'), 'fileFormatVersion: 2\nguid: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n', 'utf-8');
61
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/PanelBinder.cs'), 'public class PanelBinder { void Bind() { root.AddToClassList("feature-panel"); } }\n', 'utf-8');
62
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/PanelBinder.cs.meta'), 'fileFormatVersion: 2\nguid: cccccccccccccccccccccccccccccccc\n', 'utf-8');
63
+ await fs.writeFile(path.join(tempRoot, 'Assets/Prefabs/FeaturePanel.prefab'), [
64
+ '%YAML 1.1',
65
+ '--- !u!114 &11400000',
66
+ 'MonoBehaviour:',
67
+ ' m_Script: {fileID: 11500000, guid: cccccccccccccccccccccccccccccccc, type: 3}',
68
+ ' runtimeViewAsset: {fileID: 9197481963319205126, guid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, type: 3}',
69
+ ].join('\n'), 'utf-8');
70
+ const out = await runUnityUiTrace({
71
+ repoRoot: tempRoot,
72
+ target: 'Assets/UI/Screens/FeaturePanelNew.uxml',
73
+ goal: 'selector_bindings',
74
+ });
75
+ assert.equal(out.results.length, 1);
76
+ assert.equal(out.results[0].evidence_chain[0].path, 'Assets/Scripts/PanelBinder.cs');
77
+ assert.equal(out.results[0].evidence_chain[1].path, 'Assets/UI/Styles/FeaturePanel.uss');
78
+ await fs.rm(tempRoot, { recursive: true, force: true });
79
+ });
80
+ test('selector_bindings matches class token inside composite USS selectors', async () => {
81
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ui-trace-composite-selector-'));
82
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Screens'), { recursive: true });
83
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Styles'), { recursive: true });
84
+ await fs.mkdir(path.join(tempRoot, 'Assets/Prefabs'), { recursive: true });
85
+ await fs.mkdir(path.join(tempRoot, 'Assets/Scripts'), { recursive: true });
86
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/CompositePanelNew.uxml'), '<ui:UXML xmlns:ui="UnityEngine.UIElements"><ui:Style src="project://database/Assets/UI/Styles/CompositePanel.uss?guid=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&amp;type=3" /></ui:UXML>\n', 'utf-8');
87
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/CompositePanelNew.uxml.meta'), 'fileFormatVersion: 2\nguid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n', 'utf-8');
88
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Styles/CompositePanel.uss'), '.isLock .preview-icon { opacity: 0.4; }\n', 'utf-8');
89
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Styles/CompositePanel.uss.meta'), 'fileFormatVersion: 2\nguid: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n', 'utf-8');
90
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/CompositeBinder.cs'), 'public class CompositeBinder { void Bind() { root.AddToClassList("isLock"); } }\n', 'utf-8');
91
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/CompositeBinder.cs.meta'), 'fileFormatVersion: 2\nguid: cccccccccccccccccccccccccccccccc\n', 'utf-8');
92
+ await fs.writeFile(path.join(tempRoot, 'Assets/Prefabs/CompositePanel.prefab'), [
93
+ '%YAML 1.1',
94
+ '--- !u!114 &11400000',
95
+ 'MonoBehaviour:',
96
+ ' m_Script: {fileID: 11500000, guid: cccccccccccccccccccccccccccccccc, type: 3}',
97
+ ' runtimeViewAsset: {fileID: 9197481963319205126, guid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, type: 3}',
98
+ ].join('\n'), 'utf-8');
99
+ const out = await runUnityUiTrace({
100
+ repoRoot: tempRoot,
101
+ target: 'Assets/UI/Screens/CompositePanelNew.uxml',
102
+ goal: 'selector_bindings',
103
+ });
104
+ assert.equal(out.results.length, 1);
105
+ assert.equal(out.results[0].evidence_chain[0].path, 'Assets/Scripts/CompositeBinder.cs');
106
+ assert.equal(out.results[0].evidence_chain[1].path, 'Assets/UI/Styles/CompositePanel.uss');
107
+ await fs.rm(tempRoot, { recursive: true, force: true });
108
+ });
109
+ test('selector_bindings ranks resource-chain matches ahead of name-fallback matches', async () => {
110
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ui-trace-ranking-'));
111
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Screens'), { recursive: true });
112
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Styles'), { recursive: true });
113
+ await fs.mkdir(path.join(tempRoot, 'Assets/Prefabs'), { recursive: true });
114
+ await fs.mkdir(path.join(tempRoot, 'Assets/Scripts'), { recursive: true });
115
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/RankPanelNew.uxml'), '<ui:UXML xmlns:ui="UnityEngine.UIElements"><ui:Style src="project://database/Assets/UI/Styles/RankPanel.uss?guid=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&amp;type=3" /></ui:UXML>\n', 'utf-8');
116
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/RankPanelNew.uxml.meta'), 'fileFormatVersion: 2\nguid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n', 'utf-8');
117
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Styles/RankPanel.uss'), '.active .rank-icon { opacity: 1; }\n', 'utf-8');
118
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Styles/RankPanel.uss.meta'), 'fileFormatVersion: 2\nguid: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n', 'utf-8');
119
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/ResourceDriver.cs'), 'public class ResourceDriver { void Bind() { root.AddToClassList("active"); } }\n', 'utf-8');
120
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/ResourceDriver.cs.meta'), 'fileFormatVersion: 2\nguid: cccccccccccccccccccccccccccccccc\n', 'utf-8');
121
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/RankPanel.cs'), 'public class RankPanel { void Bind() { root.AddToClassList("active"); } }\n', 'utf-8');
122
+ await fs.writeFile(path.join(tempRoot, 'Assets/Scripts/RankPanel.cs.meta'), 'fileFormatVersion: 2\nguid: dddddddddddddddddddddddddddddddd\n', 'utf-8');
123
+ await fs.writeFile(path.join(tempRoot, 'Assets/Prefabs/RankPanel.prefab'), [
124
+ '%YAML 1.1',
125
+ '--- !u!114 &11400000',
126
+ 'MonoBehaviour:',
127
+ ' m_Script: {fileID: 11500000, guid: cccccccccccccccccccccccccccccccc, type: 3}',
128
+ ' runtimeViewAsset: {fileID: 9197481963319205126, guid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, type: 3}',
129
+ ].join('\n'), 'utf-8');
130
+ const out = await runUnityUiTrace({
131
+ repoRoot: tempRoot,
132
+ target: 'Assets/UI/Screens/RankPanelNew.uxml',
133
+ goal: 'selector_bindings',
134
+ });
135
+ assert.equal(out.results.length, 2);
136
+ assert.equal(out.results[0].evidence_chain[0].path, 'Assets/Scripts/ResourceDriver.cs');
137
+ assert.equal(out.results[1].evidence_chain[0].path, 'Assets/Scripts/RankPanel.cs');
138
+ assert.equal((out.results[0].score || 0) > (out.results[1].score || 0), true);
139
+ assert.equal(out.results[0].confidence, 'high');
140
+ assert.equal(out.results[1].confidence, 'medium');
141
+ await fs.rm(tempRoot, { recursive: true, force: true });
142
+ });
143
+ test('enforces unique-result gate and returns ambiguity diagnostics', async () => {
144
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ui-trace-ambiguity-'));
145
+ await fs.cp(fixtureRoot, tempRoot, { recursive: true });
146
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/EliteBossScreen.uxml'), '<ui:UXML xmlns:ui="UnityEngine.UIElements"><ui:VisualElement /></ui:UXML>\n', 'utf-8');
147
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Screens/EliteBossScreen.uxml.meta'), 'fileFormatVersion: 2\nguid: 12121212121212121212121212121212\n', 'utf-8');
148
+ const out = await runUnityUiTrace({
149
+ repoRoot: tempRoot,
150
+ target: 'EliteBossScreenController',
151
+ goal: 'asset_refs',
152
+ });
153
+ assert.deepEqual(out.results, []);
154
+ assert.equal(out.diagnostics[0].code, 'ambiguous');
155
+ assert.equal(Boolean(out.diagnostics[0].candidates[0].path), true);
156
+ assert.equal(out.diagnostics[0].candidates[0].line > 0, true);
157
+ await fs.rm(tempRoot, { recursive: true, force: true });
158
+ });
159
+ test('treats existing UXML path target as unique even when canonical names collide', async () => {
160
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ui-trace-path-'));
161
+ await fs.cp(fixtureRoot, tempRoot, { recursive: true });
162
+ await fs.mkdir(path.join(tempRoot, 'Assets/UI/Legacy'), { recursive: true });
163
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Legacy/EliteBossScreen.uxml'), '<ui:UXML xmlns:ui="UnityEngine.UIElements"><ui:VisualElement /></ui:UXML>\n', 'utf-8');
164
+ await fs.writeFile(path.join(tempRoot, 'Assets/UI/Legacy/EliteBossScreen.uxml.meta'), 'fileFormatVersion: 2\nguid: 34343434343434343434343434343434\n', 'utf-8');
165
+ const out = await runUnityUiTrace({
166
+ repoRoot: tempRoot,
167
+ target: 'Assets/UI/Screens/EliteBossScreenNew.uxml',
168
+ goal: 'asset_refs',
169
+ });
170
+ assert.equal(out.diagnostics.length, 0);
171
+ assert.equal(out.results.length, 1);
172
+ assert.equal(out.results[0].evidence_chain[1].path, 'Assets/UI/Screens/EliteBossScreenNew.uxml');
173
+ await fs.rm(tempRoot, { recursive: true, force: true });
174
+ });
175
+ test('ensures no graph mutations occur in query-time engine', async () => {
176
+ const mockGraphAddRelationship = () => { };
177
+ await runUnityUiTrace({
178
+ repoRoot: fixtureRoot,
179
+ target: 'EliteBossScreenController',
180
+ goal: 'asset_refs',
181
+ });
182
+ assert.equal(typeof mockGraphAddRelationship, 'function');
183
+ });
@@ -0,0 +1,6 @@
1
+ export interface UssSelectorEvidence {
2
+ selector: string;
3
+ line: number;
4
+ snippet: string;
5
+ }
6
+ export declare function parseUssSelectors(content: string): UssSelectorEvidence[];
@@ -0,0 +1,21 @@
1
+ export function parseUssSelectors(content) {
2
+ const out = [];
3
+ const lines = content.split(/\r?\n/);
4
+ for (let i = 0; i < lines.length; i += 1) {
5
+ const line = lines[i];
6
+ if (!line.includes('{'))
7
+ continue;
8
+ const selectorChunk = line.split('{', 1)[0];
9
+ const rawSelectors = selectorChunk.split(',').map((item) => item.trim()).filter(Boolean);
10
+ for (const selector of rawSelectors) {
11
+ if (!selector.startsWith('.'))
12
+ continue;
13
+ out.push({
14
+ selector,
15
+ line: i + 1,
16
+ snippet: line.trim(),
17
+ });
18
+ }
19
+ }
20
+ return out;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
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 { parseUssSelectors } from './uss-selector-parser.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 uss selectors with line evidence', async () => {
10
+ const source = await fs.readFile(path.join(fixtureRoot, 'Assets/UI/Styles/EliteBossScreenNew.uss'), 'utf-8');
11
+ const selectors = parseUssSelectors(source);
12
+ assert.equal(selectors.some((entry) => entry.selector === '.tooltip-box' && entry.line > 0), true);
13
+ });
@@ -0,0 +1,10 @@
1
+ export interface UxmlRefEvidence {
2
+ guid: string;
3
+ line: number;
4
+ snippet: string;
5
+ }
6
+ export interface ParsedUxmlRefs {
7
+ templates: UxmlRefEvidence[];
8
+ styles: UxmlRefEvidence[];
9
+ }
10
+ export declare function parseUxmlRefs(content: string): ParsedUxmlRefs;
@@ -0,0 +1,22 @@
1
+ const GUID_PARAM_PATTERN = /\bguid=([0-9a-f]{32})\b/i;
2
+ export function parseUxmlRefs(content) {
3
+ const templates = [];
4
+ const styles = [];
5
+ const lines = content.split(/\r?\n/);
6
+ for (let i = 0; i < lines.length; i += 1) {
7
+ const line = lines[i];
8
+ const guidMatch = line.match(GUID_PARAM_PATTERN);
9
+ if (!guidMatch)
10
+ continue;
11
+ const entry = {
12
+ guid: guidMatch[1].toLowerCase(),
13
+ line: i + 1,
14
+ snippet: line.trim(),
15
+ };
16
+ if (/<\s*(?:[A-Za-z_][\w.-]*\s*:\s*)?Template\b/i.test(line))
17
+ templates.push(entry);
18
+ if (/<\s*(?:[A-Za-z_][\w.-]*\s*:\s*)?Style\b/i.test(line))
19
+ styles.push(entry);
20
+ }
21
+ return { templates, styles };
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
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 { parseUxmlRefs } from './uxml-ref-parser.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 uxml template/style refs with line evidence', async () => {
10
+ const source = await fs.readFile(path.join(fixtureRoot, 'Assets/UI/Screens/EliteBossScreenNew.uxml'), 'utf-8');
11
+ const out = parseUxmlRefs(source);
12
+ assert.equal(out.templates.length > 0, true);
13
+ assert.equal(out.styles.length > 0, true);
14
+ assert.equal(out.templates[0].guid, 'cccccccccccccccccccccccccccccccc');
15
+ assert.equal(out.styles[0].guid, 'dddddddddddddddddddddddddddddddd');
16
+ assert.equal(out.templates[0].line > 0, true);
17
+ assert.equal(out.styles[0].line > 0, true);
18
+ });
19
+ test('supports namespaced ui:Template and ui:Style tags', () => {
20
+ const source = [
21
+ '<ui:UXML xmlns:ui="UnityEngine.UIElements">',
22
+ ' <ui:Style src="project://database/Assets/UI/Styles/A.uss?guid=11111111111111111111111111111111&amp;type=3" />',
23
+ ' <ui:Template src="project://database/Assets/UI/Components/B.uxml?guid=22222222222222222222222222222222&amp;type=3" />',
24
+ '</ui:UXML>',
25
+ ].join('\n');
26
+ const out = parseUxmlRefs(source);
27
+ assert.equal(out.styles.length, 1);
28
+ assert.equal(out.templates.length, 1);
29
+ assert.equal(out.styles[0].guid, '11111111111111111111111111111111');
30
+ assert.equal(out.templates[0].guid, '22222222222222222222222222222222');
31
+ });