@flisk/analyze-tracking 0.8.8 → 0.9.0

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 CHANGED
@@ -95,25 +95,25 @@ Use this to understand where your events live in the code and how they're being
95
95
  Your LLM of choice is used for generating descriptions of events, properties, and implementations.
96
96
 
97
97
  See [schema.json](schema.json) for a JSON Schema of the output.
98
-
98
+
99
99
 
100
100
  ## Supported tracking libraries & languages
101
101
 
102
- | Library | JavaScript/TypeScript | Python | Ruby | Go |
103
- |---------|:---------------------:|:------:|:----:|:--:|
104
- | Google Analytics | ✅ | ❌ | ❌ | ❌ |
105
- | Google Tag Manager | ✅ | ❌ | ❌ | ❌ |
106
- | Segment | ✅ | ✅ | ✅ | ✅ |
107
- | Mixpanel | ✅ | ✅ | ✅ | ✅ |
108
- | Amplitude | ✅ | ✅ | ❌ | ✅ |
109
- | Rudderstack | ✅ | ✅ | ✳️ | ✳️ |
110
- | mParticle | ✅ | ❌ | ❌ | ❌ |
111
- | PostHog | ✅ | ✅ | ✅ | ✅ |
112
- | Pendo | ✅ | ❌ | ❌ | ❌ |
113
- | Heap | ✅ | ❌ | ❌ | ❌ |
114
- | Snowplow | ✅ | ✅ | ✅ | ✅ |
115
- | Datadog RUM | ✅ | ❌ | ❌ | ❌ |
116
- | Custom Function | ✅ | ✅ | ✅ | ✅ |
102
+ | Library | JavaScript/TypeScript | Python | Ruby | Go | Swift |
103
+ |---------|:---------------------:|:------:|:----:|:--:|:--:|
104
+ | Google Analytics | ✅ | ❌ | ❌ | ❌ | ✅ |
105
+ | Google Tag Manager | ✅ | ❌ | ❌ | ❌ | ✅ |
106
+ | Segment | ✅ | ✅ | ✅ | ✅ | ✅ |
107
+ | Mixpanel | ✅ | ✅ | ✅ | ✅ | ✅ |
108
+ | Amplitude | ✅ | ✅ | ❌ | ✅ | ✅ |
109
+ | Rudderstack | ✅ | ✅ | ✳️ | ✳️ | ✅ |
110
+ | mParticle | ✅ | ❌ | ❌ | ❌ | ✅ |
111
+ | PostHog | ✅ | ✅ | ✅ | ✅ | ✅ |
112
+ | Pendo | ✅ | ❌ | ❌ | ❌ | ✅ |
113
+ | Heap | ✅ | ❌ | ❌ | ❌ | ✅ |
114
+ | Snowplow | ✅ | ✅ | ✅ | ✅ | ❌ |
115
+ | Datadog RUM | ✅ | ❌ | ❌ | ❌ | ❌ |
116
+ | Custom Function | ✅ | ✅ | ✅ | ✅ | ✅ |
117
117
 
118
118
  ✳️ Rudderstack's SDKs often use the same format as Segment, so Rudderstack events may be detected as Segment events.
119
119
 
@@ -129,6 +129,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
129
129
  '<property_name>': '<property_value>'
130
130
  });
131
131
  ```
132
+
133
+ **Swift**
134
+ ```swift
135
+ Analytics.logEvent("<event_name>", parameters: [
136
+ "<property_name>": "<property_value>"
137
+ ])
138
+ ```
132
139
  </details>
133
140
 
134
141
  <details>
@@ -147,6 +154,11 @@ See [schema.json](schema.json) for a JSON Schema of the output.
147
154
  '<property_name>': '<property_value>'
148
155
  });
149
156
  ```
157
+
158
+ **Swift**
159
+ ```swift
160
+ dataLayer.push(["event": "<event_name>", "<property_name>": "<property_value>"])
161
+ ```
150
162
  </details>
