@flisk/analyze-tracking 0.7.3 → 0.7.5

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
@@ -29,6 +29,8 @@ npx @flisk/analyze-tracking /path/to/project [options]
29
29
  - `-m, --model <model>`: Specify a model (ex: `gpt-4.1-nano`, `gpt-4o-mini`, `gemini-2.0-flash-lite-001`)
30
30
  - `-o, --output <output_file>`: Name of the output file (default: `tracking-schema.yaml`)
31
31
  - `-c, --customFunction <function_name>`: Specify a custom tracking function
32
+ - `--format <format>`: Output format, either `yaml` (default) or `json`. If an invalid value is provided, the CLI will exit with an error.
33
+ - `--stdout`: Print the output to the terminal instead of writing to a file (works with both YAML and JSON)
32
34
 
33
35
  🔑&nbsp; **Important:** If you are using `generateDescription`, you must set the appropriate credentials for the LLM provider you are using as an environment variable. OpenAI uses `OPENAI_API_KEY` and Google Vertex AI uses `GOOGLE_APPLICATION_CREDENTIALS`.
34
36
 
@@ -416,3 +418,5 @@ See [schema.json](schema.json) for a JSON Schema of the output.
416
418
 
417
419
  ## Contribute
418
420
  We're actively improving this package. Found a bug? Have a feature request? Open an issue or submit a pull request!
