@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.
- package/README.md +120 -19
- package/package.json +5 -3
- package/schema.json +10 -0
- package/src/analyze/index.js +20 -13
- package/src/analyze/javascript/detectors/analytics-source.js +38 -4
- package/src/analyze/javascript/extractors/event-extractor.js +29 -12
- package/src/analyze/javascript/parser.js +26 -8
- package/src/analyze/swift/constants.js +61 -0
- package/src/analyze/swift/custom.js +36 -0
- package/src/analyze/swift/index.js +708 -0
- package/src/analyze/swift/providers.js +51 -0
- package/src/analyze/swift/runtime.js +46 -0
- package/src/analyze/swift/utils.js +75 -0
- package/src/analyze/typescript/detectors/analytics-source.js +37 -3
- package/src/analyze/typescript/extractors/event-extractor.js +59 -31
- package/src/analyze/typescript/extractors/property-extractor.js +129 -91
- package/src/analyze/typescript/parser.js +27 -8
- package/src/analyze/typescript/utils/type-resolver.js +600 -21
- package/src/analyze/utils/customFunctionParser.js +29 -0
|
@@ -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 };
|