Skip to content
Open
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
220 changes: 220 additions & 0 deletions packages/enricher/src/alias-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type Parser from "web-tree-sitter";
import { cleanStringValue, getCapture } from "./ast-helpers.js";
import type { LangFamily } from "./languages.js";
import { CLIENT_NAMES } from "./languages.js";
import type { ParserManager } from "./parser-manager.js";
import type { DetectionConfig } from "./types.js";

const POSTHOG_CLASS_NAMES = new Set(["PostHog", "Posthog"]);
const GO_CONSTRUCTOR_NAMES = new Set(["New", "NewWithConfig"]);

export function getEffectiveClients(config: DetectionConfig): Set<string> {
const clients = new Set(CLIENT_NAMES);
for (const name of config.additionalClientNames) {
clients.add(name);
}
return clients;
}

export function findAliases(
pm: ParserManager,
lang: Parser.Language,
tree: Parser.Tree,
family: LangFamily,
): {
clientAliases: Set<string>;
destructuredCapture: Set<string>;
destructuredFlag: Set<string>;
} {
const effectiveClients = getEffectiveClients(pm.config);
const clientAliases = new Set<string>();
const destructuredCapture = new Set<string>();
const destructuredFlag = new Set<string>();

// Client aliases: const tracker = posthog
const aliasQuery = pm.getQuery(lang, family.queries.clientAliases);
if (aliasQuery) {
for (const match of aliasQuery.matches(tree.rootNode)) {
const aliasNode = getCapture(match.captures, "alias");
const sourceNode = getCapture(match.captures, "source");
if (aliasNode && sourceNode && effectiveClients.has(sourceNode.text)) {
clientAliases.add(aliasNode.text);
}
}
}

// Constructor aliases: new PostHog('phc_...') / posthog.New("token") / PostHog::Client.new(...)
const constructorQuery = pm.getQuery(lang, family.queries.constructorAliases);
if (constructorQuery) {
for (const match of constructorQuery.matches(tree.rootNode)) {
const aliasNode = getCapture(match.captures, "alias");
const classNode = getCapture(match.captures, "class_name");
const pkgNode = getCapture(match.captures, "pkg_name");
const funcNode = getCapture(match.captures, "func_name");

if (aliasNode && classNode && POSTHOG_CLASS_NAMES.has(classNode.text)) {
clientAliases.add(aliasNode.text);
}
if (
aliasNode &&
pkgNode &&
funcNode &&
pkgNode.text === "posthog" &&
GO_CONSTRUCTOR_NAMES.has(funcNode.text)
) {
clientAliases.add(aliasNode.text);
}
const scopeNode = getCapture(match.captures, "scope_name");
const methodNameNode = getCapture(match.captures, "method_name");
if (
aliasNode &&
scopeNode &&
classNode &&
methodNameNode &&
POSTHOG_CLASS_NAMES.has(scopeNode.text) &&
classNode.text === "Client" &&
methodNameNode.text === "new"
) {
clientAliases.add(aliasNode.text);
}
}
}

// Destructured methods: const { capture, getFeatureFlag } = posthog
if (family.queries.destructuredMethods) {
const destructQuery = pm.getQuery(lang, family.queries.destructuredMethods);
if (destructQuery) {
for (const match of destructQuery.matches(tree.rootNode)) {
const methodNode = getCapture(match.captures, "method_name");
const sourceNode = getCapture(match.captures, "source");
if (methodNode && sourceNode && effectiveClients.has(sourceNode.text)) {
const name = methodNode.text;
if (family.captureMethods.has(name)) {
destructuredCapture.add(name);
}
if (family.flagMethods.has(name)) {
destructuredFlag.add(name);
}
}
}
}
}

return { clientAliases, destructuredCapture, destructuredFlag };
}