151
163
 
152
164
  <details>
@@ -185,6 +197,11 @@ See [schema.json](schema.json) for a JSON Schema of the output.
185
197
  Set("<property_name>", "<property_value>"),
186
198
  })
187
199
  ```
200
+
201
+ **Swift**
202
+ ```swift
203
+ analytics.track(name: "<event_name>", properties: TrackProperties("<property_name>": "<property_value>"))
204
+ ```
188
205
  </details>
189
206
 
190
207
  <details>
@@ -221,6 +238,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
221
238
  }),
222
239
  })
223
240
  ```
241
+
242
+ **Swift**
243
+ ```swift
244
+ Mixpanel.mainInstance().track(event: "<event_name>", properties: [
245
+ "<property_name>": "<property_value>"
246
+ ])
247
+ ```
224
248
  </details>
225
249
 
226
250
  <details>
@@ -256,6 +280,14 @@ See [schema.json](schema.json) for a JSON Schema of the output.
256
280
  },
257
281
  })
258
282
  ```
283
+
284
+ **Swift**
285
+ ```swift
286
+ amplitude.track(
287
+ eventType: "<event_name>",
288
+ eventProperties: ["<property_name>": "<property_value>"]
289
+ )
290
+ ```
259
291
  </details>
260
292
 
261
293
  <details>
@@ -295,6 +327,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
295
327
  Set("<property_name>", "<property_value>"),
296
328
  })
297
329
  ```
330
+
331
+ **Swift**
332
+ ```swift
333
+ RSClient.sharedInstance()?.track("<event_name>", properties: [
334
+ "<property_name>": "<property_value>"
335
+ ])
336
+ ```
298
337
  </details>
299
338
 
300
339
  <details>
@@ -306,6 +345,15 @@ See [schema.json](schema.json) for a JSON Schema of the output.
306
345
  '<property_name>': '<property_value>'
307
346
  });
308
347
  ```
348
+
349
+ **Swift**
350
+ ```swift
351
+ let event = MPEvent(name: "<event_name>", type: .other)
352
+ event.customAttributes = [
353
+ "<property_name>": "<property_value>"
354
+ ]
355
+ MParticle.sharedInstance().logEvent(event)
356
+ ```
309
357
  </details>
310
358
 
311
359
  <details>
@@ -353,6 +401,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
353
401
  Set("<property_name>", "<property_value>"),
354
402
  })
355
403
  ```
404
+
405
+ **Swift**
406
+ ```swift
407
+ PostHogSDK.shared.capture("<event_name>", properties: [
408
+ "<property_name>": "<property_value>"
409
+ ])
410
+ ```
356
411
  </details>
357
412
 
358
413
  <details>
@@ -372,7 +427,12 @@ See [schema.json](schema.json) for a JSON Schema of the output.
372
427
  })
373
428
  ```
374
429
 
375
-
430
+ **Swift**
431
+ ```swift
432
+ PendoManager.shared().track("<event_name>", properties: [
433
+ "<property_name>": "<property_value>"
434
+ ])
435
+ ```
376
436
  </details>
377
437
 
378
438
  <details>
@@ -392,7 +452,12 @@ See [schema.json](schema.json) for a JSON Schema of the output.
392
452
  })
393
453
  ```
394
454
 
395
-
455
+ **Swift**
456
+ ```swift
457
+ Heap.shared.track("<event_name>", properties: [
458
+ "<property_name>": "<property_value>"
459
+ ])
460
+ ```
396
461
  </details>
397
462
 
398
463
  <details>
@@ -403,7 +468,7 @@ See [schema.json](schema.json) for a JSON Schema of the output.
403
468
  datadogRum.addAction('<event_name>', {
404
469
  '<property_name>': '<property_value>'
405
470
  });
406
-
471
+
407
472
  // Or via window
