Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ yourCustomTrackFunctionName(userId, EVENT_NAME, PROPERTIES)

If your function follows the standard format `yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES)`, you can simply pass in `yourCustomTrackFunctionName` to `--customFunction` as a shorthand.

You can also pass in multiple custom function signatures by passing in the `--customFunction` option multiple times or by passing in a space-separated list of function signatures.

```sh
npx @flisk/analyze-tracking /path/to/project --customFunction "yourFunc1" --customFunction "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
npx @flisk/analyze-tracking /path/to/project -c "yourFunc1" "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
```


## What's Generated?
A clear YAML schema that shows where your events are tracked, their properties, and more.
Expand Down
1 change: 1 addition & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const optionDefinitions = [
name: 'customFunction',
alias: 'c',
type: String,
multiple: true,
},
{
name: 'repositoryUrl',
Expand Down
62 changes: 39 additions & 23 deletions src/analyze/go/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,47 +8,63 @@ const { extractGoAST } = require('./goAstParser');
const { buildTypeContext } = require('./typeContext');
const { deduplicateEvents } = require('./eventDeduplicator');
const { extractEventsFromBody } = require('./astTraversal');
const { processGoFile } = require('./utils');
const { parseCustomFunctionSignature } = require('../utils/customFunctionParser');

/**
* Analyze a Go file and extract tracking events
* @param {string} filePath - Path to the Go file to analyze
* @param {string|null} customFunctionSignature - Signature of custom tracking function to detect (optional)
* @param {string|null} customFunctionSignatures - Signature of custom tracking function to detect (optional)
* @returns {Promise<Array>} Array of tracking events found in the file
* @throws {Error} If the file cannot be read or parsed
*/
async function analyzeGoFile(filePath, customFunctionSignature) {
async function analyzeGoFile(filePath, customFunctionSignatures = null) {
try {
const customConfig = customFunctionSignature ? parseCustomFunctionSignature(customFunctionSignature) : null;

// Read the Go file
const source = fs.readFileSync(filePath, 'utf8');
// Parse the Go file using goAstParser

// Parse the Go file using goAstParser (once)
const ast = extractGoAST(source);

// First pass: build type information for functions and variables
const typeContext = buildTypeContext(ast);

// Extract tracking events from the AST
const events = [];
let currentFunction = 'global';

// Walk through the AST
for (const node of ast) {
if (node.tag === 'func') {
currentFunction = node.name;
// Process the function body
if (node.body) {
extractEventsFromBody(node.body, events, filePath, currentFunction, customConfig, typeContext, currentFunction);

const collectEventsForConfig = (customConfig) => {
const events = [];
let currentFunction = 'global';
for (const node of ast) {
if (node.tag === 'func') {
currentFunction = node.name;
if (node.body) {
extractEventsFromBody(
node.body,
events,
filePath,
currentFunction,
customConfig,
typeContext,
currentFunction
);
}
}
}
return events;
};

let events = [];

// Built-in providers pass (null custom config)
events.push(...collectEventsForConfig(null));

// Custom configs passes
if (Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0) {
for (const customConfig of customFunctionSignatures) {
if (!customConfig) continue;
events.push(...collectEventsForConfig(customConfig));
}
}

// Deduplicate events based on eventName, source, and function
const uniqueEvents = deduplicateEvents(events);

return uniqueEvents;
} catch (error) {
console.error(`Error analyzing Go file ${filePath}:`, error.message);
Expand Down
15 changes: 9 additions & 6 deletions src/analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@

const path = require('path');
const ts = require('typescript');
const { parseCustomFunctionSignature } = require('./utils/customFunctionParser');
const { getAllFiles } = require('../utils/fileProcessor');
const { analyzeJsFile } = require('./javascript');
const { analyzeTsFile } = require('./typescript');
const { analyzePythonFile } = require('./python');
const { analyzeRubyFile } = require('./ruby');
const { analyzeGoFile } = require('./go');

async function analyzeDirectory(dirPath, customFunction) {
async function analyzeDirectory(dirPath, customFunctions) {
const allEvents = {};

const customFunctionSignatures = (customFunctions && customFunctions?.length > 0) ? customFunctions.map(parseCustomFunctionSignature) : null;

const files = getAllFiles(dirPath);
const tsFiles = files.filter(file => /\.(tsx?)$/.test(file));
const tsProgram = ts.createProgram(tsFiles, {
Expand All @@ -32,15 +35,15 @@ async function analyzeDirectory(dirPath, customFunction) {
const isGoFile = /\.(go)$/.test(file);

if (isJsFile) {
events = analyzeJsFile(file, customFunction);
events = analyzeJsFile(file, customFunctionSignatures);
} else if (isTsFile) {
events = analyzeTsFile(file, tsProgram, customFunction);
events = analyzeTsFile(file, tsProgram, customFunctionSignatures);
} else if (isPythonFile) {
events = await analyzePythonFile(file, customFunction);
events = await analyzePythonFile(file, customFunctionSignatures);
} else if (isRubyFile) {
events = await analyzeRubyFile(file, customFunction);
events = await analyzeRubyFile(file, customFunctionSignatures);
} else if (isGoFile) {
events = await analyzeGoFile(file, customFunction);
events = await analyzeGoFile(file, customFunctionSignatures);
} else {
continue;
}
Expand Down
15 changes: 12 additions & 3 deletions src/analyze/javascript/extractors/event-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,18 @@ function processEventData(eventData, source, filePath, line, functionName, custo
// Handle custom extra params
if (source === 'custom' && customConfig && eventData.extraArgs) {
for (const [paramName, argNode] of Object.entries(eventData.extraArgs)) {
properties[paramName] = {
type: inferNodeValueType(argNode)
};
if (argNode && argNode.type === NODE_TYPES.OBJECT_EXPRESSION) {
// Extract detailed properties from object expression
properties[paramName] = {
type: 'object',
properties: extractProperties(argNode)
};
} else {
// For non-object arguments, use simple type inference
properties[paramName] = {
type: inferNodeValueType(argNode)
};
}
}
}

Expand Down
24 changes: 14 additions & 10 deletions src/analyze/javascript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,29 @@
*/

const { parseFile, findTrackingEvents, FileReadError, ParseError } = require('./parser');
const { parseCustomFunctionSignature } = require('../utils/customFunctionParser');

/**
* Analyzes a JavaScript file for analytics tracking calls
* @param {string} filePath - Path to the JavaScript file to analyze
* @param {string} [customFunction] - Optional custom function name to detect
* @returns {Array<Object>} Array of tracking events found in the file
*/
function analyzeJsFile(filePath, customFunctionSignature) {
const events = [];
const customConfig = customFunctionSignature ? parseCustomFunctionSignature(customFunctionSignature) : null;

function analyzeJsFile(filePath, customFunctionSignatures = null) {
try {
// Parse the file into an AST
// Parse the file into an AST once
const ast = parseFile(filePath);

// Find and extract tracking events
const foundEvents = findTrackingEvents(ast, filePath, customConfig);
events.push(...foundEvents);
// Single pass extraction covering built-in + all custom configs
const events = findTrackingEvents(ast, filePath, customFunctionSignatures || []);

// Deduplicate events (by source | eventName | line | functionName)
const unique = new Map();
for (const evt of events) {
const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
if (!unique.has(key)) unique.set(key, evt);
}

return Array.from(unique.values());

} catch (error) {
if (error instanceof FileReadError) {
Expand All @@ -34,7 +38,7 @@ function analyzeJsFile(filePath, customFunctionSignature) {
}
}

return events;
return [];
}

module.exports = { analyzeJsFile };
91 changes: 82 additions & 9 deletions src/analyze/javascript/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,95 @@ function parseFile(filePath) {
}
}

// ---------------------------------------------
// Helper – custom function matcher
// ---------------------------------------------

/**
* Walks the AST and finds analytics tracking calls
* @param {Object} ast - Parsed AST
* @param {string} filePath - Path to the file being analyzed
* @param {Object} [customConfig] - Custom function configuration object
* @returns {Array<Object>} Array of found events
* Determines whether a CallExpression node matches the provided custom function name.
* Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track).
* The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid
* circular dependencies.
* @param {Object} node – CallExpression AST node
* @param {string} fnName – Custom function name (could include dots)
* @returns {boolean}
*/
function findTrackingEvents(ast, filePath, customConfig) {
function nodeMatchesCustomFunction(node, fnName) {
if (!fnName || !node.callee) return false;

const parts = fnName.split('.');

// Simple identifier case
if (parts.length === 1) {
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === fnName;
}

// Member expression chain case
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
return false;
}

// Walk the chain from the right-most property to the leftmost object
let currentNode = node.callee;
let idx = parts.length - 1;

while (currentNode && idx >= 0) {
const expected = parts[idx];

if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
if (
currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
currentNode.property.name !== expected
) {
return false;
}
currentNode = currentNode.object;
idx -= 1;
} else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
return idx === 0 && currentNode.name === expected;
} else {
return false;
}
}

return false;
}

/**
* Walk the AST once and find tracking events for built-in providers plus any number of custom
* function configurations. This avoids the previous O(n * customConfigs) behaviour.
*
* @param {Object} ast – Parsed AST of the source file
* @param {string} filePath – Absolute/relative path to the source file
* @param {Object[]} [customConfigs=[]] – Array of parsed custom function configurations
* @returns {Array<Object>} – List of extracted tracking events
*/
function findTrackingEvents(ast, filePath, customConfigs = []) {
const events = [];

walk.ancestor(ast, {
[NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
try {
const event = extractTrackingEvent(node, ancestors, filePath, customConfig);
if (event) {
events.push(event);
let matchedCustomConfig = null;

// Attempt to match any custom function first to avoid mis-classifying built-in providers
if (Array.isArray(customConfigs) && customConfigs.length > 0) {
for (const cfg of customConfigs) {
if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) {
matchedCustomConfig = cfg;
break;
}
}
}

if (matchedCustomConfig) {
// Force source to 'custom' and use matched config
const event = extractTrackingEvent(node, ancestors, filePath, matchedCustomConfig);
if (event) events.push(event);
} else {
// Let built-in detector figure out source (pass undefined customFunction)
const event = extractTrackingEvent(node, ancestors, filePath, null);
if (event) events.push(event);
}
} catch (error) {
console.error(`Error processing node in ${filePath}:`, error.message);
Expand Down
Loading