export function buildConstantMap(
pm: ParserManager,
lang: Parser.Language,
tree: Parser.Tree,
): Map<string, string> {
const constants = new Map<string, string>();

// JS: const/let/var declarations
const jsQuery = pm.getQuery(
lang,
`
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (string (string_fragment) @value)))

(variable_declaration
(variable_declarator
name: (identifier) @name
value: (string (string_fragment) @value)))
`,
);
if (jsQuery) {
for (const match of jsQuery.matches(tree.rootNode)) {
const nameNode = getCapture(match.captures, "name");
const valueNode = getCapture(match.captures, "value");
if (nameNode && valueNode) {
constants.set(nameNode.text, valueNode.text);
}
}
}

// Python: simple assignment — NAME = "value"
const pyQuery = pm.getQuery(
lang,
`
(expression_statement
(assignment
left: (identifier) @name
right: (string (string_content) @value)))
`,
);
if (pyQuery) {
for (const match of pyQuery.matches(tree.rootNode)) {
const nameNode = getCapture(match.captures, "name");
const valueNode = getCapture(match.captures, "value");
if (nameNode && valueNode) {
constants.set(nameNode.text, valueNode.text);
}
}
}

// Go: short var declarations and const declarations
const goVarQuery = pm.getQuery(
lang,
`
(short_var_declaration
left: (expression_list (identifier) @name)
right: (expression_list (interpreted_string_literal) @value))
`,
);
if (goVarQuery) {
for (const match of goVarQuery.matches(tree.rootNode)) {
const nameNode = getCapture(match.captures, "name");
const valueNode = getCapture(match.captures, "value");
if (nameNode && valueNode) {
constants.set(nameNode.text, cleanStringValue(valueNode.text));
}
}
}

const goConstQuery = pm.getQuery(
lang,
`
(const_declaration
(const_spec
name: (identifier) @name
value: (expression_list (interpreted_string_literal) @value)))
`,
);
if (goConstQuery) {
for (const match of goConstQuery.matches(tree.rootNode)) {
const nameNode = getCapture(match.captures, "name");
const valueNode = getCapture(match.captures, "value");
if (nameNode && valueNode) {
constants.set(nameNode.text, cleanStringValue(valueNode.text));
}
}
}

// Ruby: assignment — local var or constant
const rbQuery = pm.getQuery(
lang,
`
(assignment
left: (identifier) @name
right: (string (string_content) @value))

(assignment
left: (constant) @name
right: (string (string_content) @value))
`,
);
if (rbQuery) {
for (const match of rbQuery.matches(tree.rootNode)) {
const nameNode = getCapture(match.captures, "name");
const valueNode = getCapture(match.captures, "value");
if (nameNode && valueNode) {
constants.set(nameNode.text, valueNode.text);
}
}
}

return constants;
}
160 changes: 160 additions & 0 deletions packages/enricher/src/ast-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type Parser from "web-tree-sitter";

export interface Capture {
name: string;
node: Parser.SyntaxNode;
}

export function getCapture(
captures: Capture[],
name: string,
): Parser.SyntaxNode | null {
const found = captures.find((c) => c.name === name);
return found ? found.node : null;
}

export function extractClientName(
node: Parser.SyntaxNode,
detectNested: boolean,
): string | null {
if (node.type === "identifier") {
return node.text;
}
if (detectNested) {
if (node.type === "member_expression" || node.type === "attribute") {
const prop =
node.childForFieldName("property") ||
node.childForFieldName("attribute");
if (prop) {
return prop.text;
}
}
if (node.type === "selector_expression") {
const field = node.childForFieldName("field");
if (field) {
return field.text;
}
}
if (node.type === "optional_chain_expression") {
const inner = node.namedChildren[0];
if (inner?.type === "member_expression") {
const prop = inner.childForFieldName("property");
if (prop) {
return prop.text;
}
}
}
}
return null;
}

export function extractIdentifier(node: Parser.SyntaxNode): string | null {
if (node.type === "identifier") {
return node.text;
}
if (
node.type === "parenthesized_expression" &&
node.namedChildren.length === 1
) {
return extractIdentifier(node.namedChildren[0]);
}
return null;
}

export function extractStringFromCaseValue(
node: Parser.SyntaxNode,
): string | null {
if (node.type === "expression_list" && node.namedChildCount > 0) {
return extractStringFromNode(node.namedChildren[0]);
}
return extractStringFromNode(node);
}

export function extractStringFromNode(node: Parser.SyntaxNode): string | null {
if (node.type === "string" || node.type === "template_string") {
const content = node.namedChildren.find(
(c) =>
c.type === "string_fragment" ||
c.type === "string_content" ||
c.type === "string_value",
);
return content ? content.text : null;
}
if (
node.type === "interpreted_string_literal" ||
node.type === "raw_string_literal"
) {
return node.text.slice(1, -1);
}
if (node.type === "string_fragment" || node.type === "string_content") {
return node.text;
}
return null;
}

export function cleanStringValue(text: string): string {
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'")) ||
(text.startsWith("`") && text.endsWith("`"))
) {
return text.slice(1, -1);
}
return text;
}

const PARAM_SKIP = new Set([
"e",
"ev",
"event",
"evt",
"ctx",
"context",
"req",
"res",
"next",
"err",
"error",
"_",
"__",
]);

export function extractParams(paramsText: string): string[] {
let text = paramsText.trim();
if (text.startsWith("(")) {
text = text.slice(1);
}
if (text.endsWith(")")) {
text = text.slice(0, -1);
}
if (!text.trim()) {
return [];
}

return text
.split(",")
.map((p) => {
if (p.includes("{") || p.includes("}")) {
return "";
}
const name = p.split(":")[0].split("=")[0].replace(/[?.]/g, "").trim();
return name;
})
.filter((p) => p && !PARAM_SKIP.has(p) && !p.startsWith("..."));
}

export function walkNodes(
root: Parser.SyntaxNode,
type: string,
callback: (node: Parser.SyntaxNode) => void,
): void {
const visit = (node: Parser.SyntaxNode) => {
if (node.type === type) {
callback(node);
}
for (const child of node.namedChildren) {
visit(child);
}
};
visit(root);
}
Loading
Loading