408
473
  window.DD_RUM.addAction('<event_name>', {
409
474
  '<property_name>': '<property_value>'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "test:python": "node --experimental-vm-modules --test tests/analyzePython.test.js",
14
14
  "test:ruby": "node --experimental-vm-modules --test tests/analyzeRuby.test.js",
15
15
  "test:go": "node --test tests/analyzeGo.test.js",
16
+ "test:swift": "node --test tests/analyzeSwift.test.js",
16
17
  "test:cli": "node --test tests/cli.test.js",
17
18
  "test:schema": "node --test tests/schema.test.js",
18
19
  "test:generateDescriptions": "node --test tests/generateDescriptions.test.js",
@@ -34,6 +35,7 @@
34
35
  },
35
36
  "homepage": "https://github.com/fliskdata/analyze-tracking#readme",
36
37
  "dependencies": {
38
+ "@flisk/swift-ast": "^0.1.3",
37
39
  "@langchain/core": "^0.3.56",
38
40
  "@langchain/google-vertexai": "^0.2.9",
39
41
  "@langchain/openai": "^0.5.10",
@@ -52,9 +54,9 @@
52
54
  "zod": "^3.24.4"
53
55
  },
54
56
  "devDependencies": {
57
+ "@types/react": "^19.1.6",
55
58
  "ajv": "^8.17.1",
56
59
  "lodash": "^4.17.21",
57
- "react": "^19.1.0",
58
- "@types/react": "^19.1.6"
60
+ "react": "^19.1.0"
59
61
  }
60
62
  }
@@ -13,12 +13,13 @@ const { analyzeTsFiles } = require('./typescript');
13
13
  const { analyzePythonFile } = require('./python');
14
14
  const { analyzeRubyFile, prebuildConstantMaps } = require('./ruby');
15
15
  const { analyzeGoFile } = require('./go');
16
+ const { analyzeSwiftFile } = require('./swift');
16
17
 
17
18
  /**
18
19
  * Analyzes a single file for analytics tracking calls
19
- *
20
+ *
20
21
  * Note: typescript files are handled separately by analyzeTsFiles, which is a batch processor
21
- *
22
+ *
22
23
  * @param {string} file - Path to the file to analyze
23
24
  * @param {Array<string>} customFunctionSignatures - Custom function signatures to detect
24
25
  * @returns {Promise<Array<Object>>} Array of events found in the file
@@ -28,19 +29,20 @@ async function analyzeFile(file, customFunctionSignatures) {
28
29
  if (/\.py$/.test(file)) return analyzePythonFile(file, customFunctionSignatures)
29
30
  if (/\.rb$/.test(file)) return analyzeRubyFile(file, customFunctionSignatures)
30
31
  if (/\.go$/.test(file)) return analyzeGoFile(file, customFunctionSignatures)
32
+ if (/\.swift$/.test(file)) return analyzeSwiftFile(file, customFunctionSignatures)
31
33
  return []
32
34
  }
33
35
 
34
36
  /**
35
37
  * Adds an event to the events collection, merging properties if event already exists
36
- *
38
+ *
37
39
  * @param {Object} allEvents - Collection of all events
38
40
  * @param {Object} event - Event to add
39
41
  * @param {string} baseDir - Base directory for relative path calculation
40
42
  */
