@flisk/analyze-tracking 0.8.6 → 0.8.8
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 +31 -12
- package/package.json +1 -1
- package/schema.json +1 -0
- package/src/analyze/javascript/constants.js +6 -0
- package/src/analyze/javascript/detectors/analytics-source.js +25 -14
- package/src/analyze/javascript/extractors/event-extractor.js +62 -1
- package/src/analyze/javascript/parser.js +23 -13
- package/src/analyze/javascript/utils/import-resolver.js +233 -0
- package/src/analyze/typescript/constants.js +6 -0
- package/src/analyze/typescript/detectors/analytics-source.js +47 -10
- package/src/analyze/typescript/extractors/event-extractor.js +55 -0
- package/src/analyze/utils/customFunctionParser.js +20 -8
package/README.md
CHANGED
|
@@ -101,18 +101,19 @@ See [schema.json](schema.json) for a JSON Schema of the output.
|
|
|
101
101
|
|
|
102
102
|
| Library | JavaScript/TypeScript | Python | Ruby | Go |
|
|
103
103
|
|---------|:---------------------:|:------:|:----:|:--:|
|
|
104
|
-
| Google Analytics
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
|
|
|
112
|
-
|
|
|
113
|
-
|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
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 | ✅ | ✅ | ✅ | ✅ |
|
|
116
117
|
|
|
117
118
|
✳️ Rudderstack's SDKs often use the same format as Segment, so Rudderstack events may be detected as Segment events.
|
|
118
119
|
|
|
@@ -130,6 +131,24 @@ See [schema.json](schema.json) for a JSON Schema of the output.
|
|
|
130
131
|
```
|
|
131
132
|
</details>
|
|
132
133
|
|
|
134
|
+
<details>
|
|
135
|
+
<summary>Google Tag Manager</summary>
|
|
136
|
+
|
|
137
|
+
**JavaScript/TypeScript**
|
|
138
|
+
```js
|
|
139
|
+
dataLayer.push({
|
|
140
|
+
event: '<event_name>',
|
|
141
|
+
'<property_name>': '<property_value>'
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Or via window
|
|
145
|
+
window.dataLayer.push({
|
|
146
|
+
event: '<event_name>',
|
|
147
|
+
'<property_name>': '<property_value>'
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
</details>
|
|
151
|
+
|
|
133
152
|
<details>
|
|
134
153
|
<summary>Segment</summary>
|
|
135
154
|
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -45,20 +45,23 @@ function detectAnalyticsSource(node, customFunction) {
|
|
|
45
45
|
function isCustomFunction(node, customFunction) {
|
|
46
46
|
if (!customFunction) return false;
|
|
47
47
|
|
|
48
|
-
// Support dot-separated names like "CustomModule.track"
|
|
49
|
-
|
|
48
|
+
// Support dot-separated names like "CustomModule.track" and chained calls like "getTrackingService().track"
|
|
49
|
+
// Normalize each segment by stripping trailing parentheses
|
|
50
|
+
const parts = customFunction.split('.').map(p => p.replace(/\(\s*\)$/, ''));
|
|
50
51
|
|
|
51
52
|
// Simple identifier (no dot)
|
|
52
53
|
if (parts.length === 1) {
|
|
53
|
-
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name ===
|
|
54
|
+
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
// For dot-separated names, the callee should be a MemberExpression chain
|
|
57
|
-
|
|
57
|
+
// For dot-separated names, the callee should be a MemberExpression chain,
|
|
58
|
+
// but we also allow CallExpression in the chain (e.g., getService().track)
|
|
59
|
+
const callee = node.callee;
|
|
60
|
+
if (callee.type !== NODE_TYPES.MEMBER_EXPRESSION && callee.type !== NODE_TYPES.CALL_EXPRESSION) {
|
|
58
61
|
return false;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
return matchesMemberChain(
|
|
64
|
+
return matchesMemberChain(callee, parts);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/**
|
|
@@ -75,9 +78,8 @@ function matchesMemberChain(memberExpr, parts) {
|
|
|
75
78
|
while (currentNode && idx >= 0) {
|
|
76
79
|
const expectedPart = parts[idx];
|
|
77
80
|
|
|
78
|
-
// property should match current expectedPart
|
|
79
81
|
if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
|
|
80
|
-
// Ensure property is Identifier and matches
|
|
82
|
+
// Ensure property is Identifier and matches the expected part
|
|
81
83
|
if (
|
|
82
84
|
currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
|
|
83
85
|
currentNode.property.name !== expectedPart
|
|
@@ -85,16 +87,25 @@ function matchesMemberChain(memberExpr, parts) {
|
|
|
85
87
|
return false;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
// Move to the object
|
|
90
|
+
// Move to the object (which could itself be a MemberExpression, Identifier, or CallExpression)
|
|
89
91
|
currentNode = currentNode.object;
|
|
90
92
|
idx -= 1;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// If we encounter a CallExpression in the chain (e.g., getService().track),
|
|
97
|
+
// step into its callee without consuming an expected part.
|
|
98
|
+
if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
|
|
99
|
+
currentNode = currentNode.callee;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (currentNode.type === NODE_TYPES.IDENTIFIER) {
|
|
93
104
|
return idx === 0 && currentNode.name === expectedPart;
|
|
94
|
-
} else {
|
|
95
|
-
// Unexpected node type (e.g., ThisExpression, CallExpression, etc.)
|
|
96
|
-
return false;
|
|
97
105
|
}
|
|
106
|
+
|
|
107
|
+
// Unexpected node type (e.g., ThisExpression, Literal, etc.)
|
|
108
|
+
return false;
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
return false;
|
|
@@ -20,6 +20,7 @@ const EXTRACTION_STRATEGIES = {
|
|
|
20
20
|
googleanalytics: extractGoogleAnalyticsEvent,
|
|
21
21
|
snowplow: extractSnowplowEvent,
|
|
22
22
|
mparticle: extractMparticleEvent,
|
|
23
|
+
gtm: extractGTMEvent,
|
|
23
24
|
custom: extractCustomEvent,
|
|
24
25
|
default: extractDefaultEvent
|
|
25
26
|
};
|
|
@@ -105,6 +106,43 @@ function extractMparticleEvent(node, constantMap) {
|
|
|
105
106
|
return { eventName, propertiesNode };
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Extracts Google Tag Manager event data
|
|
111
|
+
* @param {Object} node - CallExpression node
|
|
112
|
+
* @param {Object} constantMap - Collected constant map
|
|
113
|
+
* @returns {EventData}
|
|
114
|
+
*/
|
|
115
|
+
function extractGTMEvent(node, constantMap) {
|
|
116
|
+
if (!node.arguments || node.arguments.length === 0) {
|
|
117
|
+
return { eventName: null, propertiesNode: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
|
|
121
|
+
const firstArg = node.arguments[0];
|
|
122
|
+
|
|
123
|
+
if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) {
|
|
124
|
+
return { eventName: null, propertiesNode: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Find the 'event' property
|
|
128
|
+
const eventProperty = findPropertyByKey(firstArg, 'event');
|
|
129
|
+
if (!eventProperty) {
|
|
130
|
+
return { eventName: null, propertiesNode: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const eventName = getStringValue(eventProperty.value, constantMap);
|
|
134
|
+
|
|
135
|
+
// Create a modified properties node without the 'event' property
|
|
136
|
+
const modifiedPropertiesNode = {
|
|
137
|
+
...firstArg,
|
|
138
|
+
properties: firstArg.properties.filter(prop =>
|
|
139
|
+
prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event')
|
|
140
|
+
)
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return { eventName, propertiesNode: modifiedPropertiesNode };
|
|
144
|
+
}
|
|
145
|
+
|
|
108
146
|
/**
|
|
109
147
|
* Default event extraction for standard providers
|
|
110
148
|
* @param {Object} node - CallExpression node
|
|
@@ -219,7 +257,11 @@ function getStringValue(node, constantMap = {}) {
|
|
|
219
257
|
return node.value;
|
|
220
258
|
}
|
|
221
259
|
if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
|
|
222
|
-
|
|
260
|
+
const resolved = resolveMemberExpressionToString(node, constantMap);
|
|
261
|
+
if (resolved) return resolved;
|
|
262
|
+
// Fallback: return a dotted path for member expressions when we cannot
|
|
263
|
+
// resolve to a literal (e.g., imported constants like TELEMETRY_EVENTS.X)
|
|
264
|
+
return memberExpressionToPath(node);
|
|
223
265
|
}
|
|
224
266
|
return null;
|
|
225
267
|
}
|
|
@@ -277,6 +319,25 @@ function resolveMemberExpressionToString(node, constantMap) {
|
|
|
277
319
|
return null;
|
|
278
320
|
}
|
|
279
321
|
|
|
322
|
+
// Build a dotted path string for a MemberExpression (e.g., OBJ.KEY.SUBKEY)
|
|
323
|
+
function memberExpressionToPath(node) {
|
|
324
|
+
if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
|
|
325
|
+
const parts = [];
|
|
326
|
+
let current = node;
|
|
327
|
+
while (current && current.type === NODE_TYPES.MEMBER_EXPRESSION && !current.computed) {
|
|
328
|
+
if (current.property && current.property.type === NODE_TYPES.IDENTIFIER) {
|
|
329
|
+
parts.unshift(current.property.name);
|
|
330
|
+
} else if (current.property && current.property.type === NODE_TYPES.LITERAL) {
|
|
331
|
+
parts.unshift(String(current.property.value));
|
|
332
|
+
}
|
|
333
|
+
current = current.object;
|
|
334
|
+
}
|
|
335
|
+
if (current && current.type === NODE_TYPES.IDENTIFIER) {
|
|
336
|
+
parts.unshift(current.name);
|
|
337
|
+
}
|
|
338
|
+
return parts.length ? parts.join('.') : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
280
341
|
module.exports = {
|
|
281
342
|
extractEventData,
|
|
282
343
|
processEventData
|
|
@@ -12,6 +12,7 @@ const { PARSER_OPTIONS, NODE_TYPES } = require('./constants');
|
|
|
12
12
|
const { detectAnalyticsSource } = require('./detectors');
|
|
13
13
|
const { extractEventData, processEventData } = require('./extractors');
|
|
14
14
|
const { findWrappingFunction } = require('./utils/function-finder');
|
|
15
|
+
const { collectImportedConstantStringMap } = require('./utils/import-resolver');
|
|
15
16
|
|
|
16
17
|
// Extend walker to support JSX
|
|
17
18
|
extend(walk.base);
|
|
@@ -82,19 +83,15 @@ function parseFile(filePath) {
|
|
|
82
83
|
function nodeMatchesCustomFunction(node, fnName) {
|
|
83
84
|
if (!fnName || !node.callee) return false;
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
// Support chained calls in function name by stripping trailing parens from each segment
|
|
87
|
+
const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
|
|
86
88
|
|
|
87
89
|
// Simple identifier case
|
|
88
90
|
if (parts.length === 1) {
|
|
89
|
-
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name ===
|
|
91
|
+
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
//
|
|
93
|
-
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Walk the chain from the right-most property to the leftmost object
|
|
94
|
+
// Allow MemberExpression and CallExpression within the chain (e.g., getService().track)
|
|
98
95
|
let currentNode = node.callee;
|
|
99
96
|
let idx = parts.length - 1;
|
|
100
97
|
|
|
@@ -108,13 +105,23 @@ function nodeMatchesCustomFunction(node, fnName) {
|
|
|
108
105
|
) {
|
|
109
106
|
return false;
|
|
110
107
|
}
|
|
108
|
+
// step to the object; do not decrement idx for call expressions yet
|
|
111
109
|
currentNode = currentNode.object;
|
|
112
110
|
idx -= 1;
|
|
113
|
-
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
|
|
115
|
+
// descend into the callee of the call without consuming a part
|
|
116
|
+
currentNode = currentNode.callee;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (currentNode.type === NODE_TYPES.IDENTIFIER) {
|
|
114
121
|
return idx === 0 && currentNode.name === expected;
|
|
115
|
-
} else {
|
|
116
|
-
return false;
|
|
117
122
|
}
|
|
123
|
+
|
|
124
|
+
return false;
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
return false;
|
|
@@ -183,8 +190,11 @@ function collectConstantStringMap(ast) {
|
|
|
183
190
|
function findTrackingEvents(ast, filePath, customConfigs = []) {
|
|
184
191
|
const events = [];
|
|
185
192
|
|
|
186
|
-
// Collect constant mappings once per file
|
|
187
|
-
const constantMap =
|
|
193
|
+
// Collect constant mappings once per file (locals + imported)
|
|
194
|
+
const constantMap = {
|
|
195
|
+
...collectConstantStringMap(ast),
|
|
196
|
+
...collectImportedConstantStringMap(filePath, ast)
|
|
197
|
+
};
|
|
188
198
|
|
|
189
199
|
walk.ancestor(ast, {
|
|
190
200
|
[NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const ts = require('typescript');
|
|
4
|
+
const acorn = require('acorn');
|
|
5
|
+
const jsx = require('acorn-jsx');
|
|
6
|
+
const { PARSER_OPTIONS, NODE_TYPES } = require('../constants');
|
|
7
|
+
|
|
8
|
+
const JS_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx'];
|
|
9
|
+
const TS_EXTENSIONS = ['.ts', '.tsx'];
|
|
10
|
+
const ALL_EXTENSIONS = [...JS_EXTENSIONS, ...TS_EXTENSIONS, '.json'];
|
|
11
|
+
|
|
12
|
+
function tryFileWithExtensions(basePath) {
|
|
13
|
+
for (const ext of ALL_EXTENSIONS) {
|
|
14
|
+
const full = basePath + ext;
|
|
15
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile()) return full;
|
|
16
|
+
}
|
|
17
|
+
// index.* inside directory
|
|
18
|
+
if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
|
|
19
|
+
for (const ext of ALL_EXTENSIONS) {
|
|
20
|
+
const idx = path.join(basePath, 'index' + ext);
|
|
21
|
+
if (fs.existsSync(idx) && fs.statSync(idx).isFile()) return idx;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveModulePath(specifier, fromDir) {
|
|
28
|
+
// Relative or absolute
|
|
29
|
+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
|
30
|
+
const abs = path.resolve(fromDir, specifier);
|
|
31
|
+
const direct = tryFileWithExtensions(abs);
|
|
32
|
+
if (direct) return direct;
|
|
33
|
+
} else {
|
|
34
|
+
// Bare or aliased import – search upwards for a matching folder for first segment
|
|
35
|
+
const parts = specifier.split('/');
|
|
36
|
+
let current = fromDir;
|
|
37
|
+
while (true) {
|
|
38
|
+
const candidateRoot = path.join(current, parts[0]);
|
|
39
|
+
if (fs.existsSync(candidateRoot) && fs.statSync(candidateRoot).isDirectory()) {
|
|
40
|
+
const fullBase = path.join(current, specifier);
|
|
41
|
+
const resolved = tryFileWithExtensions(fullBase);
|
|
42
|
+
if (resolved) return resolved;
|
|
43
|
+
}
|
|
44
|
+
const parent = path.dirname(current);
|
|
45
|
+
if (parent === current) break;
|
|
46
|
+
current = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extracts exported constant object maps from a JS/TS file.
|
|
53
|
+
// Returns: { ExportName: { KEY: 'value', ... }, ... }
|
|
54
|
+
function extractExportedConstStringMap(filePath) {
|
|
55
|
+
const code = fs.readFileSync(filePath, 'utf8');
|
|
56
|
+
const map = {};
|
|
57
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
58
|
+
|
|
59
|
+
if (TS_EXTENSIONS.includes(ext)) {
|
|
60
|
+
const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
|
|
61
|
+
for (const stmt of sourceFile.statements) {
|
|
62
|
+
if (ts.isVariableStatement(stmt) && stmt.modifiers && stmt.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
63
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
64
|
+
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
65
|
+
const exportName = decl.name.escapedText;
|
|
66
|
+
const obj = unwrapFreezeToObjectLiteral(decl.initializer);
|
|
67
|
+
if (obj) {
|
|
68
|
+
const entries = objectLiteralToStringMapTS(obj);
|
|
69
|
+
if (Object.keys(entries).length > 0) {
|
|
70
|
+
map[exportName] = entries;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// JS/JSX – parse with acorn + JSX
|
|
81
|
+
const parser = acorn.Parser.extend(jsx());
|
|
82
|
+
let ast;
|
|
83
|
+
try {
|
|
84
|
+
ast = parser.parse(code, { ...PARSER_OPTIONS, sourceType: 'module' });
|
|
85
|
+
} catch (_) {
|
|
86
|
+
return map;
|
|
87
|
+
}
|
|
88
|
+
// Look for export named declarations with const object or Object.freeze
|
|
89
|
+
ast.body.forEach(node => {
|
|
90
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration && node.declaration.type === 'VariableDeclaration') {
|
|
91
|
+
node.declaration.declarations.forEach(decl => {
|
|
92
|
+
if (decl.id && decl.id.type === NODE_TYPES.IDENTIFIER && decl.init) {
|
|
93
|
+
const exportName = decl.id.name;
|
|
94
|
+
const obj = unwrapFreezeToObjectLiteralJS(decl.init);
|
|
95
|
+
if (obj && obj.type === NODE_TYPES.OBJECT_EXPRESSION) {
|
|
96
|
+
const entries = objectExpressionToStringMapJS(obj);
|
|
97
|
+
if (Object.keys(entries).length > 0) {
|
|
98
|
+
map[exportName] = entries;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return map;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unwrapFreezeToObjectLiteral(initializer) {
|
|
110
|
+
if (ts.isObjectLiteralExpression(initializer)) return initializer;
|
|
111
|
+
if (ts.isCallExpression(initializer)) {
|
|
112
|
+
const callee = initializer.expression;
|
|
113
|
+
if (
|
|
114
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
115
|
+
ts.isIdentifier(callee.expression) && callee.expression.escapedText === 'Object' &&
|
|
116
|
+
callee.name.escapedText === 'freeze' &&
|
|
117
|
+
initializer.arguments.length > 0 && ts.isObjectLiteralExpression(initializer.arguments[0])
|
|
118
|
+
) {
|
|
119
|
+
return initializer.arguments[0];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function objectLiteralToStringMapTS(obj) {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const prop of obj.properties) {
|
|
128
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
129
|
+
const key = ts.isIdentifier(prop.name) ? prop.name.escapedText : ts.isStringLiteral(prop.name) ? prop.name.text : null;
|
|
130
|
+
if (!key) continue;
|
|
131
|
+
if (prop.initializer && ts.isStringLiteral(prop.initializer)) {
|
|
132
|
+
out[key] = prop.initializer.text;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function unwrapFreezeToObjectLiteralJS(init) {
|
|
139
|
+
if (init.type === NODE_TYPES.OBJECT_EXPRESSION) return init;
|
|
140
|
+
if (init.type === 'CallExpression') {
|
|
141
|
+
const callee = init.callee;
|
|
142
|
+
if (
|
|
143
|
+
callee && callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
|
|
144
|
+
callee.object && callee.object.type === NODE_TYPES.IDENTIFIER && callee.object.name === 'Object' &&
|
|
145
|
+
callee.property && callee.property.type === NODE_TYPES.IDENTIFIER && callee.property.name === 'freeze' &&
|
|
146
|
+
init.arguments && init.arguments.length > 0 && init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION
|
|
147
|
+
) {
|
|
148
|
+
return init.arguments[0];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function objectExpressionToStringMapJS(obj) {
|
|
155
|
+
const out = {};
|
|
156
|
+
obj.properties.forEach(prop => {
|
|
157
|
+
if (!prop.key || !prop.value) return;
|
|
158
|
+
const key = prop.key.name || prop.key.value;
|
|
159
|
+
if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') {
|
|
160
|
+
out[key] = prop.value.value;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Collect imported constant string maps used by this file
|
|
167
|
+
function collectImportedConstantStringMap(filePath, ast) {
|
|
168
|
+
const fromDir = path.dirname(filePath);
|
|
169
|
+
const imports = [];
|
|
170
|
+
|
|
171
|
+
// ES imports
|
|
172
|
+
ast.body.forEach(node => {
|
|
173
|
+
if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
|
|
174
|
+
const spec = node.source.value;
|
|
175
|
+
node.specifiers.forEach(s => {
|
|
176
|
+
if (s.type === 'ImportSpecifier' && s.imported && s.local) {
|
|
177
|
+
imports.push({ local: s.local.name, exported: s.imported.name, spec });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// CommonJS requires: const { X } = require('mod')
|
|
184
|
+
ast.body.forEach(node => {
|
|
185
|
+
if (node.type === 'VariableDeclaration') {
|
|
186
|
+
node.declarations.forEach(decl => {
|
|
187
|
+
if (
|
|
188
|
+
decl.init && decl.init.type === 'CallExpression' &&
|
|
189
|
+
decl.init.callee && decl.init.callee.type === NODE_TYPES.IDENTIFIER && decl.init.callee.name === 'require' &&
|
|
190
|
+
decl.init.arguments && decl.init.arguments[0] && decl.init.arguments[0].type === NODE_TYPES.LITERAL
|
|
191
|
+
) {
|
|
192
|
+
const spec = String(decl.init.arguments[0].value);
|
|
193
|
+
if (decl.id && decl.id.type === 'ObjectPattern') {
|
|
194
|
+
decl.id.properties.forEach(p => {
|
|
195
|
+
if (p.key && p.value && p.key.type === NODE_TYPES.IDENTIFIER && p.value.type === NODE_TYPES.IDENTIFIER) {
|
|
196
|
+
imports.push({ local: p.value.name, exported: p.key.name, spec });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const constantMap = {};
|
|
206
|
+
const bySpec = new Map();
|
|
207
|
+
for (const imp of imports) {
|
|
208
|
+
let resolved = bySpec.get(imp.spec);
|
|
209
|
+
if (!resolved) {
|
|
210
|
+
const file = resolveModulePath(imp.spec, fromDir);
|
|
211
|
+
bySpec.set(imp.spec, file || null);
|
|
212
|
+
resolved = file || null;
|
|
213
|
+
}
|
|
214
|
+
if (!resolved) continue;
|
|
215
|
+
try {
|
|
216
|
+
const exported = extractExportedConstStringMap(resolved);
|
|
217
|
+
const entries = exported[imp.exported];
|
|
218
|
+
if (entries && typeof entries === 'object') {
|
|
219
|
+
constantMap[imp.local] = entries;
|
|
220
|
+
}
|
|
221
|
+
} catch (_) { /* ignore resolution errors */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return constantMap;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
resolveModulePath,
|
|
229
|
+
extractExportedConstStringMap,
|
|
230
|
+
collectImportedConstantStringMap
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
|
|
@@ -44,16 +44,53 @@ function detectAnalyticsSource(node, customFunction) {
|
|
|
44
44
|
* @returns {boolean}
|
|
45
45
|
*/
|
|
46
46
|
function isCustomFunction(node, customFunction) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
if (!customFunction || !node || !node.expression) return false;
|
|
48
|
+
|
|
49
|
+
// Normalize signature parts by stripping trailing parentheses from each part
|
|
50
|
+
const parts = customFunction.split('.').map(p => p.replace(/\(\s*\)$/, ''));
|
|
51
|
+
|
|
52
|
+
return matchesExpressionChain(node.expression, parts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Recursively verify that a CallExpression/PropertyAccessExpression chain matches the expected parts.
|
|
57
|
+
* Supports patterns like getTracker().track, this.props.customTrackFunction6, tracker.track
|
|
58
|
+
*/
|
|
59
|
+
function matchesExpressionChain(expr, parts) {
|
|
60
|
+
let current = expr;
|
|
61
|
+
let idx = parts.length - 1;
|
|
62
|
+
|
|
63
|
+
while (current && idx >= 0) {
|
|
64
|
+
const expected = parts[idx];
|
|
65
|
+
|
|
66
|
+
if (ts.isPropertyAccessExpression(current)) {
|
|
67
|
+
const name = current.name?.escapedText;
|
|
68
|
+
if (name !== expected) return false;
|
|
69
|
+
current = current.expression;
|
|
70
|
+
idx -= 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (ts.isCallExpression(current)) {
|
|
75
|
+
// Step into the callee (e.g., getTracker() -> getTracker)
|
|
76
|
+
current = current.expression;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (ts.isIdentifier(current)) {
|
|
81
|
+
return idx === 0 && current.escapedText === expected;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle `this` without relying on ts.isThisExpression for compatibility across TS versions
|
|
85
|
+
if (current.kind === ts.SyntaxKind.ThisKeyword || current.kind === ts.SyntaxKind.ThisExpression) {
|
|
86
|
+
return idx === 0 && expected === 'this';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Unsupported expression kind for our matcher
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false;
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
/**
|
|
@@ -21,6 +21,7 @@ const EXTRACTION_STRATEGIES = {
|
|
|
21
21
|
googleanalytics: extractGoogleAnalyticsEvent,
|
|
22
22
|
snowplow: extractSnowplowEvent,
|
|
23
23
|
mparticle: extractMparticleEvent,
|
|
24
|
+
gtm: extractGTMEvent,
|
|
24
25
|
custom: extractCustomEvent,
|
|
25
26
|
default: extractDefaultEvent
|
|
26
27
|
};
|
|
@@ -126,6 +127,60 @@ function extractMparticleEvent(node, checker, sourceFile) {
|
|
|
126
127
|
return { eventName, propertiesNode };
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Extracts Google Tag Manager event data
|
|
132
|
+
* @param {Object} node - CallExpression node
|
|
133
|
+
* @param {Object} checker - TypeScript type checker
|
|
134
|
+
* @param {Object} sourceFile - TypeScript source file
|
|
135
|
+
* @returns {EventData}
|
|
136
|
+
*/
|
|
137
|
+
function extractGTMEvent(node, checker, sourceFile) {
|
|
138
|
+
if (!node.arguments || node.arguments.length === 0) {
|
|
139
|
+
return { eventName: null, propertiesNode: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
|
|
143
|
+
const firstArg = node.arguments[0];
|
|
144
|
+
|
|
145
|
+
if (!ts.isObjectLiteralExpression(firstArg)) {
|
|
146
|
+
return { eventName: null, propertiesNode: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Find the 'event' property
|
|
150
|
+
const eventProperty = findPropertyByKey(firstArg, 'event');
|
|
151
|
+
if (!eventProperty) {
|
|
152
|
+
return { eventName: null, propertiesNode: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const eventName = getStringValue(eventProperty.initializer, checker, sourceFile);
|
|
156
|
+
|
|
157
|
+
// Create a modified properties node without the 'event' property
|
|
158
|
+
const modifiedProperties = firstArg.properties.filter(prop => {
|
|
159
|
+
if (ts.isPropertyAssignment(prop) && prop.name) {
|
|
160
|
+
if (ts.isIdentifier(prop.name)) {
|
|
161
|
+
return prop.name.escapedText !== 'event';
|
|
162
|
+
}
|
|
163
|
+
if (ts.isStringLiteral(prop.name)) {
|
|
164
|
+
return prop.name.text !== 'event';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Create a synthetic object literal with the filtered properties
|
|
171
|
+
const modifiedPropertiesNode = ts.factory.createObjectLiteralExpression(modifiedProperties);
|
|
172
|
+
|
|
173
|
+
// Copy source positions for proper analysis
|
|
174
|
+
if (firstArg.pos !== undefined) {
|
|
175
|
+
modifiedPropertiesNode.pos = firstArg.pos;
|
|
176
|
+
}
|
|
177
|
+
if (firstArg.end !== undefined) {
|
|
178
|
+
modifiedPropertiesNode.end = firstArg.end;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { eventName, propertiesNode: modifiedPropertiesNode };
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
/**
|
|
130
185
|
* Custom extraction
|
|
131
186
|
* @param {Object} node - CallExpression node
|
|
@@ -4,15 +4,27 @@ function parseCustomFunctionSignature(signature) {
|
|
|
4
4
|
return null;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
// Supports names with module prefix like Module.func
|
|
9
|
-
const match = signature.match(/^\s*([A-Za-z0-9_.]+)\s*(?:\(([^)]*)\))?\s*$/);
|
|
10
|
-
if (!match) {
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
7
|
+
const trimmed = signature.trim();
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
// Two cases:
|
|
10
|
+
// 1) Full signature with params at the end (e.g., Module.track(EVENT_NAME, PROPERTIES)) → parse params
|
|
11
|
+
// 2) Name-only (including chains with internal calls, e.g., getService().track) → no params
|
|
12
|
+
let functionName;
|
|
13
|
+
let paramsPart = null;
|
|
14
|
+
|
|
15
|
+
if (/\)\s*$/.test(trimmed)) {
|
|
16
|
+
// Looks like it ends with a parameter list – extract the final (...) only
|
|
17
|
+
const lastOpenIdx = trimmed.lastIndexOf('(');
|
|
18
|
+
const lastCloseIdx = trimmed.lastIndexOf(')');
|
|
19
|
+
if (lastOpenIdx === -1 || lastCloseIdx < lastOpenIdx) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
functionName = trimmed.slice(0, lastOpenIdx).trim();
|
|
23
|
+
paramsPart = trimmed.slice(lastOpenIdx + 1, lastCloseIdx);
|
|
24
|
+
} else {
|
|
25
|
+
// No trailing params – treat the whole string as the function name
|
|
26
|
+
functionName = trimmed;
|
|
27
|
+
}
|
|
16
28
|
|
|
17
29
|
// Default legacy behaviour: EVENT_NAME, PROPERTIES
|
|
18
30
|
if (!paramsPart) {
|