@flisk/analyze-tracking 0.8.3 → 0.8.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,32 +8,59 @@ const path = require('path');
8
8
  const { parseCustomFunctionSignature } = require('./utils/customFunctionParser');
9
9
  const { getAllFiles } = require('../utils/fileProcessor');
10
10
  const { analyzeJsFile } = require('./javascript');
11
- const { analyzeTsFile } = require('./typescript');
11
+ const { analyzeTsFiles } = require('./typescript');
12
12
  const { analyzePythonFile } = require('./python');
13
13
  const { analyzeRubyFile } = require('./ruby');
14
14
  const { analyzeGoFile } = require('./go');
15
15
 
16
- async function analyzeDirectory(dirPath, customFunctions) {
17
- const allEvents = {};
18
-
19
- const customFunctionSignatures = (customFunctions && customFunctions?.length > 0) ? customFunctions.map(parseCustomFunctionSignature) : null;
16
+ /**
17
+ * Adds an event to the events collection, merging properties if event already exists
18
+ * @param {Object} allEvents - Collection of all events
19
+ * @param {Object} event - Event to add
20
+ * @param {string} baseDir - Base directory for relative path calculation
21
+ */
22
+ function addEventToCollection(allEvents, event, baseDir) {
23
+ const relativeFilePath = path.relative(baseDir, event.filePath);
24
+
25
+ const implementation = {
26
+ path: relativeFilePath,
27
+ line: event.line,
28
+ function: event.functionName,
29
+ destination: event.source
30
+ };
20
31
 
21
- const files = getAllFiles(dirPath);
32
+ if (!allEvents[event.eventName]) {
33
+ allEvents[event.eventName] = {
34
+ implementations: [implementation],
35
+ properties: event.properties,
36
+ };
37
+ } else {
38
+ allEvents[event.eventName].implementations.push(implementation);
39
+ allEvents[event.eventName].properties = {
40
+ ...allEvents[event.eventName].properties,
41
+ ...event.properties,
42
+ };
43
+ }
44
+ }
22
45
 
46
+ /**
47
+ * Processes all files that are not TypeScript files
48
+ * @param {Array<string>} files - Array of file paths
49
+ * @param {Object} allEvents - Collection to add events to
50
+ * @param {string} baseDir - Base directory for relative paths
51
+ * @param {Array} customFunctionSignatures - Custom function signatures to detect
52
+ */
53
+ async function processFiles(files, allEvents, baseDir, customFunctionSignatures) {
23
54
  for (const file of files) {
24
55
  let events = [];
25
56
 
26
57
  const isJsFile = /\.(jsx?)$/.test(file);
27
- const isTsFile = /\.(tsx?)$/.test(file);
28
58
  const isPythonFile = /\.(py)$/.test(file);
29
59
  const isRubyFile = /\.(rb)$/.test(file);
30
60
  const isGoFile = /\.(go)$/.test(file);
31
61
 
32
62
  if (isJsFile) {
33
63
  events = analyzeJsFile(file, customFunctionSignatures);
34
- } else if (isTsFile) {
35
- // Pass null program so analyzeTsFile will create a per-file program using the file's nearest tsconfig.json
36
- events = analyzeTsFile(file, null, customFunctionSignatures);
37
64
  } else if (isPythonFile) {
38
65
  events = await analyzePythonFile(file, customFunctionSignatures);
39
66
  } else if (isRubyFile) {
@@ -41,36 +68,42 @@ async function analyzeDirectory(dirPath, customFunctions) {
41
68
  } else if (isGoFile) {
42
69
  events = await analyzeGoFile(file, customFunctionSignatures);
43
70
  } else {
44
- continue;
71
+ continue; // Skip unsupported file types
45
72
  }
46
73
 
47
- events.forEach((event) => {
48
- const relativeFilePath = path.relative(dirPath, event.filePath);
74
+ events.forEach(event => addEventToCollection(allEvents, event, baseDir));
75
+ }
76
+ }
77
+
78
+ async function analyzeDirectory(dirPath, customFunctions) {
79
+ const allEvents = {};
80
+
81
+ const customFunctionSignatures = (customFunctions?.length > 0)
82
+ ? customFunctions.map(parseCustomFunctionSignature)
83
+ : null;
84
+
85
+ const files = getAllFiles(dirPath);
86
+
87
+ // Separate TypeScript files from others for optimized processing
88
+ const tsFiles = [];
89
+ const otherFiles = [];
90
+
91
+ for (const file of files) {
92
+ const isTsFile = /\.(tsx?)$/.test(file);
93
+ if (isTsFile) {
94
+ tsFiles.push(file);
95
+ } else {
96
+ otherFiles.push(file);
97
+ }
98
+ }
49
99
 
50
- if (!allEvents[event.eventName]) {
51
- allEvents[event.eventName] = {
52
- implementations: [{
53
- path: relativeFilePath,
54
- line: event.line,
55
- function: event.functionName,
56
- destination: event.source
57
- }],
58
- properties: event.properties,
59
- };
60
- } else {
61
- allEvents[event.eventName].implementations.push({
62
- path: relativeFilePath,
63
- line: event.line,
64
- function: event.functionName,
65
- destination: event.source
66
- });
100
+ // First process non-TypeScript files
101
+ await processFiles(otherFiles, allEvents, dirPath, customFunctionSignatures);
67
102
 
68
- allEvents[event.eventName].properties = {
69
- ...allEvents[event.eventName].properties,
70
- ...event.properties,
71
- };
72
- }
73
- });
103
+ // Process TypeScript files with optimized batch processing
104
+ if (tsFiles.length > 0) {
105
+ const tsEvents = analyzeTsFiles(tsFiles, customFunctionSignatures);
106
+ tsEvents.forEach(event => addEventToCollection(allEvents, event, dirPath));
74
107
  }
75
108
 
76
109
  return allEvents;
@@ -3,40 +3,130 @@
3
3
  * @module analyze/typescript
4
4
  */
5
5
 
6
- const { getProgram, findTrackingEvents, ProgramError, SourceFileError } = require('./parser');
6
+ const { getProgram, findTrackingEvents, ProgramError, SourceFileError, DEFAULT_COMPILER_OPTIONS } = require('./parser');
7
+ const ts = require('typescript');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Creates a standalone TypeScript program for a single file
12
+ * This is used as a fallback when the main program can't resolve the file
13
+ * @param {string} filePath - Path to the TypeScript file
14
+ * @returns {Object} TypeScript program
15
+ */
16
+ function createStandaloneProgram(filePath) {
17
+ const compilerOptions = {
18
+ ...DEFAULT_COMPILER_OPTIONS,
19
+ // We intentionally allow module resolution here so that imported constants
20
+ // (e.g. event name strings defined in a sibling file) can be followed by the
21
+ // TypeScript compiler.
22
+ isolatedModules: true
23
+ };
24
+
25
+ return ts.createProgram([filePath], compilerOptions);
26
+ }
27
+
28
+ /**
29
+ * Deduplicates events based on source, eventName, line, and functionName
30
+ * @param {Array<Object>} events - Array of events to deduplicate
31
+ * @returns {Array<Object>} Deduplicated events
32
+ */
33
+ function deduplicateEvents(events) {
34
+ const uniqueEvents = new Map();
35
+
36
+ for (const event of events) {
37
+ const key = `${event.source}|${event.eventName}|${event.line}|${event.functionName}`;
38
+ if (!uniqueEvents.has(key)) {
39
+ uniqueEvents.set(key, event);
40
+ }
41
+ }
42
+
43
+ return Array.from(uniqueEvents.values());
44
+ }
45
+
46
+ /**
47
+ * Attempts to analyze a file using a standalone program as fallback
48
+ * @param {string} filePath - Path to the TypeScript file
49
+ * @param {Array} customFunctionSignatures - Custom function signatures to detect
50
+ * @returns {Array<Object>} Array of events or empty array if failed
51
+ */
52
+ function tryStandaloneAnalysis(filePath, customFunctionSignatures) {
53
+ try {
54
+ console.warn(`Unable to resolve ${filePath} in main program. Attempting standalone analysis.`);
55
+
56
+ const standaloneProgram = createStandaloneProgram(filePath);
57
+ const sourceFile = standaloneProgram.getSourceFile(filePath);
58
+
59
+ if (!sourceFile) {
60
+ console.warn(`Standalone analysis failed: could not get source file for ${filePath}`);
61
+ return [];
62
+ }
63
+
64
+ const checker = standaloneProgram.getTypeChecker();
65
+ const events = findTrackingEvents(sourceFile, checker, filePath, customFunctionSignatures || []);
66
+
67
+ return deduplicateEvents(events);
68
+ } catch (standaloneError) {
69
+ console.warn(`Standalone analysis failed for ${filePath}: ${standaloneError.message}`);
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Gets or creates a cached TypeScript program for efficient reuse
76
+ * @param {string} filePath - Path to the TypeScript file
77
+ * @param {Map} programCache - Map of tsconfig paths to programs
78
+ * @returns {Object} TypeScript program
79
+ */
80
+ function getCachedTsProgram(filePath, programCache) {
81
+ // Locate nearest tsconfig.json (may be undefined)
82
+ const searchPath = path.dirname(filePath);
83
+ const configPath = ts.findConfigFile(searchPath, ts.sys.fileExists, 'tsconfig.json');
84
+
85
+ // We only cache when a tsconfig.json exists because the resulting program
86
+ // represents an entire project. If no config is present we build a
87
+ // stand-alone program that should not be reused for other files – otherwise
88
+ // later files would be missing from the program (which is precisely what
89
+ // caused the regression we are fixing).
90
+ const shouldCache = Boolean(configPath);
91
+ const cacheKey = configPath; // undefined when shouldCache is false
92
+
93
+ if (shouldCache && programCache.has(cacheKey)) {
94
+ return programCache.get(cacheKey);
95
+ }
96
+
97
+ const program = getProgram(filePath, null);
98
+
99
+ if (shouldCache) {
100
+ programCache.set(cacheKey, program);
101
+ }
102
+
103
+ return program;
104
+ }
7
105
 
8
106
  /**
9
107
  * Analyzes a TypeScript file for analytics tracking calls
10
108
  * @param {string} filePath - Path to the TypeScript file to analyze
11
109
  * @param {Object} [program] - Optional existing TypeScript program to reuse
12
- * @param {string} [customFunctionSignature] - Optional custom function signature to detect
110
+ * @param {Array} [customFunctionSignatures] - Optional custom function signatures to detect
13
111
  * @returns {Array<Object>} Array of tracking events found in the file
14
112
  */
15
113
  function analyzeTsFile(filePath, program = null, customFunctionSignatures = null) {
16
114
  try {
17
- // Get or create TypeScript program (only once)
115
+ // Get or create TypeScript program
18
116
  const tsProgram = getProgram(filePath, program);
19
117
 
20
118
  // Get source file from program
21
119
  const sourceFile = tsProgram.getSourceFile(filePath);
22
120
  if (!sourceFile) {
23
- throw new SourceFileError(filePath);
121
+ // Try standalone analysis as fallback
122
+ return tryStandaloneAnalysis(filePath, customFunctionSignatures);
24
123
  }
25
124
 
26
- // Get type checker
125
+ // Get type checker and find tracking events
27
126
  const checker = tsProgram.getTypeChecker();
28
-
29
- // Single-pass collection covering built-in + all custom configs
30
127
  const events = findTrackingEvents(sourceFile, checker, filePath, customFunctionSignatures || []);
31
128
 
32
- // Deduplicate events
33
- const unique = new Map();
34
- for (const evt of events) {
35
- const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
36
- if (!unique.has(key)) unique.set(key, evt);
37
- }
38
-
39
- return Array.from(unique.values());
129
+ return deduplicateEvents(events);
40
130
 
41
131
  } catch (error) {
42
132
  if (error instanceof ProgramError) {
@@ -46,9 +136,37 @@ function analyzeTsFile(filePath, program = null, customFunctionSignatures = null
46
136
  } else {
47
137
  console.error(`Error analyzing TypeScript file ${filePath}: ${error.message}`);
48
138
  }
139
+
140
+ return [];
49
141
  }
142
+ }
50
143
 
51
- return [];
144
+ /**
145
+ * Analyzes multiple TypeScript files with program reuse for better performance
146
+ * @param {Array<string>} tsFiles - Array of TypeScript file paths
147
+ * @param {Array} customFunctionSignatures - Custom function signatures to detect
148
+ * @returns {Array<Object>} Array of all tracking events found across all files
149
+ */
150
+ function analyzeTsFiles(tsFiles, customFunctionSignatures) {
151
+ const allEvents = [];
152
+ const tsProgramCache = new Map(); // tsconfig path -> program
153
+
154
+ for (const file of tsFiles) {
155
+ try {
156
+ // Use cached program or create new one
157
+ const program = getCachedTsProgram(file, tsProgramCache);
158
+ const events = analyzeTsFile(file, program, customFunctionSignatures);
159
+
160
+ allEvents.push(...events);
161
+ } catch (error) {
162
+ console.warn(`Error processing TypeScript file ${file}: ${error.message}`);
163
+ }
164
+ }
165
+
166
+ return allEvents;
52
167
  }
53
168
 
54
- module.exports = { analyzeTsFile };
169
+ module.exports = {
170
+ analyzeTsFile,
171
+ analyzeTsFiles
172
+ };
@@ -32,6 +32,103 @@ class SourceFileError extends Error {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * Default TypeScript compiler options for analysis
37
+ */
38
+ const DEFAULT_COMPILER_OPTIONS = {
39
+ target: ts.ScriptTarget.Latest,
40
+ module: ts.ModuleKind.CommonJS,
41
+ allowJs: true,
42
+ checkJs: false,
43
+ noEmit: true,
44
+ jsx: ts.JsxEmit.Preserve,
45
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
46
+ allowSyntheticDefaultImports: true,
47
+ esModuleInterop: true,
48
+ skipLibCheck: true
49
+ };
50
+
51
+ /**
52
+ * Maximum number of files to include in TypeScript program for performance
53
+ */
54
+ const MAX_FILES_THRESHOLD = 10000;
55
+
56
+ /**
57
+ * Attempts to parse tsconfig.json and extract compiler options and file names
58
+ * @param {string} configPath - Path to tsconfig.json
59
+ * @returns {Object|null} Parsed config with options and fileNames, or null if failed
60
+ */
61
+ function parseTsConfig(configPath) {
62
+ try {
63
+ const readResult = ts.readConfigFile(configPath, ts.sys.readFile);
64
+ if (readResult.error || !readResult.config) {
65
+ return null;
66
+ }
67
+
68
+ const parseResult = ts.parseJsonConfigFileContent(
69
+ readResult.config,
70
+ ts.sys,
71
+ path.dirname(configPath)
72
+ );
73
+
74
+ if (parseResult.errors && parseResult.errors.length > 0) {
75
+ return null;
76
+ }
77
+
78
+ return {
79
+ options: parseResult.options,
80
+ fileNames: parseResult.fileNames
81
+ };
82
+ } catch (error) {
83
+ console.warn(`Failed to parse tsconfig.json at ${configPath}. Error: ${error.message}`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Determines the appropriate files to include in the TypeScript program
90
+ * @param {string} filePath - Target file path
91
+ * @param {string|null} configPath - Path to tsconfig.json if found
92
+ * @returns {Object} Configuration with compilerOptions and rootNames
93
+ */
94
+ function getProgramConfiguration(filePath, configPath) {
95
+ let compilerOptions = { ...DEFAULT_COMPILER_OPTIONS };
96
+ let rootNames = [filePath];
97
+
98
+ if (!configPath) {
99
+ return { compilerOptions, rootNames };
100
+ }
101
+
102
+ const config = parseTsConfig(configPath);
103
+ if (!config) {
104
+ console.warn(`Failed to parse tsconfig.json at ${configPath}. Analyzing ${filePath} in isolation.`);
105
+ return { compilerOptions, rootNames };
106
+ }
107
+
108
+ // Inherit compiler options from tsconfig
109
+ compilerOptions = { ...compilerOptions, ...config.options };
110
+
111
+ // Determine file inclusion strategy based on project size
112
+ const projectFileCount = config.fileNames.length;
113
+
114
+ if (projectFileCount > 0 && projectFileCount <= MAX_FILES_THRESHOLD) {
115
+ // Small to medium project: include all files for better type checking
116
+ rootNames = [...config.fileNames];
117
+ if (!rootNames.includes(filePath)) {
118
+ rootNames.push(filePath);
119
+ }
120
+ } else if (projectFileCount > MAX_FILES_THRESHOLD) {
121
+ // Large project: only include the target file to avoid performance issues
122
+ console.warn(
123
+ `Large TypeScript project detected (${projectFileCount} files). ` +
124
+ `Analyzing ${filePath} in isolation for performance.`
125
+ );
126
+ rootNames = [filePath];
127
+ }
128
+
129
+ return { compilerOptions, rootNames };
130
+ }
131
+
35
132
  /**
36
133
  * Gets or creates a TypeScript program for analysis
37
134
  * @param {string} filePath - Path to the TypeScript file
@@ -45,38 +142,15 @@ function getProgram(filePath, existingProgram) {
45
142
  }
46
143
 
47
144
  try {
48
- // Try to locate a tsconfig.json nearest to the file to inherit compiler options (important for path aliases)
145
+ // Find the nearest tsconfig.json
49
146
  const searchPath = path.dirname(filePath);
50
147
  const configPath = ts.findConfigFile(searchPath, ts.sys.fileExists, 'tsconfig.json');
51
148
 
52
- let compilerOptions = {
53
- target: ts.ScriptTarget.Latest,
54
- module: ts.ModuleKind.CommonJS,
55
- allowJs: true,
56
- checkJs: false,
57
- noEmit: true,
58
- jsx: ts.JsxEmit.Preserve
59
- };
60
- let rootNames = [filePath];
61
-
62
- if (configPath) {
63
- // Read and parse the tsconfig.json
64
- const readResult = ts.readConfigFile(configPath, ts.sys.readFile);
65
- if (!readResult.error && readResult.config) {
66
- const parseResult = ts.parseJsonConfigFileContent(
67
- readResult.config,
68
- ts.sys,
69
- path.dirname(configPath)
70
- );
71
- if (!parseResult.errors || parseResult.errors.length === 0) {
72
- compilerOptions = { ...compilerOptions, ...parseResult.options };
73
- rootNames = parseResult.fileNames.length > 0 ? parseResult.fileNames : rootNames;
74
- }
75
- }
76
- }
149
+ // Get program configuration
150
+ const { compilerOptions, rootNames } = getProgramConfiguration(filePath, configPath);
77
151
 
78
- const program = ts.createProgram(rootNames, compilerOptions);
79
- return program;
152
+ // Create and return the TypeScript program
153
+ return ts.createProgram(rootNames, compilerOptions);
80
154
  } catch (error) {
81
155
  throw new ProgramError(filePath, error);
82
156
  }
@@ -94,27 +168,37 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
94
168
  const events = [];
95
169
 
96
170
  /**
97
- * Helper to test if a CallExpression matches a custom function name.
98
- * We simply rely on node.expression.getText() which preserves the fully qualified name.
171
+ * Tests if a CallExpression matches a custom function name
172
+ * @param {Object} callNode - The call expression node
173
+ * @param {string} functionName - Function name to match
174
+ * @returns {boolean} True if matches
99
175
  */
100
- const matchesCustomFn = (callNode, fnName) => {
101
- if (!fnName) return false;
176
+ function matchesCustomFunction(callNode, functionName) {
177
+ if (!functionName || !callNode.expression) {
178
+ return false;
179
+ }
180
+
102
181
  try {
103
- return callNode.expression && callNode.expression.getText() === fnName;
182
+ return callNode.expression.getText() === functionName;
104
183
  } catch {
105
184
  return false;
106
185
  }
107
- };
186
+ }
108
187
 
188
+ /**
189
+ * Recursively visits AST nodes to find tracking calls
190
+ * @param {Object} node - Current AST node
191
+ */
109
192
  function visit(node) {
110
193
  try {
111
194
  if (ts.isCallExpression(node)) {
112
- let matchedCustom = null;
195
+ let matchedCustomConfig = null;
113
196
 
197
+ // Check for custom function matches
114
198
  if (Array.isArray(customConfigs) && customConfigs.length > 0) {
115
- for (const cfg of customConfigs) {
116
- if (cfg && matchesCustomFn(node, cfg.functionName)) {
117
- matchedCustom = cfg;
199
+ for (const config of customConfigs) {
200
+ if (config && matchesCustomFunction(node, config.functionName)) {
201
+ matchedCustomConfig = config;
118
202
  break;
119
203
  }
120
204
  }
@@ -125,9 +209,12 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
125
209
  sourceFile,
126
210
  checker,
127
211
  filePath,
128
- matchedCustom /* may be null */
212
+ matchedCustomConfig
129
213
  );
130
- if (event) events.push(event);
214
+
215
+ if (event) {
216
+ events.push(event);
217
+ }
131
218
  }
132
219
 
133
220
  ts.forEachChild(node, visit);
@@ -137,7 +224,6 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
137
224
  }
138
225
 
139
226
  ts.forEachChild(sourceFile, visit);
140
-
141
227
  return events;
142
228
  }
143
229
 
@@ -172,5 +258,6 @@ module.exports = {
172
258
  getProgram,
173
259
  findTrackingEvents,
174
260
  ProgramError,
175
- SourceFileError
261
+ SourceFileError,
262
+ DEFAULT_COMPILER_OPTIONS
176
263
  };