421
+
422
+ [![Slack](https://img.shields.io/badge/Join%20Us%20on%20Slack-Flisk%20Community-611f69.svg?logo=slack)](https://join.slack.com/t/fliskcommunity/shared_invite/zt-354hesfnm-BbNzveERo9C4JwVQEWvXoA)
package/bin/cli.js CHANGED
@@ -64,6 +64,17 @@ const optionDefinitions = [
64
64
  alias: 'h',
65
65
  type: Boolean,
66
66
  },
67
+ {
68
+ name: 'stdout',
69
+ type: Boolean,
70
+ defaultValue: false,
71
+ },
72
+ {
73
+ name: 'format',
74
+ alias: 'f',
75
+ type: String,
76
+ defaultValue: 'yaml',
77
+ },
67
78
  ]
68
79
  const options = commandLineArgs(optionDefinitions);
69
80
  const {
@@ -77,6 +88,8 @@ const {
77
88
  commitHash,
78
89
  commitTimestamp,
79
90
  help,
91
+ stdout,
92
+ format,
80
93
  } = options;
81
94
 
82
95
  if (help) {
@@ -113,4 +126,19 @@ if (generateDescription) {
113
126
  }
114
127
  }
115
128
 
116
- run(path.resolve(targetDir), output, customFunction, customSourceDetails, generateDescription, provider, model);
129
+ if (format !== 'yaml' && format !== 'json') {
130
+ console.error(`Invalid format: ${format}. Please use --format yaml or --format json.`);
131
+ process.exit(1);
132
+ }
133
+
134
+ run(
135
+ path.resolve(targetDir),
136
+ output,
137
+ customFunction,
138
+ customSourceDetails,
139
+ generateDescription,
140
+ provider,
141
+ model,
142
+ stdout,
143
+ format
144
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -53,6 +53,8 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "ajv": "^8.17.1",
56
- "lodash": "^4.17.21"
56
+ "lodash": "^4.17.21",
57
+ "react": "^19.1.0",
58
+ "@types/react": "^19.1.6"
57
59
  }
58
60
  }
@@ -36,7 +36,18 @@ function detectSource(node, customFunction = null) {
36
36
  if (node.name === 'track_struct_event') return 'snowplow';
37
37
 
38
38
  // Custom tracking function
39
- if (customFunction && node.name === customFunction) return 'custom';
39
+ if (customFunction) {
40
+ // Handle simple function names (e.g., 'customTrackFunction')
41
+ if (node.name === customFunction) return 'custom';
42
+
43
+ // Handle module-scoped function names (e.g., 'CustomModule.track')
44
+ if (customFunction.includes('.')) {
45
+ const [moduleName, methodName] = customFunction.split('.');
46
+ if (node.receiver && node.receiver.name === moduleName && node.name === methodName) {
47
+ return 'custom';
48
+ }
49
+ }
50
+ }
40
51
 
41
52
  return null;
42
53
  }
@@ -14,14 +14,17 @@ const { getValueType } = require('./types');
14
14
  function extractEventName(node, source) {
15
15
  if (source === 'segment' || source === 'rudderstack') {
16
16
  // Both Segment and Rudderstack use the same format
17
- const params = node.arguments_.arguments_[0].elements;
17
+ const params = node.arguments_?.arguments_?.[0]?.elements;
18
+ if (!params || !Array.isArray(params)) {
19
+ return null;
20
+ }
18
21
  const eventProperty = params.find(param => param?.key?.unescaped?.value === 'event');
19
22
  return eventProperty?.value?.unescaped?.value || null;
20
23
  }
21
24
 
22
25
  if (source === 'mixpanel') {
23
26
  // Mixpanel Ruby SDK format: tracker.track('distinct_id', 'event_name', {...})
24
- const args = node.arguments_.arguments_;
27
+ const args = node.arguments_?.arguments_;
25
28
  if (args && args.length > 1 && args[1]?.unescaped?.value) {
26
29
  return args[1].unescaped.value;
27
30
  }
@@ -29,8 +32,8 @@ function extractEventName(node, source) {
29
32
 
30
33
  if (source === 'posthog') {
31
34
  // PostHog Ruby SDK format: posthog.capture({distinct_id: '...', event: '...', properties: {...}})
32
- const hashArg = node.arguments_.arguments_[0];
33
- if (hashArg && hashArg.elements) {
35
+ const hashArg = node.arguments_?.arguments_?.[0];
36
+ if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) {
34
37
  const eventProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'event');
35
38
  return eventProperty?.value?.unescaped?.value || null;
36
39
  }
@@ -38,14 +41,17 @@ function extractEventName(node, source) {
38
41
 
39
42
  if (source === 'snowplow') {
40
43
  // Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...)
41
- const params = node.arguments_.arguments_[0].elements;
44
+ const params = node.arguments_?.arguments_?.[0]?.elements;
45
+ if (!params || !Array.isArray(params)) {
46
+ return null;
47
+ }
42
48
  const actionProperty = params.find(param => param?.key?.unescaped?.value === 'action');
43
49
  return actionProperty?.value?.unescaped?.value || null;
44
50
  }
45
51
 
46
52
  if (source === 'custom') {
47
53
  // Custom function format: customFunction('event_name', {...})
48
- const args = node.arguments_.arguments_;
54
+ const args = node.arguments_?.arguments_;
49
55
  if (args && args.length > 0 && args[0]?.unescaped?.value) {
50
56
  return args[0].unescaped.value;
51
57
  }
@@ -65,7 +71,10 @@ async function extractProperties(node, source) {
65
71
 
66
72
  if (source === 'segment' || source === 'rudderstack') {
67
73
  // Both Segment and Rudderstack use the same format
68
- const params = node.arguments_.arguments_[0].elements;
74
+ const params = node.arguments_?.arguments_?.[0]?.elements;
75
+ if (!params || !Array.isArray(params)) {
76
+ return null;
77
+ }
69
78
  const properties = {};
70
79
 
71
80
  // Process all top-level fields except 'event'
@@ -108,7 +117,7 @@ async function extractProperties(node, source) {
108
117
 
109
118
  if (source === 'mixpanel') {
110
119
  // Mixpanel Ruby SDK: tracker.track('distinct_id', 'event_name', {properties})
111
- const args = node.arguments_.arguments_;
120
+ const args = node.arguments_?.arguments_;
112
121
  const properties = {};
113
122
 
114
123
  // Add distinct_id as property (even if it's a variable)
@@ -129,10 +138,10 @@ async function extractProperties(node, source) {
129
138
 
130
139
  if (source === 'posthog') {
131
140
  // PostHog Ruby SDK: posthog.capture({distinct_id: '...', event: '...', properties: {...}})
132
- const hashArg = node.arguments_.arguments_[0];
141
+ const hashArg = node.arguments_?.arguments_?.[0];
133
142
  const properties = {};
134
143
 
135
- if (hashArg && hashArg.elements) {
144
+ if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) {
136
145
  // Extract distinct_id if present
137
146
  const distinctIdProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'distinct_id');
138
147
  if (distinctIdProperty?.value) {
@@ -154,7 +163,10 @@ async function extractProperties(node, source) {
154
163
 
155
164
  if (source === 'snowplow') {
156
165
  // Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...)
157
- const params = node.arguments_.arguments_[0].elements;
166
+ const params = node.arguments_?.arguments_?.[0]?.elements;
167
+ if (!params || !Array.isArray(params)) {
168
+ return null;
169
+ }
158
170
  const properties = {};
159
171
 
160
172
  // Extract all struct event parameters except 'action' (which is used as the event name)
@@ -172,7 +184,7 @@ async function extractProperties(node, source) {
172
184
 
173
185
  if (source === 'custom') {
174
186
  // Custom function format: customFunction('event_name', {properties})
175
- const args = node.arguments_.arguments_;
187
+ const args = node.arguments_?.arguments_;
176
188
  if (args && args.length > 1 && args[1] instanceof HashNode) {
177
189
  return await extractHashProperties(args[1]);
178
190
  }
@@ -29,7 +29,16 @@ class TrackingVisitor {
29
29
  if (!eventName) return;
30
30
 
31
31
  const line = getLineNumber(this.code, node.location);
32
- const functionName = await findWrappingFunction(node, ancestors);
32
+
33
+ // For module-scoped custom functions, use the custom function name as the functionName
34
+ // For simple custom functions, use the wrapping function name
35
+ let functionName;
36
+ if (source === 'custom' && this.customFunction && this.customFunction.includes('.')) {
37
+ functionName = this.customFunction;
38
+ } else {
39
+ functionName = await findWrappingFunction(node, ancestors);
40
+ }
41
+
33
42
  const properties = await extractProperties(node, source);
34
43
 
35
44
  this.events.push({
@@ -44,8 +44,13 @@ function detectAnalyticsSource(node, customFunction) {
44
44
  * @returns {boolean}
45
45
  */
46
46
  function isCustomFunction(node, customFunction) {
47
- return ts.isIdentifier(node.expression) &&
48
- node.expression.escapedText === customFunction;
47
+ const canBeCustomFunction = ts.isIdentifier(node.expression) ||
48
+ ts.isPropertyAccessExpression(node.expression) ||
49
+ ts.isCallExpression(node.expression) || // For chained calls like getTracker().track()
50
+ ts.isElementAccessExpression(node.expression) || // For array/object access like trackers['analytics'].track()
51
+ (ts.isPropertyAccessExpression(node.expression?.expression) && ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track()
52
+
53
+ return canBeCustomFunction && node.expression.getText() === customFunction;
49
54
  }
50
55
 
51
56
  /**
@@ -59,7 +64,7 @@ function detectFunctionBasedProvider(node) {
59
64
  }
60
65
 
61
66
  const functionName = node.expression.escapedText;
62
-
67
+
63
68
  for (const provider of Object.values(ANALYTICS_PROVIDERS)) {
64
69
  if (provider.type === 'function' && provider.functionName === functionName) {
65
70
  return provider.name;
@@ -79,8 +84,8 @@ function detectMemberBasedProvider(node) {
79
84
  return 'unknown';
80
85
  }
81
86
 
82
- const objectName = node.expression.expression.escapedText;
83
- const methodName = node.expression.name.escapedText;
87
+ const objectName = node.expression.expression?.escapedText;
88
+ const methodName = node.expression.name?.escapedText;
84
89
 
85
90
  if (!objectName || !methodName) {
86
91
  return 'unknown';
@@ -116,6 +116,38 @@ function extractShorthandPropertySchema(checker, prop) {
116
116
  if (!symbol) {
117
117
  return { type: 'any' };
118
118
  }
119
+ const declarations = symbol.declarations || [];
120
+ for (const decl of declarations) {
121
+ // Detect destructuring from useState: const [state, setState] = useState<Type>(...)
122
+ if (
123
+ ts.isBindingElement(decl) &&
124
+ decl.parent &&
125
+ ts.isArrayBindingPattern(decl.parent) &&
126
+ decl.parent.parent &&
127
+ ts.isVariableDeclaration(decl.parent.parent) &&
128
+ decl.parent.parent.initializer &&
129
+ ts.isCallExpression(decl.parent.parent.initializer) &&
130
+ ts.isIdentifier(decl.parent.parent.initializer.expression) &&
131
+ decl.parent.parent.initializer.expression.escapedText === 'useState'
132
+ ) {
133
+ // Try to get type from generic argument
134
+ const callExpr = decl.parent.parent.initializer;
135
+ if (callExpr.typeArguments && callExpr.typeArguments.length > 0) {
136
+ const typeNode = callExpr.typeArguments[0];
137
+ const type = checker.getTypeFromTypeNode(typeNode);
138
+ const typeString = checker.typeToString(type);
139
+ return resolveTypeToProperties(checker, typeString);
140
+ }
141
+ // Fallback: get type from initial value
142
+ if (callExpr.arguments && callExpr.arguments.length > 0) {
143
+ const initType = checker.getTypeAtLocation(callExpr.arguments[0]);
144
+ const typeString = checker.typeToString(initType);
145
+ return resolveTypeToProperties(checker, typeString);
146
+ }
147
+ // Default to any
148
+ return { type: 'any' };
149
+ }
150
+ }
119
151
 
120
152
  const propType = checker.getTypeAtLocation(prop.name);
121
153
  const typeString = checker.typeToString(propType);
@@ -318,6 +350,7 @@ function resolveTypeSchema(checker, typeString) {
318
350
  * @returns {string|null} Literal type or null
319
351
  */
320
352
  function getLiteralType(node) {
353
+ if (!node) return null;
321
354
  if (ts.isStringLiteral(node)) return 'string';
322
355
  if (ts.isNumericLiteral(node)) return 'number';
323
356
  if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean';
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  const ts = require('typescript');
7
+ const { isReactHookCall } = require('./type-resolver');
7
8
 
8
9
  /**
9
10
  * Finds the name of the function that wraps a given node
@@ -12,17 +13,17 @@ const ts = require('typescript');
12
13
  */
13
14
  function findWrappingFunction(node) {
14
15
  let current = node;
15
-
16
+
16
17
  while (current) {
17
18
  const functionName = extractFunctionName(current);
18
-
19
+
19
20
  if (functionName) {
20
21
  return functionName;
21
22
  }
22
-
23
+
23
24
  current = current.parent;
24
25
  }
25
-
26
+
26
27
  return 'global';
27
28
  }
28
29
 
@@ -36,27 +37,27 @@ function extractFunctionName(node) {
36
37
  if (ts.isFunctionDeclaration(node)) {
37
38
  return node.name ? node.name.escapedText : 'anonymous';
38
39
  }
39
-
40
+
40
41
  // Method declaration in class
41
42
  if (ts.isMethodDeclaration(node)) {
42
43
  return node.name ? node.name.escapedText : 'anonymous';
43
44
  }
44
-
45
+
45
46
  // Arrow function or function expression
46
47
  if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
47
48
  return findParentFunctionName(node) || 'anonymous';
48
49
  }
49
-
50
+
50
51
  // Constructor
51
52
  if (ts.isConstructorDeclaration(node)) {
52
53
  return 'constructor';
53
54
  }
54
-
55
+
55
56
  // Getter/Setter
56
57
  if (ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) {
57
58
  return node.name ? `${ts.isGetAccessorDeclaration(node) ? 'get' : 'set'} ${node.name.escapedText}` : 'anonymous';
58
59
  }
59
-
60
+
60
61
  return null;
61
62
  }
62
63
 
@@ -67,14 +68,38 @@ function extractFunctionName(node) {
67
68
  */
68
69
  function findParentFunctionName(node) {
69
70
  const parent = node.parent;
70
-
71
+
71
72
  if (!parent) return null;
72
-
73
+
74
+ if (
75
+ ts.isCallExpression(parent) &&
76
+ ts.isIdentifier(parent.expression) &&
77
+ isReactHookCall(parent)
78
+ ) {
79
+ if (
80
+ parent.parent &&
81
+ ts.isVariableDeclaration(parent.parent) &&
82
+ parent.parent.name
83
+ ) {
84
+ return `${parent.expression.escapedText}(${parent.parent.name.escapedText})`;
85
+ }
86
+ return `${parent.expression.escapedText}()`;
87
+ }
88
+
73
89
  // Variable declaration: const myFunc = () => {}
74
90
  if (ts.isVariableDeclaration(parent) && parent.name) {
91
+ // Check if initializer is a recognized React hook call
92
+ if (
93
+ parent.initializer &&
94
+ ts.isCallExpression(parent.initializer) &&
95
+ ts.isIdentifier(parent.initializer.expression) &&
96
+ isReactHookCall(parent.initializer)
97
+ ) {
98
+ return `${parent.initializer.expression.escapedText}(${parent.name.escapedText})`;
99
+ }
75
100
  return parent.name.escapedText;
76
101
  }
77
-
102
+
78
103
  // Property assignment: { myFunc: () => {} }
79
104
  if (ts.isPropertyAssignment(parent) && parent.name) {
80
105
  if (ts.isIdentifier(parent.name)) {
@@ -84,20 +109,20 @@ function findParentFunctionName(node) {
84
109
  return parent.name.text;
85
110
  }
86
111
  }
87
-
112
+
88
113
  // Method property in object literal: { myFunc() {} }
89
114
  if (ts.isMethodDeclaration(parent) && parent.name) {
90
115
  return parent.name.escapedText;
91
116
  }
92
-
117
+
93
118
  // Binary expression assignment: obj.myFunc = () => {}
94
- if (ts.isBinaryExpression(parent) &&
95
- parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
119
+ if (ts.isBinaryExpression(parent) &&
120
+ parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
96
121
  if (ts.isPropertyAccessExpression(parent.left)) {
97
122
  return parent.left.name.escapedText;
98
123
  }
99
124
  }
100
-
125
+
101
126
  // Call expression argument: someFunc(() => {})
102
127
  if (ts.isCallExpression(parent)) {
103
128
  const argIndex = parent.arguments.indexOf(node);
@@ -105,7 +130,7 @@ function findParentFunctionName(node) {
105
130
  return `anonymous-callback-${argIndex}`;
106
131
  }
107
132
  }
108
-
133
+
109
134
  return null;
110
135
  }
111
136
 
@@ -184,10 +184,25 @@ function getBasicTypeOfArrayElement(checker, element) {
184
184
  return 'any';
185
185
  }
186
186
 
187
+ /**
188
+ * Checks if a CallExpression is a React hook (useCallback, useState, etc)
189
+ * @param {Object} node - CallExpression node
190
+ * @param {string[]} hookNames - List of hook names to check
191
+ * @returns {boolean}
192
+ */
193
+ function isReactHookCall(node, hookNames = ['useCallback', 'useState', 'useEffect', 'useMemo', 'useReducer']) {
194
+ if (!node || !node.expression) return false;
195
+ if (ts.isIdentifier(node.expression)) {
196
+ return hookNames.includes(node.expression.escapedText);
197
+ }
198
+ return false;
199
+ }
200
+
187
201
  module.exports = {
188
202
  resolveIdentifierToInitializer,
189
203
  getTypeOfNode,
190
204
  resolveTypeToProperties,
191
205
  isCustomType,
192
- getBasicTypeOfArrayElement
206
+ getBasicTypeOfArrayElement,
207
+ isReactHookCall
193
208
  };
package/src/index.js CHANGED
@@ -5,13 +5,13 @@
5
5
 
6
6
  const { analyzeDirectory } = require('./analyze');
7
7
  const { getRepoDetails } = require('./utils/repoDetails');
8
- const { generateYamlSchema } = require('./utils/yamlGenerator');
8
+ const { generateYamlSchema, generateJsonSchema } = require('./utils/yamlGenerator');
9
9
  const { generateDescriptions } = require('./generateDescriptions');
10
10
 
11
11
  const { ChatOpenAI } = require('@langchain/openai');
12
12
  const { ChatVertexAI } = require('@langchain/google-vertexai');
13
13
 
14
- async function run(targetDir, outputPath, customFunction, customSourceDetails, generateDescription, provider, model) {
14
+ async function run(targetDir, outputPath, customFunction, customSourceDetails, generateDescription, provider, model, stdout, format) {
15
15
  let events = await analyzeDirectory(targetDir, customFunction);
16
16
  if (generateDescription) {
17
17
  let llm;
@@ -35,7 +35,11 @@ async function run(targetDir, outputPath, customFunction, customSourceDetails, g
35
35
  events = await generateDescriptions(events, targetDir, llm);
36
36
  }
37
37
  const repoDetails = await getRepoDetails(targetDir, customSourceDetails);
38
- generateYamlSchema(events, repoDetails, outputPath);
38
+ if (format === 'json') {
39
+ generateJsonSchema(events, repoDetails, outputPath, stdout);
40
+ } else {
41
+ generateYamlSchema(events, repoDetails, outputPath, stdout);
42
+ }
39
43
  }
40
44
 
41
45
  module.exports = { run };
@@ -9,7 +9,7 @@ const yaml = require('js-yaml');
9
9
  const VERSION = 1
10
10
  const SCHEMA_URL = "https://raw.githubusercontent.com/fliskdata/analyze-tracking/main/schema.json";
11
11
 
12
- function generateYamlSchema(events, repository, outputPath) {
12
+ function generateYamlSchema(events, repository, outputPath, stdout = false) {
13
13
  const schema = {
14
14
  version: VERSION,
15
15
  source: repository,
@@ -21,8 +21,27 @@ function generateYamlSchema(events, repository, outputPath) {
21
21
  };
22
22
  const yamlOutput = yaml.dump(schema, options);
23
23
  const yamlFile = `# yaml-language-server: $schema=${SCHEMA_URL}\n${yamlOutput}`;
24
- fs.writeFileSync(outputPath, yamlFile, 'utf8');
25
- console.log(`Tracking schema YAML file generated: ${outputPath}`);
24
+ if (stdout) {
25
+ process.stdout.write(yamlFile);
26
+ } else {
27
+ fs.writeFileSync(outputPath, yamlFile, 'utf8');
28
+ console.log(`Tracking schema YAML file generated: ${outputPath}`);
29
+ }
26
30
  }
27
31
 
28
- module.exports = { generateYamlSchema };
32
+ function generateJsonSchema(events, repository, outputPath, stdout = false) {
33
+ const schema = {
34
+ version: VERSION,
35
+ source: repository,
36
+ events,
37
+ };
38
+ const jsonFile = JSON.stringify(schema, null, 2);
39
+ if (stdout) {
40
+ process.stdout.write(jsonFile);
41
+ } else {
42
+ fs.writeFileSync(outputPath, jsonFile, 'utf8');
43
+ console.log(`Tracking schema JSON file generated: ${outputPath}`);
44
+ }
45
+ }
46
+
47
+ module.exports = { generateYamlSchema, generateJsonSchema };