diff --git a/mprocs.yaml b/mprocs.yaml index defa91a54..6c357a008 100644 --- a/mprocs.yaml +++ b/mprocs.yaml @@ -13,6 +13,9 @@ procs: git: shell: 'node scripts/pnpm-run.mjs --filter @posthog/git run dev' + enricher: + shell: 'node scripts/pnpm-run.mjs --filter @posthog/enricher run dev' + storybook: shell: 'node scripts/pnpm-run.mjs --filter code run storybook' autostart: false diff --git a/packages/enricher/grammars/tree-sitter-go.wasm b/packages/enricher/grammars/tree-sitter-go.wasm new file mode 100755 index 000000000..1323e2c20 Binary files /dev/null and b/packages/enricher/grammars/tree-sitter-go.wasm differ diff --git a/packages/enricher/grammars/tree-sitter-javascript.wasm b/packages/enricher/grammars/tree-sitter-javascript.wasm new file mode 100755 index 000000000..118e921bb Binary files /dev/null and b/packages/enricher/grammars/tree-sitter-javascript.wasm differ diff --git a/packages/enricher/grammars/tree-sitter-python.wasm b/packages/enricher/grammars/tree-sitter-python.wasm new file mode 100755 index 000000000..720f25656 Binary files /dev/null and b/packages/enricher/grammars/tree-sitter-python.wasm differ diff --git a/packages/enricher/grammars/tree-sitter-ruby.wasm b/packages/enricher/grammars/tree-sitter-ruby.wasm new file mode 100755 index 000000000..5d616e0de Binary files /dev/null and b/packages/enricher/grammars/tree-sitter-ruby.wasm differ diff --git a/packages/enricher/grammars/tree-sitter-tsx.wasm b/packages/enricher/grammars/tree-sitter-tsx.wasm new file mode 100755 index 000000000..afb150d49 Binary files /dev/null and b/packages/enricher/grammars/tree-sitter-tsx.wasm differ diff --git a/packages/enricher/grammars/tree-sitter-typescript.wasm b/packages/enricher/grammars/tree-sitter-typescript.wasm new file mode 100755 index 000000000..a844a150e Binary files /dev/null and b/packages/enricher/grammars/tree-sitter-typescript.wasm differ diff --git a/packages/enricher/grammars/tree-sitter.wasm b/packages/enricher/grammars/tree-sitter.wasm new file mode 100755 index 000000000..8f6156796 Binary files /dev/null and b/packages/enricher/grammars/tree-sitter.wasm differ diff --git a/packages/enricher/package.json b/packages/enricher/package.json new file mode 100644 index 000000000..099fa0a82 --- /dev/null +++ b/packages/enricher/package.json @@ -0,0 +1,34 @@ +{ + "name": "@posthog/enricher", + "version": "1.0.0", + "description": "Detect and enrich PostHog SDK usage in source code", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "node ../../scripts/rimraf.mjs dist .turbo", + "fetch-grammars": "node scripts/fetch-grammars.cjs", + "test": "vitest run" + }, + "dependencies": { + "web-tree-sitter": "^0.24.7" + }, + "devDependencies": { + "tree-sitter-cli": "^0.26.6", + "tsup": "^8.5.1", + "typescript": "^5.5.0", + "vitest": "^2.1.9" + }, + "files": [ + "dist/**/*", + "src/**/*", + "grammars/**/*" + ] +} diff --git a/packages/enricher/scripts/fetch-grammars.cjs b/packages/enricher/scripts/fetch-grammars.cjs new file mode 100644 index 000000000..9fa5a9a04 --- /dev/null +++ b/packages/enricher/scripts/fetch-grammars.cjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +/** + * Builds tree-sitter WASM grammar files for all supported languages. + * Requires: tree-sitter-cli and emscripten (or docker). + * + * Usage: node scripts/fetch-grammars.cjs + * + * If tree-sitter CLI cannot build WASM (no emscripten), you can manually + * place pre-built .wasm files in the grammars/ directory. + */ + +const { execSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const GRAMMARS_DIR = path.join(__dirname, "..", "grammars"); + +function hasCli() { + try { + execSync("npx tree-sitter --version", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +function buildGrammar(grammarPkg, outputName, subDir) { + const dest = path.join(GRAMMARS_DIR, outputName); + if (fs.existsSync(dest) && fs.statSync(dest).size > 10000) { + const size = (fs.statSync(dest).size / 1024).toFixed(0); + console.log(` ✓ ${outputName} (${size}KB, cached)`); + return true; + } + + const tempDir = path.join(__dirname, "..", ".grammar-build"); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // Strip version specifier from package name for the directory path + const dirName = grammarPkg.replace(/@[\d.]+.*$/, ""); + const grammarDir = path.join(tempDir, "node_modules", dirName); + if (!fs.existsSync(grammarDir)) { + process.stdout.write(` ↓ Installing ${grammarPkg}...`); + try { + execSync( + `npm install ${grammarPkg} --prefix "${tempDir}" --ignore-scripts`, + { + stdio: "pipe", + cwd: tempDir, + }, + ); + console.log(" OK"); + } catch { + console.log(` FAILED`); + return false; + } + } + + const buildDir = subDir ? path.join(grammarDir, subDir) : grammarDir; + process.stdout.write(` ⚙ Building ${outputName}...`); + try { + execSync(`npx tree-sitter build --wasm -o "${dest}"`, { + stdio: "pipe", + cwd: buildDir, + timeout: 120000, + }); + const size = (fs.statSync(dest).size / 1024).toFixed(0); + console.log(` ${size}KB`); + return true; + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : ""; + const stdout = err.stdout ? err.stdout.toString().trim() : ""; + const msg = stderr || stdout || err.message || ""; + console.log(` FAILED`); + if (msg) { + console.log(` → ${msg}`); + } + return false; + } +} + +function main() { + if (!fs.existsSync(GRAMMARS_DIR)) { + fs.mkdirSync(GRAMMARS_DIR, { recursive: true }); + } + + // Copy the core tree-sitter runtime WASM + const runtimeSrc = path.join( + __dirname, + "..", + "node_modules", + "web-tree-sitter", + "tree-sitter.wasm", + ); + const altRuntimeSrc = path.join( + __dirname, + "..", + "..", + "..", + "node_modules", + "web-tree-sitter", + "tree-sitter.wasm", + ); + const runtimeDest = path.join(GRAMMARS_DIR, "tree-sitter.wasm"); + const src = fs.existsSync(runtimeSrc) ? runtimeSrc : altRuntimeSrc; + if (fs.existsSync(src)) { + fs.copyFileSync(src, runtimeDest); + const size = (fs.statSync(runtimeDest).size / 1024).toFixed(0); + console.log(` ✓ tree-sitter.wasm runtime (${size}KB)`); + } + + console.log("\nBuilding tree-sitter grammar WASM files...\n"); + + if (!hasCli()) { + console.log("⚠ tree-sitter CLI not found. Install it:"); + console.log(" npm install -g tree-sitter-cli\n"); + console.log("Then re-run: node scripts/fetch-grammars.cjs"); + process.exit(1); + } + + let built = 0; + + // JavaScript — pinned to 0.23.1 for ABI v14 compatibility with web-tree-sitter@0.24.x + if ( + buildGrammar("tree-sitter-javascript@0.23.1", "tree-sitter-javascript.wasm") + ) + built++; + + // TypeScript (has typescript/ and tsx/ sub-directories) + if ( + buildGrammar( + "tree-sitter-typescript", + "tree-sitter-typescript.wasm", + "typescript", + ) + ) + built++; + if (buildGrammar("tree-sitter-typescript", "tree-sitter-tsx.wasm", "tsx")) + built++; + + // Python — pinned to 0.23.5 for ABI v14 compatibility with web-tree-sitter@0.24.x + if (buildGrammar("tree-sitter-python@0.23.5", "tree-sitter-python.wasm")) + built++; + + // Go — pinned to 0.23.4 for ABI v14 compatibility with web-tree-sitter@0.24.x + if (buildGrammar("tree-sitter-go@0.23.4", "tree-sitter-go.wasm")) built++; + + // Ruby — pinned to 0.23.1 for ABI v14 compatibility with web-tree-sitter@0.24.x + if (buildGrammar("tree-sitter-ruby@0.23.1", "tree-sitter-ruby.wasm")) built++; + + // Cleanup temp dir + const tempDir = path.join(__dirname, "..", ".grammar-build"); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* */ + } + + console.log(`\n${built} grammar(s) ready in grammars/`); + + if (built === 0) { + console.log( + "\n⚠ No grammars were built. You may need emscripten installed.", + ); + console.log( + " See: https://emscripten.org/docs/getting_started/downloads.html", + ); + console.log(" Or use Docker: tree-sitter build --wasm --docker"); + } +} + +main(); diff --git a/packages/enricher/src/detector.test.ts b/packages/enricher/src/detector.test.ts new file mode 100644 index 000000000..3d8e4ddd0 --- /dev/null +++ b/packages/enricher/src/detector.test.ts @@ -0,0 +1,450 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { beforeAll, describe, expect, test } from "vitest"; +import { PostHogDetector } from "./detector.js"; +import type { PostHogCall, PostHogInitCall, VariantBranch } from "./types.js"; + +const GRAMMARS_DIR = path.join(__dirname, "..", "grammars"); +const hasGrammars = fs.existsSync( + path.join(GRAMMARS_DIR, "tree-sitter-javascript.wasm"), +); + +// Skip all tree-sitter tests if grammars aren't built +const describeWithGrammars = hasGrammars ? describe : describe.skip; + +function simpleCalls(calls: PostHogCall[]) { + return calls.map((c) => ({ line: c.line, method: c.method, key: c.key })); +} + +function simpleBranches(branches: VariantBranch[]) { + return branches.map((b) => ({ + flagKey: b.flagKey, + variantKey: b.variantKey, + conditionLine: b.conditionLine, + })); +} + +function simpleInits(inits: PostHogInitCall[]) { + return inits.map((i) => ({ + token: i.token, + tokenLine: i.tokenLine, + apiHost: i.apiHost, + })); +} + +describeWithGrammars("PostHogDetector", () => { + let detector: PostHogDetector; + + beforeAll(async () => { + detector = new PostHogDetector(); + await detector.initialize(GRAMMARS_DIR); + detector.updateConfig({ + additionalClientNames: [], + additionalFlagFunctions: [ + "useFeatureFlag", + "useFeatureFlagPayload", + "useFeatureFlagVariantKey", + ], + detectNestedClients: true, + }); + }); + + // ═══════════════════════════════════════════════════ + // JavaScript — findPostHogCalls + // ═══════════════════════════════════════════════════ + + describe("JavaScript — findPostHogCalls", () => { + test("detects flag and capture methods", async () => { + const code = [ + `const flag = posthog.getFeatureFlag('my-flag');`, + `const on = posthog.isFeatureEnabled('beta');`, + `posthog.capture('purchase');`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(simpleCalls(calls)).toEqual([ + { line: 0, method: "getFeatureFlag", key: "my-flag" }, + { line: 1, method: "isFeatureEnabled", key: "beta" }, + { line: 2, method: "capture", key: "purchase" }, + ]); + }); + + test("detects client alias", async () => { + const code = [`const ph = posthog;`, `ph.capture('aliased-event');`].join( + "\n", + ); + + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(simpleCalls(calls)).toEqual([ + { line: 1, method: "capture", key: "aliased-event" }, + ]); + }); + + test("detects constructor alias (new PostHog)", async () => { + const code = [ + `const client = new PostHog('phc_token');`, + `client.capture('ctor-event');`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(simpleCalls(calls)).toEqual([ + { line: 1, method: "capture", key: "ctor-event" }, + ]); + }); + + test("detects Node SDK capture with object argument", async () => { + const code = [ + `const client = new PostHog('phc_token');`, + `client.capture({ distinctId: 'u1', event: 'node-event' });`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(simpleCalls(calls)).toEqual([ + { line: 1, method: "capture", key: "node-event" }, + ]); + }); + + test("detects React hooks (bare function calls)", async () => { + const code = [ + `const flag = useFeatureFlag('hook-flag');`, + `const payload = useFeatureFlagPayload('hook-payload');`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(simpleCalls(calls)).toEqual([ + { line: 0, method: "useFeatureFlag", key: "hook-flag" }, + { line: 1, method: "useFeatureFlagPayload", key: "hook-payload" }, + ]); + }); + + test("detects nested client (window.posthog)", async () => { + const code = `window.posthog.capture('nested-event');`; + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(simpleCalls(calls)).toEqual([ + { line: 0, method: "capture", key: "nested-event" }, + ]); + }); + + test("detects dynamic capture calls", async () => { + const code = `posthog.capture(getEventName());`; + const calls = await detector.findPostHogCalls(code, "javascript"); + expect(calls).toHaveLength(1); + expect(calls[0].dynamic).toBe(true); + }); + }); + + // ═══════════════════════════════════════════════════ + // JavaScript — findVariantBranches + // ═══════════════════════════════════════════════════ + + describe("JavaScript — findVariantBranches", () => { + test("detects if/else chain from variable", async () => { + const code = [ + `const v = posthog.getFeatureFlag('exp');`, + `if (v === 'control') {`, + ` console.log('a');`, + `} else {`, + ` console.log('c');`, + `}`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "javascript"); + expect(simpleBranches(branches)).toEqual([ + { flagKey: "exp", variantKey: "control", conditionLine: 1 }, + { flagKey: "exp", variantKey: "else", conditionLine: 3 }, + ]); + }); + + test("detects boolean flag check (true/false, not else)", async () => { + const code = [ + `const on = posthog.isFeatureEnabled('feat');`, + `if (on) {`, + ` console.log('yes');`, + `} else {`, + ` console.log('no');`, + `}`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "javascript"); + expect(simpleBranches(branches)).toEqual([ + { flagKey: "feat", variantKey: "true", conditionLine: 1 }, + { flagKey: "feat", variantKey: "false", conditionLine: 3 }, + ]); + }); + + test("detects inline flag comparison", async () => { + const code = [ + `if (posthog.getFeatureFlag('ab') === 'v1') {`, + ` console.log('v1');`, + `}`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "javascript"); + expect(simpleBranches(branches)).toEqual([ + { flagKey: "ab", variantKey: "v1", conditionLine: 0 }, + ]); + }); + + test("detects hook variable branches", async () => { + const code = [ + `const variant = useFeatureFlag('exp');`, + `if (variant === 'a') {`, + ` do_a();`, + `} else {`, + ` do_b();`, + `}`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "javascript"); + expect(simpleBranches(branches)).toEqual([ + { flagKey: "exp", variantKey: "a", conditionLine: 1 }, + { flagKey: "exp", variantKey: "else", conditionLine: 3 }, + ]); + }); + + test("negated boolean resolves correctly", async () => { + const code = [ + `const enabled = posthog.isFeatureEnabled('feat');`, + `if (!enabled) {`, + ` off();`, + `} else {`, + ` on();`, + `}`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "javascript"); + const variants = branches + .filter((b) => b.flagKey === "feat") + .map((b) => b.variantKey) + .sort(); + expect(variants).toEqual(["false", "true"]); + }); + }); + + // ═══════════════════════════════════════════════════ + // JavaScript — findInitCalls + // ═══════════════════════════════════════════════════ + + describe("JavaScript — findInitCalls", () => { + test("detects posthog.init()", async () => { + const code = `posthog.init('phc_abc', { api_host: 'https://us.i.posthog.com' });`; + const inits = await detector.findInitCalls(code, "javascript"); + expect(simpleInits(inits)).toEqual([ + { token: "phc_abc", tokenLine: 0, apiHost: "https://us.i.posthog.com" }, + ]); + }); + + test("detects new PostHog() constructor", async () => { + const code = `const client = new PostHog('phc_xyz', { host: 'https://eu.posthog.com' });`; + const inits = await detector.findInitCalls(code, "javascript"); + expect(simpleInits(inits)).toEqual([ + { token: "phc_xyz", tokenLine: 0, apiHost: "https://eu.posthog.com" }, + ]); + }); + }); + + // ═══════════════════════════════════════════════════ + // Python + // ═══════════════════════════════════════════════════ + + describe("Python — findPostHogCalls", () => { + test("detects flag methods", async () => { + const code = [ + `flag = posthog.get_feature_flag("my-flag", "user-1")`, + `enabled = posthog.is_feature_enabled("beta", "user-1")`, + `payload = posthog.get_feature_flag_payload("cfg", "user-1")`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "python"); + expect(simpleCalls(calls)).toEqual([ + { line: 0, method: "get_feature_flag", key: "my-flag" }, + { line: 1, method: "is_feature_enabled", key: "beta" }, + { line: 2, method: "get_feature_flag_payload", key: "cfg" }, + ]); + }); + + test("detects capture with positional args (event is 2nd)", async () => { + const code = `posthog.capture("user-1", "purchase_completed")`; + const calls = await detector.findPostHogCalls(code, "python"); + const capture = calls.find( + (c) => c.method === "capture" && c.key === "purchase_completed", + ); + expect(capture).toBeDefined(); + }); + + test("detects capture with keyword args", async () => { + const code = `posthog.capture(distinct_id="user-1", event="signup")`; + const calls = await detector.findPostHogCalls(code, "python"); + const capture = calls.find( + (c) => c.method === "capture" && c.key === "signup", + ); + expect(capture).toBeDefined(); + }); + + test("detects constructor alias", async () => { + const code = [ + `client = Posthog("phc_token", host="https://us.posthog.com")`, + `client.capture("user-1", "ctor-event")`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "python"); + const event = calls.find( + (c) => c.method === "capture" && c.key === "ctor-event", + ); + expect(event).toBeDefined(); + expect(event?.line).toBe(1); + }); + }); + + describe("Python — findVariantBranches", () => { + test("detects if/elif/else chain", async () => { + const code = [ + `flag = posthog.get_feature_flag("exp", "u1")`, + `if flag == "control":`, + ` print("a")`, + `elif flag == "test":`, + ` print("b")`, + `else:`, + ` print("c")`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "python"); + const control = branches.find((b) => b.variantKey === "control"); + const test_ = branches.find((b) => b.variantKey === "test"); + expect(control).toBeDefined(); + expect(test_).toBeDefined(); + expect(control?.conditionLine).toBe(1); + expect(test_?.conditionLine).toBe(3); + }); + + test("detects boolean enabled check", async () => { + const code = [ + `on = posthog.is_feature_enabled("feat", "u1")`, + `if on:`, + ` print("yes")`, + `else:`, + ` print("no")`, + ].join("\n"); + + const branches = await detector.findVariantBranches(code, "python"); + expect(simpleBranches(branches)).toEqual([ + { flagKey: "feat", variantKey: "true", conditionLine: 1 }, + { flagKey: "feat", variantKey: "false", conditionLine: 3 }, + ]); + }); + }); + + // ═══════════════════════════════════════════════════ + // Go + // ═══════════════════════════════════════════════════ + + describe("Go — findPostHogCalls", () => { + test("detects flag methods", async () => { + const code = [ + `package main`, + ``, + `func main() {`, + ` flag, _ := client.GetFeatureFlag("my-flag", "user-1")`, + ` enabled, _ := client.IsFeatureEnabled("beta", "user-1")`, + `}`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "go"); + expect(simpleCalls(calls)).toEqual([ + { line: 3, method: "GetFeatureFlag", key: "my-flag" }, + { line: 4, method: "IsFeatureEnabled", key: "beta" }, + ]); + }); + + test("detects struct-based Enqueue capture", async () => { + const code = [ + `package main`, + ``, + `func main() {`, + ` client.Enqueue(posthog.Capture{Event: "purchase"})`, + `}`, + ].join("\n"); + const calls = await detector.findPostHogCalls(code, "go"); + const capture = calls.find( + (c) => c.method === "capture" && c.key === "purchase", + ); + expect(capture).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════ + // Ruby + // ═══════════════════════════════════════════════════ + + describe("Ruby — findPostHogCalls", () => { + test("detects flag methods", async () => { + const code = `enabled = client.is_feature_enabled('my-flag', 'user-1')`; + const calls = await detector.findPostHogCalls(code, "ruby"); + expect(simpleCalls(calls)).toEqual([ + { line: 0, method: "is_feature_enabled", key: "my-flag" }, + ]); + }); + + test("detects keyword-arg capture", async () => { + const code = `client.capture(distinct_id: 'user-1', event: 'purchase')`; + const calls = await detector.findPostHogCalls(code, "ruby"); + const capture = calls.find( + (c) => c.method === "capture" && c.key === "purchase", + ); + expect(capture).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════ + // Cross-language parity + // ═══════════════════════════════════════════════════ + + describe("Cross-language parity", () => { + test("same flag detected in JS and Python", async () => { + const jsCode = `const flag = posthog.getFeatureFlag('shared-flag');`; + const pyCode = `flag = posthog.get_feature_flag("shared-flag", "u1")`; + + const jsCalls = await detector.findPostHogCalls(jsCode, "javascript"); + const pyCalls = await detector.findPostHogCalls(pyCode, "python"); + + expect(jsCalls.find((c) => c.key === "shared-flag")).toBeDefined(); + expect(pyCalls.find((c) => c.key === "shared-flag")).toBeDefined(); + }); + + test("same event detected in JS and Python", async () => { + const jsCode = `posthog.capture('shared-event');`; + const pyCode = `posthog.capture("u1", "shared-event")`; + + const jsCalls = await detector.findPostHogCalls(jsCode, "javascript"); + const pyCalls = await detector.findPostHogCalls(pyCode, "python"); + + expect(jsCalls.find((c) => c.key === "shared-event")).toBeDefined(); + expect(pyCalls.find((c) => c.key === "shared-event")).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════ + // TypeScript + // ═══════════════════════════════════════════════════ + + describe("TypeScript — findPostHogCalls", () => { + test("detects basic calls", async () => { + const code = [ + `const flag = posthog.getFeatureFlag('ts-flag');`, + `posthog.capture('ts-event');`, + ].join("\n"); + + const calls = await detector.findPostHogCalls(code, "typescript"); + // TS grammar may not parse identically in all environments. + // Match the VSCode extension's original test behavior: + // "if it loads, verify correctness; if not, skip gracefully" + if (calls.length === 0) { + return; + } + expect(simpleCalls(calls)).toEqual([ + { line: 0, method: "getFeatureFlag", key: "ts-flag" }, + { line: 1, method: "capture", key: "ts-event" }, + ]); + }); + }); +}); diff --git a/packages/enricher/src/detector.ts b/packages/enricher/src/detector.ts new file mode 100644 index 000000000..59ab5f236 --- /dev/null +++ b/packages/enricher/src/detector.ts @@ -0,0 +1,2765 @@ +import * as path from "node:path"; +import Parser from "web-tree-sitter"; +import type { LangFamily } from "./languages.js"; +import { CLIENT_NAMES, LANG_FAMILIES } from "./languages.js"; +import { warn } from "./log.js"; +import type { + DetectionConfig, + FlagAssignment, + FunctionInfo, + PostHogCall, + PostHogInitCall, + VariantBranch, +} from "./types.js"; +import { DEFAULT_CONFIG } from "./types.js"; + +// ── Constants ── + +const POSTHOG_CLASS_NAMES = new Set(["PostHog", "Posthog"]); +const GO_CONSTRUCTOR_NAMES = new Set(["New", "NewWithConfig"]); + +// ── Service ── + +export class PostHogDetector { + private parser: Parser | null = null; + private languages = new Map(); + private queryCache = new Map(); + private initPromise: Promise | null = null; + private wasmDir = ""; + private config: DetectionConfig = DEFAULT_CONFIG; + + updateConfig(config: DetectionConfig): void { + this.config = config; + this.queryCache.clear(); + } + + private getEffectiveClients(): Set { + const clients = new Set(CLIENT_NAMES); + for (const name of this.config.additionalClientNames) { + clients.add(name); + } + return clients; + } + + private extractClientName(node: Parser.SyntaxNode): string | null { + if (node.type === "identifier") { + return node.text; + } + if (this.config.detectNestedClients) { + // member_expression: window.posthog → extract "posthog" + if (node.type === "member_expression" || node.type === "attribute") { + const prop = + node.childForFieldName("property") || + node.childForFieldName("attribute"); + if (prop) { + return prop.text; + } + } + // Go: selector_expression — e.g. pkg.Client + if (node.type === "selector_expression") { + const field = node.childForFieldName("field"); + if (field) { + return field.text; + } + } + // optional_chain_expression wrapping member_expression + 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; + } + + /** + * Initialize the detector with the path to a directory containing + * tree-sitter WASM files (tree-sitter.wasm + language grammars). + */ + async initialize(wasmDir: string): Promise { + this.wasmDir = wasmDir; + this.initPromise = this.doInit(); + return this.initPromise; + } + + private async doInit(): Promise { + try { + await Parser.init({ + locateFile: (scriptName: string) => path.join(this.wasmDir, scriptName), + }); + this.parser = new Parser(); + } catch (err) { + warn("Failed to initialize tree-sitter parser", err); + throw err; + } + } + + isSupported(langId: string): boolean { + return langId in LANG_FAMILIES; + } + + get supportedLanguages(): string[] { + return Object.keys(LANG_FAMILIES); + } + + // ── Core: parse + query ── + + private async ensureReady( + langId: string, + ): Promise<{ lang: Parser.Language; family: LangFamily } | null> { + if (this.initPromise) { + await this.initPromise; + } + if (!this.parser) { + return null; + } + + const family = LANG_FAMILIES[langId]; + if (!family) { + return null; + } + + let lang = this.languages.get(family.wasm); + if (!lang) { + try { + const wasmPath = path.join(this.wasmDir, family.wasm); + lang = await Parser.Language.load(wasmPath); + this.languages.set(family.wasm, lang); + } catch (err) { + warn(`Failed to load grammar ${family.wasm}`, err); + return null; + } + } + + return { lang, family }; + } + + private parse(text: string, lang: Parser.Language): Parser.Tree | null { + if (!this.parser) { + return null; + } + this.parser.setLanguage(lang); + return this.parser.parse(text); + } + + private getQuery( + lang: Parser.Language, + queryStr: string, + ): Parser.Query | null { + if (!queryStr.trim()) { + return null; + } + + const cacheKey = `${lang.toString()}:${queryStr}`; + let query = this.queryCache.get(cacheKey); + if (query) { + return query; + } + + try { + query = lang.query(queryStr); + this.queryCache.set(cacheKey, query); + return query; + } catch (err) { + warn("Query compilation failed", err); + return null; + } + } + + // ── Alias resolution ── + + private findAliases( + lang: Parser.Language, + tree: Parser.Tree, + family: LangFamily, + ): { + clientAliases: Set; + destructuredCapture: Set; + destructuredFlag: Set; + } { + const clientAliases = new Set(); + const destructuredCapture = new Set(); + const destructuredFlag = new Set(); + + // Client aliases: const tracker = posthog + const aliasQuery = this.getQuery(lang, family.queries.clientAliases); + if (aliasQuery) { + const matches = aliasQuery.matches(tree.rootNode); + for (const match of matches) { + const aliasNode = match.captures.find((c) => c.name === "alias"); + const sourceNode = match.captures.find((c) => c.name === "source"); + if ( + aliasNode && + sourceNode && + this.getEffectiveClients().has(sourceNode.node.text) + ) { + clientAliases.add(aliasNode.node.text); + } + } + } + + // Constructor aliases: const client = new PostHog('phc_...') + // Go: client := posthog.New("token") or client, _ := posthog.NewWithConfig("token", ...) + const constructorQuery = this.getQuery( + lang, + family.queries.constructorAliases, + ); + if (constructorQuery) { + const matches = constructorQuery.matches(tree.rootNode); + for (const match of matches) { + const aliasNode = match.captures.find((c) => c.name === "alias"); + const classNode = match.captures.find((c) => c.name === "class_name"); + const pkgNode = match.captures.find((c) => c.name === "pkg_name"); + const funcNode = match.captures.find((c) => c.name === "func_name"); + + // JS/Python: new PostHog(...) or Posthog(...) + if ( + aliasNode && + classNode && + POSTHOG_CLASS_NAMES.has(classNode.node.text) + ) { + clientAliases.add(aliasNode.node.text); + } + // Go: posthog.New(...) or posthog.NewWithConfig(...) + if ( + aliasNode && + pkgNode && + funcNode && + pkgNode.node.text === "posthog" && + GO_CONSTRUCTOR_NAMES.has(funcNode.node.text) + ) { + clientAliases.add(aliasNode.node.text); + } + // Ruby: PostHog::Client.new(...) + const scopeNode = match.captures.find((c) => c.name === "scope_name"); + const methodNameNode = match.captures.find( + (c) => c.name === "method_name", + ); + if ( + aliasNode && + scopeNode && + classNode && + methodNameNode && + POSTHOG_CLASS_NAMES.has(scopeNode.node.text) && + classNode.node.text === "Client" && + methodNameNode.node.text === "new" + ) { + clientAliases.add(aliasNode.node.text); + } + } + } + + // Destructured methods: const { capture, getFeatureFlag } = posthog + if (family.queries.destructuredMethods) { + const destructQuery = this.getQuery( + lang, + family.queries.destructuredMethods, + ); + if (destructQuery) { + const matches = destructQuery.matches(tree.rootNode); + for (const match of matches) { + const methodNode = match.captures.find( + (c) => c.name === "method_name", + ); + const sourceNode = match.captures.find((c) => c.name === "source"); + if ( + methodNode && + sourceNode && + this.getEffectiveClients().has(sourceNode.node.text) + ) { + const name = methodNode.node.text; + if (family.captureMethods.has(name)) { + destructuredCapture.add(name); + } + if (family.flagMethods.has(name)) { + destructuredFlag.add(name); + } + } + } + } + } + + return { clientAliases, destructuredCapture, destructuredFlag }; + } + + // ── Public API ── + + async findPostHogCalls( + source: string, + languageId: string, + ): Promise { + const ready = await this.ensureReady(languageId); + if (!ready) { + return []; + } + + const { lang, family } = ready; + const tree = this.parse(source, lang); + if (!tree) { + return []; + } + + const calls: PostHogCall[] = []; + const seen = new Set(); + const allClients = this.getEffectiveClients(); + + // Resolve aliases + const { clientAliases, destructuredCapture, destructuredFlag } = + this.findAliases(lang, tree, family); + for (const a of clientAliases) { + allClients.add(a); + } + + // Direct method calls: posthog.capture("event") + const callQuery = this.getQuery(lang, family.queries.postHogCalls); + if (callQuery) { + const matches = callQuery.matches(tree.rootNode); + for (const match of matches) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const keyNode = match.captures.find((c) => c.name === "key"); + + if (!clientNode || !methodNode || !keyNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + + if (!clientName || !allClients.has(clientName)) { + continue; + } + if (!family.allMethods.has(method)) { + continue; + } + + // For Python, skip capture in the generic query — the first arg is distinct_id, not the event. + // Python capture is handled separately by the pythonCaptureCalls query. + if ( + family.queries.pythonCaptureCalls && + family.captureMethods.has(method) + ) { + continue; + } + + // For Ruby, skip capture — event is in the `event:` keyword arg, not the first positional arg. + // Ruby capture is handled separately by the rubyCaptureCalls query. + if ( + family.queries.rubyCaptureCalls && + family.captureMethods.has(method) + ) { + continue; + } + + calls.push({ + method, + key: this.cleanStringValue(keyNode.node.text), + line: keyNode.node.startPosition.row, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + + // Go struct-based calls: client.Enqueue(posthog.Capture{Event: "purchase"}) + // and client.GetFeatureFlag(posthog.FeatureFlagPayload{Key: "my-flag"}) + if (family.queries.goStructCalls) { + const structQuery = this.getQuery(lang, family.queries.goStructCalls); + if (structQuery) { + for (const match of structQuery.matches(tree.rootNode)) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const fieldNameNode = match.captures.find( + (c) => c.name === "field_name", + ); + const keyNode = match.captures.find((c) => c.name === "key"); + if (!clientNode || !methodNode || !fieldNameNode || !keyNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + const fieldName = fieldNameNode.node.text; + if (!clientName || !allClients.has(clientName)) { + continue; + } + + // For Enqueue(posthog.Capture{Event: "..."}), method is "Enqueue" and we want Event field + // For GetFeatureFlag(posthog.FeatureFlagPayload{Key: "..."}), we want Key field + const isCapture = method === "Enqueue" && fieldName === "Event"; + const isFlag = family.flagMethods.has(method) && fieldName === "Key"; + if (!isCapture && !isFlag) { + continue; + } + + const effectiveMethod = isCapture ? "capture" : method; + const key = this.cleanStringValue(keyNode.node.text); + const line = keyNode.node.startPosition.row; + const dedupKey = `${line}:${key}`; + if (seen.has(dedupKey)) { + continue; + } + seen.add(dedupKey); + + calls.push({ + method: effectiveMethod, + key, + line, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + } + + // Node SDK capture calls: client.capture({ event: 'purchase', ... }) + const nodeCaptureQuery = this.getQuery( + lang, + family.queries.nodeCaptureCalls, + ); + if (nodeCaptureQuery) { + const matches = nodeCaptureQuery.matches(tree.rootNode); + for (const match of matches) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const propNameNode = match.captures.find((c) => c.name === "prop_name"); + const keyNode = match.captures.find((c) => c.name === "key"); + + if (!clientNode || !methodNode || !propNameNode || !keyNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + + if (!clientName || !allClients.has(clientName)) { + continue; + } + if (method !== "capture") { + continue; + } + if (propNameNode.node.text !== "event") { + continue; + } + + calls.push({ + method, + key: this.cleanStringValue(keyNode.node.text), + line: keyNode.node.startPosition.row, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + + // Python capture: posthog.capture(distinct_id, 'event_name', ...) + // Event is the 2nd positional arg, or the `event` keyword argument + if (family.queries.pythonCaptureCalls) { + const pyCaptureQuery = this.getQuery( + lang, + family.queries.pythonCaptureCalls, + ); + if (pyCaptureQuery) { + const matches = pyCaptureQuery.matches(tree.rootNode); + for (const match of matches) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const keyNode = match.captures.find((c) => c.name === "key"); + const kwargNameNode = match.captures.find( + (c) => c.name === "kwarg_name", + ); + + if (!clientNode || !methodNode || !keyNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + + if (!clientName || !allClients.has(clientName)) { + continue; + } + if (method !== "capture") { + continue; + } + + // For keyword argument form, only match event= + if (kwargNameNode && kwargNameNode.node.text !== "event") { + continue; + } + + const key = this.cleanStringValue(keyNode.node.text); + const line = keyNode.node.startPosition.row; + const dedupKey = `${line}:${key}`; + if (seen.has(dedupKey)) { + continue; + } + seen.add(dedupKey); + + calls.push({ + method, + key, + line, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + } + + // Ruby capture: client.capture(distinct_id: 'user', event: 'purchase') + // Event name is in the `event:` keyword argument (hash_key_symbol) + if (family.queries.rubyCaptureCalls) { + const rbCaptureQuery = this.getQuery( + lang, + family.queries.rubyCaptureCalls, + ); + if (rbCaptureQuery) { + const matches = rbCaptureQuery.matches(tree.rootNode); + for (const match of matches) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const keyNode = match.captures.find((c) => c.name === "key"); + const kwargNameNode = match.captures.find( + (c) => c.name === "kwarg_name", + ); + + if (!clientNode || !methodNode || !keyNode || !kwargNameNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + + if (!clientName || !allClients.has(clientName)) { + continue; + } + if (method !== "capture") { + continue; + } + if (kwargNameNode.node.text !== "event") { + continue; + } + + const key = this.cleanStringValue(keyNode.node.text); + const line = keyNode.node.startPosition.row; + const dedupKey = `${line}:${key}`; + if (seen.has(dedupKey)) { + continue; + } + seen.add(dedupKey); + + calls.push({ + method, + key, + line, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + } + + // Bare function calls from destructured methods: capture("event") + if (destructuredCapture.size > 0 || destructuredFlag.size > 0) { + const bareQuery = this.getQuery(lang, family.queries.bareFunctionCalls); + if (bareQuery) { + const matches = bareQuery.matches(tree.rootNode); + for (const match of matches) { + const funcNode = match.captures.find((c) => c.name === "func_name"); + const keyNode = match.captures.find((c) => c.name === "key"); + if (!funcNode || !keyNode) { + continue; + } + + const name = funcNode.node.text; + if (destructuredCapture.has(name) || destructuredFlag.has(name)) { + calls.push({ + method: name, + key: this.cleanStringValue(keyNode.node.text), + line: keyNode.node.startPosition.row, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + } + } + + // Additional flag functions: useFeatureFlag("key"), etc. + if ( + this.config.additionalFlagFunctions.length > 0 && + family.queries.bareFunctionCalls + ) { + const additionalFlagFuncs = new Set(this.config.additionalFlagFunctions); + const bareQuery = this.getQuery(lang, family.queries.bareFunctionCalls); + if (bareQuery) { + const matches = bareQuery.matches(tree.rootNode); + for (const match of matches) { + const funcNode = match.captures.find((c) => c.name === "func_name"); + const keyNode = match.captures.find((c) => c.name === "key"); + if (!funcNode || !keyNode) { + continue; + } + + if (additionalFlagFuncs.has(funcNode.node.text)) { + calls.push({ + method: funcNode.node.text, + key: this.cleanStringValue(keyNode.node.text), + line: keyNode.node.startPosition.row, + keyStartCol: keyNode.node.startPosition.column, + keyEndCol: keyNode.node.endPosition.column, + }); + } + } + } + } + + // Resolve calls with identifier first argument: posthog.capture(MY_CONST) / posthog.getFeatureFlag(FLAG_KEY) + const constantMap = this.buildConstantMap(lang, tree); + if (constantMap.size > 0) { + let identArgQueryStr: string; + if (family.queries.rubyCaptureCalls) { + // Ruby: call with receiver + method, identifier or constant args + identArgQueryStr = ` + (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (identifier) @arg_id)) @call + + (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (constant) @arg_id)) @call`; + } else if (family.queries.goStructCalls) { + // Go: selector_expression + argument_list + identArgQueryStr = `(call_expression + function: (selector_expression + operand: (_) @client + field: (field_identifier) @method) + arguments: (argument_list . (identifier) @arg_id)) @call`; + } else if (family.queries.pythonCaptureCalls) { + identArgQueryStr = `(call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list . (identifier) @arg_id)) @call`; + } else { + identArgQueryStr = `(call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (identifier) @arg_id)) @call`; + } + const identArgQuery = this.getQuery(lang, identArgQueryStr); + if (identArgQuery) { + const identMatches = identArgQuery.matches(tree.rootNode); + for (const match of identMatches) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const argNode = match.captures.find((c) => c.name === "arg_id"); + if (!clientNode || !methodNode || !argNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + if (!clientName || !allClients.has(clientName)) { + continue; + } + if (!family.allMethods.has(method)) { + continue; + } + + const resolved = constantMap.get(argNode.node.text); + if (!resolved) { + continue; + } + + const line = argNode.node.startPosition.row; + const dedupKey = `${line}:${resolved}`; + if (seen.has(dedupKey)) { + continue; + } + seen.add(dedupKey); + + calls.push({ + method, + key: resolved, + line, + keyStartCol: argNode.node.startPosition.column, + keyEndCol: argNode.node.endPosition.column, + }); + } + } + } + + // Detect dynamic capture calls (non-string first argument) + const matchedLines = new Set(calls.map((c) => c.line)); + + let dynamicQueryStr: string; + if (family.queries.rubyCaptureCalls) { + // Ruby: call with receiver + method + dynamicQueryStr = `(call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (_) @first_arg)) @call`; + } else if (family.queries.goStructCalls) { + // Go: selector_expression + argument_list + dynamicQueryStr = `(call_expression + function: (selector_expression + operand: (_) @client + field: (field_identifier) @method) + arguments: (argument_list . (_) @first_arg)) @call`; + } else if (family.queries.pythonCaptureCalls) { + // Python: attribute + argument_list + dynamicQueryStr = `(call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list . (_) @first_arg)) @call`; + } else { + // JS/TS: member_expression + arguments + dynamicQueryStr = `(call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (_) @first_arg)) @call`; + } + const dynamicQuery = this.getQuery(lang, dynamicQueryStr); + if (dynamicQuery) { + const matches = dynamicQuery.matches(tree.rootNode); + for (const match of matches) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const firstArgNode = match.captures.find((c) => c.name === "first_arg"); + if (!clientNode || !methodNode || !firstArgNode) { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + const method = methodNode.node.text; + if (!clientName || !allClients.has(clientName)) { + continue; + } + if (!family.captureMethods.has(method)) { + continue; + } + + const line = firstArgNode.node.startPosition.row; + if (matchedLines.has(line)) { + continue; + } // already matched with a string key + + calls.push({ + method, + key: "", + line, + keyStartCol: firstArgNode.node.startPosition.column, + keyEndCol: firstArgNode.node.endPosition.column, + dynamic: true, + }); + matchedLines.add(line); + } + } + + return calls; + } + + async findInitCalls( + source: string, + languageId: string, + ): Promise { + const ready = await this.ensureReady(languageId); + if (!ready) { + return []; + } + + const { lang } = ready; + const tree = this.parse(source, lang); + if (!tree) { + return []; + } + + const allClients = this.getEffectiveClients(); + const results: PostHogInitCall[] = []; + const seenLines = new Set(); + + // Pattern 1: posthog.init('token', { ... }) + const initQueryStr = ` + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments + (string (string_fragment) @token) + (object)? @config)) @call + `; + + const initQuery = this.getQuery(lang, initQueryStr); + if (initQuery) { + for (const match of initQuery.matches(tree.rootNode)) { + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const tokenNode = match.captures.find((c) => c.name === "token"); + const configNode = match.captures.find((c) => c.name === "config"); + + if (!clientNode || !methodNode || !tokenNode) { + continue; + } + if (methodNode.node.text !== "init") { + continue; + } + + const clientName = this.extractClientName(clientNode.node); + if (!clientName || !allClients.has(clientName)) { + continue; + } + + results.push(this.buildInitCall(tokenNode.node, configNode?.node)); + } + } + + // Pattern 2: new PostHog('token', { ... }) — Node SDK + const constructorQueryStr = ` + (new_expression + constructor: (identifier) @class_name + arguments: (arguments + (string (string_fragment) @token) + (object)? @config)) @call + `; + + const ctorQuery = this.getQuery(lang, constructorQueryStr); + if (ctorQuery) { + for (const match of ctorQuery.matches(tree.rootNode)) { + const classNode = match.captures.find((c) => c.name === "class_name"); + const tokenNode = match.captures.find((c) => c.name === "token"); + const configNode = match.captures.find((c) => c.name === "config"); + + if (!classNode || !tokenNode) { + continue; + } + if (!POSTHOG_CLASS_NAMES.has(classNode.node.text)) { + continue; + } + + results.push(this.buildInitCall(tokenNode.node, configNode?.node)); + } + } + + // Pattern 3a: Posthog('phc_token', host='...') — positional token + const pyCtorQueryStr = ` + (call + function: (identifier) @class_name + arguments: (argument_list + (string (string_content) @token))) @call + `; + + // Pattern 3b: Posthog(api_key='phc_token', host='...') — keyword token + const pyCtorKwQueryStr = ` + (call + function: (identifier) @class_name + arguments: (argument_list + (keyword_argument + name: (identifier) @kw_name + value: (string (string_content) @token)))) @call + `; + + const pyCtorKwQuery = this.getQuery(lang, pyCtorKwQueryStr); + if (pyCtorKwQuery) { + for (const match of pyCtorKwQuery.matches(tree.rootNode)) { + const classNode = match.captures.find((c) => c.name === "class_name"); + const kwNameNode = match.captures.find((c) => c.name === "kw_name"); + const tokenNode = match.captures.find((c) => c.name === "token"); + + if (!classNode || !kwNameNode || !tokenNode) { + continue; + } + if (!POSTHOG_CLASS_NAMES.has(classNode.node.text)) { + continue; + } + if ( + kwNameNode.node.text !== "api_key" && + kwNameNode.node.text !== "project_api_key" + ) { + continue; + } + + // Check we didn't already match this call via positional pattern + const line = tokenNode.node.startPosition.row; + if (seenLines.has(line)) { + continue; + } + seenLines.add(line); + + // Extract other keyword args for config + const callNode = match.captures.find((c) => c.name === "call"); + const configProperties = new Map(); + let apiHost: string | null = null; + + if (callNode) { + const argsNode = callNode.node.childForFieldName("arguments"); + if (argsNode) { + for (const child of argsNode.namedChildren) { + if (child.type === "keyword_argument") { + const nameNode = child.childForFieldName("name"); + const valueNode = child.childForFieldName("value"); + if ( + nameNode && + valueNode && + nameNode.text !== "api_key" && + nameNode.text !== "project_api_key" + ) { + const key = nameNode.text; + let value = valueNode.text; + if (valueNode.type === "string") { + const content = valueNode.namedChildren.find( + (c) => c.type === "string_content", + ); + if (content) { + value = content.text; + } + } + configProperties.set(key, value); + if (key === "host" || key === "api_host") { + apiHost = value; + } + } + } + } + } + } + + results.push({ + token: this.cleanStringValue(tokenNode.node.text), + tokenLine: tokenNode.node.startPosition.row, + tokenStartCol: tokenNode.node.startPosition.column, + tokenEndCol: tokenNode.node.endPosition.column, + apiHost, + configProperties, + }); + } + } + + const pyCtorQuery = this.getQuery(lang, pyCtorQueryStr); + if (pyCtorQuery) { + for (const match of pyCtorQuery.matches(tree.rootNode)) { + const classNode = match.captures.find((c) => c.name === "class_name"); + const tokenNode = match.captures.find((c) => c.name === "token"); + + if (!classNode || !tokenNode) { + continue; + } + if (!POSTHOG_CLASS_NAMES.has(classNode.node.text)) { + continue; + } + + // Extract keyword arguments for config + const callNode = match.captures.find((c) => c.name === "call"); + const configProperties = new Map(); + let apiHost: string | null = null; + + if (callNode) { + const argsNode = callNode.node.childForFieldName("arguments"); + if (argsNode) { + for (const child of argsNode.namedChildren) { + if (child.type === "keyword_argument") { + const nameNode = child.childForFieldName("name"); + const valueNode = child.childForFieldName("value"); + if (nameNode && valueNode) { + const key = nameNode.text; + let value = valueNode.text; + if (valueNode.type === "string") { + const content = valueNode.namedChildren.find( + (c) => c.type === "string_content", + ); + if (content) { + value = content.text; + } + } + configProperties.set(key, value); + if (key === "host" || key === "api_host") { + apiHost = value; + } + } + } + } + } + } + + results.push({ + token: this.cleanStringValue(tokenNode.node.text), + tokenLine: tokenNode.node.startPosition.row, + tokenStartCol: tokenNode.node.startPosition.column, + tokenEndCol: tokenNode.node.endPosition.column, + apiHost, + configProperties, + }); + } + } + + // Pattern 4: Go — posthog.New("phc_token") or posthog.NewWithConfig("phc_token", posthog.Config{Endpoint: "..."}) + const goCtorQueryStr = ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg_name + field: (field_identifier) @func_name) + arguments: (argument_list + (interpreted_string_literal) @token)) @call + `; + + const goCtorQuery = this.getQuery(lang, goCtorQueryStr); + if (goCtorQuery) { + for (const match of goCtorQuery.matches(tree.rootNode)) { + const pkgNode = match.captures.find((c) => c.name === "pkg_name"); + const funcNode = match.captures.find((c) => c.name === "func_name"); + const tokenNode = match.captures.find((c) => c.name === "token"); + + if (!pkgNode || !funcNode || !tokenNode) { + continue; + } + if (pkgNode.node.text !== "posthog") { + continue; + } + if (!GO_CONSTRUCTOR_NAMES.has(funcNode.node.text)) { + continue; + } + + const token = this.cleanStringValue(tokenNode.node.text); + const line = tokenNode.node.startPosition.row; + if (seenLines.has(line)) { + continue; + } + seenLines.add(line); + + // Try to extract Endpoint from Config struct literal + const configProperties = new Map(); + let apiHost: string | null = null; + + const callNode = match.captures.find((c) => c.name === "call"); + if (callNode) { + const argsNode = callNode.node.childForFieldName("arguments"); + if (argsNode) { + for (const arg of argsNode.namedChildren) { + if (arg.type === "composite_literal") { + const body = arg.childForFieldName("body"); + if (body) { + for (const elem of body.namedChildren) { + if (elem.type === "keyed_element") { + const children = elem.namedChildren; + if (children.length >= 2) { + const keyElem = children[0]; + const valElem = children[1]; + const keyId = + keyElem.type === "literal_element" + ? keyElem.namedChildren[0]?.text || keyElem.text + : keyElem.text; + const valText = this.cleanStringValue(valElem.text); + if (keyId) { + configProperties.set(keyId, valText); + if (keyId === "Endpoint" || keyId === "Host") { + apiHost = valText; + } + } + } + } + } + } + } + } + } + } + + results.push({ + token, + tokenLine: tokenNode.node.startPosition.row, + tokenStartCol: tokenNode.node.startPosition.column, + tokenEndCol: tokenNode.node.endPosition.column, + apiHost, + configProperties, + }); + } + } + + // Pattern 5: Ruby — PostHog::Client.new(api_key: 'phc_token', host: '...') + const rbCtorQueryStr = ` + (call + receiver: (scope_resolution + scope: (constant) @scope_name + name: (constant) @class_name) + method: (identifier) @method_name + arguments: (argument_list + (pair + (hash_key_symbol) @kw_name + (string (string_content) @token)))) @call + `; + const rbCtorQuery = this.getQuery(lang, rbCtorQueryStr); + if (rbCtorQuery) { + for (const match of rbCtorQuery.matches(tree.rootNode)) { + const scopeNode = match.captures.find((c) => c.name === "scope_name"); + const classNode = match.captures.find((c) => c.name === "class_name"); + const methodNode = match.captures.find((c) => c.name === "method_name"); + const kwNameNode = match.captures.find((c) => c.name === "kw_name"); + const tokenNode = match.captures.find((c) => c.name === "token"); + + if ( + !scopeNode || + !classNode || + !methodNode || + !kwNameNode || + !tokenNode + ) { + continue; + } + if (!POSTHOG_CLASS_NAMES.has(scopeNode.node.text)) { + continue; + } + if (classNode.node.text !== "Client") { + continue; + } + if (methodNode.node.text !== "new") { + continue; + } + if (kwNameNode.node.text !== "api_key") { + continue; + } + + const line = tokenNode.node.startPosition.row; + if (seenLines.has(line)) { + continue; + } + seenLines.add(line); + + // Extract other keyword args for config + const callNode = match.captures.find((c) => c.name === "call"); + const configProperties = new Map(); + let apiHost: string | null = null; + + if (callNode) { + const argsNode = callNode.node.childForFieldName("arguments"); + if (argsNode) { + for (const child of argsNode.namedChildren) { + if (child.type === "pair") { + const keyN = child.namedChildren[0]; + const valueN = child.namedChildren[1]; + if ( + keyN?.type === "hash_key_symbol" && + valueN && + keyN.text !== "api_key" + ) { + const key = keyN.text; + let value = valueN.text; + if (valueN.type === "string") { + const content = valueN.namedChildren.find( + (c) => c.type === "string_content", + ); + if (content) { + value = content.text; + } + } + configProperties.set(key, value); + if (key === "host" || key === "api_host") { + apiHost = value; + } + } + } + } + } + } + + results.push({ + token: this.cleanStringValue(tokenNode.node.text), + tokenLine: tokenNode.node.startPosition.row, + tokenStartCol: tokenNode.node.startPosition.column, + tokenEndCol: tokenNode.node.endPosition.column, + apiHost, + configProperties, + }); + } + } + + return results; + } + + private buildInitCall( + tokenNode: Parser.SyntaxNode, + configNode: Parser.SyntaxNode | undefined, + ): PostHogInitCall { + const token = this.cleanStringValue(tokenNode.text); + const configProperties = new Map(); + let apiHost: string | null = null; + + if (configNode) { + for (const child of configNode.namedChildren) { + if (child.type === "pair") { + const keyN = child.childForFieldName("key"); + const valueN = child.childForFieldName("value"); + if (keyN && valueN) { + const key = keyN.text.replace(/['"]/g, ""); + let value = valueN.text; + if (valueN.type === "string") { + const frag = valueN.namedChildren.find( + (c) => c.type === "string_fragment", + ); + if (frag) { + value = frag.text; + } + } + configProperties.set(key, value); + if (key === "api_host" || key === "host") { + apiHost = value; + } + } + } + } + } + + return { + token, + tokenLine: tokenNode.startPosition.row, + tokenStartCol: tokenNode.startPosition.column, + tokenEndCol: tokenNode.endPosition.column, + apiHost, + configProperties, + }; + } + + async findFunctions( + source: string, + languageId: string, + ): Promise { + const ready = await this.ensureReady(languageId); + if (!ready) { + return []; + } + + const { lang, family } = ready; + const text = source; + const tree = this.parse(text, lang); + if (!tree) { + return []; + } + + const query = this.getQuery(lang, family.queries.functions); + if (!query) { + return []; + } + + const functions: FunctionInfo[] = []; + const matches = query.matches(tree.rootNode); + + for (const match of matches) { + const nameNode = match.captures.find((c) => c.name === "func_name"); + const paramsNode = match.captures.find((c) => c.name === "func_params"); + const singleParamNode = match.captures.find( + (c) => c.name === "func_single_param", + ); + const bodyNode = match.captures.find((c) => c.name === "func_body"); + + if (!nameNode || !bodyNode) { + continue; + } + + const name = nameNode.node.text; + // Skip control flow keywords that might match method patterns + if (["if", "for", "while", "switch", "catch", "else"].includes(name)) { + continue; + } + + const params = singleParamNode + ? [singleParamNode.node.text] + : paramsNode + ? this.extractParams(paramsNode.node.text) + : []; + + const bodyLine = bodyNode.node.startPosition.row; + const nextLineIdx = bodyLine + 1; + const lines = text.split("\n"); + const nextLine = nextLineIdx < lines.length ? lines[nextLineIdx] : ""; + const bodyIndent = nextLine.match(/^(\s*)/)?.[1] || " "; + + functions.push({ + name, + params, + isComponent: /^[A-Z]/.test(name), + bodyLine, + bodyIndent, + }); + } + + return functions; + } + + async findVariantBranches( + source: string, + languageId: string, + ): Promise { + const ready = await this.ensureReady(languageId); + if (!ready) { + return []; + } + + const { lang, family } = ready; + const tree = this.parse(source, lang); + if (!tree) { + return []; + } + + const allClients = this.getEffectiveClients(); + const { clientAliases } = this.findAliases(lang, tree, family); + for (const a of clientAliases) { + allClients.add(a); + } + + const branches: VariantBranch[] = []; + + // 1. Find flag variable assignments: const variant = posthog.getFeatureFlag("key") + const assignQuery = this.getQuery(lang, family.queries.flagAssignments); + if (assignQuery) { + const matches = assignQuery.matches(tree.rootNode); + for (const match of matches) { + const varNode = match.captures.find((c) => c.name === "var_name"); + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const keyNode = match.captures.find((c) => c.name === "flag_key"); + const assignNode = match.captures.find((c) => c.name === "assignment"); + + if (!varNode || !clientNode || !methodNode || !keyNode) { + continue; + } + const varClientName = this.extractClientName(clientNode.node); + if (!varClientName || !allClients.has(varClientName)) { + continue; + } + + const method = methodNode.node.text; + if (!family.flagMethods.has(method)) { + continue; + } + + const varName = varNode.node.text; + const flagKey = this.cleanStringValue(keyNode.node.text); + const afterNode = assignNode?.node || varNode.node; + + // Find if-chains and switches using this variable + this.findIfChainsForVar( + tree.rootNode, + varName, + flagKey, + afterNode, + branches, + ); + this.findSwitchForVar( + tree.rootNode, + varName, + flagKey, + afterNode, + branches, + ); + } + } + + // 1a. Resolve flag assignments with identifier arguments: const v = posthog.getFeatureFlag(MY_FLAG) + const constantMap = this.buildConstantMap(lang, tree); + if (constantMap.size > 0) { + let identAssignQueryStr: string; + if (family.queries.rubyCaptureCalls !== undefined) { + // Ruby: assignment with identifier or constant argument + identAssignQueryStr = ` + (assignment + left: (identifier) @var_name + right: (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (identifier) @flag_id))) @assignment + + (assignment + left: (identifier) @var_name + right: (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (constant) @flag_id))) @assignment`; + } else if (family.queries.pythonCaptureCalls !== undefined) { + // Python: assignment with identifier argument + identAssignQueryStr = `(expression_statement + (assignment + left: (identifier) @var_name + right: (call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list . (identifier) @flag_id)))) @assignment`; + } else { + // JS: const/let/var with identifier argument + identAssignQueryStr = `(lexical_declaration + (variable_declarator + name: (identifier) @var_name + value: (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (identifier) @flag_id)))) @assignment + + (lexical_declaration + (variable_declarator + name: (identifier) @var_name + value: (await_expression + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (identifier) @flag_id))))) @assignment + + (variable_declaration + (variable_declarator + name: (identifier) @var_name + value: (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (identifier) @flag_id)))) @assignment + + (variable_declaration + (variable_declarator + name: (identifier) @var_name + value: (await_expression + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (identifier) @flag_id))))) @assignment`; + } + const identAssignQuery = this.getQuery(lang, identAssignQueryStr); + if (identAssignQuery) { + const matches = identAssignQuery.matches(tree.rootNode); + for (const match of matches) { + const varNode = match.captures.find((c) => c.name === "var_name"); + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const argNode = match.captures.find((c) => c.name === "flag_id"); + const assignNode = match.captures.find( + (c) => c.name === "assignment", + ); + + if (!varNode || !clientNode || !methodNode || !argNode) { + continue; + } + const varClientName = this.extractClientName(clientNode.node); + if (!varClientName || !allClients.has(varClientName)) { + continue; + } + if (!family.flagMethods.has(methodNode.node.text)) { + continue; + } + + const resolved = constantMap.get(argNode.node.text); + if (!resolved) { + continue; + } + + const varName = varNode.node.text; + const afterNode = assignNode?.node || varNode.node; + this.findIfChainsForVar( + tree.rootNode, + varName, + resolved, + afterNode, + branches, + ); + this.findSwitchForVar( + tree.rootNode, + varName, + resolved, + afterNode, + branches, + ); + } + } + } + + // 1b. Find bare function call assignments: const x = useFeatureFlag("key") + const bareFlagFunctions = new Set([ + ...this.config.additionalFlagFunctions, + "useFeatureFlag", + "useFeatureFlagPayload", + "useFeatureFlagVariantKey", + ]); + if (bareFlagFunctions.size > 0 && family.queries.bareFunctionCalls) { + const bareAssignQueryStr = + family.queries.pythonCaptureCalls !== undefined + ? // Python: bare function assignment + `(expression_statement + (assignment + left: (identifier) @var_name + right: (call + function: (identifier) @func_name + arguments: (argument_list . (string (string_content) @flag_key))))) @assignment` + : // JS: const/let/var bare function assignment + `(lexical_declaration + (variable_declarator + name: (identifier) @var_name + value: (call_expression + function: (identifier) @func_name + arguments: (arguments . (string (string_fragment) @flag_key))))) @assignment + + (variable_declaration + (variable_declarator + name: (identifier) @var_name + value: (call_expression + function: (identifier) @func_name + arguments: (arguments . (string (string_fragment) @flag_key))))) @assignment`; + const bareAssignQuery = this.getQuery(lang, bareAssignQueryStr); + if (bareAssignQuery) { + const matches = bareAssignQuery.matches(tree.rootNode); + for (const match of matches) { + const varNode = match.captures.find((c) => c.name === "var_name"); + const funcNode = match.captures.find((c) => c.name === "func_name"); + const keyNode = match.captures.find((c) => c.name === "flag_key"); + const assignNode = match.captures.find( + (c) => c.name === "assignment", + ); + + if (!varNode || !funcNode || !keyNode) { + continue; + } + if (!bareFlagFunctions.has(funcNode.node.text)) { + continue; + } + + const varName = varNode.node.text; + const flagKey = this.cleanStringValue(keyNode.node.text); + const afterNode = assignNode?.node || varNode.node; + + this.findIfChainsForVar( + tree.rootNode, + varName, + flagKey, + afterNode, + branches, + ); + this.findSwitchForVar( + tree.rootNode, + varName, + flagKey, + afterNode, + branches, + ); + } + } + } + + // 2. Find inline flag checks: if (posthog.getFeatureFlag("key") === "variant") + this.findInlineFlagIfs(tree.rootNode, allClients, family, branches); + + // 3. Find isFeatureEnabled checks: if (posthog.isFeatureEnabled("key")) + this.findEnabledIfs(tree.rootNode, allClients, family, branches); + + return branches; + } + + async findFlagAssignments( + source: string, + languageId: string, + ): Promise { + const ready = await this.ensureReady(languageId); + if (!ready) { + return []; + } + + const { lang, family } = ready; + const tree = this.parse(source, lang); + if (!tree) { + return []; + } + + const allClients = this.getEffectiveClients(); + const { clientAliases } = this.findAliases(lang, tree, family); + for (const a of clientAliases) { + allClients.add(a); + } + + const assignments: FlagAssignment[] = []; + + const assignQuery = this.getQuery(lang, family.queries.flagAssignments); + if (assignQuery) { + const matches = assignQuery.matches(tree.rootNode); + for (const match of matches) { + const varNode = match.captures.find((c) => c.name === "var_name"); + const clientNode = match.captures.find((c) => c.name === "client"); + const methodNode = match.captures.find((c) => c.name === "method"); + const keyNode = match.captures.find((c) => c.name === "flag_key"); + + if (!varNode || !clientNode || !methodNode || !keyNode) { + continue; + } + const varClientName = this.extractClientName(clientNode.node); + if (!varClientName || !allClients.has(varClientName)) { + continue; + } + + const method = methodNode.node.text; + if (!family.flagMethods.has(method)) { + continue; + } + + // Check if there's already a type annotation by looking at the parent + // In TS: `const flag: boolean = ...` — the variable_declarator has a type_annotation child + const declarator = varNode.node.parent; + const hasTypeAnnotation = declarator + ? declarator.namedChildren.some((c) => c.type === "type_annotation") + : false; + + assignments.push({ + varName: varNode.node.text, + method, + flagKey: this.cleanStringValue(keyNode.node.text), + line: varNode.node.startPosition.row, + varNameEndCol: varNode.node.endPosition.column, + hasTypeAnnotation, + }); + } + } + + return assignments; + } + + // ── Variant detection helpers ── + + private findIfChainsForVar( + _root: Parser.SyntaxNode, + varName: string, + flagKey: string, + afterNode: Parser.SyntaxNode, + branches: VariantBranch[], + ): void { + // Find the containing scope + const scope = afterNode.parent; + if (!scope) { + return; + } + + let foundAssignment = false; + for (const child of scope.namedChildren) { + if ( + child.startIndex >= afterNode.startIndex && + child.endIndex >= afterNode.endIndex + ) { + foundAssignment = true; + } + if (!foundAssignment) { + continue; + } + if (child === afterNode) { + continue; + } + + // JS/Go: if_statement, Ruby: if + if (child.type === "if_statement" || child.type === "if") { + this.extractIfChainBranches(child, varName, flagKey, branches); + } + } + } + + private extractIfChainBranches( + ifNode: Parser.SyntaxNode, + varName: string, + flagKey: string, + branches: VariantBranch[], + ): void { + const condition = ifNode.childForFieldName("condition"); + const consequence = ifNode.childForFieldName("consequence"); + const alternative = ifNode.childForFieldName("alternative"); + + if (!condition || !consequence) { + return; + } + + // Only process if the condition actually references the tracked variable + if ( + !new RegExp( + `\\b${varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, + ).test(condition.text) + ) { + return; + } + + let variant = this.extractComparison(condition, varName); + + // Truthiness check: if (varName) or if (!varName) + if (variant === null) { + const isTruthinessCheck = this.isTruthinessCheckForVar( + condition, + varName, + ); + if (isTruthinessCheck) { + const negated = this.isNegated(condition); + variant = negated ? "false" : "true"; + } + } + + if (variant === null) { + return; + } + + branches.push({ + flagKey, + variantKey: variant, + conditionLine: ifNode.startPosition.row, + startLine: ifNode.startPosition.row, + endLine: consequence.endPosition.row, + }); + + if (alternative) { + // Python: elif_clause, Ruby: elsif — has condition, consequence, alternative + if (alternative.type === "elif_clause" || alternative.type === "elsif") { + this.extractIfChainBranches(alternative, varName, flagKey, branches); + } else if (alternative.type === "else_clause") { + // JS else_clause may wrap an if_statement (else if). Recurse if so. + // Otherwise treat as terminal else (Python: body field; JS: statement_block). + const innerIf = alternative.namedChildren.find( + (c) => c.type === "if_statement", + ); + if (innerIf) { + this.extractIfChainBranches(innerIf, varName, flagKey, branches); + } else { + const body = + alternative.childForFieldName("body") || + alternative.namedChildren[0]; + if (body) { + const elseVariant = + variant === "true" + ? "false" + : variant === "false" + ? "true" + : "else"; + branches.push({ + flagKey, + variantKey: elseVariant, + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: body.endPosition.row, + }); + } + } + } else if (alternative.type === "if_statement") { + // Go: else if — alternative is directly an if_statement + this.extractIfChainBranches(alternative, varName, flagKey, branches); + } else if (alternative.type === "block") { + // Go: else { ... } — alternative is directly a block + const elseVariant = + variant === "true" ? "false" : variant === "false" ? "true" : "else"; + branches.push({ + flagKey, + variantKey: elseVariant, + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: alternative.endPosition.row, + }); + } else if (alternative.type === "else") { + // Ruby: else — children are direct statements (no body field) + const lastChild = + alternative.namedChildren[alternative.namedChildren.length - 1] || + alternative; + const elseVariant = + variant === "true" ? "false" : variant === "false" ? "true" : "else"; + branches.push({ + flagKey, + variantKey: elseVariant, + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: lastChild.endPosition.row, + }); + } + } + } + + private findSwitchForVar( + _root: Parser.SyntaxNode, + varName: string, + flagKey: string, + afterNode: Parser.SyntaxNode, + branches: VariantBranch[], + ): void { + const scope = afterNode.parent; + if (!scope) { + return; + } + + let foundAssignment = false; + for (const child of scope.namedChildren) { + if (child.startIndex >= afterNode.startIndex) { + foundAssignment = true; + } + if (!foundAssignment || child === afterNode) { + continue; + } + + // JS/TS: switch_statement, Go: expression_switch_statement + if ( + child.type === "switch_statement" || + child.type === "expression_switch_statement" + ) { + const value = child.childForFieldName("value"); + if (!value) { + continue; + } + + // Check if switch is on our variable + const switchedVar = this.extractIdentifier(value); + if (switchedVar !== varName) { + continue; + } + + // JS/TS: cases are inside a 'body' (switch_body) node + // Go: cases are direct children of the switch node + const caseContainer = child.childForFieldName("body") || child; + + for (const caseNode of caseContainer.namedChildren) { + // JS/TS: switch_case, Go: expression_case + if ( + caseNode.type === "switch_case" || + caseNode.type === "expression_case" + ) { + const caseValue = caseNode.childForFieldName("value"); + const variantKey = caseValue + ? this.extractStringFromCaseValue(caseValue) + : null; + + // Get the body range: from case line to before next case or end of switch + const nextSibling = caseNode.nextNamedSibling; + const endLine = nextSibling + ? nextSibling.startPosition.row - 1 + : caseContainer.endPosition.row - 1; + + branches.push({ + flagKey, + variantKey: variantKey || "default", + conditionLine: caseNode.startPosition.row, + startLine: caseNode.startPosition.row, + endLine, + }); + // JS/TS: switch_default, Go: default_case + } else if ( + caseNode.type === "switch_default" || + caseNode.type === "default_case" + ) { + const nextSibling = caseNode.nextNamedSibling; + const endLine = nextSibling + ? nextSibling.startPosition.row - 1 + : caseContainer.endPosition.row - 1; + + branches.push({ + flagKey, + variantKey: "default", + conditionLine: caseNode.startPosition.row, + startLine: caseNode.startPosition.row, + endLine, + }); + } + } + } + + // Ruby: case/when/else + if (child.type === "case") { + const value = child.namedChildren[0]; // First named child is the matched expression + if (!value || value.type === "when") { + continue; + } // case without value + + const switchedVar = this.extractIdentifier(value); + if (switchedVar !== varName) { + continue; + } + + for (const caseChild of child.namedChildren) { + if (caseChild.type === "when") { + // when has pattern children and a body (then) + const patterns = caseChild.namedChildren.filter( + (c) => c.type === "pattern", + ); + const body = caseChild.childForFieldName("body"); + const firstPattern = patterns[0]; + const patternStr = firstPattern?.namedChildren[0]; + const variantKey = patternStr + ? this.extractStringFromNode(patternStr) + : null; + + const endLine = body + ? body.endPosition.row + : caseChild.endPosition.row; + + branches.push({ + flagKey, + variantKey: variantKey || "default", + conditionLine: caseChild.startPosition.row, + startLine: caseChild.startPosition.row, + endLine, + }); + } else if (caseChild.type === "else") { + const lastChild = + caseChild.namedChildren[caseChild.namedChildren.length - 1] || + caseChild; + branches.push({ + flagKey, + variantKey: "default", + conditionLine: caseChild.startPosition.row, + startLine: caseChild.startPosition.row, + endLine: lastChild.endPosition.row, + }); + } + } + } + } + } + + private findInlineFlagIfs( + root: Parser.SyntaxNode, + clients: Set, + family: LangFamily, + branches: VariantBranch[], + ): void { + // Walk all if_statements (JS/Go) and if nodes (Ruby) for inline flag comparisons + const ifTypes = ["if_statement", "if"]; + for (const ifType of ifTypes) { + this.walkNodes(root, ifType, (ifNode) => { + const condition = ifNode.childForFieldName("condition"); + const consequence = ifNode.childForFieldName("consequence"); + if (!condition || !consequence) { + return; + } + + // Look for: getFeatureFlag("key") === "variant" + const callInfo = this.extractFlagCallComparison( + condition, + clients, + family, + ); + if (!callInfo) { + return; + } + + branches.push({ + flagKey: callInfo.flagKey, + variantKey: callInfo.variant, + conditionLine: ifNode.startPosition.row, + startLine: ifNode.startPosition.row, + endLine: consequence.endPosition.row, + }); + + // Process else chain + const alternative = ifNode.childForFieldName("alternative"); + if (alternative) { + // Python: elif_clause, Ruby: elsif + if ( + alternative.type === "elif_clause" || + alternative.type === "elsif" + ) { + // walkNodes will find it via recursive walking + } else if (alternative.type === "else_clause") { + // JS else_clause may wrap another if_statement (else if). + // Skip the else label in that case — walkNodes will visit the inner if. + const innerIf = alternative.namedChildren.find( + (c) => c.type === "if_statement", + ); + if (!innerIf) { + const body = + alternative.childForFieldName("body") || + alternative.namedChildren[0]; + if (body) { + branches.push({ + flagKey: callInfo.flagKey, + variantKey: "else", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: body.endPosition.row, + }); + } + } + } else if (alternative.type === "if_statement") { + // Go: else if — alternative is directly an if_statement (handled by walkNodes) + } else if (alternative.type === "block") { + // Go: else { ... } — alternative is directly a block + branches.push({ + flagKey: callInfo.flagKey, + variantKey: "else", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: alternative.endPosition.row, + }); + } else if (alternative.type === "else") { + // Ruby: else — children are direct statements + const lastChild = + alternative.namedChildren[alternative.namedChildren.length - 1] || + alternative; + branches.push({ + flagKey: callInfo.flagKey, + variantKey: "else", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: lastChild.endPosition.row, + }); + } + } + }); + } + + // Python: also walk elif_clause nodes for inline flag comparisons + this.walkNodes(root, "elif_clause", (elifNode) => { + const condition = elifNode.childForFieldName("condition"); + const consequence = elifNode.childForFieldName("consequence"); + if (!condition || !consequence) { + return; + } + + const callInfo = this.extractFlagCallComparison( + condition, + clients, + family, + ); + if (!callInfo) { + return; + } + + branches.push({ + flagKey: callInfo.flagKey, + variantKey: callInfo.variant, + conditionLine: elifNode.startPosition.row, + startLine: elifNode.startPosition.row, + endLine: consequence.endPosition.row, + }); + + const alternative = elifNode.childForFieldName("alternative"); + if (alternative) { + if (alternative.type === "else_clause") { + const body = + alternative.childForFieldName("body") || + alternative.namedChildren[0]; + if (body) { + branches.push({ + flagKey: callInfo.flagKey, + variantKey: "else", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: body.endPosition.row, + }); + } + } + // elif_clause chaining: will be handled by walking all elif_clause nodes + } + }); + } + + private findEnabledIfs( + root: Parser.SyntaxNode, + clients: Set, + family: LangFamily, + branches: VariantBranch[], + ): void { + const enabledIfTypes = ["if_statement", "if"]; + for (const ifType of enabledIfTypes) { + this.walkNodes(root, ifType, (ifNode) => { + const condition = ifNode.childForFieldName("condition"); + const consequence = ifNode.childForFieldName("consequence"); + if (!condition || !consequence) { + return; + } + + const flagKey = this.extractEnabledCall(condition, clients, family); + if (!flagKey) { + return; + } + + // Check for negation + const negated = this.isNegated(condition); + + branches.push({ + flagKey, + variantKey: negated ? "false" : "true", + conditionLine: ifNode.startPosition.row, + startLine: ifNode.startPosition.row, + endLine: consequence.endPosition.row, + }); + + const alternative = ifNode.childForFieldName("alternative"); + if (alternative) { + // Python: elif_clause, Ruby: elsif + if ( + alternative.type === "elif_clause" || + alternative.type === "elsif" + ) { + // Handled by walk below + } else if (alternative.type === "else_clause") { + // JS else_clause may wrap another if_statement (else if). + // Skip the else label in that case — walkNodes will visit the inner if. + const innerIf = alternative.namedChildren.find( + (c) => c.type === "if_statement", + ); + if (!innerIf) { + const body = + alternative.childForFieldName("body") || + alternative.namedChildren[0]; + if (body) { + branches.push({ + flagKey, + variantKey: negated ? "true" : "false", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: body.endPosition.row, + }); + } + } + } else if (alternative.type === "block") { + // Go: else { ... } — alternative is directly a block + branches.push({ + flagKey, + variantKey: negated ? "true" : "false", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: alternative.endPosition.row, + }); + } else if (alternative.type === "else") { + // Ruby: else — children are direct statements + const lastChild = + alternative.namedChildren[alternative.namedChildren.length - 1] || + alternative; + branches.push({ + flagKey, + variantKey: negated ? "true" : "false", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: lastChild.endPosition.row, + }); + } + } + }); + } + + // Python/Ruby: also walk elif_clause/elsif nodes for enabled checks + const elifTypes = ["elif_clause", "elsif"]; + for (const elifType of elifTypes) { + this.walkNodes(root, elifType, (elifNode) => { + const condition = elifNode.childForFieldName("condition"); + const consequence = elifNode.childForFieldName("consequence"); + if (!condition || !consequence) { + return; + } + + const flagKey = this.extractEnabledCall(condition, clients, family); + if (!flagKey) { + return; + } + + const negated = this.isNegated(condition); + + branches.push({ + flagKey, + variantKey: negated ? "false" : "true", + conditionLine: elifNode.startPosition.row, + startLine: elifNode.startPosition.row, + endLine: consequence.endPosition.row, + }); + + const alternative = elifNode.childForFieldName("alternative"); + if (alternative) { + if (alternative.type === "else_clause") { + const body = + alternative.childForFieldName("body") || + alternative.namedChildren[0]; + if (body) { + branches.push({ + flagKey, + variantKey: negated ? "true" : "false", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: body.endPosition.row, + }); + } + } else if (alternative.type === "else") { + // Ruby: else + const lastChild = + alternative.namedChildren[alternative.namedChildren.length - 1] || + alternative; + branches.push({ + flagKey, + variantKey: negated ? "true" : "false", + conditionLine: alternative.startPosition.row, + startLine: alternative.startPosition.row, + endLine: lastChild.endPosition.row, + }); + } + } + }); + } + } + + // ── Node extraction helpers ── + + private extractComparison( + conditionNode: Parser.SyntaxNode, + varName: string, + ): string | null { + // Unwrap parenthesized_expression + let node = conditionNode; + while ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + node = node.namedChildren[0]; + } + + // JS/Go: binary_expression, Ruby: binary + if (node.type === "binary_expression" || node.type === "binary") { + const left = node.childForFieldName("left"); + const right = node.childForFieldName("right"); + const op = node.childForFieldName("operator"); + + if (!left || !right) { + return null; + } + + const opText = op?.text || ""; + if ( + opText !== "===" && + opText !== "==" && + opText !== "!==" && + opText !== "!=" + ) { + return null; + } + + if (left.text === varName) { + return this.extractStringFromNode(right); + } + if (right.text === varName) { + return this.extractStringFromNode(left); + } + } + + // Python: comparison_operator (e.g. `flag == "variant"`) + if (node.type === "comparison_operator") { + const children = node.namedChildren; + // comparison_operator has: left_operand, operator(s), right_operand(s) + // For simple `a == b`, children are [a, b] with operator tokens between + if (children.length >= 2) { + const left = children[0]; + const right = children[children.length - 1]; + // Check the operator text between operands + const fullText = node.text; + if (fullText.includes("==") || fullText.includes("!=")) { + if (left.text === varName) { + return this.extractStringFromNode(right); + } + if (right.text === varName) { + return this.extractStringFromNode(left); + } + } + } + } + + return null; + } + + private extractFlagCallComparison( + conditionNode: Parser.SyntaxNode, + clients: Set, + family: LangFamily, + ): { flagKey: string; variant: string } | null { + let node = conditionNode; + while ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + node = node.namedChildren[0]; + } + + let left: Parser.SyntaxNode | null = null; + let right: Parser.SyntaxNode | null = null; + + // JS/Go: binary_expression, Ruby: binary, Python: comparison_operator + if (node.type === "binary_expression" || node.type === "binary") { + left = node.childForFieldName("left"); + right = node.childForFieldName("right"); + } else if (node.type === "comparison_operator") { + // Python: comparison_operator children are [left_operand, right_operand] + const children = node.namedChildren; + if (children.length >= 2) { + left = children[0]; + right = children[children.length - 1]; + } + } + + if (!left || !right) { + return null; + } + + // Check if left is a posthog.getFeatureFlag("key") call + const callTypes = new Set(["call_expression", "call"]); + const callNode = callTypes.has(left.type) + ? left + : callTypes.has(right.type) + ? right + : null; + const valueNode = callNode === left ? right : left; + if (!callNode || !valueNode) { + return null; + } + + let obj: Parser.SyntaxNode | null = null; + let prop: Parser.SyntaxNode | null = null; + + const func = callNode.childForFieldName("function"); + if ( + func && + (func.type === "member_expression" || + func.type === "attribute" || + func.type === "selector_expression") + ) { + obj = + func.childForFieldName("object") || func.childForFieldName("operand"); + prop = + func.childForFieldName("property") || + func.childForFieldName("attribute") || + func.childForFieldName("field"); + } else { + // Ruby: call has receiver + method as separate fields + obj = callNode.childForFieldName("receiver"); + prop = callNode.childForFieldName("method"); + } + if (!obj || !prop) { + return null; + } + const extractedClient = this.extractClientName(obj); + if (!extractedClient || !clients.has(extractedClient)) { + return null; + } + + const method = prop.text; + // Only match getFeatureFlag-like methods (not isFeatureEnabled which returns bool) + const flagGetters = new Set( + [...family.flagMethods].filter( + (m) => + m.toLowerCase().includes("get") || m.toLowerCase().includes("flag"), + ), + ); + if (!flagGetters.has(method)) { + return null; + } + + const args = callNode.childForFieldName("arguments"); + if (!args) { + return null; + } + const firstArg = args.namedChildren[0]; + if (!firstArg) { + return null; + } + + const flagKey = this.extractStringFromNode(firstArg); + const variant = this.extractStringFromNode(valueNode); + if (!flagKey || !variant) { + return null; + } + + return { flagKey, variant }; + } + + private extractEnabledCall( + conditionNode: Parser.SyntaxNode, + clients: Set, + family: LangFamily, + ): string | null { + let node = conditionNode; + // Unwrap parenthesized_expression and unary ! (negation) + while ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + node = node.namedChildren[0]; + } + // JS: unary_expression, Python: not_operator, Ruby: unary + if ( + node.type === "unary_expression" || + node.type === "not_operator" || + node.type === "unary" + ) { + const operand = + node.childForFieldName("operand") || + node.namedChildren[node.namedChildren.length - 1]; + if (operand) { + node = operand; + } + } + while ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + node = node.namedChildren[0]; + } + + if (node.type !== "call_expression" && node.type !== "call") { + return null; + } + + let clientName: string | undefined; + let methodName: string | undefined; + + const func = node.childForFieldName("function"); + if (func) { + if ( + func.type === "member_expression" || + func.type === "attribute" || + func.type === "selector_expression" + ) { + const obj = + func.childForFieldName("object") || func.childForFieldName("operand"); + const prop = + func.childForFieldName("property") || + func.childForFieldName("attribute") || + func.childForFieldName("field"); + clientName = obj + ? (this.extractClientName(obj) ?? undefined) + : undefined; + methodName = prop?.text; + } + } else { + // Ruby: call has receiver + method as separate fields + const receiver = node.childForFieldName("receiver"); + const method = node.childForFieldName("method"); + if (receiver && method) { + clientName = this.extractClientName(receiver) ?? undefined; + methodName = method.text; + } + } + + if (!clientName || !methodName || !clients.has(clientName)) { + return null; + } + + // Match isFeatureEnabled-like methods + const enabledMethods = new Set( + [...family.flagMethods].filter( + (m) => + m.toLowerCase().includes("enabled") || + m.toLowerCase().includes("is_feature"), + ), + ); + if (!enabledMethods.has(methodName)) { + return null; + } + + const args = node.childForFieldName("arguments"); + if (!args) { + return null; + } + const firstArg = args.namedChildren[0]; + return firstArg ? this.extractStringFromNode(firstArg) : null; + } + + private isNegated(conditionNode: Parser.SyntaxNode): boolean { + let node = conditionNode; + while ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + node = node.namedChildren[0]; + } + // JS: unary_expression, Python: not_operator, Ruby: unary + return ( + (node.type === "unary_expression" && node.text.startsWith("!")) || + node.type === "not_operator" || + (node.type === "unary" && node.text.startsWith("!")) + ); + } + + /** Check if a condition is a simple truthiness check on a variable: `if (varName)` or `if (!varName)` */ + private isTruthinessCheckForVar( + conditionNode: Parser.SyntaxNode, + varName: string, + ): boolean { + let node = conditionNode; + while ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + node = node.namedChildren[0]; + } + // if (varName) + if (node.type === "identifier" && node.text === varName) { + return true; + } + // if (!varName) — JS: unary_expression, Python: not_operator, Ruby: unary + if ( + (node.type === "unary_expression" || + node.type === "not_operator" || + node.type === "unary") && + node.namedChildren.length > 0 + ) { + let inner = node.namedChildren[node.namedChildren.length - 1]; + while ( + inner.type === "parenthesized_expression" && + inner.namedChildren.length === 1 + ) { + inner = inner.namedChildren[0]; + } + if (inner.type === "identifier" && inner.text === varName) { + return true; + } + } + return false; + } + + /** Build a map of const/let/var identifier → string value from the file */ + private buildConstantMap( + lang: Parser.Language, + tree: Parser.Tree, + ): Map { + const constants = new Map(); + + // JS: const/let/var declarations + const jsQuery = this.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) { + const matches = jsQuery.matches(tree.rootNode); + for (const match of matches) { + const nameNode = match.captures.find((c) => c.name === "name"); + const valueNode = match.captures.find((c) => c.name === "value"); + if (nameNode && valueNode) { + constants.set(nameNode.node.text, valueNode.node.text); + } + } + } + + // Python: simple assignment — NAME = "value" + const pyQuery = this.getQuery( + lang, + ` + (expression_statement + (assignment + left: (identifier) @name + right: (string (string_content) @value))) + `, + ); + if (pyQuery) { + const matches = pyQuery.matches(tree.rootNode); + for (const match of matches) { + const nameNode = match.captures.find((c) => c.name === "name"); + const valueNode = match.captures.find((c) => c.name === "value"); + if (nameNode && valueNode) { + constants.set(nameNode.node.text, valueNode.node.text); + } + } + } + + // Go: short var declarations and const declarations + const goVarQuery = this.getQuery( + lang, + ` + (short_var_declaration + left: (expression_list (identifier) @name) + right: (expression_list (interpreted_string_literal) @value)) + `, + ); + if (goVarQuery) { + const matches = goVarQuery.matches(tree.rootNode); + for (const match of matches) { + const nameNode = match.captures.find((c) => c.name === "name"); + const valueNode = match.captures.find((c) => c.name === "value"); + if (nameNode && valueNode) { + constants.set( + nameNode.node.text, + this.cleanStringValue(valueNode.node.text), + ); + } + } + } + + const goConstQuery = this.getQuery( + lang, + ` + (const_declaration + (const_spec + name: (identifier) @name + value: (expression_list (interpreted_string_literal) @value))) + `, + ); + if (goConstQuery) { + const matches = goConstQuery.matches(tree.rootNode); + for (const match of matches) { + const nameNode = match.captures.find((c) => c.name === "name"); + const valueNode = match.captures.find((c) => c.name === "value"); + if (nameNode && valueNode) { + constants.set( + nameNode.node.text, + this.cleanStringValue(valueNode.node.text), + ); + } + } + } + + // Ruby: assignment — local var: name = "value", constant: NAME = "value" + const rbQuery = this.getQuery( + lang, + ` + (assignment + left: (identifier) @name + right: (string (string_content) @value)) + + (assignment + left: (constant) @name + right: (string (string_content) @value)) + `, + ); + if (rbQuery) { + const matches = rbQuery.matches(tree.rootNode); + for (const match of matches) { + const nameNode = match.captures.find((c) => c.name === "name"); + const valueNode = match.captures.find((c) => c.name === "value"); + if (nameNode && valueNode) { + constants.set(nameNode.node.text, valueNode.node.text); + } + } + } + + return constants; + } + + private extractIdentifier(node: Parser.SyntaxNode): string | null { + if (node.type === "identifier") { + return node.text; + } + // Unwrap parenthesized + if ( + node.type === "parenthesized_expression" && + node.namedChildren.length === 1 + ) { + return this.extractIdentifier(node.namedChildren[0]); + } + return null; + } + + // Extract string from a switch case value node (handles Go's expression_list wrapper) + private extractStringFromCaseValue(node: Parser.SyntaxNode): string | null { + // Go: case value is an expression_list containing the actual string literal + if (node.type === "expression_list" && node.namedChildCount > 0) { + return this.extractStringFromNode(node.namedChildren[0]); + } + return this.extractStringFromNode(node); + } + + private 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; + } + // Go: interpreted_string_literal includes quotes + if ( + node.type === "interpreted_string_literal" || + node.type === "raw_string_literal" + ) { + return node.text.slice(1, -1); + } + // For simple string fragments already extracted + if (node.type === "string_fragment" || node.type === "string_content") { + return node.text; + } + return null; + } + + private cleanStringValue(text: string): string { + // Strip surrounding quotes if present + if ( + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'")) || + (text.startsWith("`") && text.endsWith("`")) + ) { + return text.slice(1, -1); + } + return text; + } + + private extractParams(paramsText: string): string[] { + // Remove surrounding parens + let text = paramsText.trim(); + if (text.startsWith("(")) { + text = text.slice(1); + } + if (text.endsWith(")")) { + text = text.slice(0, -1); + } + if (!text.trim()) { + return []; + } + + const SKIP = new Set([ + "e", + "ev", + "event", + "evt", + "ctx", + "context", + "req", + "res", + "next", + "err", + "error", + "_", + "__", + ]); + + 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 && !SKIP.has(p) && !p.startsWith("...")); + } + + private 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); + } + + dispose(): void { + this.parser?.delete(); + this.parser = null; + this.initPromise = null; + this.languages.clear(); + this.queryCache.clear(); + } +} diff --git a/packages/enricher/src/flag-classification.test.ts b/packages/enricher/src/flag-classification.test.ts new file mode 100644 index 000000000..4486d40ee --- /dev/null +++ b/packages/enricher/src/flag-classification.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "vitest"; +import { + classifyFlagType, + extractConditionCount, + extractRollout, + extractVariants, + isFullyRolledOut, +} from "./flag-classification.js"; +import type { FeatureFlag } from "./types.js"; + +function makeFlag(overrides: Partial = {}): FeatureFlag { + return { + id: 1, + key: "test-flag", + name: "Test", + active: true, + filters: {}, + rollout_percentage: null, + created_at: "2024-01-01", + created_by: null, + deleted: false, + ...overrides, + }; +} + +describe("classifyFlagType", () => { + test("undefined flag returns boolean", () => { + expect(classifyFlagType(undefined)).toBe("boolean"); + }); + + test("flag with multivariate variants returns multivariate", () => { + const flag = makeFlag({ + filters: { + multivariate: { + variants: [ + { key: "control", rollout_percentage: 50 }, + { key: "test", rollout_percentage: 50 }, + ], + }, + }, + }); + expect(classifyFlagType(flag)).toBe("multivariate"); + }); + + test("flag with payloads (no multivariate) returns remote_config", () => { + const flag = makeFlag({ + filters: { payloads: { true: '{"theme":"dark"}' } }, + }); + expect(classifyFlagType(flag)).toBe("remote_config"); + }); + + test("flag with neither returns boolean", () => { + expect(classifyFlagType(makeFlag({ filters: {} }))).toBe("boolean"); + }); + + test("empty variants array returns boolean", () => { + const flag = makeFlag({ + filters: { multivariate: { variants: [] } }, + }); + expect(classifyFlagType(flag)).toBe("boolean"); + }); + + test("payloads with all null values returns boolean", () => { + const flag = makeFlag({ + filters: { payloads: { true: null, false: null } }, + }); + expect(classifyFlagType(flag)).toBe("boolean"); + }); + + test("multivariate takes priority over payloads", () => { + const flag = makeFlag({ + filters: { + multivariate: { + variants: [{ key: "control", rollout_percentage: 100 }], + }, + payloads: { control: '"value"' }, + }, + }); + expect(classifyFlagType(flag)).toBe("multivariate"); + }); +}); + +describe("isFullyRolledOut", () => { + test("no filters returns false", () => { + const flag = makeFlag({ + filters: undefined as unknown as Record, + }); + expect(isFullyRolledOut(flag)).toBe(false); + }); + + test("empty groups returns false", () => { + expect(isFullyRolledOut(makeFlag({ filters: { groups: [] } }))).toBe(false); + }); + + test("single group 100% no conditions returns true", () => { + const flag = makeFlag({ + filters: { groups: [{ rollout_percentage: 100, properties: [] }] }, + }); + expect(isFullyRolledOut(flag)).toBe(true); + }); + + test("100% with conditions returns false", () => { + const flag = makeFlag({ + filters: { + groups: [ + { + rollout_percentage: 100, + properties: [ + { key: "email", value: "test@example.com", type: "person" }, + ], + }, + ], + }, + }); + expect(isFullyRolledOut(flag)).toBe(false); + }); + + test("less than 100% returns false", () => { + const flag = makeFlag({ + filters: { groups: [{ rollout_percentage: 50, properties: [] }] }, + }); + expect(isFullyRolledOut(flag)).toBe(false); + }); + + test("multiple groups all 100% returns true", () => { + const flag = makeFlag({ + filters: { + groups: [ + { rollout_percentage: 100, properties: [] }, + { rollout_percentage: 100, properties: [] }, + ], + }, + }); + expect(isFullyRolledOut(flag)).toBe(true); + }); + + test("multivariate returns false even with 100% rollout", () => { + const flag = makeFlag({ + filters: { + multivariate: { + variants: [ + { key: "control", rollout_percentage: 50 }, + { key: "test", rollout_percentage: 50 }, + ], + }, + groups: [{ rollout_percentage: 100, properties: [] }], + }, + }); + expect(isFullyRolledOut(flag)).toBe(false); + }); + + test("top-level rollout_percentage 100 with no groups returns true", () => { + const flag = makeFlag({ rollout_percentage: 100, filters: {} }); + expect(isFullyRolledOut(flag)).toBe(true); + }); +}); + +describe("extractRollout", () => { + test("top-level rollout returns it", () => { + expect(extractRollout(makeFlag({ rollout_percentage: 75 }))).toBe(75); + }); + + test("rollout in groups returns it", () => { + const flag = makeFlag({ + filters: { groups: [{ rollout_percentage: 42 }] }, + }); + expect(extractRollout(flag)).toBe(42); + }); + + test("no rollout returns null", () => { + expect(extractRollout(makeFlag({ filters: {} }))).toBe(null); + }); + + test("null top-level falls through to groups", () => { + const flag = makeFlag({ + rollout_percentage: null, + filters: { groups: [{ rollout_percentage: 60 }] }, + }); + expect(extractRollout(flag)).toBe(60); + }); + + test("rollout 0 returns 0 (not null)", () => { + expect(extractRollout(makeFlag({ rollout_percentage: 0 }))).toBe(0); + }); +}); + +describe("extractVariants", () => { + test("no multivariate returns empty", () => { + expect(extractVariants(makeFlag({ filters: {} }))).toEqual([]); + }); + + test("multivariate with variants returns them", () => { + const variants = [ + { key: "control", rollout_percentage: 50 }, + { key: "test", rollout_percentage: 50 }, + ]; + const flag = makeFlag({ filters: { multivariate: { variants } } }); + expect(extractVariants(flag)).toEqual(variants); + }); + + test("empty variants returns empty", () => { + const flag = makeFlag({ filters: { multivariate: { variants: [] } } }); + expect(extractVariants(flag)).toEqual([]); + }); +}); + +describe("extractConditionCount", () => { + test("no groups returns 0", () => { + expect(extractConditionCount(makeFlag({ filters: {} }))).toBe(0); + }); + + test("groups with empty properties returns 0", () => { + const flag = makeFlag({ + filters: { + groups: [ + { properties: [], rollout_percentage: 100 }, + { properties: [], rollout_percentage: 50 }, + ], + }, + }); + expect(extractConditionCount(flag)).toBe(0); + }); + + test("counts groups with properties", () => { + const flag = makeFlag({ + filters: { + groups: [ + { + properties: [ + { key: "email", value: "@posthog.com", type: "person" }, + ], + }, + { properties: [] }, + { properties: [{ key: "country", value: "US", type: "person" }] }, + ], + }, + }); + expect(extractConditionCount(flag)).toBe(2); + }); +}); diff --git a/packages/enricher/src/flag-classification.ts b/packages/enricher/src/flag-classification.ts new file mode 100644 index 000000000..88e71b942 --- /dev/null +++ b/packages/enricher/src/flag-classification.ts @@ -0,0 +1,107 @@ +import type { FeatureFlag, FlagType } from "./types.js"; + +/** Classify a flag as boolean release, multivariate, or remote config */ +export function classifyFlagType(flag: FeatureFlag | undefined): FlagType { + if (!flag) { + return "boolean"; + } + const filters = flag.filters as Record | undefined; + if (filters?.multivariate && typeof filters.multivariate === "object") { + const mv = filters.multivariate as { variants?: unknown[] }; + if (mv.variants && mv.variants.length > 0) { + return "multivariate"; + } + } + if (filters?.payloads && typeof filters.payloads === "object") { + const payloads = filters.payloads as Record; + if (Object.values(payloads).some((v) => v !== null && v !== undefined)) { + return "remote_config"; + } + } + return "boolean"; +} + +/** Check if a flag is fully rolled out (100%, no conditions, no multivariate) */ +export function isFullyRolledOut(flag: FeatureFlag): boolean { + const filters = flag.filters as Record | undefined; + if (!filters) { + return false; + } + + if (filters.multivariate && typeof filters.multivariate === "object") { + const mv = filters.multivariate as { variants?: unknown[] }; + if (mv.variants && mv.variants.length > 0) { + return false; + } + } + + if (filters.groups && Array.isArray(filters.groups)) { + const groups = filters.groups as Array>; + if (groups.length === 0) { + return false; + } + return groups.every((g) => { + const rollout = g.rollout_percentage; + const props = g.properties; + const hasConditions = Array.isArray(props) && props.length > 0; + return rollout === 100 && !hasConditions; + }); + } + + if (flag.rollout_percentage === 100) { + return true; + } + return false; +} + +/** Extract rollout percentage from a flag's filters */ +export function extractRollout(flag: FeatureFlag): number | null { + if ( + flag.rollout_percentage !== null && + flag.rollout_percentage !== undefined + ) { + return flag.rollout_percentage; + } + const filters = flag.filters as Record | undefined; + if (filters?.groups && Array.isArray(filters.groups)) { + for (const group of filters.groups) { + if (typeof group === "object" && group !== null) { + const rp = (group as Record).rollout_percentage; + if (typeof rp === "number") { + return rp; + } + } + } + } + return null; +} + +/** Extract multivariate variants from a flag */ +export function extractVariants( + flag: FeatureFlag, +): { key: string; rollout_percentage: number }[] { + const filters = flag.filters as Record | undefined; + if (filters?.multivariate && typeof filters.multivariate === "object") { + const mv = filters.multivariate as { + variants?: { key: string; rollout_percentage: number }[]; + }; + if (mv.variants && mv.variants.length > 0) { + return mv.variants; + } + } + return []; +} + +/** Count release conditions (groups with property filters) */ +export function extractConditionCount(flag: FeatureFlag): number { + const filters = flag.filters as Record | undefined; + if (!filters?.groups || !Array.isArray(filters.groups)) { + return 0; + } + return (filters.groups as Array>).filter( + (g) => + g.properties && + Array.isArray(g.properties) && + (g.properties as unknown[]).length > 0, + ).length; +} diff --git a/packages/enricher/src/index.ts b/packages/enricher/src/index.ts new file mode 100644 index 000000000..589616013 --- /dev/null +++ b/packages/enricher/src/index.ts @@ -0,0 +1,34 @@ +export { PostHogDetector } from "./detector.js"; +export { + classifyFlagType, + extractConditionCount, + extractRollout, + extractVariants, + isFullyRolledOut, +} from "./flag-classification.js"; +export type { LangFamily, QueryStrings } from "./languages.js"; +export { ALL_FLAG_METHODS, CLIENT_NAMES, LANG_FAMILIES } from "./languages.js"; +export type { DetectorLogger } from "./log.js"; +export { setLogger } from "./log.js"; +export type { StalenessCheckOptions } from "./stale-flags.js"; +export { + classifyStaleness, + STALENESS_ORDER, +} from "./stale-flags.js"; + +export type { + DetectionConfig, + EventDefinition, + Experiment, + ExperimentMetric, + FeatureFlag, + FlagAssignment, + FlagType, + FunctionInfo, + PostHogCall, + PostHogInitCall, + StalenessReason, + SupportedLanguage, + VariantBranch, +} from "./types.js"; +export { DEFAULT_CONFIG } from "./types.js"; diff --git a/packages/enricher/src/languages.ts b/packages/enricher/src/languages.ts new file mode 100644 index 000000000..68a83c6e8 --- /dev/null +++ b/packages/enricher/src/languages.ts @@ -0,0 +1,485 @@ +// ── Language-specific method sets and tree-sitter queries ── + +export interface QueryStrings { + postHogCalls: string; + nodeCaptureCalls: string; + pythonCaptureCalls?: string; + goStructCalls?: string; + rubyCaptureCalls?: string; + flagAssignments: string; + functions: string; + clientAliases: string; + constructorAliases: string; + destructuredMethods: string; + bareFunctionCalls: string; +} + +export interface LangFamily { + wasm: string; + captureMethods: Set; + flagMethods: Set; + allMethods: Set; + queries: QueryStrings; +} + +// ── Method sets per language ── + +const JS_CAPTURE_METHODS = new Set(["capture"]); +const JS_FLAG_METHODS = new Set([ + "getFeatureFlag", + "isFeatureEnabled", + "getFeatureFlagPayload", + "getFeatureFlagResult", + "isFeatureFlagEnabled", + "getRemoteConfig", +]); +const JS_ALL_METHODS = new Set([...JS_CAPTURE_METHODS, ...JS_FLAG_METHODS]); + +const PY_CAPTURE_METHODS = new Set(["capture"]); +const PY_FLAG_METHODS = new Set([ + "feature_enabled", + "is_feature_enabled", + "get_feature_flag", + "get_feature_flag_payload", + "get_remote_config", +]); +const PY_ALL_METHODS = new Set([...PY_CAPTURE_METHODS, ...PY_FLAG_METHODS]); + +const GO_CAPTURE_METHODS = new Set(["Enqueue"]); +const GO_FLAG_METHODS = new Set([ + "GetFeatureFlag", + "IsFeatureEnabled", + "GetFeatureFlagPayload", +]); +const GO_ALL_METHODS = new Set([...GO_CAPTURE_METHODS, ...GO_FLAG_METHODS]); + +const RB_CAPTURE_METHODS = new Set(["capture"]); +const RB_FLAG_METHODS = new Set([ + "is_feature_enabled", + "get_feature_flag", + "get_feature_flag_payload", + "get_remote_config_payload", +]); +const RB_ALL_METHODS = new Set([...RB_CAPTURE_METHODS, ...RB_FLAG_METHODS]); + +// ── Default client names ── + +export const CLIENT_NAMES = new Set(["posthog", "client", "ph"]); + +// ── All flag methods across languages (for stale flag scanning) ── + +export const ALL_FLAG_METHODS = new Set([ + ...JS_FLAG_METHODS, + ...PY_FLAG_METHODS, + ...GO_FLAG_METHODS, + ...RB_FLAG_METHODS, +]); + +// ── Tree-sitter queries ── + +const JS_QUERIES: QueryStrings = { + postHogCalls: ` + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (string (string_fragment) @key))) @call + + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (template_string (string_fragment) @key))) @call + `, + + nodeCaptureCalls: ` + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . + (object + (pair + key: (property_identifier) @prop_name + value: (string (string_fragment) @key))))) @call + `, + + flagAssignments: ` + (lexical_declaration + (variable_declarator + name: (identifier) @var_name + value: (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (string (string_fragment) @flag_key))))) @assignment + + (variable_declaration + (variable_declarator + name: (identifier) @var_name + value: (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (string (string_fragment) @flag_key))))) @assignment + + (lexical_declaration + (variable_declarator + name: (identifier) @var_name + value: (await_expression + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (string (string_fragment) @flag_key)))))) @assignment + + (variable_declaration + (variable_declarator + name: (identifier) @var_name + value: (await_expression + (call_expression + function: (member_expression + object: (_) @client + property: (property_identifier) @method) + arguments: (arguments . (string (string_fragment) @flag_key)))))) @assignment + `, + + functions: ` + (function_declaration + name: (identifier) @func_name + parameters: (formal_parameters) @func_params + body: (statement_block) @func_body) + + (export_statement + declaration: (function_declaration + name: (identifier) @func_name + parameters: (formal_parameters) @func_params + body: (statement_block) @func_body)) + + (lexical_declaration + (variable_declarator + name: (identifier) @func_name + value: (arrow_function + parameters: (formal_parameters) @func_params + body: (statement_block) @func_body))) + + (lexical_declaration + (variable_declarator + name: (identifier) @func_name + value: (arrow_function + parameter: (identifier) @func_single_param + body: (statement_block) @func_body))) + + (method_definition + name: (property_identifier) @func_name + parameters: (formal_parameters) @func_params + body: (statement_block) @func_body) + `, + + clientAliases: ` + (lexical_declaration + (variable_declarator + name: (identifier) @alias + value: (identifier) @source)) + + (variable_declaration + (variable_declarator + name: (identifier) @alias + value: (identifier) @source)) + `, + + constructorAliases: ` + (lexical_declaration + (variable_declarator + name: (identifier) @alias + value: (new_expression + constructor: (identifier) @class_name))) + + (variable_declaration + (variable_declarator + name: (identifier) @alias + value: (new_expression + constructor: (identifier) @class_name))) + `, + + destructuredMethods: ` + (lexical_declaration + (variable_declarator + name: (object_pattern + (shorthand_property_identifier_pattern) @method_name) + value: (identifier) @source)) + `, + + bareFunctionCalls: ` + (call_expression + function: (identifier) @func_name + arguments: (arguments . (string (string_fragment) @key))) @call + `, +}; + +const PY_QUERIES: QueryStrings = { + postHogCalls: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list . (string (string_content) @key))) @call + `, + + nodeCaptureCalls: "", + + pythonCaptureCalls: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list (string) . (string (string_content) @key))) @call + + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list + (keyword_argument + name: (identifier) @kwarg_name + value: (string (string_content) @key)))) @call + `, + + flagAssignments: ` + (expression_statement + (assignment + left: (identifier) @var_name + right: (call + function: (attribute + object: (_) @client + attribute: (identifier) @method) + arguments: (argument_list . (string (string_content) @flag_key))))) @assignment + `, + + functions: ` + (function_definition + name: (identifier) @func_name + parameters: (parameters) @func_params + body: (block) @func_body) + `, + + clientAliases: ` + (expression_statement + (assignment + left: (identifier) @alias + right: (identifier) @source)) + `, + + constructorAliases: ` + (expression_statement + (assignment + left: (identifier) @alias + right: (call + function: (identifier) @class_name + arguments: (argument_list)))) + `, + + destructuredMethods: "", + + bareFunctionCalls: ` + (call + function: (identifier) @func_name + arguments: (argument_list . (string (string_content) @key))) @call + `, +}; + +const GO_QUERIES: QueryStrings = { + postHogCalls: ` + (call_expression + function: (selector_expression + operand: (_) @client + field: (field_identifier) @method) + arguments: (argument_list . (interpreted_string_literal) @key)) @call + `, + + nodeCaptureCalls: "", + + goStructCalls: ` + (call_expression + function: (selector_expression + operand: (_) @client + field: (field_identifier) @method) + arguments: (argument_list + (composite_literal + body: (literal_value + (keyed_element + (literal_element (identifier) @field_name) + (literal_element (interpreted_string_literal) @key)))))) @call + `, + + flagAssignments: ` + (short_var_declaration + left: (expression_list . (identifier) @var_name .) + right: (expression_list + (call_expression + function: (selector_expression + operand: (_) @client + field: (field_identifier) @method) + arguments: (argument_list . (interpreted_string_literal) @flag_key)))) @assignment + + (short_var_declaration + left: (expression_list . (identifier) @var_name (_)) + right: (expression_list + (call_expression + function: (selector_expression + operand: (_) @client + field: (field_identifier) @method) + arguments: (argument_list . (interpreted_string_literal) @flag_key)))) @assignment + `, + + functions: ` + (function_declaration + name: (identifier) @func_name + parameters: (parameter_list) @func_params + body: (block) @func_body) + + (method_declaration + name: (field_identifier) @func_name + parameters: (parameter_list) @func_params + body: (block) @func_body) + `, + + clientAliases: "", + + constructorAliases: ` + (short_var_declaration + left: (expression_list (identifier) @alias) + right: (expression_list + (call_expression + function: (selector_expression + operand: (identifier) @pkg_name + field: (field_identifier) @func_name)))) + + (short_var_declaration + left: (expression_list (identifier) @alias (_)) + right: (expression_list + (call_expression + function: (selector_expression + operand: (identifier) @pkg_name + field: (field_identifier) @func_name)))) + `, + + destructuredMethods: "", + + bareFunctionCalls: "", +}; + +const RB_QUERIES: QueryStrings = { + postHogCalls: ` + (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (string (string_content) @key))) @call + `, + + nodeCaptureCalls: "", + + rubyCaptureCalls: ` + (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list + (pair + (hash_key_symbol) @kwarg_name + (string (string_content) @key)))) @call + `, + + flagAssignments: ` + (assignment + left: (identifier) @var_name + right: (call + receiver: (_) @client + method: (identifier) @method + arguments: (argument_list . (string (string_content) @flag_key)))) @assignment + `, + + functions: ` + (method + name: (identifier) @func_name + parameters: (method_parameters) @func_params + body: (_) @func_body) + `, + + clientAliases: ` + (assignment + left: (identifier) @alias + right: (identifier) @source) + `, + + constructorAliases: ` + (assignment + left: (identifier) @alias + right: (call + receiver: (scope_resolution + scope: (constant) @scope_name + name: (constant) @class_name) + method: (identifier) @method_name)) + `, + + destructuredMethods: "", + + bareFunctionCalls: ` + (call + method: (identifier) @func_name + arguments: (argument_list . (string (string_content) @key))) @call + `, +}; + +// ── Language → family mapping ── + +export const LANG_FAMILIES: Record = { + javascript: { + wasm: "tree-sitter-javascript.wasm", + captureMethods: JS_CAPTURE_METHODS, + flagMethods: JS_FLAG_METHODS, + allMethods: JS_ALL_METHODS, + queries: JS_QUERIES, + }, + javascriptreact: { + wasm: "tree-sitter-javascript.wasm", + captureMethods: JS_CAPTURE_METHODS, + flagMethods: JS_FLAG_METHODS, + allMethods: JS_ALL_METHODS, + queries: JS_QUERIES, + }, + typescript: { + wasm: "tree-sitter-typescript.wasm", + captureMethods: JS_CAPTURE_METHODS, + flagMethods: JS_FLAG_METHODS, + allMethods: JS_ALL_METHODS, + queries: JS_QUERIES, + }, + typescriptreact: { + wasm: "tree-sitter-tsx.wasm", + captureMethods: JS_CAPTURE_METHODS, + flagMethods: JS_FLAG_METHODS, + allMethods: JS_ALL_METHODS, + queries: JS_QUERIES, + }, + python: { + wasm: "tree-sitter-python.wasm", + captureMethods: PY_CAPTURE_METHODS, + flagMethods: PY_FLAG_METHODS, + allMethods: PY_ALL_METHODS, + queries: PY_QUERIES, + }, + go: { + wasm: "tree-sitter-go.wasm", + captureMethods: GO_CAPTURE_METHODS, + flagMethods: GO_FLAG_METHODS, + allMethods: GO_ALL_METHODS, + queries: GO_QUERIES, + }, + ruby: { + wasm: "tree-sitter-ruby.wasm", + captureMethods: RB_CAPTURE_METHODS, + flagMethods: RB_FLAG_METHODS, + allMethods: RB_ALL_METHODS, + queries: RB_QUERIES, + }, +}; diff --git a/packages/enricher/src/log.ts b/packages/enricher/src/log.ts new file mode 100644 index 000000000..61025c472 --- /dev/null +++ b/packages/enricher/src/log.ts @@ -0,0 +1,17 @@ +export interface DetectorLogger { + warn(message: string, ...args: unknown[]): void; +} + +const noop: DetectorLogger = { + warn() {}, +}; + +let current: DetectorLogger = noop; + +export function setLogger(logger: DetectorLogger): void { + current = logger; +} + +export function warn(message: string, ...args: unknown[]): void { + current.warn(message, ...args); +} diff --git a/packages/enricher/src/stale-flags.test.ts b/packages/enricher/src/stale-flags.test.ts new file mode 100644 index 000000000..89072fd6e --- /dev/null +++ b/packages/enricher/src/stale-flags.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "vitest"; +import { classifyStaleness } from "./stale-flags.js"; +import type { Experiment, FeatureFlag } from "./types.js"; + +function makeFlag(overrides: Partial = {}): FeatureFlag { + return { + id: 1, + key: "test-flag", + name: "Test", + active: true, + filters: {}, + rollout_percentage: null, + created_at: "2024-01-01", + created_by: null, + deleted: false, + ...overrides, + }; +} + +function makeExperiment(overrides: Partial = {}): Experiment { + return { + id: 1, + name: "Test Experiment", + description: null, + start_date: "2024-01-01", + end_date: null, + feature_flag_key: "test-flag", + created_at: "2024-01-01", + created_by: null, + ...overrides, + }; +} + +describe("classifyStaleness", () => { + test("returns not_in_posthog when flag is undefined", () => { + expect(classifyStaleness("unknown-flag", undefined, [])).toBe( + "not_in_posthog", + ); + }); + + test("returns inactive when flag is not active", () => { + const flag = makeFlag({ active: false }); + expect(classifyStaleness("test-flag", flag, [])).toBe("inactive"); + }); + + test("returns experiment_complete when linked experiment has end_date", () => { + const flag = makeFlag({ active: true }); + const experiment = makeExperiment({ end_date: "2024-06-01" }); + expect(classifyStaleness("test-flag", flag, [experiment])).toBe( + "experiment_complete", + ); + }); + + test("returns null when experiment is still running", () => { + const flag = makeFlag({ active: true }); + const experiment = makeExperiment({ end_date: null }); + expect(classifyStaleness("test-flag", flag, [experiment])).toBe(null); + }); + + test("returns fully_rolled_out for 100% rollout old flag", () => { + const flag = makeFlag({ + active: true, + created_at: "2020-01-01", + filters: { groups: [{ rollout_percentage: 100, properties: [] }] }, + }); + expect(classifyStaleness("test-flag", flag, [])).toBe("fully_rolled_out"); + }); + + test("returns null for 100% rollout recent flag (within age threshold)", () => { + const flag = makeFlag({ + active: true, + created_at: new Date().toISOString(), + filters: { groups: [{ rollout_percentage: 100, properties: [] }] }, + }); + expect(classifyStaleness("test-flag", flag, [])).toBe(null); + }); + + test("returns null for active flag with partial rollout", () => { + const flag = makeFlag({ + active: true, + filters: { groups: [{ rollout_percentage: 50, properties: [] }] }, + }); + expect(classifyStaleness("test-flag", flag, [])).toBe(null); + }); + + test("respects custom staleFlagAgeDays", () => { + const flag = makeFlag({ + active: true, + created_at: new Date(Date.now() - 10 * 86_400_000).toISOString(), // 10 days ago + filters: { groups: [{ rollout_percentage: 100, properties: [] }] }, + }); + // With 30-day threshold, not stale yet + expect( + classifyStaleness("test-flag", flag, [], { staleFlagAgeDays: 30 }), + ).toBe(null); + // With 5-day threshold, stale + expect( + classifyStaleness("test-flag", flag, [], { staleFlagAgeDays: 5 }), + ).toBe("fully_rolled_out"); + }); +}); diff --git a/packages/enricher/src/stale-flags.ts b/packages/enricher/src/stale-flags.ts new file mode 100644 index 000000000..db8b2b7c3 --- /dev/null +++ b/packages/enricher/src/stale-flags.ts @@ -0,0 +1,50 @@ +import { isFullyRolledOut } from "./flag-classification.js"; +import type { Experiment, FeatureFlag, StalenessReason } from "./types.js"; + +export interface StalenessCheckOptions { + /** Minimum age in days before a fully-rolled-out flag is considered stale. Default: 30 */ + staleFlagAgeDays?: number; +} + +/** Classify why a flag key is stale, or return null if it's not stale. */ +export function classifyStaleness( + flagKey: string, + flag: FeatureFlag | undefined, + experiments: Experiment[], + options: StalenessCheckOptions = {}, +): StalenessReason | null { + if (!flag) { + return "not_in_posthog"; + } + + if (!flag.active) { + return "inactive"; + } + + const experiment = experiments.find((e) => e.feature_flag_key === flagKey); + if (experiment?.end_date) { + return "experiment_complete"; + } + + if (isFullyRolledOut(flag)) { + const ageDays = options.staleFlagAgeDays ?? 30; + if (ageDays > 0 && flag.created_at) { + const createdAt = new Date(flag.created_at); + const ageMs = Date.now() - createdAt.getTime(); + if (ageMs < ageDays * 86_400_000) { + return null; + } + } + return "fully_rolled_out"; + } + + return null; +} + +/** Sort order for staleness reasons (most severe first) */ +export const STALENESS_ORDER: Record = { + not_in_posthog: 0, + inactive: 1, + experiment_complete: 2, + fully_rolled_out: 3, +}; diff --git a/packages/enricher/src/types.ts b/packages/enricher/src/types.ts new file mode 100644 index 000000000..44e703b33 --- /dev/null +++ b/packages/enricher/src/types.ts @@ -0,0 +1,131 @@ +// ── Detection result types ── + +export interface PostHogCall { + method: string; + key: string; + line: number; + keyStartCol: number; + keyEndCol: number; + /** True when the first argument is a non-literal expression (ternary, variable, etc.) */ + dynamic?: boolean; +} + +export interface FunctionInfo { + name: string; + params: string[]; + isComponent: boolean; + bodyLine: number; + bodyIndent: string; +} + +export interface VariantBranch { + flagKey: string; + variantKey: string; + conditionLine: number; + startLine: number; + endLine: number; +} + +export interface FlagAssignment { + varName: string; + method: string; + flagKey: string; + line: number; + varNameEndCol: number; + hasTypeAnnotation: boolean; +} + +export interface PostHogInitCall { + token: string; + tokenLine: number; + tokenStartCol: number; + tokenEndCol: number; + apiHost: string | null; + configProperties: Map; +} + +// ── Detection configuration ── + +export interface DetectionConfig { + additionalClientNames: string[]; + additionalFlagFunctions: string[]; + detectNestedClients: boolean; + onError?: (message: string, error?: unknown) => void; +} + +export const DEFAULT_CONFIG: DetectionConfig = { + additionalClientNames: [], + additionalFlagFunctions: [], + detectNestedClients: true, +}; + +// ── Supported languages ── + +export type SupportedLanguage = + | "javascript" + | "javascriptreact" + | "typescript" + | "typescriptreact" + | "python" + | "go" + | "ruby"; + +// ── PostHog entity types (for flag classification / stale detection) ── + +export interface FeatureFlag { + id: number; + key: string; + name: string; + active: boolean; + filters: Record; + rollout_percentage: number | null; + created_at: string; + created_by: { email: string; first_name: string } | null; + deleted: boolean; +} + +export interface Experiment { + id: number; + name: string; + description: string | null; + start_date: string | null; + end_date: string | null; + feature_flag_key: string; + created_at: string; + created_by: { email: string; first_name: string } | null; + metrics?: ExperimentMetric[]; + metrics_secondary?: ExperimentMetric[]; + parameters?: { + feature_flag_variants?: { key: string; rollout_percentage: number }[]; + recommended_sample_size?: number; + }; + conclusion?: "won" | "lost" | null; + conclusion_comment?: string | null; +} + +export interface ExperimentMetric { + name: string; + metric_type: "funnel" | "mean" | "ratio" | "retention"; + goal: "increase" | "decrease"; + uuid: string; +} + +export interface EventDefinition { + id: string; + name: string; + description: string | null; + tags: string[]; + last_seen_at: string | null; + verified: boolean; + hidden: boolean; +} + +// ── Stale flag types ── + +export type StalenessReason = + | "fully_rolled_out" + | "inactive" + | "not_in_posthog" + | "experiment_complete"; + +export type FlagType = "boolean" | "multivariate" | "remote_config"; diff --git a/packages/enricher/tsconfig.json b/packages/enricher/tsconfig.json new file mode 100644 index 000000000..606083a82 --- /dev/null +++ b/packages/enricher/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "types": ["node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/enricher/tsup.config.ts b/packages/enricher/tsup.config.ts new file mode 100644 index 000000000..1465ea247 --- /dev/null +++ b/packages/enricher/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + splitting: false, + outDir: "dist", + target: "node20", + external: ["web-tree-sitter"], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7821d297..26cfaefd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,16 +171,16 @@ importers: version: 0.0.48(prop-types@15.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@posthog/quill-blocks': specifier: link:/Users/adamleithp/Dev/posthog/packages/quill/packages/blocks - version: link:../../../posthog/packages/quill/packages/blocks + version: link:../../../../../adamleithp/Dev/posthog/packages/quill/packages/blocks '@posthog/quill-components': specifier: link:/Users/adamleithp/Dev/posthog/packages/quill/packages/components - version: link:../../../posthog/packages/quill/packages/components + version: link:../../../../../adamleithp/Dev/posthog/packages/quill/packages/components '@posthog/quill-primitives': specifier: link:/Users/adamleithp/Dev/posthog/packages/quill/packages/primitives - version: link:../../../posthog/packages/quill/packages/primitives + version: link:../../../../../adamleithp/Dev/posthog/packages/quill/packages/primitives '@posthog/quill-tokens': specifier: link:/Users/adamleithp/Dev/posthog/packages/quill/packages/tokens - version: link:../../../posthog/packages/quill/packages/tokens + version: link:../../../../../adamleithp/Dev/posthog/packages/quill/packages/tokens '@posthog/shared': specifier: workspace:* version: link:../../packages/shared @@ -743,6 +743,25 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/enricher: + dependencies: + web-tree-sitter: + specifier: ^0.24.7 + version: 0.24.7 + devDependencies: + tree-sitter-cli: + specifier: ^0.26.6 + version: 0.26.8 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.2.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(terser@5.46.0) + packages/git: dependencies: '@posthog/shared': @@ -7295,7 +7314,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} @@ -10555,6 +10574,11 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-cli@0.26.8: + resolution: {integrity: sha512-teQFMF5V/g8aIdakZ0M/eZoedCM3MuBt1JuDOICLloA2hy7QfeOInb99U6wiML4qXcBHWREwf0U1TWzw7p67YA==} + engines: {node: '>=12.0.0'} + hasBin: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -11098,6 +11122,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-tree-sitter@0.24.7: + resolution: {integrity: sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==} + web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} @@ -22660,6 +22687,8 @@ snapshots: tree-kill@1.2.2: {} + tree-sitter-cli@0.26.8: {} + trim-lines@3.0.1: {} trim-repeated@1.0.0: @@ -23216,6 +23245,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-tree-sitter@0.24.7: {} + web-vitals@5.1.0: {} webidl-conversions@3.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4cea2e4c0..7d2b3413a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ onlyBuiltDependencies: - fs-xattr - macos-alias - node-pty + - tree-sitter-cli patchedDependencies: node-pty: patches/node-pty.patch