@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 +84 -19
- package/package.json +5 -3
- package/src/analyze/index.js +20 -13
- 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/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.
|
|
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
|
}
|
package/src/analyze/index.js
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
};
|