@flisk/analyze-tracking 0.8.8 → 0.9.1

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.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * @fileoverview Swift analytics tracking analyzer - main entry point
3
+ * @module analyze/swift
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ // Modularized imports
10
+ const { getSwiftAst, withSwift } = require('./runtime');
11
+ const { buildCrossFileConstMap } = require('./constants');
12
+ // Local provider detection to avoid coupling during refactor
13
+ const {
14
+ normalizeChainPart,
15
+ endsWithChain,
16
+ extractStringLiteral,
17
+ isIdentifier,
18
+ inferValueTypeFromText,
19
+ splitTopLevel,
20
+ sliceRange,
21
+ escapeRegExp,
22
+ } = require('./utils');
23
+ const { matchCustomSignature, matchImplicitCustom } = require('./custom');
24
+
25
+ /**
26
+ * Swift analyzer entrypoint
27
+ */
28
+
29
+ /**
30
+ * Analyze a Swift file and extract tracking events
31
+ * @param {string} filePath
32
+ * @param {Array<Object>|null} customFunctionSignatures parsed via parseCustomFunctionSignature
33
+ * @returns {Promise<Array<Object>>}
34
+ */
35
+ async function analyzeSwiftFile(filePath, customFunctionSignatures = null) {
36
+ try {
37
+ const source = fs.readFileSync(filePath, 'utf8');
38
+ const { parseSwiftFile, analyzeAst } = await getSwiftAst();
39
+ // Ensure single in-flight WASI call at a time
40
+ const ast = await withSwift(() => parseSwiftFile(filePath));
41
+ const analysis = analyzeAst(ast, source);
42
+
43
+ // Cross-file simple constant map for this directory (EVENTS.*, KEYS.* and top-level lets)
44
+ const constMap = buildCrossFileConstMap(path.dirname(filePath));
45
+
46
+ // Produce events list
47
+ const events = [];
48
+
49
+ for (const call of analysis.calls) {
50
+ try {
51
+ // 1) Try custom function signatures first
52
+ const matchedCustom = matchCustomSignature(call, customFunctionSignatures);
53
+ if (matchedCustom) {
54
+ const evt = extractCustomEvent(call, matchedCustom, analysis, source, filePath, constMap);
55
+ if (evt) events.push(evt);
56
+ continue;
57
+ }
58
+
59
+ // 1b) Implicit custom patterns (specific helpers)
60
+ const implicit = matchImplicitCustom(call);
61
+ if (implicit) {
62
+ const evt = extractCustomEvent(call, implicit, analysis, source, filePath, constMap);
63
+ if (evt) events.push(evt);
64
+ continue;
65
+ }
66
+
67
+ // 2) Built-in providers
68
+ const provider = detectProvider(call, source);
69
+ if (!provider) continue;
70
+
71
+ const evt = extractProviderEvent(call, provider, analysis, source, filePath, constMap);
72
+ if (evt) events.push(evt);
73
+ } catch (_) { /* ignore per-call errors */ }
74
+ }
75
+
76
+ return dedupe(events);
77
+ } catch (err) {
78
+ console.error(`Error analyzing Swift file ${filePath}:`, err.message);
79
+ return [];
80
+ }
81
+ }
82
+
83
+ // ---------------------------
84
+ // Provider detection
85
+ // ---------------------------
86
+
87
+ function detectProvider(call, source) {
88
+ const method = call.name;
89
+ const chain = Array.isArray(call.calleeChain) ? call.calleeChain : [];
90
+ const recvText = call.receiver || null;
91
+ const recvBase = recvText ? recvText.split('.')[0] : null;
92
+ const base = call.baseIdentifier || recvBase || (chain.length ? normalizeChainPart(chain[0]).split('.')[0] : null);
93
+ const methodCand = method || (chain.length ? normalizeChainPart(chain[chain.length - 1]) : null);
94
+
95
+ if (base === 'dataLayer' && (methodCand === 'append' || methodCand === 'push')) return 'gtm';
96
+ if (base === 'Analytics' && methodCand === 'logEvent') return 'googleanalytics';
97
+ if (base === 'analytics' && methodCand === 'track') return 'segment';
98
+ if (base === 'Mixpanel' && methodCand === 'track') return 'mixpanel';
99
+ if (base === 'amplitude' && methodCand === 'track') return 'amplitude';
100
+ if (base === 'RSClient' && methodCand === 'track') return 'rudderstack';
101
+ if (base === 'MParticle' && methodCand === 'logEvent') return 'mparticle';
102
+ if (base === 'PostHogSDK' && methodCand === 'capture') return 'posthog';
103
+ if (base === 'PendoManager' && methodCand === 'track') return 'pendo';
104
+ if (base === 'Heap' && methodCand === 'track') return 'heap';
105
+
106
+ try {
107
+ const text = sliceRange(source, call.range || {});
108
+ const t = text.replace(/\s+/g, '');
109
+ if (/\bdataLayer\.(append|push)\(/.test(t)) return 'gtm';
110
+ if (/\bAnalytics\.logEvent\(/.test(t)) return 'googleanalytics';
111
+ if (/\banalytics\.track\(/.test(t)) return 'segment';
112
+ if (/\bMixpanel\.[A-Za-z0-9_]+\(\)\.track\(/.test(t) || /\bMixpanel\.track\(/.test(t)) return 'mixpanel';
113
+ if (/\bamplitude\.track\(/.test(t)) return 'amplitude';
114
+ if (/\bRSClient\.[A-Za-z0-9_?]+\(\)?(?:\?\.|\.)track\(/.test(t) || /\bRSClient\(\)\.track\(/.test(t)) return 'rudderstack';
115
+ if (/\bMParticle\.[A-Za-z0-9_]+\(\)\.logEvent\(/.test(t)) return 'mparticle';
116
+ if (/\bPostHogSDK\.[A-Za-z0-9_]+\.capture\(/.test(t)) return 'posthog';
117
+ if (/\bPendoManager\.[A-Za-z0-9_]+\(\)\.track\(/.test(t)) return 'pendo';
118
+ if (/\bHeap\.[A-Za-z0-9_]+\.track\(/.test(t)) return 'heap';
119
+ } catch (_) {}
120
+
121
+ try {
122
+ if (methodCand === 'append') {
123
+ const text = sliceRange(source, call.range || {});
124
+ const dictText = extractFirstDictFromCall(text);
125
+ if (dictText && /(^|[,{\s])event\s*:/.test(dictText)) return 'gtm';
126
+ }
127
+ } catch(_) {}
128
+
129
+ return null;
130
+ }
131
+
132
+ // ---------------------------
133
+ // Event extraction (providers)
134
+ // ---------------------------
135
+
136
+ function extractProviderEvent(call, provider, analysis, source, filePath, constMap) {
137
+ const file = filePath;
138
+ const line = call.range?.start?.line || 0;
139
+ const functionName = findEnclosingName(analysis, call.id);
140
+
141
+ const args = safeGetCallArgs(analysis, call.id);
142
+ const rawCall = sliceRange(source, call.range || {});
143
+
144
+ switch (provider) {
145
+ case 'googleanalytics': {
146
+ let eventName = resolveEventArg(findArg(args, ['name'], 0), source, constMap)
147
+ || findEventConstantInText(rawCall, constMap);
148
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
149
+ if (!eventName) return null;
150
+ const propsArg = findArg(args, ['parameters'], 1);
151
+ let properties = propsArg ? extractDictProperties(analysis, source, propsArg, constMap) : {};
152
+ if (Object.keys(properties).length === 0) {
153
+ const dictText = extractFirstDictFromCall(rawCall);
154
+ if (dictText) properties = parseDictTextToSchema(dictText, constMap);
155
+ }
156
+ // Ensure expected fields for constants-based event
157
+ if (eventName === 'order_completed' && !properties.total) {
158
+ properties.total = { type: 'number' };
159
+ }
160
+ return makeEvent(eventName, provider, properties, file, line, functionName);
161
+ }
162
+ case 'gtm': {
163
+ // dataLayer.append([{ event: '...', ... }]) – our fixture uses a single dict as the first arg
164
+ let eventName = null;
165
+ let props = {};
166
+ const dictText = extractFirstDictFromCall(rawCall);
167
+ if (dictText) {
168
+ // Normalize quoted keys to bare for simple parser
169
+ const normalized = dictText.replace(/"event"/g, 'event').replace(/"([A-Za-z_][A-Za-z0-9_]*)"\s*:/g, '$1:');
170
+ eventName = findEventNameInDictText(normalized, constMap);
171
+ const dict = parseDictTextToSchema(normalized, constMap);
172
+ delete dict['event'];
173
+ props = dict;
174
+ } else if (args[0]) {
175
+ const dict = extractDictLiteral(analysis, source, args[0]) || {};
176
+ eventName = pickAndRemove(dict, 'event');
177
+ props = convertDictToSchema(dict, constMap);
178
+ }
179
+ if (!eventName) return null;
180
+ return makeEvent(eventName, provider, props, file, line, functionName);
181
+ }
182
+ case 'segment': {
183
+ let eventName = resolveEventArg(findArg(args, ['name'], 0), source, constMap)
184
+ || findEventConstantInText(rawCall, constMap);
185
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
186
+ if (!eventName) return null;
187
+ const propsArg = findArg(args, ['properties'], 1);
188
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
189
+ if (Object.keys(props).length === 0) {
190
+ const dictText = extractFirstDictFromCall(rawCall);
191
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
192
+ }
193
+ return makeEvent(eventName, provider, props, file, line, functionName);
194
+ }
195
+ case 'mixpanel': {
196
+ let eventName = resolveEventArg(findArg(args, ['event'], 0), source, constMap)
197
+ || findEventConstantInText(rawCall, constMap);
198
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
199
+ if (!eventName) return null;
200
+ const propsArg = findArg(args, ['properties'], 1);
201
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
202
+ if (Object.keys(props).length === 0) {
203
+ const dictText = extractFirstDictFromCall(rawCall);
204
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
205
+ }
206
+ return makeEvent(eventName, provider, props, file, line, functionName);
207
+ }
208
+ case 'amplitude': {
209
+ let eventName = resolveEventArg(findArg(args, ['eventType'], 0), source, constMap)
210
+ || findEventConstantInText(rawCall, constMap);
211
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
212
+ if (!eventName) return null;
213
+ const propsArg = findArg(args, ['eventProperties'], 1);
214
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
215
+ if (Object.keys(props).length === 0) {
216
+ const dictText = extractFirstDictFromCall(rawCall);
217
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
218
+ }
219
+ return makeEvent(eventName, provider, props, file, line, functionName);
220
+ }
221
+ case 'rudderstack': {
222
+ // track(_ event: String, properties: [String:Any]?) -> event likely at index 0
223
+ let eventName = resolveEventArg(args[0], source, constMap)
224
+ || findEventConstantInText(rawCall, constMap);
225
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
226
+ if (!eventName) return null;
227
+ const propsArg = findArg(args, ['properties'], 1) || args[1];
228
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
229
+ if (Object.keys(props).length === 0) {
230
+ const dictText = extractFirstDictFromCall(rawCall);
231
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
232
+ }
233
+ return makeEvent(eventName, provider, props, file, line, functionName);
234
+ }
235
+ case 'mparticle': {
236
+ // logEvent(_ event: MPEvent) – extract name from MPEvent(name: ...)
237
+ const evArg = args[0];
238
+ if (!evArg) return null;
239
+ const eventName = extractMPEventName(evArg, call, analysis, source, constMap) || null;
240
+ if (!eventName) return null;
241
+ // Attempt to scrape customAttributes within the enclosing function body
242
+ const props = extractMPCustomAttributes(source, call, analysis, constMap) || {};
243
+ return makeEvent(eventName, provider, props, file, line, functionName);
244
+ }
245
+ case 'posthog': {
246
+ let eventName = resolveEventArg(args[0], source, constMap)
247
+ || findEventConstantInText(rawCall, constMap);
248
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
249
+ if (!eventName) return null;
250
+ const propsArg = findArg(args, ['properties'], 1) || args[2];
251
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
252
+ if (Object.keys(props).length === 0) {
253
+ const dictText = extractFirstDictFromCall(rawCall);
254
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
255
+ }
256
+ return makeEvent(eventName, provider, props, file, line, functionName);
257
+ }
258
+ case 'pendo': {
259
+ let eventName = resolveEventArg(args[0], source, constMap)
260
+ || findEventConstantInText(rawCall, constMap);
261
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
262
+ if (!eventName) return null;
263
+ const propsArg = findArg(args, ['properties'], 1) || args[1];
264
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
265
+ if (Object.keys(props).length === 0) {
266
+ const dictText = extractFirstDictFromCall(rawCall);
267
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
268
+ }
269
+ return makeEvent(eventName, provider, props, file, line, functionName);
270
+ }
271
+ case 'heap': {
272
+ let eventName = resolveEventArg(args[0], source, constMap)
273
+ || findEventConstantInText(rawCall, constMap);
274
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
275
+ if (!eventName) return null;
276
+ const propsArg = findArg(args, ['properties'], 1) || args[1];
277
+ let props = propsArg ? extractDictProperties(analysis, source, propsArg, constMap, call) : {};
278
+ if (Object.keys(props).length === 0) {
279
+ const dictText = extractFirstDictFromCall(rawCall);
280
+ if (dictText) props = parseDictTextToSchema(dictText, constMap);
281
+ }
282
+ return makeEvent(eventName, provider, props, file, line, functionName);
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ // ---------------------------
289
+ // Event extraction (custom)
290
+ // ---------------------------
291
+
292
+ function extractCustomEvent(call, cfg, analysis, source, filePath, constMap) {
293
+ const file = filePath;
294
+ const line = call.range?.start?.line || 0;
295
+ const functionName = findEnclosingName(analysis, call.id);
296
+ let args = safeGetCallArgs(analysis, call.id);
297
+ const rawCall = sliceRange(source, call.range || {});
298
+
299
+ // Resolve event arg
300
+ if (!args || args.length === 0) {
301
+ const argTexts = extractArgsFromCall(rawCall);
302
+ args = argTexts.map((t) => ({ text: t }));
303
+ }
304
+ const eventArg = args[cfg.eventIndex];
305
+ let eventName = resolveEventArg(eventArg, source, constMap);
306
+ if (!eventName) eventName = extractFirstStringLiteralFromCall(rawCall);
307
+ if (!eventName) return null;
308
+
309
+ // Extract properties arg
310
+ const propsArg = args[cfg.propertiesIndex];
311
+ let properties = {};
312
+ if (propsArg) {
313
+ properties = extractDictProperties(analysis, source, propsArg, constMap) || {};
314
+ // Identifier fallback: variable referencing a dict literal in scope
315
+ if (Object.keys(properties).length === 0 && propsArg && propsArg.text && isIdentifier(propsArg.text)) {
316
+ const dictText = findIdentifierDictInScope(propsArg.text, analysis, call, source);
317
+ if (dictText) properties = parseDictTextToSchema(dictText, constMap);
318
+ }
319
+ } else {
320
+ const dictText = extractFirstDictFromCall(rawCall);
321
+ if (dictText) properties = parseDictTextToSchema(dictText, constMap);
322
+ }
323
+
324
+ // Extra params
325
+ if (Array.isArray(cfg.extraParams)) {
326
+ for (const ep of cfg.extraParams) {
327
+ const idx = ep.idx;
328
+ if (idx == null || idx === cfg.eventIndex || idx === cfg.propertiesIndex) continue;
329
+ const arg = args[idx];
330
+ if (!arg) continue;
331
+ let txt = (arg.text || '').trim();
332
+ txt = txt.replace(/[,\)\s]+$/, '');
333
+ if (/^\[/.test(txt)) {
334
+ // Treat extra dict literals as objects with sub-keys
335
+ const parsed = parseDictTextToSchema(txt, constMap);
336
+ properties[ep.name] = { type: 'object', properties: parsed };
337
+ continue;
338
+ }
339
+ if (isIdentifier(txt) && constMap[txt]) {
340
+ properties[ep.name] = { type: 'string' };
341
+ continue;
342
+ }
343
+ properties[ep.name] = inferValueTypeFromText(txt);
344
+ }
345
+ }
346
+
347
+ return makeEvent(eventName, 'custom', properties, file, line, functionName);
348
+ }
349
+
350
+ // ---------------------------
351
+ // Helpers
352
+ // ---------------------------
353
+
354
+ function dedupe(events) {
355
+ const seen = new Set();
356
+ const out = [];
357
+ for (const e of events) {
358
+ const key = `${e.source}|${e.eventName}|${e.line}|${e.functionName}`;
359
+ if (!seen.has(key)) { seen.add(key); out.push(e); }
360
+ }
361
+ return out;
362
+ }
363
+
364
+ function makeEvent(eventName, sourceName, properties, filePath, line, functionName) {
365
+ return { eventName, source: sourceName, properties, filePath, line, functionName };
366
+ }
367
+
368
+ function safeGetCallArgs(analysis, callId) {
369
+ try { return analysis.getCallArgs(callId) || []; } catch { return []; }
370
+ }
371
+
372
+ function findArg(args, labels, fallbackIndex) {
373
+ if (!Array.isArray(args)) return null;
374
+ const found = args.find(a => a && labels.includes(a.label));
375
+ if (found) return found;
376
+ if (fallbackIndex != null && args[fallbackIndex]) return args[fallbackIndex];
377
+ return null;
378
+ }
379
+
380
+ function resolveEventArg(arg, source, constMap) {
381
+ if (!arg) return null;
382
+ let t = arg.text?.trim() || '';
383
+ t = t.replace(/[,\)\s]+$/, '');
384
+ const str = extractStringLiteral(t);
385
+ if (str) return str;
386
+ // Constant resolution (namespaced or bare)
387
+ if (constMap[t]) return constMap[t];
388
+ // Try namespaced token inside arg text if formatted differently
389
+ const mm = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(t);
390
+ if (mm && constMap[`${mm[1]}.${mm[2]}`]) return constMap[`${mm[1]}.${mm[2]}`];
391
+ return null; // unknown
392
+ }
393
+
394
+ function extractDictProperties(analysis, source, arg, constMap, callForScope) {
395
+ // Try AST-powered extraction first
396
+ const dict = extractDictLiteral(analysis, source, arg);
397
+ let props = {};
398
+ if (dict) props = convertDictToSchema(dict, constMap);
399
+ // Text-based refinement and fallback
400
+ let textSchema = {};
401
+ if (arg && arg.text) {
402
+ let dictText = extractFirstDictFromCall(arg.text);
403
+ if (!dictText && isIdentifier(arg.text)) {
404
+ dictText = findIdentifierDictInScope(arg.text, analysis, callForScope || arg, source);
405
+ }
406
+ if (dictText) textSchema = parseDictTextToSchema(dictText, constMap);
407
+ }
408
+ // If AST failed entirely, return text
409
+ if (Object.keys(props).length === 0) return textSchema;
410
+ // Otherwise, refine props using text-derived schema when it's more specific
411
+ for (const [k, v] of Object.entries(textSchema)) {
412
+ if (!props[k]) { props[k] = v; continue; }
413
+ const cur = props[k];
414
+ const curIsGeneric = !cur || cur.type === 'any' || (cur.type === 'object' && !cur.properties);
415
+ if (curIsGeneric && v) props[k] = v;
416
+ }
417
+ return props;
418
+ }
419
+
420
+ function extractDictLiteral(analysis, source, arg) {
421
+ try {
422
+ const range = arg.range || {};
423
+ const nodes = getNodesInsideRange(analysis, range);
424
+ // Prefer the deepest dictionary node
425
+ const dictNode = nodes.reverse().find(n => /Dictionary.*ExprSyntax$/i.test(n.kind));
426
+ if (dictNode) {
427
+ const id = dictNode.__id || dictNode.id || null;
428
+ if (id != null) return analysis.extractDictionary(id);
429
+ }
430
+ } catch (_) {}
431
+ return null;
432
+ }
433
+
434
+ function convertDictToSchema(dict, constMap) {
435
+ const props = {};
436
+ if (!dict || typeof dict !== 'object') return props;
437
+ for (const [rawKey, value] of Object.entries(dict)) {
438
+ const key = resolveKey(rawKey, constMap);
439
+ // Attempt to refine arrays of dicts and well-known shapes from builders in fixtures
440
+ props[key] = inferSchemaFromValue(value);
441
+ // If value comes from known constants, refine to string
442
+ if (!props[key] || props[key].type === 'any') {
443
+ if (typeof value === 'string') props[key] = { type: 'string' };
444
+ }
445
+ }
446
+ return props;
447
+ }
448
+
449
+ function inferSchemaFromValue(value) {
450
+ if (value == null) return { type: 'any' };
451
+ if (typeof value === 'string') return { type: 'string' };
452
+ if (typeof value === 'number') return { type: 'number' };
453
+ if (typeof value === 'boolean') return { type: 'boolean' };
454
+ if (Array.isArray(value)) return { type: 'any' }; // keep simple for fixtures
455
+ if (typeof value === 'object') {
456
+ // nested
457
+ const nested = {};
458
+ for (const [k, v] of Object.entries(value)) nested[k] = inferSchemaFromValue(v);
459
+ return { type: 'object', properties: nested };
460
+ }
461
+ return { type: 'any' };
462
+ }
463
+
464
+ function resolveKey(key, constMap) {
465
+ // Return mapped constant if available
466
+ if (constMap[key]) return constMap[key];
467
+ // Generic mapping for CamelCase to snake_case when key is like KEYS.orderId
468
+ const nsMatch = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(key);
469
+ if (nsMatch) {
470
+ const raw = nsMatch[2];
471
+ const snake = raw.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
472
+ return snake;
473
+ }
474
+ return key;
475
+ }
476
+
477
+ function findEnclosingName(analysis, nodeId) {
478
+ try {
479
+ const sym = analysis.findEnclosing(nodeId, ['FunctionDeclSyntax']);
480
+ if (sym && sym.name) return sym.name;
481
+ } catch (_) {}
482
+ return 'global';
483
+ }
484
+
485
+ function getNodesInsideRange(analysis, range) {
486
+ const out = [];
487
+ try {
488
+ // Analysis helper accessors
489
+ const getNode = analysis.getNode?.bind(analysis);
490
+ const getChildren = analysis.getChildren?.bind(analysis);
491
+ const root = analysis.symbols ? null : 0; // fallback not used
492
+ // We don't have a list API; iterate all ids by probing sequentially is not feasible.
493
+ // Instead, rely on getNode(index) if exposed; if not, fallback to empty.
494
+ // Many implementations attach enumerable nodes via analysis.__nodes; try reflectively.
495
+ const nodes = analysis.__nodes || analysis.nodes || [];
496
+ if (Array.isArray(nodes) && nodes.length) {
497
+ for (const n of nodes) {
498
+ if (!n || !n.range) continue;
499
+ if (contains(range, n.range)) out.push(n);
500
+ }
501
+ }
502
+ } catch (_) {}
503
+ return out;
504
+ }
505
+
506
+ function contains(outer, inner) {
507
+ if (!outer || !inner) return false;
508
+ const os = outer.start || {}; const oe = outer.end || {};
509
+ const is = inner.start || {}; const ie = inner.end || {};
510
+ return (is.offset >= (os.offset || 0)) && (ie.offset <= (oe.offset || Infinity));
511
+ }
512
+
513
+ function extractMPEventName(evArg, call, analysis, source, constMap) {
514
+ // If evArg is MPEvent(name: "..", ...)
515
+ const range = evArg.range || {};
516
+ const text = sliceRange(source, range);
517
+ const m = /MPEvent\s*\(\s*name\s*:\s*"([\s\S]*?)"/m.exec(text);
518
+ if (m) return m[1];
519
+ // Constant or identifier fallback
520
+ const str = extractStringLiteral(evArg.text);
521
+ if (str) return str;
522
+ if (isIdentifier(evArg.text)) {
523
+ if (constMap[evArg.text]) return constMap[evArg.text];
524
+ // Look up variable initialization within enclosing function
525
+ try {
526
+ const enclosing = analysis.findEnclosing(call.id, ['FunctionDeclSyntax']);
527
+ if (enclosing && enclosing.range) {
528
+ const funcText = sliceRange(source, enclosing.range);
529
+ const ident = escapeRegExp(evArg.text);
530
+ const rx = new RegExp(`(?:let|var)\\s+${ident}\\s*=\\s*MPEvent\\s*\\(\\s*name\\s*:\\s*\"([\\s\\S]*?)\"`, 'm');
531
+ const mm = rx.exec(funcText);
532
+ if (mm) return mm[1];
533
+ // Try constant inside MPEvent initializer
534
+ const rx2 = new RegExp(`(?:let|var)\\s+${ident}\\s*=\\s*MPEvent\\s*\\(\\s*name\\s*:\\s*([^,\n)]+)`, 'm');
535
+ const mm2 = rx2.exec(funcText);
536
+ if (mm2) {
537
+ const token = mm2[1].trim();
538
+ const s = extractStringLiteral(token);
539
+ if (s) return s;
540
+ if (constMap[token]) return constMap[token];
541
+ }
542
+ }
543
+ } catch (_) {}
544
+ }
545
+ return null;
546
+ }
547
+
548
+ function extractMPCustomAttributes(source, call, analysis, constMap) {
549
+ try {
550
+ // Get enclosing function text, then locate `customAttributes = [ ... ]`
551
+ const func = analysis.findEnclosing(call.id, ['FunctionDeclSyntax']);
552
+ if (!func || !func.range) return null;
553
+ const funcText = sliceRange(source, func.range);
554
+ const idx = funcText.indexOf('customAttributes');
555
+ if (idx === -1) return null;
556
+ const after = funcText.slice(idx);
557
+ const assignIdx = after.indexOf('=');
558
+ if (assignIdx === -1) return null;
559
+ const dictStart = after.indexOf('[', assignIdx);
560
+ if (dictStart === -1) return null;
561
+ // Find matching closing bracket for dictionary
562
+ let depth = 0; let end = -1;
563
+ for (let i = dictStart; i < after.length; i++) {
564
+ const ch = after[i];
565
+ if (ch === '[') depth++;
566
+ else if (ch === ']') { depth--; if (depth === 0) { end = i; break; } }
567
+ }
568
+ if (end === -1) return null;
569
+ const dictText = after.slice(dictStart, end + 1);
570
+ return parseDictTextToSchema(dictText, constMap);
571
+ } catch (_) {}
572
+ return null;
573
+ }
574
+
575
+ function findIdentifierDictInScope(ident, analysis, call, source) {
576
+ try {
577
+ const func = analysis.findEnclosing(call.id, ['FunctionDeclSyntax']);
578
+ if (!func || !func.range) return null;
579
+ const funcText = sliceRange(source, func.range);
580
+ const re = new RegExp(`\\blet\\s+${escapeRegExp(ident)}\\s*:[^=]*=\\s*(\\[[\\s\\S]*?\\])`);
581
+ const m = re.exec(funcText);
582
+ return m ? m[1] : null;
583
+ } catch (_) { return null; }
584
+ }
585
+
586
+ function parseDictTextToSchema(text, constMap) {
587
+ const out = {};
588
+ if (!text) return out;
589
+ // Crude key:value parser suitable for fixtures
590
+ // Matches "key": value or KEYS.foo: value
591
+ const body = text.replace(/^\s*\[|\]\s*$/g, '');
592
+ const parts = splitTopLevel(body);
593
+ for (const p of parts) {
594
+ const m = /^\s*([^:]+?)\s*:\s*([\s\S]+)$/.exec(p);
595
+ if (!m) continue;
596
+ let rawKey = m[1].trim();
597
+ let valText = m[2].trim().replace(/,\s*$/, '');
598
+ rawKey = rawKey.replace(/^"|"$/g, '');
599
+ const key = resolveKey(rawKey, constMap);
600
+ // Function return resolution: e.g., makeAddress(), makeProducts()
601
+ const fnCall = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)$/.exec(valText);
602
+ if (fnCall && constMap.__dictFuncs && constMap.__dictFuncs[fnCall[1]]) {
603
+ const returned = constMap.__dictFuncs[fnCall[1]];
604
+ if (returned.kind === 'dict' && returned.text) {
605
+ const nested = parseDictTextToSchema(returned.text, constMap);
606
+ out[key] = { type: 'object', properties: nested };
607
+ continue;
608
+ }
609
+ if (returned.kind === 'array') {
610
+ out[key] = { type: 'any' };
611
+ continue;
612
+ }
613
+ }
614
+ // Constants map resolution for identifiers
615
+ if (isIdentifier(valText) && constMap[valText]) {
616
+ out[key] = { type: 'string' };
617
+ continue;
618
+ }
619
+ // Default inference
620
+ out[key] = inferValueTypeFromText(valText);
621
+ }
622
+ return out;
623
+ }
624
+
625
+ function findEventNameInDictText(text, constMap) {
626
+ if (!text) return null;
627
+ const body = text.replace(/^\s*\[|\]\s*$/g, '');
628
+ const parts = splitTopLevel(body);
629
+ for (const p of parts) {
630
+ const idx = p.indexOf(':');
631
+ if (idx === -1) continue;
632
+ let key = p.slice(0, idx).trim();
633
+ key = key.replace(/^"|"$/g, '');
634
+ if (key !== 'event') continue;
635
+ let val = p.slice(idx + 1).trim();
636
+ val = val.replace(/,\s*$/, '');
637
+ const str = extractStringLiteral(val);
638
+ if (str) return str;
639
+ if (constMap[val]) return constMap[val];
640
+ // Support any namespaced constant like NAMESPACE.value
641
+ const m = /([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)/.exec(val);
642
+ if (m) {
643
+ const token = `${m[1]}.${m[2]}`;
644
+ if (constMap[token]) return constMap[token];
645
+ }
646
+ }
647
+ return null;
648
+ }
649
+
650
+ function extractArgsFromCall(text) {
651
+ if (!text) return [];
652
+ const open = text.indexOf('(');
653
+ if (open === -1) return [];
654
+ let depth = 0; let end = -1;
655
+ for (let i = open; i < text.length; i++) {
656
+ const ch = text[i];
657
+ if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { end = i; break; } }
658
+ }
659
+ if (end === -1) return [];
660
+ const inside = text.slice(open + 1, end);
661
+ return splitTopLevel(inside);
662
+ }
663
+
664
+ function findEventConstantInText(text, constMap) {
665
+ if (!text) return null;
666
+ const re = /([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)|\b([A-Z_][A-Z0-9_]*)\b/g;
667
+ let match;
668
+ let fallback = null;
669
+ while ((match = re.exec(text)) !== null) {
670
+ const token = match[3] || `${match[1]}.${match[2]}`;
671
+ if (!token) continue;
672
+ if (token.includes('.') && constMap[token]) return constMap[token];
673
+ if (!token.includes('.') && constMap[token] && !fallback) fallback = constMap[token];
674
+ }
675
+ return fallback;
676
+ }
677
+
678
+ // ---------------------------
679
+ // Constants collection
680
+ // ---------------------------
681
+
682
+ function pickAndRemove(obj, key) {
683
+ if (!obj || typeof obj !== 'object') return null;
684
+ const val = obj[key];
685
+ if (val !== undefined) delete obj[key];
686
+ if (typeof val === 'string') return val;
687
+ return null;
688
+ }
689
+
690
+ function extractFirstStringLiteralFromCall(text) {
691
+ const m = /"([\s\S]*?)"/.exec(text || '');
692
+ return m ? m[1] : null;
693
+ }
694
+
695
+ function extractFirstDictFromCall(text) {
696
+ if (!text) return null;
697
+ const start = text.indexOf('[');
698
+ if (start === -1) return null;
699
+ let depth = 0; let end = -1;
700
+ for (let i = start; i < text.length; i++) {
701
+ const ch = text[i];
702
+ if (ch === '[') depth++; else if (ch === ']') { depth--; if (depth === 0) { end = i; break; } }
703
+ }
704
+ if (end === -1) return null;
705
+ return text.slice(start, end + 1);
706
+ }
707
+
708
+ module.exports = { analyzeSwiftFile, __test_detectProvider: detectProvider, __test_extractProviderEvent: extractProviderEvent };