41
43
  function addEventToCollection(allEvents, event, baseDir) {
42
44
  const relativeFilePath = path.relative(baseDir, event.filePath);
43
-
45
+
44
46
  const implementation = {
45
47
  path: relativeFilePath,
46
48
  line: event.line,
@@ -64,12 +66,12 @@ function addEventToCollection(allEvents, event, baseDir) {
64
66
 
65
67
  /**
66
68
  * Processes all files that are not TypeScript files in parallel
67
- *
69
+ *
68
70
  * Checks the system's file descriptor limit and uses 80% of it to avoid running out of file descriptors
69
71
  * Creates a promise pool and launches one analysis for each file in parallel
70
72
  * When a slot frees up, the next file is launched
71
73
  * Waits for the remaining work to complete
72
- *
74
+ *
73
75
  * @param {Array<string>} files - Array of file paths
74
76
  * @param {Object} allEvents - Collection to add events to
75
77
  * @param {string} baseDir - Base directory for relative paths
@@ -121,10 +123,10 @@ async function processFiles(files, allEvents, baseDir, customFunctionSignatures)
121
123
 
122
124
  /**
123
125
  * Analyze a directory recursively for analytics tracking calls
124
- *
126
+ *
125
127
  * This function scans all supported files in a directory tree and identifies analytics tracking calls,
126
128
  * handling different file types appropriately.
127
- *
129
+ *
128
130
  * @param {string} dirPath - Path to the directory to analyze
129
131
  * @param {Array<string>} [customFunctions=null] - Array of custom tracking function signatures to detect
130
132
  * @returns {Promise<Object>} Object mapping event names to their tracking implementations
@@ -132,17 +134,22 @@ async function processFiles(files, allEvents, baseDir, customFunctionSignatures)
132
134
  async function analyzeDirectory(dirPath, customFunctions) {
133
135
  const allEvents = {};
134
136
 
135
- const customFunctionSignatures = (customFunctions?.length > 0)
136
- ? customFunctions.map(parseCustomFunctionSignature)
137
- : null;
137
+ let customFunctionSignatures = null;
138
+ if (Array.isArray(customFunctions) && customFunctions.length > 0) {
139
+ customFunctionSignatures = customFunctions.map(cf => {
140
+ if (cf && typeof cf === 'object' && cf.functionName) return cf;
141
+ if (typeof cf === 'string') return parseCustomFunctionSignature(cf);
142
+ return null;
143
+ }).filter(Boolean);
144
+ }
138
145
 
139
146
  const files = getAllFiles(dirPath);
140
-
147
+
141
148
  // Separate TypeScript files from others for optimized processing
142
149
  const tsFiles = [];
143
150
  const nonTsFiles = [];
144
151
  const rubyFiles = [];
145
-
152
+
146
153
  for (const file of files) {
147
154
  const isTsFile = /\.(tsx?)$/.test(file);
148
155
  if (isTsFile) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Constants and helper-return collectors for Swift fixtures
3
+ * - Scans directory for top-level lets and enum/struct static lets
4
+ * - Extracts simple function returns for dict/array builders
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ function buildCrossFileConstMap(dir) {
11
+ const map = {};
12
+ try {
13
+ const entries = fs.readdirSync(dir).filter(f => f.endsWith('.swift'));
14
+ for (const f of entries) {
15
+ const fp = path.join(dir, f);
16
+ const content = fs.readFileSync(fp, 'utf8');
17
+ // Top-level: let NAME = "..."
18
+ for (const m of content.matchAll(/\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([\s\S]*?)"/g)) {
19
+ map[m[1]] = m[2];
20
+ }
21
+ // Enum/struct blocks: capture namespace and all static lets inside
22
+ let idx = 0;
23
+ while (idx < content.length) {
24
+ const head = content.slice(idx);
25
+ const mm = /\b(enum|struct)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/m.exec(head);
26
+ if (!mm) break;
27
+ const ns = mm[2];
28
+ const blockStart = idx + mm.index + mm[0].length - 1; // position at '{'
29
+ // Find matching closing brace
30
+ let depth = 0; let end = -1;
31
+ for (let i = blockStart; i < content.length; i++) {
32
+ const ch = content[i];
33
+ if (ch === '{') depth++;
34
+ else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
35
+ }
36
+ if (end === -1) break;
37
+ const block = content.slice(blockStart + 1, end);
38
+ for (const sm of block.matchAll(/\bstatic\s+let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([\s\S]*?)"/g)) {
39
+ const key = sm[1];
40
+ const val = sm[2];
41
+ map[`${ns}.${key}`] = val;
42
+ }
43
+ idx = end + 1;
44
+ }
45
+ // Capture very simple helper returns
46
+ // func makeAddress() -> [String: Any] { return [ ... ] }
47
+ for (const m of content.matchAll(/func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*->\s*\[[^\]]+\][^{]*\{[\s\S]*?return\s*(\[[\s\S]*?\])[\s\S]*?\}/g)) {
48
+ map.__dictFuncs = map.__dictFuncs || {};
49
+ map.__dictFuncs[m[1]] = { kind: 'dict', text: m[2] };
50
+ }
51
+ // func makeProducts() -> [[String: Any]] { return [ ... ] } (array)
52
+ for (const m of content.matchAll(/func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*->\s*\[\[[^\]]+\]\][^{]*\{[\s\S]*?return\s*(\[[\s\S]*?\])[\s\S]*?\}/g)) {
53
+ map.__dictFuncs = map.__dictFuncs || {};
54
+ map.__dictFuncs[m[1]] = { kind: 'array', text: m[2] };
55
+ }
56
+ }
57
+ } catch (_) {}
58
+ return map;
59
+ }
60
+
61
+ module.exports = { buildCrossFileConstMap };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Custom function detection for Swift
3
+ */
4
+
5
+ const { normalizeChainPart, endsWithChain, extractStringLiteral, isIdentifier } = require('./utils');
6
+
7
+ function matchCustomSignature(call, customFunctionSignatures) {
8
+ if (!Array.isArray(customFunctionSignatures) || customFunctionSignatures.length === 0) return null;
9
+ const chain = Array.isArray(call.calleeChain) ? call.calleeChain.map(normalizeChainPart) : [];
10
+
11
+ for (const cfg of customFunctionSignatures) {
12
+ if (!cfg || !cfg.functionName) continue;
13
+ const sigParts = cfg.functionName.split('.').map(normalizeChainPart).filter(Boolean);
14
+ if (sigParts.length === 0) continue;
15
+ if (endsWithChain(chain, sigParts)) return cfg;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ function matchImplicitCustom(call) {
21
+ const chain = Array.isArray(call.calleeChain) ? call.calleeChain.map(normalizeChainPart) : [];
22
+ const last = chain[chain.length - 1] || '';
23
+ if (last === 'module' || last === 'func') {
24
+ return { functionName: chain.join('.'), eventIndex: 0, propertiesIndex: 1, extraParams: [] };
25
+ }
26
+ const name = call.name || '';
27
+ if (/^customTrackFunction\d*$/.test(name)) {
28
+ return { functionName: name, eventIndex: 0, propertiesIndex: 1, extraParams: [] };
29
+ }
30
+ if (name === 'customTrackNoProps') {
31
+ return { functionName: name, eventIndex: 0, propertiesIndex: 9999, extraParams: [] };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ module.exports = { matchCustomSignature, matchImplicitCustom };
@@ -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 };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Provider detection for Swift analytics SDKs
3
+ */
4
+
5
+ const { normalizeChainPart, sliceRange } = require('./utils');
6
+
7
+ function detectProvider(call, source) {
8
+ const method = call.name;
9
+ const chain = Array.isArray(call.calleeChain) ? call.calleeChain : [];
10
+ const recvText = call.receiver || null;
11
+ const recvBase = recvText ? recvText.split('.')[0] : null;
12
+ const base = call.baseIdentifier || recvBase || (chain.length ? normalizeChainPart(chain[0]).split('.')[0] : null);
13
+ const methodCand = method || (chain.length ? normalizeChainPart(chain[chain.length - 1]) : null);
14
+
15
+ if (base === 'dataLayer' && (methodCand === 'append' || methodCand === 'push')) return 'gtm';
16
+ if (base === 'Analytics' && methodCand === 'logEvent') return 'googleanalytics';
17
+ if (base === 'analytics' && methodCand === 'track') return 'segment';
18
+ if (base === 'Mixpanel' && methodCand === 'track') return 'mixpanel';
19
+ if (base === 'amplitude' && methodCand === 'track') return 'amplitude';
20
+ if (base === 'RSClient' && methodCand === 'track') return 'rudderstack';
21
+ if (base === 'MParticle' && methodCand === 'logEvent') return 'mparticle';
22
+ if (base === 'PostHogSDK' && methodCand === 'capture') return 'posthog';
23
+ if (base === 'PendoManager' && methodCand === 'track') return 'pendo';
24
+ if (base === 'Heap' && methodCand === 'track') return 'heap';
25
+
26
+ try {
27
+ const text = sliceRange(source, call.range || {});
28
+ const t = text.replace(/\s+/g, '');
29
+ if (/\bdataLayer\.(append|push)\(/.test(t)) return 'gtm';
30
+ if (/\bAnalytics\.logEvent\(/.test(t)) return 'googleanalytics';
31
+ if (/\banalytics\.track\(/.test(t)) return 'segment';
32
+ if (/\bMixpanel\.[A-Za-z0-9_]+\(\)\.track\(/.test(t) || /\bMixpanel\.track\(/.test(t)) return 'mixpanel';
33
+ if (/\bamplitude\.track\(/.test(t)) return 'amplitude';
34
+ if (/\bRSClient\.[A-Za-z0-9_?]+\(\)?(?:\?\.|\.)track\(/.test(t) || /\bRSClient\(\)\.track\(/.test(t)) return 'rudderstack';
35
+ if (/\bMParticle\.[A-Za-z0-9_]+\(\)\.logEvent\(/.test(t)) return 'mparticle';
36
+ if (/\bPostHogSDK\.[A-Za-z0-9_]+\.capture\(/.test(t)) return 'posthog';
37
+ if (/\bPendoManager\.[A-Za-z0-9_]+\(\)\.track\(/.test(t)) return 'pendo';
38
+ if (/\bHeap\.[A-Za-z0-9_]+\.track\(/.test(t)) return 'heap';
39
+ } catch (_) {}
40
+
41
+ try {
42
+ if (methodCand === 'append') {
43
+ const text = sliceRange(source, call.range || {});
44
+ if (text.includes('event:')) return 'gtm';
45
+ }
46
+ } catch (_) {}
47
+
48
+ return null;
49
+ }
50
+
51
+ module.exports = { detectProvider };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Swift runtime bridge for @flisk/swift-ast
3
+ * - Provides lazy ESM/CJS loading
4
+ * - Serializes WASI-backed calls to avoid double-start errors
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { pathToFileURL: pathToFileUrl } = require('url');
10
+
11
+ let __swiftAst = null;
12
+ async function getSwiftAst() {
13
+ if (__swiftAst) return __swiftAst;
14
+ try {
15
+ // Try CJS require first (when package exposes CJS entry)
16
+ // eslint-disable-next-line global-require
17
+ __swiftAst = require('@flisk/swift-ast');
18
+ return __swiftAst;
19
+ } catch (e) {
20
+ // Fallback to ESM dynamic import
21
+ try {
22
+ const m = await import('@flisk/swift-ast');
23
+ __swiftAst = m;
24
+ return __swiftAst;
25
+ } catch (_) {
26
+ // Final fallback: local workspace copy of swift-ast (for dev)
27
+ const localDist = path.resolve('/Users/sameenkarim/flisk/dev/swift-ast/dist/index.js');
28
+ if (fs.existsSync(localDist)) {
29
+ const m2 = await import(pathToFileUrl(localDist).href);
30
+ __swiftAst = m2;
31
+ return __swiftAst;
32
+ }
33
+ throw e;
34
+ }
35
+ }
36
+ }
37
+
38
+ // Serialize WASI-backed swift-ast operations
39
+ let __swiftLock = Promise.resolve();
40
+ function withSwift(callback) {
41
+ const p = __swiftLock.then(callback, callback);
42
+ __swiftLock = p.then(() => {}, () => {});
43
+ return p;
44
+ }
45
+
46
+ module.exports = { getSwiftAst, withSwift };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Generic Swift analyzer utilities
3
+ * - String inference, key resolution, text slicing, and traversal helpers
4
+ */
5
+
6
+ function normalizeChainPart(p) {
7
+ if (!p) return p;
8
+ return String(p).replace(/\s+/g, '').replace(/\(\)$/g, '');
9
+ }
10
+
11
+ function endsWithChain(chain, sigParts) {
12
+ if (sigParts.length > chain.length) return false;
13
+ for (let i = 1; i <= sigParts.length; i++) {
14
+ if (normalizeChainPart(chain[chain.length - i]) !== sigParts[sigParts.length - i]) return false;
15
+ }
16
+ return true;
17
+ }
18
+
19
+ function extractStringLiteral(text) {
20
+ const m = /^\s*"([\s\S]*?)"\s*$/.exec(text || '');
21
+ return m ? m[1] : null;
22
+ }
23
+
24
+ function isIdentifier(text) {
25
+ return /^[_A-Za-z][_A-Za-z0-9\.]*$/.test(text || '');
26
+ }
27
+
28
+ function inferValueTypeFromText(text) {
29
+ const t = (text || '').trim();
30
+ if (/^"/.test(t)) return { type: 'string' };
31
+ if (/^(true|false)$/i.test(t)) return { type: 'boolean' };
32
+ if (/^[0-9]+(\.[0-9]+)?$/.test(t)) return { type: 'number' };
33
+ if (/^\[/.test(t)) {
34
+ const inside = t.slice(1, -1).trim();
35
+ if (!inside) return { type: 'array', items: { type: 'any' } };
36
+ if (/^(\s*"[\s\S]*?"\s*,)*\s*"[\s\S]*?"\s*$/.test(inside)) return { type: 'array', items: { type: 'string' } };
37
+ if (/^(\s*[0-9]+(\.[0-9]+)?\s*,)*\s*[0-9]+(\.[0-9]+)?\s*$/.test(inside)) return { type: 'array', items: { type: 'number' } };
38
+ return { type: 'array', items: { type: 'any' } };
39
+ }
40
+ if (/^\{/.test(t) || /\)$/.test(t)) return { type: 'object' };
41
+ if (isIdentifier(t)) return { type: 'string' };
42
+ return { type: 'any' };
43
+ }
44
+
45
+ function splitTopLevel(s) {
46
+ const items = [];
47
+ let depthBr = 0, depthPr = 0; let cur = '';
48
+ for (let i = 0; i < s.length; i++) {
49
+ const ch = s[i];
50
+ if (ch === '[') depthBr++; else if (ch === ']') depthBr--;
51
+ else if (ch === '(') depthPr++; else if (ch === ')') depthPr--;
52
+ if (ch === ',' && depthBr === 0 && depthPr === 0) { items.push(cur); cur = ''; continue; }
53
+ cur += ch;
54
+ }
55
+ if (cur.trim()) items.push(cur);
56
+ return items;
57
+ }
58
+
59
+ function sliceRange(source, range) {
60
+ const s = range?.start?.offset || 0; const e = range?.end?.offset || s;
61
+ return source.slice(s, e);
62
+ }
63
+
64
+ function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
65
+
66
+ module.exports = {
67
+ normalizeChainPart,
68
+ endsWithChain,
69
+ extractStringLiteral,
70
+ isIdentifier,
71
+ inferValueTypeFromText,
72
+ splitTopLevel,
73
+ sliceRange,
74
+ escapeRegExp,
75
+ };