From cd4d87a86c0170422a2d8087c1cb8e210c1c0d7f Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 14:31:09 -0300 Subject: [PATCH 01/14] feat(revenue-analytics): Add setup-revenue-analytics command with full detection pipeline Wire up `setup-revenue-analytics` yargs subcommand in bin.ts with language detection, Stripe SDK/call pattern scanning, PostHog distinct_id detection, runtime Stripe docs fetching with fallback, and agent prompt builder. Supports Node, Python, Ruby, PHP, Go, Java, and .NET. --- bin.ts | 43 +++ .../__tests__/language-detection.test.ts | 117 ++++++ src/setup-revenue-analytics/index.ts | 298 +++++++++++++++ .../language-detection.ts | 96 +++++ .../package-manager.ts | 49 +++ .../posthog-detection.ts | 118 ++++++ src/setup-revenue-analytics/prompt-builder.ts | 133 +++++++ .../stripe-detection.ts | 341 ++++++++++++++++++ .../stripe-docs-fallback.ts | 172 +++++++++ .../stripe-docs-fetcher.ts | 152 ++++++++ src/setup-revenue-analytics/stripe-docs.ts | 36 ++ src/setup-revenue-analytics/types.ts | 50 +++ 12 files changed, 1605 insertions(+) create mode 100644 src/setup-revenue-analytics/__tests__/language-detection.test.ts create mode 100644 src/setup-revenue-analytics/index.ts create mode 100644 src/setup-revenue-analytics/language-detection.ts create mode 100644 src/setup-revenue-analytics/package-manager.ts create mode 100644 src/setup-revenue-analytics/posthog-detection.ts create mode 100644 src/setup-revenue-analytics/prompt-builder.ts create mode 100644 src/setup-revenue-analytics/stripe-detection.ts create mode 100644 src/setup-revenue-analytics/stripe-docs-fallback.ts create mode 100644 src/setup-revenue-analytics/stripe-docs-fetcher.ts create mode 100644 src/setup-revenue-analytics/stripe-docs.ts create mode 100644 src/setup-revenue-analytics/types.ts diff --git a/bin.ts b/bin.ts index a61e4cc..803fdf0 100644 --- a/bin.ts +++ b/bin.ts @@ -504,6 +504,49 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'You must specify a subcommand (add or remove)') .help(); }) + .command( + 'setup-revenue-analytics', + 'Set up Stripe revenue analytics with PostHog', + (yargs) => { + return yargs.options({ + 'install-dir': { + describe: + 'Directory of the project to set up\nenv: POSTHOG_WIZARD_INSTALL_DIR', + type: 'string', + }, + }); + }, + (argv) => { + const options = { ...argv }; + + void (async () => { + // Always use LoggingUI for this subcommand (simple linear flow) + setUI(new LoggingUI()); + + const { readApiKeyFromEnv } = await import( + './src/utils/env-api-key.js' + ); + const { buildSession } = await import('./src/lib/wizard-session.js'); + const { runSetupRevenueAnalytics } = await import( + './src/setup-revenue-analytics/index.js' + ); + + const apiKey = + (options.apiKey as string | undefined) || readApiKeyFromEnv(); + + const session = buildSession({ + debug: options.debug as boolean | undefined, + installDir: options.installDir as string | undefined, + ci: options.ci as boolean | undefined, + localMcp: options.localMcp as boolean | undefined, + apiKey, + projectId: options.projectId as string | undefined, + }); + + await runSetupRevenueAnalytics(session); + })(); + }, + ) .help() .alias('help', 'h') .version() diff --git a/src/setup-revenue-analytics/__tests__/language-detection.test.ts b/src/setup-revenue-analytics/__tests__/language-detection.test.ts new file mode 100644 index 0000000..3ae031f --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/language-detection.test.ts @@ -0,0 +1,117 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { + detectLanguageFromFiles, + languageFromIntegration, +} from '../language-detection'; +import { Integration } from '../../lib/constants'; + +describe('languageFromIntegration', () => { + const cases: [Integration, string | null][] = [ + [Integration.nextjs, 'node'], + [Integration.nuxt, 'node'], + [Integration.vue, 'node'], + [Integration.reactRouter, 'node'], + [Integration.tanstackStart, 'node'], + [Integration.tanstackRouter, 'node'], + [Integration.reactNative, 'node'], + [Integration.angular, 'node'], + [Integration.astro, 'node'], + [Integration.sveltekit, 'node'], + [Integration.javascript_web, 'node'], + [Integration.javascriptNode, 'node'], + [Integration.django, 'python'], + [Integration.flask, 'python'], + [Integration.fastapi, 'python'], + [Integration.python, 'python'], + [Integration.laravel, 'php'], + [Integration.rails, 'ruby'], + [Integration.ruby, 'ruby'], + [Integration.swift, null], + [Integration.android, null], + ]; + + test.each(cases)('%s → %s', (integration, expected) => { + expect(languageFromIntegration(integration)).toBe(expected); + }); +}); + +describe('detectLanguageFromFiles', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-lang-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('detects node from package.json', async () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); + }); + + test('detects python from requirements.txt', async () => { + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe==5.0.0'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('python'); + }); + + test('detects python from pyproject.toml', async () => { + fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[project]'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('python'); + }); + + test('detects ruby from Gemfile', async () => { + fs.writeFileSync( + path.join(tmpDir, 'Gemfile'), + "source 'https://rubygems.org'", + ); + expect(await detectLanguageFromFiles(tmpDir)).toBe('ruby'); + }); + + test('detects php from composer.json', async () => { + fs.writeFileSync(path.join(tmpDir, 'composer.json'), '{}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('php'); + }); + + test('detects go from go.mod', async () => { + fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module myapp'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('go'); + }); + + test('detects java from build.gradle', async () => { + fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'plugins {}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('java'); + }); + + test('detects java from pom.xml', async () => { + fs.writeFileSync(path.join(tmpDir, 'pom.xml'), ''); + expect(await detectLanguageFromFiles(tmpDir)).toBe('java'); + }); + + test('detects dotnet from .csproj', async () => { + fs.writeFileSync(path.join(tmpDir, 'MyApp.csproj'), ''); + expect(await detectLanguageFromFiles(tmpDir)).toBe('dotnet'); + }); + + test('detects dotnet from .sln', async () => { + fs.writeFileSync( + path.join(tmpDir, 'MyApp.sln'), + 'Microsoft Visual Studio Solution', + ); + expect(await detectLanguageFromFiles(tmpDir)).toBe('dotnet'); + }); + + test('returns null for empty directory', async () => { + expect(await detectLanguageFromFiles(tmpDir)).toBeNull(); + }); + + test('returns first match when multiple indicators present', async () => { + // node (package.json) comes before python in indicator order + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); + }); +}); diff --git a/src/setup-revenue-analytics/index.ts b/src/setup-revenue-analytics/index.ts new file mode 100644 index 0000000..31c8866 --- /dev/null +++ b/src/setup-revenue-analytics/index.ts @@ -0,0 +1,298 @@ +/** + * Entry point for the setup-revenue-analytics command. + * + * Orchestrates: language detection → Stripe detection → PostHog distinct_id + * detection → Stripe docs fetching → prompt building → agent execution. + */ + +import type { WizardSession } from '../lib/wizard-session'; +import { getUI } from '../ui'; +import { detectLanguage } from './language-detection'; +import { detectStripe } from './stripe-detection'; +import { detectPostHogDistinctId } from './posthog-detection'; +import { getStripeDocs } from './stripe-docs'; +import { buildRevenueAnalyticsPrompt } from './prompt-builder'; +import { + initializeAgent, + runAgent, + AgentSignals, + AgentErrorType, + buildWizardMetadata, + checkAllSettingsConflicts, + backupAndFixClaudeSettings, + restoreClaudeSettings, +} from '../lib/agent-interface'; +import { getOrAskForProjectData } from '../utils/setup-utils'; +import { + evaluateWizardReadiness, + WizardReadiness, +} from '../lib/health-checks/readiness'; +import { analytics } from '../utils/analytics'; +import { initLogFile, logToFile, enableDebugLogs } from '../utils/debug'; +import { + wizardAbort, + WizardError, + registerCleanup, +} from '../utils/wizard-abort'; +import { formatScanReport, writeScanReport } from '../lib/yara-hooks'; + +export async function runSetupRevenueAnalytics( + session: WizardSession, +): Promise { + initLogFile(); + logToFile('[setup-revenue-analytics] START'); + + if (session.debug) { + enableDebugLogs(); + } + + getUI().intro('PostHog Revenue Analytics Setup'); + + // 1. Detect language + getUI().log.step('Detecting project language...'); + const language = await detectLanguage(session.installDir); + if (!language) { + return wizardAbort({ + message: + 'Could not detect the project language. Revenue analytics setup requires a Node.js, Python, Ruby, PHP, Go, Java, or .NET project.', + }); + } + logToFile(`[setup-revenue-analytics] language=${language}`); + getUI().log.success(`Detected language: ${language}`); + + // 2. Detect Stripe SDK + getUI().log.step('Scanning for Stripe SDK...'); + const stripeResult = detectStripe(session.installDir, language); + if (!stripeResult) { + return wizardAbort({ + message: + 'No Stripe SDK detected in this project. Install the Stripe SDK for your language first, then re-run this command.', + }); + } + logToFile( + `[setup-revenue-analytics] stripe=${stripeResult.sdkPackage} v=${stripeResult.sdkVersion}`, + ); + getUI().log.success( + `Found Stripe SDK: ${stripeResult.sdkPackage}${ + stripeResult.sdkVersion ? ` v${stripeResult.sdkVersion}` : '' + }`, + ); + + if (stripeResult.customerCreationCalls.length > 0) { + getUI().log.info( + ` Customer creation calls: ${stripeResult.customerCreationCalls.length} location(s)`, + ); + } + if (stripeResult.chargeCalls.length > 0) { + getUI().log.info( + ` Charge/payment calls: ${stripeResult.chargeCalls.length} location(s)`, + ); + } + + // 3. Detect PostHog distinct_id + getUI().log.step('Looking for PostHog distinct_id usage...'); + const posthogResult = await detectPostHogDistinctId( + session.installDir, + language, + ); + if (posthogResult.distinctIdExpression) { + logToFile( + `[setup-revenue-analytics] distinct_id=${posthogResult.distinctIdExpression}`, + ); + getUI().log.success( + `Found distinct_id expression: ${posthogResult.distinctIdExpression}`, + ); + } else { + getUI().log.warn( + 'Could not detect PostHog distinct_id usage. The agent will ask you during setup.', + ); + } + + // 4. Fetch Stripe docs + getUI().log.step('Fetching Stripe documentation...'); + const stripeDocs = await getStripeDocs(language, stripeResult.sdkVersion); + getUI().log.success('Stripe docs ready'); + + // 5. Build prompt + const prompt = buildRevenueAnalyticsPrompt({ + language, + stripeDetection: stripeResult, + posthogDetection: posthogResult, + stripeDocs, + }); + logToFile('[setup-revenue-analytics] prompt built'); + + // 6. Health check + if (!session.readinessResult) { + logToFile('[setup-revenue-analytics] evaluating readiness'); + const readiness = await evaluateWizardReadiness(); + if (readiness.decision === WizardReadiness.No) { + await getUI().showBlockingOutage(readiness); + } + } + + // 7. Settings conflict check + const settingsConflicts = checkAllSettingsConflicts(session.installDir); + if (settingsConflicts.length > 0) { + await getUI().showSettingsOverride(settingsConflicts, () => + backupAndFixClaudeSettings(session.installDir), + ); + } + + // 8. Authenticate + logToFile('[setup-revenue-analytics] starting auth'); + const { projectApiKey, host, accessToken, projectId, cloudRegion } = + await getOrAskForProjectData({ + signup: session.signup, + ci: session.ci, + apiKey: session.apiKey, + projectId: session.projectId, + }); + + session.credentials = { accessToken, projectApiKey, host, projectId }; + getUI().setCredentials(session.credentials); + + analytics.wizardCapture('revenue_analytics started', { + language, + stripe_version: stripeResult.sdkVersion, + customer_creation_calls: stripeResult.customerCreationCalls.length, + charge_calls: stripeResult.chargeCalls.length, + distinct_id_found: !!posthogResult.distinctIdExpression, + }); + + // 9. Compute MCP URL and skills URL + const mcpUrl = session.localMcp + ? 'http://localhost:8787/mcp' + : process.env.MCP_URL || + (cloudRegion === 'eu' + ? 'https://mcp-eu.posthog.com/mcp' + : 'https://mcp.posthog.com/mcp'); + + const skillsBaseUrl = session.localMcp + ? 'http://localhost:8765' + : 'https://github.com/PostHog/context-mill/releases/latest/download'; + + // 10. Initialize and run agent + const wizardFlags = await analytics.getAllFlagsForWizard(); + const wizardMetadata = buildWizardMetadata(wizardFlags); + + const restoreSettings = () => restoreClaudeSettings(session.installDir); + getUI().onEnterScreen('outro', restoreSettings); + + if (session.yaraReport) { + registerCleanup(() => { + const reportPath = writeScanReport(); + if (reportPath) { + const summary = formatScanReport(); + getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); + } + }); + } + + getUI().startRun(); + + const spinner = getUI().spinner(); + + const { detectPackageManager: getPackageManagerDetector } = await import( + './package-manager.js' + ); + const detectPackageManager = getPackageManagerDetector(language); + + const agent = await initializeAgent( + { + workingDirectory: session.installDir, + posthogMcpUrl: mcpUrl, + posthogApiKey: accessToken, + posthogApiHost: host, + detectPackageManager, + skillsBaseUrl, + wizardFlags, + wizardMetadata, + }, + { + installDir: session.installDir, + debug: session.debug, + forceInstall: false, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + menu: false, + benchmark: session.benchmark, + projectId: session.projectId, + apiKey: session.apiKey, + yaraReport: session.yaraReport, + }, + ); + + const agentResult = await runAgent( + agent, + prompt, + { + installDir: session.installDir, + debug: session.debug, + forceInstall: false, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + menu: false, + benchmark: session.benchmark, + projectId: session.projectId, + apiKey: session.apiKey, + yaraReport: session.yaraReport, + }, + spinner, + { + estimatedDurationMinutes: 3, + spinnerMessage: 'Setting up revenue analytics...', + successMessage: 'Revenue analytics setup complete', + errorMessage: 'Revenue analytics setup failed', + }, + ); + + // Handle errors + if (agentResult.error === AgentErrorType.MCP_MISSING) { + await wizardAbort({ + message: `Could not access the PostHog MCP server.\n\nPlease try again, or follow the manual setup guide:\nhttps://posthog.com/docs/revenue-analytics/connect-to-customers`, + error: new WizardError('Agent could not access PostHog MCP server', { + error_type: AgentErrorType.MCP_MISSING, + signal: AgentSignals.ERROR_MCP_MISSING, + }), + }); + } + + if (agentResult.error === AgentErrorType.YARA_VIOLATION) { + await wizardAbort({ + message: + 'Security violation detected.\n\nPlease report this to: wizard@posthog.com', + error: new WizardError('YARA scanner terminated session', { + error_type: AgentErrorType.YARA_VIOLATION, + }), + }); + } + + if ( + agentResult.error === AgentErrorType.RATE_LIMIT || + agentResult.error === AgentErrorType.API_ERROR + ) { + await wizardAbort({ + message: `API Error\n\n${ + agentResult.message || 'Unknown error' + }\n\nPlease report this to: wizard@posthog.com`, + error: new WizardError(`API error: ${agentResult.message}`, { + error_type: agentResult.error, + }), + }); + } + + analytics.wizardCapture('revenue_analytics completed', { + language, + }); + + getUI().outro( + 'Revenue analytics setup complete! Visit your PostHog dashboard to see revenue data.', + ); + + await analytics.shutdown('success'); +} diff --git a/src/setup-revenue-analytics/language-detection.ts b/src/setup-revenue-analytics/language-detection.ts new file mode 100644 index 0000000..9a600e7 --- /dev/null +++ b/src/setup-revenue-analytics/language-detection.ts @@ -0,0 +1,96 @@ +/** + * Language detection for setup-revenue-analytics. + * + * Reuses the existing framework detection from the wizard, mapping + * detected integrations to base languages. Falls back to scanning + * for language indicator files when framework detection fails. + */ + +import { Integration } from '../lib/constants'; +import type { Language } from './types'; +import fg from 'fast-glob'; + +const INTEGRATION_TO_LANGUAGE: Record = { + [Integration.nextjs]: 'node', + [Integration.nuxt]: 'node', + [Integration.vue]: 'node', + [Integration.reactRouter]: 'node', + [Integration.tanstackStart]: 'node', + [Integration.tanstackRouter]: 'node', + [Integration.reactNative]: 'node', + [Integration.angular]: 'node', + [Integration.astro]: 'node', + [Integration.sveltekit]: 'node', + [Integration.javascript_web]: 'node', + [Integration.javascriptNode]: 'node', + [Integration.django]: 'python', + [Integration.flask]: 'python', + [Integration.fastapi]: 'python', + [Integration.python]: 'python', + [Integration.laravel]: 'php', + [Integration.rails]: 'ruby', + [Integration.ruby]: 'ruby', + [Integration.swift]: null, + [Integration.android]: null, +}; + +interface LanguageIndicator { + language: Language; + patterns: string[]; +} + +const LANGUAGE_INDICATORS: LanguageIndicator[] = [ + { language: 'node', patterns: ['package.json'] }, + { + language: 'python', + patterns: ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py'], + }, + { language: 'ruby', patterns: ['Gemfile'] }, + { language: 'php', patterns: ['composer.json'] }, + { language: 'go', patterns: ['go.mod'] }, + { + language: 'java', + patterns: ['build.gradle', 'build.gradle.kts', 'pom.xml'], + }, + { language: 'dotnet', patterns: ['*.csproj', '*.sln'] }, +]; + +export function languageFromIntegration( + integration: Integration, +): Language | null { + return INTEGRATION_TO_LANGUAGE[integration] ?? null; +} + +export async function detectLanguageFromFiles( + installDir: string, +): Promise { + for (const { language, patterns } of LANGUAGE_INDICATORS) { + const matches = await fg(patterns, { + cwd: installDir, + deep: 1, + onlyFiles: true, + }); + if (matches.length > 0) { + return language; + } + } + return null; +} + +/** + * Detect the codebase language. Tries framework detection first, + * then falls back to file-based detection. + */ +export async function detectLanguage( + installDir: string, +): Promise { + const { detectIntegration } = await import('../run.js'); + const integration = await detectIntegration(installDir); + + if (integration) { + const language = languageFromIntegration(integration); + if (language) return language; + } + + return detectLanguageFromFiles(installDir); +} diff --git a/src/setup-revenue-analytics/package-manager.ts b/src/setup-revenue-analytics/package-manager.ts new file mode 100644 index 0000000..bcfa7f1 --- /dev/null +++ b/src/setup-revenue-analytics/package-manager.ts @@ -0,0 +1,49 @@ +/** + * Map language to the appropriate package manager detector. + */ + +import type { PackageManagerDetector } from '../lib/package-manager-detection'; +import { + detectNodePackageManagers, + detectPythonPackageManagers, + composerPackageManager, + bundlerPackageManager, + gradlePackageManager, +} from '../lib/package-manager-detection'; +import type { Language } from './types'; + +const DETECTORS: Record = { + node: detectNodePackageManagers, + python: detectPythonPackageManagers, + php: () => composerPackageManager(), + ruby: () => bundlerPackageManager(), + java: () => gradlePackageManager(), + go: () => + Promise.resolve({ + detected: [{ name: 'go', label: 'Go Modules', installCommand: 'go get' }], + primary: { name: 'go', label: 'Go Modules', installCommand: 'go get' }, + recommendation: 'Use Go Modules (go get).', + }), + dotnet: () => + Promise.resolve({ + detected: [ + { + name: 'nuget', + label: 'NuGet', + installCommand: 'dotnet add package', + }, + ], + primary: { + name: 'nuget', + label: 'NuGet', + installCommand: 'dotnet add package', + }, + recommendation: 'Use NuGet (dotnet add package).', + }), +}; + +export function detectPackageManager( + language: Language, +): PackageManagerDetector { + return DETECTORS[language]; +} diff --git a/src/setup-revenue-analytics/posthog-detection.ts b/src/setup-revenue-analytics/posthog-detection.ts new file mode 100644 index 0000000..84a745a --- /dev/null +++ b/src/setup-revenue-analytics/posthog-detection.ts @@ -0,0 +1,118 @@ +/** + * PostHog distinct_id detection — scans for how the project references distinct_id. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import fg from 'fast-glob'; +import type { Language, PostHogDistinctIdResult } from './types'; + +const FILE_EXTENSIONS: Record = { + node: '**/*.{ts,js,tsx,jsx,mjs,cjs}', + python: '**/*.py', + ruby: '**/*.rb', + php: '**/*.php', + go: '**/*.go', + java: '**/*.{java,kt,kts}', + dotnet: '**/*.{cs,fs}', +}; + +const IGNORE_DIRS = [ + '**/node_modules/**', + '**/venv/**', + '**/.venv/**', + '**/env/**', + '**/.env/**', + '**/vendor/**', + '**/dist/**', + '**/build/**', + '**/.git/**', + '**/bin/**', + '**/obj/**', +]; + +/** + * Patterns that capture the distinct_id expression from posthog.identify() calls. + * Group 1 should be the distinct_id argument. + */ +const IDENTIFY_PATTERNS: Record = { + node: [ + /posthog\.identify\(\s*(['"`]?)([\w.[\]]+)\1/, + /posthog\.capture\([^,]+,\s*\{[^}]*distinct_?[Ii]d:\s*([^\s,}]+)/, + /distinctId\s*[:=]\s*([^\s,;]+)/, + ], + python: [ + /posthog\.identify\(\s*([^\s,)]+)/, + /posthog\.capture\(\s*([^\s,)]+)/, + /distinct_id\s*=\s*([^\s,)]+)/, + ], + ruby: [ + /posthog\.identify\(\s*\{\s*distinct_id:\s*([^\s,}]+)/, + /posthog\.capture\(\s*\{\s*distinct_id:\s*([^\s,}]+)/, + /distinct_id:\s*([^\s,}]+)/, + ], + php: [ + /PostHog::identify\(\s*\[\s*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + /PostHog::capture\(\s*\[\s*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + /['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + ], + go: [ + /DistinctId:\s*([^\s,}]+)/, + /posthog\.Capture\([^)]*DistinctId:\s*([^\s,}]+)/, + ], + java: [/\.distinctId\(\s*([^\s,)]+)/, /setDistinctId\(\s*([^\s,)]+)/], + dotnet: [ + /DistinctId\s*=\s*([^\s,}]+)/, + /posthog\.Capture\([^,]*,\s*([^\s,)]+)/, + ], +}; + +export async function detectPostHogDistinctId( + installDir: string, + language: Language, +): Promise { + const files = await fg(FILE_EXTENSIONS[language], { + cwd: installDir, + ignore: IGNORE_DIRS, + }); + + const patterns = IDENTIFY_PATTERNS[language]; + + for (const file of files) { + try { + const content = fs.readFileSync(path.join(installDir, file), 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const pattern of patterns) { + const match = pattern.exec(line); + if (match) { + // Use the last capturing group (the actual expression) + const expression = match[match.length - 1]; + if ( + expression && + !expression.startsWith("'") && + !expression.startsWith('"') && + !expression.startsWith('`') + ) { + return { + distinctIdExpression: expression, + sourceFile: file, + sourceLine: line.trim(), + }; + } + } + } + } + } catch { + continue; + } + } + + return { + distinctIdExpression: null, + sourceFile: null, + sourceLine: null, + }; +} diff --git a/src/setup-revenue-analytics/prompt-builder.ts b/src/setup-revenue-analytics/prompt-builder.ts new file mode 100644 index 0000000..1dfad79 --- /dev/null +++ b/src/setup-revenue-analytics/prompt-builder.ts @@ -0,0 +1,133 @@ +/** + * Build the agent prompt for revenue analytics setup. + * + * Assembles all detected context (language, Stripe calls, PostHog distinct_id, + * Stripe docs) into a structured prompt the agent can follow step-by-step. + */ + +import { AgentSignals } from '../lib/agent-interface'; +import type { + Language, + StripeDetectionResult, + PostHogDistinctIdResult, + StripeDocsForLanguage, +} from './types'; + +interface PromptContext { + language: Language; + stripeDetection: StripeDetectionResult; + posthogDetection: PostHogDistinctIdResult; + stripeDocs: StripeDocsForLanguage; +} + +export function buildRevenueAnalyticsPrompt(context: PromptContext): string { + const { language, stripeDetection, posthogDetection, stripeDocs } = context; + + const distinctIdSection = posthogDetection.distinctIdExpression + ? `- PostHog distinct_id expression: \`${posthogDetection.distinctIdExpression}\` (found in ${posthogDetection.sourceFile})` + : `- PostHog distinct_id: **Not detected** — you MUST ask the user what expression they use for their PostHog distinct_id (e.g., user.id, request.user.pk, session.userId). Do not proceed until you know this value.`; + + const customerCreationSection = + stripeDetection.customerCreationCalls.length > 0 + ? stripeDetection.customerCreationCalls + .map((c) => ` - ${c.file}:${c.line} — \`${c.snippet}\``) + .join('\n') + : ' (none found — search the codebase yourself)'; + + const chargeSection = + stripeDetection.chargeCalls.length > 0 + ? stripeDetection.chargeCalls + .map((c) => ` - ${c.file}:${c.line} [${c.type}] — \`${c.snippet}\``) + .join('\n') + : ' (none found — search the codebase yourself)'; + + const checkoutNote = stripeDetection.usesCheckoutSessions + ? ` +IMPORTANT — Stripe Checkout detected: +This project uses checkout.Session.create, which means Stripe may auto-create customers. +If there is no explicit Customer.create call, you need to handle this in the checkout.session.completed webhook handler instead. +In the webhook handler, after receiving the session object, update the customer with the PostHog metadata: + +${stripeDocs.customerUpdate.fullExample} + +Tip: Set client_reference_id to the internal user ID when creating Checkout Sessions. This lets you look up the user in your database when the webhook fires.` + : ''; + + return `You are setting up PostHog revenue analytics for a ${language} project that uses Stripe. + +Your goal: Modify the codebase so that every Stripe customer has a \`posthog_person_distinct_id\` metadata field. This connects Stripe revenue data to PostHog persons. + +## Context + +- Language: ${language} +- Stripe SDK: ${stripeDetection.sdkPackage}${ + stripeDetection.sdkVersion ? ` v${stripeDetection.sdkVersion}` : '' + } +${distinctIdSection} + +### Customer creation locations: +${customerCreationSection} + +### Charge/payment locations: +${chargeSection} + +## Instructions + +Follow these steps IN ORDER: + +### STEP 1: Update Stripe Customer Creation + +For each Stripe Customer.create call, add \`posthog_person_distinct_id\` to the metadata. + +**Pattern for this language:** +\`\`\` +${stripeDocs.customerCreate.fullExample} +\`\`\` + +Rules: +- If the call already has a metadata object, ADD the \`posthog_person_distinct_id\` key to it. Do NOT overwrite existing metadata. +- If the call does not have a metadata object, add one with the \`posthog_person_distinct_id\` key. +- Use the PostHog distinct_id expression detected above${ + posthogDetection.distinctIdExpression + ? ` (\`${posthogDetection.distinctIdExpression}\`)` + : '' + } as the value. Adapt it to the variable scope at the call site. +- Preserve all existing arguments and code structure. + +### STEP 2: Add Customer Update Before Charges + +For each charge/payment call (PaymentIntent.create, Subscription.create, Invoice.create, checkout.Session.create), add a Stripe Customer.update/modify call BEFORE the charge. This ensures existing customers (created before Step 1) get tagged. + +**Pattern for this language:** +\`\`\` +${stripeDocs.customerUpdate.fullExample} +\`\`\` + +Rules: +- Add the update call immediately before the charge call. +- Extract the customer ID from the charge call's arguments (it's usually a \`customer\` field). +- The update only sets metadata — do not modify the charge/payment logic itself. +- If the customer ID is not available at that point, trace back to find it. +${checkoutNote} + +### STEP 3: Verify + +After making all changes, read each modified file to verify: +- No syntax errors +- Existing code logic is preserved +- The metadata field is correctly set +- Imports are present if needed + +## Constraints + +- Do NOT modify the charge/payment logic itself — only add metadata to customer creation and add customer.update calls before charges. +- Do NOT remove any existing code. +- Do NOT add new packages or dependencies. +- Preserve all imports and error handling. +- If you cannot determine the distinct_id expression from context, add a \`TODO\` comment: \`// TODO: Replace with your PostHog distinct_id\` + +When done, emit: ${ + AgentSignals.STATUS + } Revenue analytics setup complete — modified files summary. +`; +} diff --git a/src/setup-revenue-analytics/stripe-detection.ts b/src/setup-revenue-analytics/stripe-detection.ts new file mode 100644 index 0000000..f48c514 --- /dev/null +++ b/src/setup-revenue-analytics/stripe-detection.ts @@ -0,0 +1,341 @@ +/** + * Stripe SDK detection — finds Stripe packages, versions, and API call patterns. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import fg from 'fast-glob'; +import type { + Language, + StripeDetectionResult, + StripeCallLocation, + StripeChargeCall, +} from './types'; + +const STRIPE_PACKAGES: Record = { + node: [{ file: 'package.json', pattern: /"stripe"/ }], + python: [ + { file: 'requirements.txt', pattern: /^stripe([>=~!<\s]|$)/m }, + { file: 'pyproject.toml', pattern: /["']stripe["']/ }, + { file: 'Pipfile', pattern: /stripe/ }, + ], + ruby: [{ file: 'Gemfile', pattern: /['"]stripe['"]/ }], + php: [{ file: 'composer.json', pattern: /"stripe\/stripe-php"/ }], + go: [{ file: 'go.mod', pattern: /github\.com\/stripe\/stripe-go/ }], + java: [ + { file: 'build.gradle', pattern: /com\.stripe:stripe-java/ }, + { file: 'build.gradle.kts', pattern: /com\.stripe:stripe-java/ }, + { file: 'pom.xml', pattern: /stripe-java/ }, + ], + dotnet: [{ file: '*.csproj', pattern: /Stripe\.net/i }], +}; + +const CUSTOMER_CREATE_PATTERNS: Record = { + node: /stripe\.customers\.create\s*\(/, + python: /stripe\.Customer\.create\s*\(/, + ruby: /Stripe::Customer\.create\s*\(/, + php: /(?:\$stripe->customers->create|\\\s*Stripe\\\s*Customer::create)\s*\(/, + go: /customer\.New\s*\(/, + java: /Customer\.create\s*\(/, + dotnet: /(?:CustomerService|customerService).*\.Create/, +}; + +type ChargeType = StripeChargeCall['type']; + +const CHARGE_PATTERNS: Record< + Language, + { pattern: RegExp; type: ChargeType }[] +> = { + node: [ + { pattern: /stripe\.paymentIntents\.create\s*\(/, type: 'payment_intent' }, + { pattern: /stripe\.subscriptions\.create\s*\(/, type: 'subscription' }, + { + pattern: /stripe\.checkout\.sessions\.create\s*\(/, + type: 'checkout_session', + }, + { pattern: /stripe\.invoices\.create\s*\(/, type: 'invoice' }, + ], + python: [ + { pattern: /stripe\.PaymentIntent\.create\s*\(/, type: 'payment_intent' }, + { pattern: /stripe\.Subscription\.create\s*\(/, type: 'subscription' }, + { + pattern: /stripe\.checkout\.Session\.create\s*\(/, + type: 'checkout_session', + }, + { pattern: /stripe\.Invoice\.create\s*\(/, type: 'invoice' }, + ], + ruby: [ + { pattern: /Stripe::PaymentIntent\.create\s*\(/, type: 'payment_intent' }, + { pattern: /Stripe::Subscription\.create\s*\(/, type: 'subscription' }, + { + pattern: /Stripe::Checkout::Session\.create\s*\(/, + type: 'checkout_session', + }, + { pattern: /Stripe::Invoice\.create\s*\(/, type: 'invoice' }, + ], + php: [ + { + pattern: /(?:paymentIntents->create|PaymentIntent::create)\s*\(/, + type: 'payment_intent', + }, + { + pattern: /(?:subscriptions->create|Subscription::create)\s*\(/, + type: 'subscription', + }, + { + pattern: /(?:checkout->sessions->create|Session::create)\s*\(/, + type: 'checkout_session', + }, + { pattern: /(?:invoices->create|Invoice::create)\s*\(/, type: 'invoice' }, + ], + go: [ + { pattern: /paymentintent\.New\s*\(/, type: 'payment_intent' }, + { pattern: /sub\.New\s*\(/, type: 'subscription' }, + { pattern: /session\.New\s*\(/, type: 'checkout_session' }, + { pattern: /invoice\.New\s*\(/, type: 'invoice' }, + ], + java: [ + { pattern: /PaymentIntent\.create\s*\(/, type: 'payment_intent' }, + { pattern: /Subscription\.create\s*\(/, type: 'subscription' }, + { pattern: /Session\.create\s*\(/, type: 'checkout_session' }, + { pattern: /Invoice\.create\s*\(/, type: 'invoice' }, + ], + dotnet: [ + { pattern: /PaymentIntentService.*\.Create/, type: 'payment_intent' }, + { pattern: /SubscriptionService.*\.Create/, type: 'subscription' }, + { pattern: /SessionService.*\.Create/, type: 'checkout_session' }, + { pattern: /InvoiceService.*\.Create/, type: 'invoice' }, + ], +}; + +const FILE_EXTENSIONS: Record = { + node: '**/*.{ts,js,tsx,jsx,mjs,cjs}', + python: '**/*.py', + ruby: '**/*.rb', + php: '**/*.php', + go: '**/*.go', + java: '**/*.{java,kt,kts}', + dotnet: '**/*.{cs,fs}', +}; + +const IGNORE_DIRS = [ + '**/node_modules/**', + '**/venv/**', + '**/.venv/**', + '**/env/**', + '**/.env/**', + '**/vendor/**', + '**/dist/**', + '**/build/**', + '**/.git/**', + '**/bin/**', + '**/obj/**', +]; + +function detectStripePackage( + installDir: string, + language: Language, +): string | null { + const checks = STRIPE_PACKAGES[language]; + for (const { file, pattern } of checks) { + try { + if (file.includes('*')) { + const matches = fg.sync(file, { cwd: installDir, deep: 2 }); + for (const match of matches) { + const content = fs.readFileSync( + path.join(installDir, match), + 'utf-8', + ); + if (pattern.test(content)) return match; + } + } else { + const filePath = path.join(installDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + if (pattern.test(content)) return file; + } + } catch { + continue; + } + } + return null; +} + +function extractStripeVersion( + installDir: string, + language: Language, +): string | null { + try { + switch (language) { + case 'node': { + // Try package-lock.json first + for (const lockfile of [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + ]) { + const lockPath = path.join(installDir, lockfile); + if (!fs.existsSync(lockPath)) continue; + const content = fs.readFileSync(lockPath, 'utf-8'); + + if (lockfile === 'package-lock.json') { + const parsed = JSON.parse(content); + const stripePkg = + parsed.packages?.['node_modules/stripe'] ?? + parsed.dependencies?.stripe; + if (stripePkg?.version) return stripePkg.version; + } else if (lockfile === 'yarn.lock') { + const match = content.match(/stripe@[^:]+:\s+version\s+"([^"]+)"/); + if (match) return match[1]; + } else if (lockfile === 'pnpm-lock.yaml') { + const match = content.match(/stripe@([^\s:]+)/); + if (match) return match[1]; + } + } + // Fallback to package.json version range + const pkgJson = JSON.parse( + fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'), + ); + const ver = + pkgJson.dependencies?.stripe ?? pkgJson.devDependencies?.stripe; + return ver ?? null; + } + case 'python': { + for (const lockfile of ['requirements.txt', 'poetry.lock', 'uv.lock']) { + const lockPath = path.join(installDir, lockfile); + if (!fs.existsSync(lockPath)) continue; + const content = fs.readFileSync(lockPath, 'utf-8'); + const match = content.match(/stripe[=~>=<]+([0-9][0-9.]*)/i); + if (match) return match[1]; + } + return null; + } + case 'ruby': { + const lockPath = path.join(installDir, 'Gemfile.lock'); + if (fs.existsSync(lockPath)) { + const content = fs.readFileSync(lockPath, 'utf-8'); + const match = content.match(/stripe\s+\(([^)]+)\)/); + if (match) return match[1]; + } + return null; + } + case 'php': { + const lockPath = path.join(installDir, 'composer.lock'); + if (fs.existsSync(lockPath)) { + const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + const pkg = parsed.packages?.find( + (p: { name: string }) => p.name === 'stripe/stripe-php', + ); + if (pkg?.version) return pkg.version.replace(/^v/, ''); + } + return null; + } + case 'go': { + const sumPath = path.join(installDir, 'go.sum'); + if (fs.existsSync(sumPath)) { + const content = fs.readFileSync(sumPath, 'utf-8'); + const match = content.match( + /github\.com\/stripe\/stripe-go\/v\d+\s+v([^\s]+)/, + ); + if (match) return match[1]; + } + return null; + } + case 'java': { + for (const buildFile of [ + 'build.gradle', + 'build.gradle.kts', + 'pom.xml', + ]) { + const filePath = path.join(installDir, buildFile); + if (!fs.existsSync(filePath)) continue; + const content = fs.readFileSync(filePath, 'utf-8'); + const match = content.match(/stripe-java[:'"\s]+([0-9][0-9.]*)/); + if (match) return match[1]; + } + return null; + } + case 'dotnet': { + const csprojFiles = fg.sync('**/*.csproj', { + cwd: installDir, + deep: 3, + }); + for (const file of csprojFiles) { + const content = fs.readFileSync(path.join(installDir, file), 'utf-8'); + const match = content.match( + /Stripe\.net['"]\s+Version=['"]([^'"]+)/i, + ); + if (match) return match[1]; + } + return null; + } + } + } catch { + return null; + } +} + +function scanForPatterns( + installDir: string, + language: Language, + pattern: RegExp, +): StripeCallLocation[] { + const results: StripeCallLocation[] = []; + const files = fg.sync(FILE_EXTENSIONS[language], { + cwd: installDir, + ignore: IGNORE_DIRS, + }); + + for (const file of files) { + try { + const content = fs.readFileSync(path.join(installDir, file), 'utf-8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (pattern.test(lines[i])) { + results.push({ + file, + line: i + 1, + snippet: lines[i].trim(), + }); + } + } + } catch { + continue; + } + } + return results; +} + +export function detectStripe( + installDir: string, + language: Language, +): StripeDetectionResult | null { + const packageFile = detectStripePackage(installDir, language); + if (!packageFile) return null; + + const sdkVersion = extractStripeVersion(installDir, language); + + const customerCreationCalls = scanForPatterns( + installDir, + language, + CUSTOMER_CREATE_PATTERNS[language], + ); + + const chargeCalls: StripeChargeCall[] = []; + for (const { pattern, type } of CHARGE_PATTERNS[language]) { + const locations = scanForPatterns(installDir, language, pattern); + chargeCalls.push(...locations.map((loc) => ({ ...loc, type }))); + } + + const usesCheckoutSessions = chargeCalls.some( + (c) => c.type === 'checkout_session', + ); + + return { + sdkPackage: packageFile, + sdkVersion, + language, + customerCreationCalls, + chargeCalls, + usesCheckoutSessions, + }; +} diff --git a/src/setup-revenue-analytics/stripe-docs-fallback.ts b/src/setup-revenue-analytics/stripe-docs-fallback.ts new file mode 100644 index 0000000..8ddd0b7 --- /dev/null +++ b/src/setup-revenue-analytics/stripe-docs-fallback.ts @@ -0,0 +1,172 @@ +/** + * Hardcoded fallback Stripe code examples for each language. + * Used when runtime fetching from Stripe's docs site fails. + * + * These examples are sourced from Stripe's official API docs and + * the PostHog revenue analytics documentation. + */ + +import type { Language, StripeDocsForLanguage } from './types'; + +export const STRIPE_DOCS_FALLBACK: Record = { + node: { + customerCreate: { + pattern: 'stripe.customers.create({ ... })', + metadataExample: + 'metadata: { posthog_person_distinct_id: user.posthogDistinctId }', + fullExample: `const customer = await stripe.customers.create({ + email: user.email, + metadata: { posthog_person_distinct_id: user.posthogDistinctId }, +});`, + }, + customerUpdate: { + pattern: 'stripe.customers.update(customerId, { ... })', + metadataExample: + '{ metadata: { posthog_person_distinct_id: user.posthogDistinctId } }', + fullExample: `await stripe.customers.update( + user.stripeCustomerId, + { metadata: { posthog_person_distinct_id: user.posthogDistinctId } } +);`, + }, + }, + + python: { + customerCreate: { + pattern: 'stripe.Customer.create(...)', + metadataExample: + 'metadata={"posthog_person_distinct_id": user.posthog_distinct_id}', + fullExample: `customer = stripe.Customer.create( + email=user.email, + metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, +)`, + }, + customerUpdate: { + pattern: 'stripe.Customer.modify(customer_id, ...)', + metadataExample: + 'metadata={"posthog_person_distinct_id": user.posthog_distinct_id}', + fullExample: `stripe.Customer.modify( + user.stripe_customer_id, + metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, +)`, + }, + }, + + ruby: { + customerCreate: { + pattern: 'Stripe::Customer.create({ ... })', + metadataExample: + 'metadata: { posthog_person_distinct_id: user.posthog_distinct_id }', + fullExample: `customer = Stripe::Customer.create({ + email: user.email, + metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, +})`, + }, + customerUpdate: { + pattern: 'Stripe::Customer.update(customer_id, { ... })', + metadataExample: + '{ metadata: { posthog_person_distinct_id: user.posthog_distinct_id } }', + fullExample: `Stripe::Customer.update( + user.stripe_customer_id, + { metadata: { posthog_person_distinct_id: user.posthog_distinct_id } } +)`, + }, + }, + + php: { + customerCreate: { + pattern: '$stripe->customers->create([...])', + metadataExample: + "'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId]", + fullExample: `$customer = $stripe->customers->create([ + 'email' => $user->email, + 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], +]);`, + }, + customerUpdate: { + pattern: '$stripe->customers->update($customerId, [...])', + metadataExample: + "['metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId]]", + fullExample: `$stripe->customers->update( + $user->stripeCustomerId, + ['metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId]] +);`, + }, + }, + + go: { + customerCreate: { + pattern: 'customer.New(params)', + metadataExample: + 'params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID)', + fullExample: `params := &stripe.CustomerParams{ + Email: stripe.String(user.Email), +} +params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) +cust, err := customer.New(params)`, + }, + customerUpdate: { + pattern: 'customer.Update(customerId, params)', + metadataExample: + 'params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID)', + fullExample: `params := &stripe.CustomerParams{} +params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) +_, err := customer.Update(user.StripeCustomerID, params)`, + }, + }, + + java: { + customerCreate: { + pattern: 'Customer.create(params)', + metadataExample: + '.putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId())', + fullExample: `CustomerCreateParams params = CustomerCreateParams.builder() + .setEmail(user.getEmail()) + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build(); +Customer customer = Customer.create(params);`, + }, + customerUpdate: { + pattern: 'Customer.retrieve(customerId).update(params)', + metadataExample: + '.putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId())', + fullExample: `CustomerUpdateParams params = CustomerUpdateParams.builder() + .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .build(); +Customer.retrieve(user.getStripeCustomerId()).update(params);`, + }, + }, + + dotnet: { + customerCreate: { + pattern: 'customerService.CreateAsync(options)', + metadataExample: `Metadata = new Dictionary +{ + { "posthog_person_distinct_id", user.PosthogDistinctId }, +}`, + fullExample: `var options = new CustomerCreateOptions +{ + Email = user.Email, + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, +}; +var customer = await customerService.CreateAsync(options);`, + }, + customerUpdate: { + pattern: 'customerService.UpdateAsync(customerId, options)', + metadataExample: `Metadata = new Dictionary +{ + { "posthog_person_distinct_id", user.PosthogDistinctId }, +}`, + fullExample: `var options = new CustomerUpdateOptions +{ + Metadata = new Dictionary + { + { "posthog_person_distinct_id", user.PosthogDistinctId }, + }, +}; +await customerService.UpdateAsync(user.StripeCustomerId, options);`, + }, + }, +}; diff --git a/src/setup-revenue-analytics/stripe-docs-fetcher.ts b/src/setup-revenue-analytics/stripe-docs-fetcher.ts new file mode 100644 index 0000000..ab9d788 --- /dev/null +++ b/src/setup-revenue-analytics/stripe-docs-fetcher.ts @@ -0,0 +1,152 @@ +/** + * Runtime Stripe docs fetcher — retrieves code examples from Stripe's API docs. + * + * Fetches from known, stable Stripe docs URLs and extracts language-specific + * code examples. Falls back to hardcoded examples if fetching fails. + */ + +import { logToFile } from '../utils/debug'; +import type { Language, StripeDocsForLanguage } from './types'; +import { STRIPE_DOCS_FALLBACK } from './stripe-docs-fallback'; + +const STRIPE_DOCS_URLS = { + customerCreate: 'https://docs.stripe.com/api/customers/create', + customerUpdate: 'https://docs.stripe.com/api/customers/update', +}; + +const LANGUAGE_TO_STRIPE_TAB: Record = { + node: 'node', + python: 'python', + ruby: 'ruby', + php: 'php', + go: 'go', + java: 'java', + dotnet: 'dotnet', +}; + +/** + * Extract code blocks for a specific language from Stripe's API docs HTML. + * + * Stripe docs use a structured format where code examples are wrapped in + * elements with language-specific identifiers. + */ +function extractCodeBlock(html: string, language: Language): string | null { + const tab = LANGUAGE_TO_STRIPE_TAB[language]; + + // Stripe docs use `data-language="node"` (or similar) attributes on code blocks. + // Try multiple patterns to be resilient to minor HTML changes. + const patterns = [ + // Pattern:
...
+ new RegExp( + `]*class="[^"]*language-${tab}[^"]*"[^>]*>([\\s\\S]*?)`, + 'i', + ), + // Pattern: data-language="{tab}" attribute + new RegExp( + `data-language="${tab}"[^>]*>[\\s\\S]*?]*>([\\s\\S]*?)`, + 'i', + ), + // Pattern: id containing the language name within a code section + new RegExp( + `id="[^"]*${tab}[^"]*"[^>]*>[\\s\\S]*?]*>([\\s\\S]*?)`, + 'i', + ), + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + if (match?.[1]) { + return decodeHtmlEntities(match[1].trim()); + } + } + + return null; +} + +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/<[^>]+>/g, ''); // Strip remaining HTML tags +} + +async function fetchPage(url: string): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'PostHog-Wizard/1.0', + Accept: 'text/html', + }, + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + logToFile( + `[stripe-docs-fetcher] fetch failed: ${url} status=${response.status}`, + ); + return null; + } + + return await response.text(); + } catch (error) { + logToFile( + `[stripe-docs-fetcher] fetch error: ${url} error=${String(error)}`, + ); + return null; + } +} + +/** + * Fetch Stripe docs for a specific language at runtime. + * Returns null if fetching or parsing fails. + */ +export async function fetchStripeDocs( + language: Language, +): Promise { + const [createHtml, updateHtml] = await Promise.all([ + fetchPage(STRIPE_DOCS_URLS.customerCreate), + fetchPage(STRIPE_DOCS_URLS.customerUpdate), + ]); + + if (!createHtml || !updateHtml) { + logToFile('[stripe-docs-fetcher] failed to fetch one or more pages'); + return null; + } + + const createCode = extractCodeBlock(createHtml, language); + const updateCode = extractCodeBlock(updateHtml, language); + + if (!createCode || !updateCode) { + logToFile( + `[stripe-docs-fetcher] could not extract code for language=${language}`, + ); + return null; + } + + // We have the raw code examples from Stripe. Build our structured format. + // The fetched examples are the "full example" — the pattern and metadata + // example come from our fallback (they're stable across versions). + const fallback = STRIPE_DOCS_FALLBACK[language]; + + return { + customerCreate: { + pattern: fallback.customerCreate.pattern, + metadataExample: fallback.customerCreate.metadataExample, + fullExample: createCode, + }, + customerUpdate: { + pattern: fallback.customerUpdate.pattern, + metadataExample: fallback.customerUpdate.metadataExample, + fullExample: updateCode, + }, + }; +} diff --git a/src/setup-revenue-analytics/stripe-docs.ts b/src/setup-revenue-analytics/stripe-docs.ts new file mode 100644 index 0000000..d641d74 --- /dev/null +++ b/src/setup-revenue-analytics/stripe-docs.ts @@ -0,0 +1,36 @@ +/** + * Stripe docs orchestrator — tries runtime fetch, falls back to hardcoded examples. + * Caches results for the session. + */ + +import { logToFile } from '../utils/debug'; +import type { Language, StripeDocsForLanguage } from './types'; +import { fetchStripeDocs } from './stripe-docs-fetcher'; +import { STRIPE_DOCS_FALLBACK } from './stripe-docs-fallback'; + +const cache = new Map(); + +export async function getStripeDocs( + language: Language, + _sdkVersion?: string | null, +): Promise { + const cached = cache.get(language); + if (cached) return cached; + + const fetched = await fetchStripeDocs(language); + if (fetched) { + logToFile(`[stripe-docs] using fetched docs for ${language}`); + cache.set(language, fetched); + return fetched; + } + + logToFile(`[stripe-docs] using fallback docs for ${language}`); + const fallback = STRIPE_DOCS_FALLBACK[language]; + cache.set(language, fallback); + return fallback; +} + +/** Clear the cache (for testing). */ +export function clearStripeDocsCache(): void { + cache.clear(); +} diff --git a/src/setup-revenue-analytics/types.ts b/src/setup-revenue-analytics/types.ts new file mode 100644 index 0000000..100958b --- /dev/null +++ b/src/setup-revenue-analytics/types.ts @@ -0,0 +1,50 @@ +/** + * Shared types for the setup-revenue-analytics command. + */ + +export type Language = + | 'node' + | 'python' + | 'ruby' + | 'php' + | 'go' + | 'java' + | 'dotnet'; + +export interface StripeCallLocation { + file: string; + line: number; + snippet: string; +} + +export interface StripeChargeCall extends StripeCallLocation { + type: 'payment_intent' | 'subscription' | 'checkout_session' | 'invoice'; +} + +export interface StripeDetectionResult { + sdkPackage: string; + sdkVersion: string | null; + language: Language; + customerCreationCalls: StripeCallLocation[]; + chargeCalls: StripeChargeCall[]; + usesCheckoutSessions: boolean; +} + +export interface PostHogDistinctIdResult { + distinctIdExpression: string | null; + sourceFile: string | null; + sourceLine: string | null; +} + +export interface StripeDocsForLanguage { + customerCreate: { + pattern: string; + metadataExample: string; + fullExample: string; + }; + customerUpdate: { + pattern: string; + metadataExample: string; + fullExample: string; + }; +} From 19bb4ede8476dc3c3a4dc4fe8e3005acb2fb5853 Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 14:52:52 -0300 Subject: [PATCH 02/14] test(revenue-analytics): Add Stripe and PostHog detection unit tests Cover Node, Python, Ruby, PHP, Go, Java, .NET for Stripe SDK detection, customer creation scanning, and charge pattern matching. Test PostHog distinct_id extraction from identify/capture calls across languages. Fix Python distinct_id pattern ordering for keyword arguments. --- .../__tests__/posthog-detection.test.ts | 148 +++++++++ .../__tests__/stripe-detection.test.ts | 283 ++++++++++++++++++ .../posthog-detection.ts | 2 +- 3 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 src/setup-revenue-analytics/__tests__/posthog-detection.test.ts create mode 100644 src/setup-revenue-analytics/__tests__/stripe-detection.test.ts diff --git a/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts new file mode 100644 index 0000000..dde7cb6 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts @@ -0,0 +1,148 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { detectPostHogDistinctId } from '../posthog-detection'; + +function createFixture(files: Record): { + dir: string; + cleanup: () => void; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-posthog-')); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(dir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +describe('detectPostHogDistinctId', () => { + describe('Node.js', () => { + test('finds distinct_id from posthog.identify call', async () => { + const { dir, cleanup } = createFixture({ + 'src/analytics.ts': `import posthog from 'posthog-js'; +posthog.identify(user.id, { name: user.name });`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('user.id'); + expect(result.sourceFile).toBe('src/analytics.ts'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from distinctId assignment', async () => { + const { dir, cleanup } = createFixture({ + 'src/posthog.ts': `const distinctId = session.user.id;`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('session.user.id'); + } finally { + cleanup(); + } + }); + + test('returns null when no PostHog usage found', async () => { + const { dir, cleanup } = createFixture({ + 'src/app.ts': `console.log('hello');`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + }); + + describe('Python', () => { + test('finds distinct_id from posthog.identify', async () => { + const { dir, cleanup } = createFixture({ + 'analytics.py': `import posthog +posthog.identify(request.user.pk)`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('request.user.pk'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from posthog.capture', async () => { + const { dir, cleanup } = createFixture({ + 'events.py': `posthog.capture(user.id, 'purchase')`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from keyword argument', async () => { + const { dir, cleanup } = createFixture({ + 'track.py': `posthog.capture(distinct_id=user.email, event='sign_up')`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('user.email'); + } finally { + cleanup(); + } + }); + }); + + describe('Ruby', () => { + test('finds distinct_id from hash', async () => { + const { dir, cleanup } = createFixture({ + 'app/services/tracking.rb': `posthog.capture({ + distinct_id: current_user.id, + event: 'purchase' +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'ruby'); + expect(result.distinctIdExpression).toBe('current_user.id'); + } finally { + cleanup(); + } + }); + }); + + describe('Go', () => { + test('finds DistinctId from struct field', async () => { + const { dir, cleanup } = createFixture({ + 'analytics/track.go': `client.Enqueue(posthog.Capture{ + DistinctId: user.ID, + Event: "purchase", +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'go'); + expect(result.distinctIdExpression).toBe('user.ID'); + } finally { + cleanup(); + } + }); + }); + + test('ignores string literal distinct_ids', async () => { + const { dir, cleanup } = createFixture({ + 'test.ts': `posthog.identify("hardcoded-id");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + // Should not match string literals — we want variable expressions + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); +}); diff --git a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts new file mode 100644 index 0000000..5d6d948 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts @@ -0,0 +1,283 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { detectStripe } from '../stripe-detection'; + +function createFixture(files: Record): { + dir: string; + cleanup: () => void; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-stripe-')); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(dir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +describe('detectStripe', () => { + describe('Node.js', () => { + test('detects Stripe from package.json', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('package.json'); + expect(result!.language).toBe('node'); + } finally { + cleanup(); + } + }); + + test('extracts version from package-lock.json', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'package-lock.json': JSON.stringify({ + packages: { 'node_modules/stripe': { version: '14.21.0' } }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkVersion).toBe('14.21.0'); + } finally { + cleanup(); + } + }); + + test('finds customer creation calls', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'src/billing.ts': `import Stripe from 'stripe'; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +const customer = await stripe.customers.create({ + email: user.email, +});`, + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.customerCreationCalls).toHaveLength(1); + expect(result!.customerCreationCalls[0].file).toBe('src/billing.ts'); + expect(result!.customerCreationCalls[0].line).toBe(4); + } finally { + cleanup(); + } + }); + + test('finds charge patterns', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'src/payments.ts': `const intent = await stripe.paymentIntents.create({ + amount: 1000, + currency: 'usd', +}); + +const sub = await stripe.subscriptions.create({ + customer: customerId, +}); + +const session = await stripe.checkout.sessions.create({ + mode: 'payment', +});`, + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.chargeCalls).toHaveLength(3); + expect(result!.chargeCalls.map((c) => c.type)).toEqual([ + 'payment_intent', + 'subscription', + 'checkout_session', + ]); + expect(result!.usesCheckoutSessions).toBe(true); + } finally { + cleanup(); + } + }); + + test('returns null when Stripe is not installed', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { express: '^4.0.0' }, + }), + }); + try { + expect(detectStripe(dir, 'node')).toBeNull(); + } finally { + cleanup(); + } + }); + }); + + describe('Python', () => { + test('detects Stripe from requirements.txt', () => { + const { dir, cleanup } = createFixture({ + 'requirements.txt': 'stripe>=5.0.0\nflask', + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('requirements.txt'); + } finally { + cleanup(); + } + }); + + test('finds Python customer creation', () => { + const { dir, cleanup } = createFixture({ + 'requirements.txt': 'stripe>=5.0.0', + 'billing.py': `import stripe + +customer = stripe.Customer.create( + email=user.email, +)`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.customerCreationCalls).toHaveLength(1); + } finally { + cleanup(); + } + }); + + test('finds Python charge patterns', () => { + const { dir, cleanup } = createFixture({ + 'requirements.txt': 'stripe>=5.0.0', + 'payments.py': `import stripe + +intent = stripe.PaymentIntent.create( + amount=1000, + currency="usd", +) + +sub = stripe.Subscription.create( + customer=customer_id, +)`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.chargeCalls).toHaveLength(2); + } finally { + cleanup(); + } + }); + }); + + describe('Ruby', () => { + test('detects Stripe from Gemfile', () => { + const { dir, cleanup } = createFixture({ + Gemfile: "gem 'stripe'", + }); + try { + const result = detectStripe(dir, 'ruby'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('Gemfile'); + } finally { + cleanup(); + } + }); + + test('finds Ruby customer creation', () => { + const { dir, cleanup } = createFixture({ + Gemfile: "gem 'stripe'", + 'app/services/billing.rb': `Stripe::Customer.create({ + email: user.email, +})`, + }); + try { + const result = detectStripe(dir, 'ruby'); + expect(result!.customerCreationCalls).toHaveLength(1); + } finally { + cleanup(); + } + }); + }); + + describe('PHP', () => { + test('detects Stripe from composer.json', () => { + const { dir, cleanup } = createFixture({ + 'composer.json': JSON.stringify({ + require: { 'stripe/stripe-php': '^12.0' }, + }), + }); + try { + const result = detectStripe(dir, 'php'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('composer.json'); + } finally { + cleanup(); + } + }); + }); + + describe('Go', () => { + test('detects Stripe from go.mod', () => { + const { dir, cleanup } = createFixture({ + 'go.mod': `module myapp + +require github.com/stripe/stripe-go/v76 v76.0.0`, + }); + try { + const result = detectStripe(dir, 'go'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('go.mod'); + } finally { + cleanup(); + } + }); + }); + + describe('Java', () => { + test('detects Stripe from build.gradle', () => { + const { dir, cleanup } = createFixture({ + 'build.gradle': `dependencies { + implementation 'com.stripe:stripe-java:24.0.0' +}`, + }); + try { + const result = detectStripe(dir, 'java'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('build.gradle'); + } finally { + cleanup(); + } + }); + }); + + describe('.NET', () => { + test('detects Stripe from .csproj', () => { + const { dir, cleanup } = createFixture({ + 'MyApp.csproj': ` + + + +`, + }); + try { + const result = detectStripe(dir, 'dotnet'); + expect(result).not.toBeNull(); + expect(result!.sdkVersion).toBe('43.0.0'); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/src/setup-revenue-analytics/posthog-detection.ts b/src/setup-revenue-analytics/posthog-detection.ts index 84a745a..7481bdf 100644 --- a/src/setup-revenue-analytics/posthog-detection.ts +++ b/src/setup-revenue-analytics/posthog-detection.ts @@ -43,8 +43,8 @@ const IDENTIFY_PATTERNS: Record = { ], python: [ /posthog\.identify\(\s*([^\s,)]+)/, - /posthog\.capture\(\s*([^\s,)]+)/, /distinct_id\s*=\s*([^\s,)]+)/, + /posthog\.capture\(\s*([^\s,)]+)/, ], ruby: [ /posthog\.identify\(\s*\{\s*distinct_id:\s*([^\s,}]+)/, From 64c85b53bb05555b9ab3e319ae9b01c0b7be5e8a Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 14:55:28 -0300 Subject: [PATCH 03/14] test(revenue-analytics): Add prompt builder and Stripe docs tests Test prompt assembly for all 7 languages, distinct_id inclusion/fallback, checkout session handling, and Stripe docs fallback when fetcher fails. --- .../__tests__/prompt-builder.test.ts | 132 ++++++++++++++++++ .../__tests__/stripe-docs.test.ts | 59 ++++++++ 2 files changed, 191 insertions(+) create mode 100644 src/setup-revenue-analytics/__tests__/prompt-builder.test.ts create mode 100644 src/setup-revenue-analytics/__tests__/stripe-docs.test.ts diff --git a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts new file mode 100644 index 0000000..00c7733 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts @@ -0,0 +1,132 @@ +import { buildRevenueAnalyticsPrompt } from '../prompt-builder'; +import type { StripeDetectionResult, PostHogDistinctIdResult } from '../types'; +import { STRIPE_DOCS_FALLBACK } from '../stripe-docs-fallback'; + +function makeContext(overrides?: { + language?: StripeDetectionResult['language']; + customerCalls?: StripeDetectionResult['customerCreationCalls']; + chargeCalls?: StripeDetectionResult['chargeCalls']; + distinctId?: string | null; + usesCheckout?: boolean; +}) { + const language = overrides?.language ?? 'node'; + return { + language, + stripeDetection: { + sdkPackage: 'package.json', + sdkVersion: '14.21.0', + language, + customerCreationCalls: overrides?.customerCalls ?? [ + { + file: 'src/billing.ts', + line: 10, + snippet: 'stripe.customers.create({', + }, + ], + chargeCalls: overrides?.chargeCalls ?? [ + { + file: 'src/payments.ts', + line: 20, + snippet: 'stripe.paymentIntents.create({', + type: 'payment_intent' as const, + }, + ], + usesCheckoutSessions: overrides?.usesCheckout ?? false, + } satisfies StripeDetectionResult, + posthogDetection: { + distinctIdExpression: + overrides && 'distinctId' in overrides + ? overrides.distinctId ?? null + : 'user.id', + sourceFile: overrides?.distinctId === null ? null : 'src/analytics.ts', + sourceLine: + overrides?.distinctId === null ? null : 'posthog.identify(user.id)', + } satisfies PostHogDistinctIdResult, + stripeDocs: STRIPE_DOCS_FALLBACK[language], + }; +} + +describe('buildRevenueAnalyticsPrompt', () => { + test('includes language and Stripe SDK info', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('node'); + expect(prompt).toContain('v14.21.0'); + }); + + test('includes detected distinct_id expression', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('`user.id`'); + expect(prompt).toContain('src/analytics.ts'); + }); + + test('instructs agent to ask when distinct_id not detected', () => { + const prompt = buildRevenueAnalyticsPrompt( + makeContext({ distinctId: null }), + ); + expect(prompt).toContain('Not detected'); + expect(prompt).toContain('MUST ask the user'); + }); + + test('includes customer creation locations', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('src/billing.ts:10'); + }); + + test('includes charge call locations with types', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('src/payments.ts:20 [payment_intent]'); + }); + + test('handles no customer creation calls found', () => { + const prompt = buildRevenueAnalyticsPrompt( + makeContext({ customerCalls: [] }), + ); + expect(prompt).toContain('none found'); + expect(prompt).toContain('search the codebase yourself'); + }); + + test('includes checkout session note when detected', () => { + const prompt = buildRevenueAnalyticsPrompt( + makeContext({ + usesCheckout: true, + chargeCalls: [ + { + file: 'src/checkout.ts', + line: 5, + snippet: 'stripe.checkout.sessions.create({', + type: 'checkout_session', + }, + ], + }), + ); + expect(prompt).toContain('Stripe Checkout detected'); + expect(prompt).toContain('checkout.session.completed webhook'); + }); + + test('does not include checkout note when not using checkout', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).not.toContain('Stripe Checkout detected'); + }); + + test('includes Stripe docs code examples', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('stripe.customers.create'); + expect(prompt).toContain('stripe.customers.update'); + expect(prompt).toContain('posthog_person_distinct_id'); + }); + + test('includes constraints section', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Do NOT modify the charge/payment logic'); + expect(prompt).toContain('Do NOT remove any existing code'); + }); + + test.each(['python', 'ruby', 'php', 'go', 'java', 'dotnet'] as const)( + 'generates prompt for %s', + (language) => { + const prompt = buildRevenueAnalyticsPrompt(makeContext({ language })); + expect(prompt).toContain(language); + expect(prompt).toContain('posthog_person_distinct_id'); + }, + ); +}); diff --git a/src/setup-revenue-analytics/__tests__/stripe-docs.test.ts b/src/setup-revenue-analytics/__tests__/stripe-docs.test.ts new file mode 100644 index 0000000..cd67f4e --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/stripe-docs.test.ts @@ -0,0 +1,59 @@ +import { getStripeDocs, clearStripeDocsCache } from '../stripe-docs'; +import { STRIPE_DOCS_FALLBACK } from '../stripe-docs-fallback'; +import type { Language } from '../types'; + +// Mock the fetcher to avoid real HTTP calls in tests +jest.mock('../stripe-docs-fetcher', () => ({ + fetchStripeDocs: jest.fn().mockResolvedValue(null), +})); + +describe('getStripeDocs', () => { + beforeEach(() => { + clearStripeDocsCache(); + }); + + test('returns fallback docs when fetcher returns null', async () => { + const docs = await getStripeDocs('node'); + expect(docs).toEqual(STRIPE_DOCS_FALLBACK.node); + }); + + test('caches results across calls', async () => { + const docs1 = await getStripeDocs('python'); + const docs2 = await getStripeDocs('python'); + expect(docs1).toBe(docs2); // Same reference + }); + + test.each(['node', 'python', 'ruby', 'php', 'go', 'java', 'dotnet'] as const)( + 'returns docs for %s', + async (language: Language) => { + const docs = await getStripeDocs(language); + expect(docs.customerCreate).toBeDefined(); + expect(docs.customerCreate.pattern).toBeTruthy(); + expect(docs.customerCreate.metadataExample).toContain( + 'posthog_person_distinct_id', + ); + expect(docs.customerUpdate).toBeDefined(); + expect(docs.customerUpdate.pattern).toBeTruthy(); + expect(docs.customerUpdate.metadataExample).toContain( + 'posthog_person_distinct_id', + ); + }, + ); +}); + +describe('STRIPE_DOCS_FALLBACK', () => { + test.each(['node', 'python', 'ruby', 'php', 'go', 'java', 'dotnet'] as const)( + '%s has complete customer create and update examples', + (language: Language) => { + const docs = STRIPE_DOCS_FALLBACK[language]; + expect(docs.customerCreate.fullExample).toBeTruthy(); + expect(docs.customerCreate.fullExample).toContain( + 'posthog_person_distinct_id', + ); + expect(docs.customerUpdate.fullExample).toBeTruthy(); + expect(docs.customerUpdate.fullExample).toContain( + 'posthog_person_distinct_id', + ); + }, + ); +}); From f8db015ea5a064eed522c7f274f2e1dd39c1f6d4 Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 16:13:02 -0300 Subject: [PATCH 04/14] fix(revenue-analytics): Improve PostHog distinct_id detection patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive patterns from PostHog docs for all SDK variants: - Frontend: identify(), get_distinct_id(), React Native - Backend: capture() with distinctId property, alias() - Android/Kotlin: PostHog.identify(distinctId = ...) named params - Java: posthog.capture/identify/alias with method call args - .NET: Identify/CaptureAsync, DistinctId property assignment Also skip test files, filter placeholder values from docs examples, and fix the "agent will ask" message — the agent now searches the codebase itself and falls back to a TODO placeholder. --- .../__tests__/posthog-detection.test.ts | 288 ++++++++++++++++-- .../__tests__/prompt-builder.test.ts | 4 +- src/setup-revenue-analytics/index.ts | 2 +- .../posthog-detection.ts | 158 ++++++++-- src/setup-revenue-analytics/prompt-builder.ts | 6 +- 5 files changed, 418 insertions(+), 40 deletions(-) diff --git a/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts index dde7cb6..c983d8e 100644 --- a/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts +++ b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts @@ -20,8 +20,8 @@ function createFixture(files: Record): { } describe('detectPostHogDistinctId', () => { - describe('Node.js', () => { - test('finds distinct_id from posthog.identify call', async () => { + describe('Node.js / JavaScript', () => { + test('finds distinct_id from posthog.identify() — frontend SDK', async () => { const { dir, cleanup } = createFixture({ 'src/analytics.ts': `import posthog from 'posthog-js'; posthog.identify(user.id, { name: user.name });`, @@ -35,7 +35,37 @@ posthog.identify(user.id, { name: user.name });`, } }); - test('finds distinct_id from distinctId assignment', async () => { + test('finds distinct_id from client.capture() — backend Node SDK', async () => { + const { dir, cleanup } = createFixture({ + 'src/events.ts': `client.capture({ + distinctId: req.user.id, + event: 'purchase', +});`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('req.user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from client.alias() — backend Node SDK', async () => { + const { dir, cleanup } = createFixture({ + 'src/auth.ts': `client.alias({ + distinctId: user.frontendId, + alias: user.backendId, +});`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('user.frontendId'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from variable assignment', async () => { const { dir, cleanup } = createFixture({ 'src/posthog.ts': `const distinctId = session.user.id;`, }); @@ -47,6 +77,18 @@ posthog.identify(user.id, { name: user.name });`, } }); + test('finds distinct_id from get_distinct_id() assignment', async () => { + const { dir, cleanup } = createFixture({ + 'src/tracking.ts': `const userId = posthog.get_distinct_id();`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('userId'); + } finally { + cleanup(); + } + }); + test('returns null when no PostHog usage found', async () => { const { dir, cleanup } = createFixture({ 'src/app.ts': `console.log('hello');`, @@ -61,7 +103,7 @@ posthog.identify(user.id, { name: user.name });`, }); describe('Python', () => { - test('finds distinct_id from posthog.identify', async () => { + test('finds distinct_id from posthog.identify()', async () => { const { dir, cleanup } = createFixture({ 'analytics.py': `import posthog posthog.identify(request.user.pk)`, @@ -74,7 +116,7 @@ posthog.identify(request.user.pk)`, } }); - test('finds distinct_id from posthog.capture', async () => { + test('finds distinct_id from posthog.capture() — positional arg', async () => { const { dir, cleanup } = createFixture({ 'events.py': `posthog.capture(user.id, 'purchase')`, }); @@ -97,10 +139,22 @@ posthog.identify(request.user.pk)`, cleanup(); } }); + + test('finds distinct_id from posthog.alias()', async () => { + const { dir, cleanup } = createFixture({ + 'alias.py': `posthog.alias(previous_id=old_id, distinct_id=user.uuid)`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('user.uuid'); + } finally { + cleanup(); + } + }); }); describe('Ruby', () => { - test('finds distinct_id from hash', async () => { + test('finds distinct_id from capture() hash', async () => { const { dir, cleanup } = createFixture({ 'app/services/tracking.rb': `posthog.capture({ distinct_id: current_user.id, @@ -114,10 +168,71 @@ posthog.identify(request.user.pk)`, cleanup(); } }); + + test('finds distinct_id from identify() hash', async () => { + const { dir, cleanup } = createFixture({ + 'app/controllers/sessions_controller.rb': `posthog.identify({ + distinct_id: @user.id, + properties: { email: @user.email } +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'ruby'); + expect(result.distinctIdExpression).toBe('@user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from alias() hash', async () => { + const { dir, cleanup } = createFixture({ + 'app/services/alias.rb': `posthog.alias({ + distinct_id: user.frontend_id, + alias: user.backend_id, +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'ruby'); + expect(result.distinctIdExpression).toBe('user.frontend_id'); + } finally { + cleanup(); + } + }); + }); + + describe('PHP', () => { + test('finds distinct_id from PostHog::capture()', async () => { + const { dir, cleanup } = createFixture({ + 'app/Http/Controllers/EventController.php': `PostHog::capture([ + 'distinctId' => $user->id, + 'event' => 'purchase', +]);`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'php'); + expect(result.distinctIdExpression).toBe('$user->id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from PostHog::identify()', async () => { + const { dir, cleanup } = createFixture({ + 'app/Listeners/LoginListener.php': `PostHog::identify([ + 'distinctId' => Auth::id(), +]);`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'php'); + expect(result.distinctIdExpression).toBe('Auth::id()'); + } finally { + cleanup(); + } + }); }); describe('Go', () => { - test('finds DistinctId from struct field', async () => { + test('finds DistinctId from posthog.Capture struct', async () => { const { dir, cleanup } = createFixture({ 'analytics/track.go': `client.Enqueue(posthog.Capture{ DistinctId: user.ID, @@ -131,18 +246,155 @@ posthog.identify(request.user.pk)`, cleanup(); } }); + + test('finds DistinctId from posthog.Identify struct', async () => { + const { dir, cleanup } = createFixture({ + 'analytics/identify.go': `client.Enqueue(posthog.Identify{ + DistinctId: req.UserID, +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'go'); + expect(result.distinctIdExpression).toBe('req.UserID'); + } finally { + cleanup(); + } + }); + + test('finds DistinctId from posthog.Alias struct', async () => { + const { dir, cleanup } = createFixture({ + 'analytics/alias.go': `client.Enqueue(posthog.Alias{ + DistinctId: user.FrontendID, + Alias: user.BackendID, +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'go'); + expect(result.distinctIdExpression).toBe('user.FrontendID'); + } finally { + cleanup(); + } + }); + }); + + describe('Java / Kotlin', () => { + test('finds distinct_id from posthog.capture() — Java', async () => { + const { dir, cleanup } = createFixture({ + 'src/main/java/Analytics.java': `posthog.capture(user.getId(), "purchase");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'java'); + expect(result.distinctIdExpression).toBe('user.getId()'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from posthog.identify() — Java', async () => { + const { dir, cleanup } = createFixture({ + 'src/main/java/Auth.java': `posthog.identify(session.getUserId());`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'java'); + expect(result.distinctIdExpression).toBe('session.getUserId()'); + } finally { + cleanup(); + } + }); + + test('finds distinctId from PostHog.identify() — Kotlin/Android SDK', async () => { + const { dir, cleanup } = createFixture({ + 'app/src/main/kotlin/Analytics.kt': `PostHog.identify(distinctId = currentUser.uid)`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'java'); + expect(result.distinctIdExpression).toBe('currentUser.uid'); + } finally { + cleanup(); + } + }); + }); + + describe('.NET', () => { + test('finds DistinctId from property assignment', async () => { + const { dir, cleanup } = createFixture({ + 'Services/Analytics.cs': `var options = new CaptureOptions +{ + DistinctId = user.Id, + Event = "purchase", +};`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'dotnet'); + expect(result.distinctIdExpression).toBe('user.Id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from Capture() call', async () => { + const { dir, cleanup } = createFixture({ + 'Services/Tracking.cs': `await posthog.CaptureAsync(userId, "purchase");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'dotnet'); + expect(result.distinctIdExpression).toBe('userId'); + } finally { + cleanup(); + } + }); }); - test('ignores string literal distinct_ids', async () => { - const { dir, cleanup } = createFixture({ - 'test.ts': `posthog.identify("hardcoded-id");`, - }); - try { - const result = await detectPostHogDistinctId(dir, 'node'); - // Should not match string literals — we want variable expressions - expect(result.distinctIdExpression).toBeNull(); - } finally { - cleanup(); - } + describe('filtering', () => { + test('ignores string literal distinct_ids', async () => { + const { dir, cleanup } = createFixture({ + 'test.ts': `posthog.identify("hardcoded-id");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + + test('ignores placeholder values from docs examples', async () => { + const { dir, cleanup } = createFixture({ + 'example.ts': `posthog.identify(distinct_id, { email: 'test@test.com' });`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + // "distinct_id" is a placeholder, should be skipped + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + + test('ignores test files', async () => { + const { dir, cleanup } = createFixture({ + 'src/__tests__/analytics.test.ts': `posthog.identify(testUser.id);`, + 'src/analytics.spec.ts': `posthog.identify(mockUser.id);`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + + test('prefers identify() over capture() when both present', async () => { + const { dir, cleanup } = createFixture({ + 'src/posthog.ts': `posthog.identify(auth.user.id, { name: user.name }); +posthog.capture('page_view', { url: window.location.href });`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('auth.user.id'); + } finally { + cleanup(); + } + }); }); }); diff --git a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts index 00c7733..708558a 100644 --- a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts +++ b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts @@ -63,8 +63,8 @@ describe('buildRevenueAnalyticsPrompt', () => { const prompt = buildRevenueAnalyticsPrompt( makeContext({ distinctId: null }), ); - expect(prompt).toContain('Not detected'); - expect(prompt).toContain('MUST ask the user'); + expect(prompt).toContain('Not automatically detected'); + expect(prompt).toContain('MUST search the codebase'); }); test('includes customer creation locations', () => { diff --git a/src/setup-revenue-analytics/index.ts b/src/setup-revenue-analytics/index.ts index 31c8866..caf91c3 100644 --- a/src/setup-revenue-analytics/index.ts +++ b/src/setup-revenue-analytics/index.ts @@ -104,7 +104,7 @@ export async function runSetupRevenueAnalytics( ); } else { getUI().log.warn( - 'Could not detect PostHog distinct_id usage. The agent will ask you during setup.', + 'Could not detect PostHog distinct_id usage. The agent will search your codebase to find it.', ); } diff --git a/src/setup-revenue-analytics/posthog-detection.ts b/src/setup-revenue-analytics/posthog-detection.ts index 7481bdf..e74110e 100644 --- a/src/setup-revenue-analytics/posthog-detection.ts +++ b/src/setup-revenue-analytics/posthog-detection.ts @@ -1,5 +1,11 @@ /** * PostHog distinct_id detection — scans for how the project references distinct_id. + * + * Patterns sourced from the PostHog docs "Identifying users" page. + * Each language has SDK-specific patterns for identify(), capture(), alias(), + * and get_distinct_id() calls. Patterns are ordered by specificity so that + * the most informative match (e.g. an identify() call) wins over a generic + * property access. */ import * as fs from 'node:fs'; @@ -29,44 +35,166 @@ const IGNORE_DIRS = [ '**/.git/**', '**/bin/**', '**/obj/**', + '**/__tests__/**', + '**/*.test.*', + '**/*.spec.*', ]; /** - * Patterns that capture the distinct_id expression from posthog.identify() calls. - * Group 1 should be the distinct_id argument. + * Patterns ordered by specificity — identify() calls first (most reliable + * indicator of what the app uses as distinct_id), then capture() calls + * (backend SDKs require distinct_id), then alias/assignment patterns. + * + * The last capturing group in each regex is the distinct_id expression. */ const IDENTIFY_PATTERNS: Record = { node: [ + // posthog.identify(user.id, ...) — frontend JS/TS SDK /posthog\.identify\(\s*(['"`]?)([\w.[\]]+)\1/, - /posthog\.capture\([^,]+,\s*\{[^}]*distinct_?[Ii]d:\s*([^\s,}]+)/, - /distinctId\s*[:=]\s*([^\s,;]+)/, + + // client.capture({ distinctId: user.id, ... }) — backend Node SDK + /\.capture\(\s*\{[^}]*distinctId:\s*([^\s,}]+)/, + + // client.alias({ distinctId: user.id, ... }) — backend Node SDK + /\.alias\(\s*\{[^}]*distinctId:\s*([^\s,}]+)/, + + // const distinctId = session.user.id — variable assignment + /(?:const|let|var)\s+distinctId\s*=\s*([^\s,;]+)/, + + // distinctId: user.id — object property (e.g. in capture/alias call) + /distinctId:\s*([^\s,}]+)/, + + // result = posthog.get_distinct_id() — reveals variable holding the id + /(\w[\w.]*)\s*=\s*posthog\.get_distinct_id\(\)/, ], + python: [ + // posthog.identify(request.user.pk) — first positional arg /posthog\.identify\(\s*([^\s,)]+)/, + + // posthog.capture(distinct_id=user.id, ...) — keyword arg (check before positional) /distinct_id\s*=\s*([^\s,)]+)/, + + // posthog.capture(user.id, 'event') — first positional arg /posthog\.capture\(\s*([^\s,)]+)/, + + // posthog.alias(previous_id=..., distinct_id=...) — alias call + /posthog\.alias\([^)]*distinct_id\s*=\s*([^\s,)]+)/, ], + ruby: [ - /posthog\.identify\(\s*\{\s*distinct_id:\s*([^\s,}]+)/, - /posthog\.capture\(\s*\{\s*distinct_id:\s*([^\s,}]+)/, + // posthog.identify({ distinct_id: current_user.id, ... }) + /\.identify\(\s*\{[^}]*distinct_id:\s*([^\s,}]+)/, + + // posthog.capture({ distinct_id: current_user.id, ... }) + /\.capture\(\s*\{[^}]*distinct_id:\s*([^\s,}]+)/, + + // posthog.alias({ distinct_id: value, ... }) + /\.alias\(\s*\{[^}]*distinct_id:\s*([^\s,}]+)/, + + // distinct_id: current_user.id — hash key in any context /distinct_id:\s*([^\s,}]+)/, ], + php: [ - /PostHog::identify\(\s*\[\s*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, - /PostHog::capture\(\s*\[\s*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + // PostHog::identify(['distinctId' => $user->id, ...]) + /PostHog::identify\(\s*\[[^\]]*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + + // PostHog::capture(['distinctId' => $user->id, ...]) + /PostHog::capture\(\s*\[[^\]]*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + + // PostHog::alias(['distinctId' => $user->id, ...]) + /PostHog::alias\(\s*\[[^\]]*['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, + + // 'distinctId' => $user->id — array key in any context /['"]distinctId['"]\s*=>\s*([^\s,\]]+)/, ], + go: [ + // posthog.Identify{ DistinctId: user.ID, ... } + /posthog\.Identify\{[^}]*DistinctId:\s*([^\s,}]+)/, + + // posthog.Capture{ DistinctId: user.ID, ... } + /posthog\.Capture\{[^}]*DistinctId:\s*([^\s,}]+)/, + + // posthog.Alias{ DistinctId: user.ID, ... } + /posthog\.Alias\{[^}]*DistinctId:\s*([^\s,}]+)/, + + // DistinctId: user.ID — struct field in any context /DistinctId:\s*([^\s,}]+)/, - /posthog\.Capture\([^)]*DistinctId:\s*([^\s,}]+)/, ], - java: [/\.distinctId\(\s*([^\s,)]+)/, /setDistinctId\(\s*([^\s,)]+)/], + + java: [ + // posthog.identify(user.getId(), ...) — first positional arg + /posthog\.identify\(\s*([\w.]+(?:\(\))?)/, + + // posthog.capture(user.getId(), ...) — first positional arg + /posthog\.capture\(\s*([\w.]+(?:\(\))?)/, + + // posthog.alias(frontendId, backendId) — first arg is distinct_id + /posthog\.alias\(\s*([\w.]+(?:\(\))?)/, + + // PostHog.identify(distinctId = value, ...) — Kotlin named param (Android SDK) + /PostHog\.identify\(\s*distinctId\s*=\s*([\w.]+(?:\(\))?)/, + + // .distinctId(user.getId()) — builder pattern + /\.distinctId\(\s*([\w.]+(?:\(\))?)/, + + // setDistinctId(user.getId()) + /setDistinctId\(\s*([\w.]+(?:\(\))?)/, + ], + dotnet: [ + // posthog.Identify(userId, ...) or posthog.IdentifyAsync(userId, ...) + /\.Identify(?:Async)?\(\s*([^\s,)]+)/, + + // posthog.Capture(userId, ...) or posthog.CaptureAsync(userId, ...) + /\.Capture(?:Async)?\(\s*([^\s,)]+)/, + + // DistinctId = user.Id — property assignment in options object /DistinctId\s*=\s*([^\s,}]+)/, - /posthog\.Capture\([^,]*,\s*([^\s,)]+)/, ], }; +/** + * Values that are clearly placeholder/example strings, not real expressions. + * These come from PostHog docs examples and should be skipped. + */ +const PLACEHOLDER_VALUES = new Set([ + 'distinct_id', + 'distinctId', + 'distinctid', + 'distinct_id_one', + 'distinct_id_two', + 'user_A', + 'user1', + 'user2', + 'frontend_id', + 'backend_id', +]); + +function isValidExpression(expression: string): boolean { + if (!expression) return false; + + // Skip string literals + if ( + expression.startsWith("'") || + expression.startsWith('"') || + expression.startsWith('`') + ) { + return false; + } + + // Skip placeholder values commonly seen in docs/examples + if (PLACEHOLDER_VALUES.has(expression)) return false; + + // Skip purely numeric values + if (/^\d+$/.test(expression)) return false; + + // Must look like a variable/property access (contains at least one letter) + return /[a-zA-Z]/.test(expression); +} + export async function detectPostHogDistinctId( installDir: string, language: Language, @@ -88,14 +216,8 @@ export async function detectPostHogDistinctId( for (const pattern of patterns) { const match = pattern.exec(line); if (match) { - // Use the last capturing group (the actual expression) const expression = match[match.length - 1]; - if ( - expression && - !expression.startsWith("'") && - !expression.startsWith('"') && - !expression.startsWith('`') - ) { + if (isValidExpression(expression)) { return { distinctIdExpression: expression, sourceFile: file, diff --git a/src/setup-revenue-analytics/prompt-builder.ts b/src/setup-revenue-analytics/prompt-builder.ts index 1dfad79..25d7818 100644 --- a/src/setup-revenue-analytics/prompt-builder.ts +++ b/src/setup-revenue-analytics/prompt-builder.ts @@ -25,7 +25,11 @@ export function buildRevenueAnalyticsPrompt(context: PromptContext): string { const distinctIdSection = posthogDetection.distinctIdExpression ? `- PostHog distinct_id expression: \`${posthogDetection.distinctIdExpression}\` (found in ${posthogDetection.sourceFile})` - : `- PostHog distinct_id: **Not detected** — you MUST ask the user what expression they use for their PostHog distinct_id (e.g., user.id, request.user.pk, session.userId). Do not proceed until you know this value.`; + : `- PostHog distinct_id: **Not automatically detected**. Before making changes, you MUST search the codebase to find how the user is identified. Look for: + - \`posthog.identify(...)\` calls — the first argument is the distinct_id + - \`posthog.capture(...)\` calls — look for \`distinctId\` or \`distinct_id\` properties + - User ID patterns in auth/session code (e.g., \`user.id\`, \`req.user.id\`, \`request.user.pk\`, \`session.userId\`, \`currentUser.id\`) + If you truly cannot find it, use a clear TODO placeholder: \`"TODO_POSTHOG_DISTINCT_ID"\` so the user can fill it in.`; const customerCreationSection = stripeDetection.customerCreationCalls.length > 0 From 9f4c45e6e542cf6a1fdac954a96531159cf7112e Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 16:28:56 -0300 Subject: [PATCH 05/14] fix(revenue-analytics): Replace hardcoded distinct_id values with placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stripe docs fallback examples used made-up properties like user.posthogDistinctId that the agent would copy verbatim. Replace all with placeholders. Rewrite prompt to add a "CRITICAL FIRST STEP" section that teaches the agent HOW to determine the actual distinct_id value: - Search for posthog.identify() — first arg is the distinct_id - Search for posthog.capture() — look for distinctId property - Trace the value to the Stripe call site - Explicitly warn: "Do NOT invent properties like user.posthogDistinctId" --- .../__tests__/prompt-builder.test.ts | 25 +++++- src/setup-revenue-analytics/prompt-builder.ts | 55 +++++++++----- .../stripe-docs-fallback.ts | 76 ++++++++++--------- 3 files changed, 96 insertions(+), 60 deletions(-) diff --git a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts index 708558a..cf61964 100644 --- a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts +++ b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts @@ -53,18 +53,37 @@ describe('buildRevenueAnalyticsPrompt', () => { expect(prompt).toContain('v14.21.0'); }); - test('includes detected distinct_id expression', () => { + test('includes detected distinct_id expression and source line', () => { const prompt = buildRevenueAnalyticsPrompt(makeContext()); expect(prompt).toContain('`user.id`'); expect(prompt).toContain('src/analytics.ts'); + expect(prompt).toContain('posthog.identify(user.id)'); }); - test('instructs agent to ask when distinct_id not detected', () => { + test('instructs agent to search codebase when distinct_id not detected', () => { const prompt = buildRevenueAnalyticsPrompt( makeContext({ distinctId: null }), ); expect(prompt).toContain('Not automatically detected'); - expect(prompt).toContain('MUST search the codebase'); + expect(prompt).toContain('Search the codebase'); + }); + + test('explains how to find distinct_id from posthog.identify', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('posthog.identify('); + expect(prompt).toContain('FIRST ARGUMENT is the distinct_id'); + }); + + test('warns not to invent properties', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Do NOT invent'); + expect(prompt).toContain('user.posthogDistinctId'); + }); + + test('uses placeholder in code examples', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain(''); + expect(prompt).toContain('Replace `` with the actual'); }); test('includes customer creation locations', () => { diff --git a/src/setup-revenue-analytics/prompt-builder.ts b/src/setup-revenue-analytics/prompt-builder.ts index 25d7818..de214f0 100644 --- a/src/setup-revenue-analytics/prompt-builder.ts +++ b/src/setup-revenue-analytics/prompt-builder.ts @@ -24,12 +24,8 @@ export function buildRevenueAnalyticsPrompt(context: PromptContext): string { const { language, stripeDetection, posthogDetection, stripeDocs } = context; const distinctIdSection = posthogDetection.distinctIdExpression - ? `- PostHog distinct_id expression: \`${posthogDetection.distinctIdExpression}\` (found in ${posthogDetection.sourceFile})` - : `- PostHog distinct_id: **Not automatically detected**. Before making changes, you MUST search the codebase to find how the user is identified. Look for: - - \`posthog.identify(...)\` calls — the first argument is the distinct_id - - \`posthog.capture(...)\` calls — look for \`distinctId\` or \`distinct_id\` properties - - User ID patterns in auth/session code (e.g., \`user.id\`, \`req.user.id\`, \`request.user.pk\`, \`session.userId\`, \`currentUser.id\`) - If you truly cannot find it, use a clear TODO placeholder: \`"TODO_POSTHOG_DISTINCT_ID"\` so the user can fill it in.`; + ? `- PostHog distinct_id: \`${posthogDetection.distinctIdExpression}\` (found in ${posthogDetection.sourceFile}: \`${posthogDetection.sourceLine}\`)` + : `- PostHog distinct_id: **Not automatically detected**.`; const customerCreationSection = stripeDetection.customerCreationCalls.length > 0 @@ -50,9 +46,7 @@ export function buildRevenueAnalyticsPrompt(context: PromptContext): string { IMPORTANT — Stripe Checkout detected: This project uses checkout.Session.create, which means Stripe may auto-create customers. If there is no explicit Customer.create call, you need to handle this in the checkout.session.completed webhook handler instead. -In the webhook handler, after receiving the session object, update the customer with the PostHog metadata: - -${stripeDocs.customerUpdate.fullExample} +In the webhook handler, after receiving the session object, update the customer with the PostHog metadata. Tip: Set client_reference_id to the internal user ID when creating Checkout Sessions. This lets you look up the user in your database when the webhook fires.` : ''; @@ -75,6 +69,29 @@ ${customerCreationSection} ### Charge/payment locations: ${chargeSection} +## CRITICAL FIRST STEP: Determine the PostHog distinct_id value + +Before writing ANY code, you must determine what this project uses as the PostHog distinct_id. This is the value that must go into \`posthog_person_distinct_id\` metadata on Stripe customers. + +${ + posthogDetection.distinctIdExpression + ? `The pre-scan detected \`${posthogDetection.distinctIdExpression}\` (from \`${posthogDetection.sourceLine}\`). Verify this is correct by reading the file, then determine how to access the same value at each Stripe call site.` + : `Search the codebase for how the distinct_id is set.` +} + +How to find it: +1. Search for \`posthog.identify(\` — the FIRST ARGUMENT is the distinct_id. This is the most reliable source. + Example: \`posthog.identify(email, { ... })\` → the distinct_id is \`email\` + Example: \`posthog.identify(user.id, { ... })\` → the distinct_id is \`user.id\` +2. Search for \`posthog.capture(\` or \`client.capture(\` — look for \`distinctId\` or \`distinct_id\` in the arguments. +3. Search for \`posthog.get_distinct_id()\` — the variable it's assigned to tells you what holds the distinct_id. + +Once you know WHAT value is the distinct_id (e.g. \`email\`, \`user.id\`, \`userId\`), determine HOW to access that same value at each Stripe call site. The variable name may differ between files — trace the data flow. For example: +- Frontend calls \`posthog.identify(email)\` → backend receives email as a parameter → use that parameter at the Stripe call site +- If the value isn't directly available, you may need to pass it through or look it up + +Do NOT invent properties like \`user.posthogDistinctId\` — this field does not exist. Use the actual value from the codebase. + ## Instructions Follow these steps IN ORDER: @@ -83,31 +100,28 @@ Follow these steps IN ORDER: For each Stripe Customer.create call, add \`posthog_person_distinct_id\` to the metadata. -**Pattern for this language:** +**API pattern (replace \`\` with the actual value):** \`\`\` ${stripeDocs.customerCreate.fullExample} \`\`\` Rules: +- Replace \`\` with the actual distinct_id expression available at this call site. - If the call already has a metadata object, ADD the \`posthog_person_distinct_id\` key to it. Do NOT overwrite existing metadata. -- If the call does not have a metadata object, add one with the \`posthog_person_distinct_id\` key. -- Use the PostHog distinct_id expression detected above${ - posthogDetection.distinctIdExpression - ? ` (\`${posthogDetection.distinctIdExpression}\`)` - : '' - } as the value. Adapt it to the variable scope at the call site. +- If the call does not have a metadata object, add one. - Preserve all existing arguments and code structure. ### STEP 2: Add Customer Update Before Charges -For each charge/payment call (PaymentIntent.create, Subscription.create, Invoice.create, checkout.Session.create), add a Stripe Customer.update/modify call BEFORE the charge. This ensures existing customers (created before Step 1) get tagged. +For each charge/payment call (PaymentIntent.create, Subscription.create, Invoice.create, checkout.Session.create), add a Stripe Customer.update/modify call BEFORE the charge. -**Pattern for this language:** +**API pattern (replace \`\` with the actual value):** \`\`\` ${stripeDocs.customerUpdate.fullExample} \`\`\` Rules: +- Replace \`\` with the actual distinct_id expression available at this call site. - Add the update call immediately before the charge call. - Extract the customer ID from the charge call's arguments (it's usually a \`customer\` field). - The update only sets metadata — do not modify the charge/payment logic itself. @@ -119,7 +133,7 @@ ${checkoutNote} After making all changes, read each modified file to verify: - No syntax errors - Existing code logic is preserved -- The metadata field is correctly set +- The metadata field uses the correct distinct_id value (NOT a made-up property) - Imports are present if needed ## Constraints @@ -127,8 +141,9 @@ After making all changes, read each modified file to verify: - Do NOT modify the charge/payment logic itself — only add metadata to customer creation and add customer.update calls before charges. - Do NOT remove any existing code. - Do NOT add new packages or dependencies. +- Do NOT invent new properties or fields. Use only values that already exist in the codebase. - Preserve all imports and error handling. -- If you cannot determine the distinct_id expression from context, add a \`TODO\` comment: \`// TODO: Replace with your PostHog distinct_id\` +- If you truly cannot determine the distinct_id after searching, use \`"TODO_POSTHOG_DISTINCT_ID"\` as a string placeholder. When done, emit: ${ AgentSignals.STATUS diff --git a/src/setup-revenue-analytics/stripe-docs-fallback.ts b/src/setup-revenue-analytics/stripe-docs-fallback.ts index 8ddd0b7..8de33a2 100644 --- a/src/setup-revenue-analytics/stripe-docs-fallback.ts +++ b/src/setup-revenue-analytics/stripe-docs-fallback.ts @@ -2,8 +2,10 @@ * Hardcoded fallback Stripe code examples for each language. * Used when runtime fetching from Stripe's docs site fails. * - * These examples are sourced from Stripe's official API docs and - * the PostHog revenue analytics documentation. + * IMPORTANT: Examples use as a placeholder. + * The prompt instructs the agent to replace this with the actual + * distinct_id expression found in the codebase (e.g. the value + * passed to posthog.identify()). */ import type { Language, StripeDocsForLanguage } from './types'; @@ -13,19 +15,19 @@ export const STRIPE_DOCS_FALLBACK: Record = { customerCreate: { pattern: 'stripe.customers.create({ ... })', metadataExample: - 'metadata: { posthog_person_distinct_id: user.posthogDistinctId }', + 'metadata: { posthog_person_distinct_id: }', fullExample: `const customer = await stripe.customers.create({ email: user.email, - metadata: { posthog_person_distinct_id: user.posthogDistinctId }, + metadata: { posthog_person_distinct_id: }, });`, }, customerUpdate: { pattern: 'stripe.customers.update(customerId, { ... })', metadataExample: - '{ metadata: { posthog_person_distinct_id: user.posthogDistinctId } }', + '{ metadata: { posthog_person_distinct_id: } }', fullExample: `await stripe.customers.update( - user.stripeCustomerId, - { metadata: { posthog_person_distinct_id: user.posthogDistinctId } } + customerId, + { metadata: { posthog_person_distinct_id: } } );`, }, }, @@ -34,19 +36,19 @@ export const STRIPE_DOCS_FALLBACK: Record = { customerCreate: { pattern: 'stripe.Customer.create(...)', metadataExample: - 'metadata={"posthog_person_distinct_id": user.posthog_distinct_id}', + 'metadata={"posthog_person_distinct_id": }', fullExample: `customer = stripe.Customer.create( email=user.email, - metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, + metadata={"posthog_person_distinct_id": }, )`, }, customerUpdate: { pattern: 'stripe.Customer.modify(customer_id, ...)', metadataExample: - 'metadata={"posthog_person_distinct_id": user.posthog_distinct_id}', + 'metadata={"posthog_person_distinct_id": }', fullExample: `stripe.Customer.modify( - user.stripe_customer_id, - metadata={"posthog_person_distinct_id": user.posthog_distinct_id}, + customer_id, + metadata={"posthog_person_distinct_id": }, )`, }, }, @@ -55,19 +57,19 @@ export const STRIPE_DOCS_FALLBACK: Record = { customerCreate: { pattern: 'Stripe::Customer.create({ ... })', metadataExample: - 'metadata: { posthog_person_distinct_id: user.posthog_distinct_id }', + 'metadata: { posthog_person_distinct_id: }', fullExample: `customer = Stripe::Customer.create({ email: user.email, - metadata: { posthog_person_distinct_id: user.posthog_distinct_id }, + metadata: { posthog_person_distinct_id: }, })`, }, customerUpdate: { pattern: 'Stripe::Customer.update(customer_id, { ... })', metadataExample: - '{ metadata: { posthog_person_distinct_id: user.posthog_distinct_id } }', + '{ metadata: { posthog_person_distinct_id: } }', fullExample: `Stripe::Customer.update( - user.stripe_customer_id, - { metadata: { posthog_person_distinct_id: user.posthog_distinct_id } } + customer_id, + { metadata: { posthog_person_distinct_id: } } )`, }, }, @@ -76,19 +78,19 @@ export const STRIPE_DOCS_FALLBACK: Record = { customerCreate: { pattern: '$stripe->customers->create([...])', metadataExample: - "'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId]", + "'metadata' => ['posthog_person_distinct_id' => ]", fullExample: `$customer = $stripe->customers->create([ 'email' => $user->email, - 'metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId], + 'metadata' => ['posthog_person_distinct_id' => ], ]);`, }, customerUpdate: { pattern: '$stripe->customers->update($customerId, [...])', metadataExample: - "['metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId]]", + "['metadata' => ['posthog_person_distinct_id' => ]]", fullExample: `$stripe->customers->update( - $user->stripeCustomerId, - ['metadata' => ['posthog_person_distinct_id' => $user->posthogDistinctId]] + $customerId, + ['metadata' => ['posthog_person_distinct_id' => ]] );`, }, }, @@ -97,20 +99,20 @@ export const STRIPE_DOCS_FALLBACK: Record = { customerCreate: { pattern: 'customer.New(params)', metadataExample: - 'params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID)', + 'params.AddMetadata("posthog_person_distinct_id", )', fullExample: `params := &stripe.CustomerParams{ Email: stripe.String(user.Email), } -params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) +params.AddMetadata("posthog_person_distinct_id", ) cust, err := customer.New(params)`, }, customerUpdate: { pattern: 'customer.Update(customerId, params)', metadataExample: - 'params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID)', + 'params.AddMetadata("posthog_person_distinct_id", )', fullExample: `params := &stripe.CustomerParams{} -params.AddMetadata("posthog_person_distinct_id", user.PosthogDistinctID) -_, err := customer.Update(user.StripeCustomerID, params)`, +params.AddMetadata("posthog_person_distinct_id", ) +_, err := customer.Update(stripeCustomerID, params)`, }, }, @@ -118,21 +120,21 @@ _, err := customer.Update(user.StripeCustomerID, params)`, customerCreate: { pattern: 'Customer.create(params)', metadataExample: - '.putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId())', + '.putMetadata("posthog_person_distinct_id", )', fullExample: `CustomerCreateParams params = CustomerCreateParams.builder() .setEmail(user.getEmail()) - .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .putMetadata("posthog_person_distinct_id", ) .build(); Customer customer = Customer.create(params);`, }, customerUpdate: { pattern: 'Customer.retrieve(customerId).update(params)', metadataExample: - '.putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId())', + '.putMetadata("posthog_person_distinct_id", )', fullExample: `CustomerUpdateParams params = CustomerUpdateParams.builder() - .putMetadata("posthog_person_distinct_id", user.getPosthogDistinctId()) + .putMetadata("posthog_person_distinct_id", ) .build(); -Customer.retrieve(user.getStripeCustomerId()).update(params);`, +Customer.retrieve(stripeCustomerId).update(params);`, }, }, @@ -141,14 +143,14 @@ Customer.retrieve(user.getStripeCustomerId()).update(params);`, pattern: 'customerService.CreateAsync(options)', metadataExample: `Metadata = new Dictionary { - { "posthog_person_distinct_id", user.PosthogDistinctId }, + { "posthog_person_distinct_id", }, }`, fullExample: `var options = new CustomerCreateOptions { Email = user.Email, Metadata = new Dictionary { - { "posthog_person_distinct_id", user.PosthogDistinctId }, + { "posthog_person_distinct_id", }, }, }; var customer = await customerService.CreateAsync(options);`, @@ -157,16 +159,16 @@ var customer = await customerService.CreateAsync(options);`, pattern: 'customerService.UpdateAsync(customerId, options)', metadataExample: `Metadata = new Dictionary { - { "posthog_person_distinct_id", user.PosthogDistinctId }, + { "posthog_person_distinct_id", }, }`, fullExample: `var options = new CustomerUpdateOptions { Metadata = new Dictionary { - { "posthog_person_distinct_id", user.PosthogDistinctId }, + { "posthog_person_distinct_id", }, }, }; -await customerService.UpdateAsync(user.StripeCustomerId, options);`, +await customerService.UpdateAsync(stripeCustomerId, options);`, }, }, }; From 8ac9adbd341ff0101b33956c219ec84338097f06 Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 16:35:57 -0300 Subject: [PATCH 06/14] fix(revenue-analytics): Support monorepos in language and Stripe detection Language detection now searches up to depth 3 with ignore patterns, finding package.json/requirements.txt/Gemfile etc. in subdirectories like frontend/ or backend/. Stripe package detection uses glob search (**/) instead of reading fixed root paths. Version extraction resolves lockfiles relative to the directory where the package file was found, not from installDir. --- .../__tests__/language-detection.test.ts | 34 +++++++++ .../__tests__/stripe-detection.test.ts | 69 +++++++++++++++++++ .../language-detection.ts | 34 ++++++--- .../stripe-detection.ts | 47 ++++++------- 4 files changed, 153 insertions(+), 31 deletions(-) diff --git a/src/setup-revenue-analytics/__tests__/language-detection.test.ts b/src/setup-revenue-analytics/__tests__/language-detection.test.ts index 3ae031f..c770fbd 100644 --- a/src/setup-revenue-analytics/__tests__/language-detection.test.ts +++ b/src/setup-revenue-analytics/__tests__/language-detection.test.ts @@ -114,4 +114,38 @@ describe('detectLanguageFromFiles', () => { fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe'); expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); }); + + describe('monorepo support', () => { + test('detects node from subdirectory package.json', async () => { + fs.mkdirSync(path.join(tmpDir, 'backend'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'backend', 'package.json'), '{}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); + }); + + test('detects python from subdirectory requirements.txt', async () => { + fs.mkdirSync(path.join(tmpDir, 'server'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'server', 'requirements.txt'), + 'django', + ); + expect(await detectLanguageFromFiles(tmpDir)).toBe('python'); + }); + + test('detects ruby from nested Gemfile', async () => { + fs.mkdirSync(path.join(tmpDir, 'api'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'api', 'Gemfile'), "gem 'rails'"); + expect(await detectLanguageFromFiles(tmpDir)).toBe('ruby'); + }); + + test('ignores node_modules subdirectories', async () => { + fs.mkdirSync(path.join(tmpDir, 'node_modules', 'stripe'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpDir, 'node_modules', 'stripe', 'package.json'), + '{}', + ); + expect(await detectLanguageFromFiles(tmpDir)).toBeNull(); + }); + }); }); diff --git a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts index 5d6d948..e46f1bf 100644 --- a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts +++ b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts @@ -280,4 +280,73 @@ require github.com/stripe/stripe-go/v76 v76.0.0`, } }); }); + + describe('monorepo support', () => { + test('detects Stripe from subdirectory package.json', () => { + const { dir, cleanup } = createFixture({ + 'backend/package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('backend/package.json'); + } finally { + cleanup(); + } + }); + + test('extracts version from subdirectory lockfile', () => { + const { dir, cleanup } = createFixture({ + 'backend/package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'backend/package-lock.json': JSON.stringify({ + packages: { 'node_modules/stripe': { version: '14.21.0' } }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkVersion).toBe('14.21.0'); + } finally { + cleanup(); + } + }); + + test('finds customer creation calls in subdirectory', () => { + const { dir, cleanup } = createFixture({ + 'backend/package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'backend/src/billing.ts': `const customer = await stripe.customers.create({ + email: user.email, +});`, + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.customerCreationCalls).toHaveLength(1); + expect(result!.customerCreationCalls[0].file).toBe( + 'backend/src/billing.ts', + ); + } finally { + cleanup(); + } + }); + + test('detects Python Stripe in subdirectory', () => { + const { dir, cleanup } = createFixture({ + 'server/requirements.txt': 'stripe>=5.0.0\nflask', + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('server/requirements.txt'); + } finally { + cleanup(); + } + }); + }); }); diff --git a/src/setup-revenue-analytics/language-detection.ts b/src/setup-revenue-analytics/language-detection.ts index 9a600e7..2ef93d2 100644 --- a/src/setup-revenue-analytics/language-detection.ts +++ b/src/setup-revenue-analytics/language-detection.ts @@ -40,19 +40,36 @@ interface LanguageIndicator { } const LANGUAGE_INDICATORS: LanguageIndicator[] = [ - { language: 'node', patterns: ['package.json'] }, + { language: 'node', patterns: ['**/package.json'] }, { language: 'python', - patterns: ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py'], + patterns: [ + '**/requirements.txt', + '**/pyproject.toml', + '**/Pipfile', + '**/setup.py', + ], }, - { language: 'ruby', patterns: ['Gemfile'] }, - { language: 'php', patterns: ['composer.json'] }, - { language: 'go', patterns: ['go.mod'] }, + { language: 'ruby', patterns: ['**/Gemfile'] }, + { language: 'php', patterns: ['**/composer.json'] }, + { language: 'go', patterns: ['**/go.mod'] }, { language: 'java', - patterns: ['build.gradle', 'build.gradle.kts', 'pom.xml'], + patterns: ['**/build.gradle', '**/build.gradle.kts', '**/pom.xml'], }, - { language: 'dotnet', patterns: ['*.csproj', '*.sln'] }, + { language: 'dotnet', patterns: ['**/*.csproj', '**/*.sln'] }, +]; + +const IGNORE_DIRS = [ + '**/node_modules/**', + '**/venv/**', + '**/.venv/**', + '**/env/**', + '**/.env/**', + '**/vendor/**', + '**/dist/**', + '**/build/**', + '**/.git/**', ]; export function languageFromIntegration( @@ -67,8 +84,9 @@ export async function detectLanguageFromFiles( for (const { language, patterns } of LANGUAGE_INDICATORS) { const matches = await fg(patterns, { cwd: installDir, - deep: 1, + deep: 3, onlyFiles: true, + ignore: IGNORE_DIRS, }); if (matches.length > 0) { return language; diff --git a/src/setup-revenue-analytics/stripe-detection.ts b/src/setup-revenue-analytics/stripe-detection.ts index f48c514..e9c00da 100644 --- a/src/setup-revenue-analytics/stripe-detection.ts +++ b/src/setup-revenue-analytics/stripe-detection.ts @@ -139,19 +139,15 @@ function detectStripePackage( const checks = STRIPE_PACKAGES[language]; for (const { file, pattern } of checks) { try { - if (file.includes('*')) { - const matches = fg.sync(file, { cwd: installDir, deep: 2 }); - for (const match of matches) { - const content = fs.readFileSync( - path.join(installDir, match), - 'utf-8', - ); - if (pattern.test(content)) return match; - } - } else { - const filePath = path.join(installDir, file); - const content = fs.readFileSync(filePath, 'utf-8'); - if (pattern.test(content)) return file; + const globPattern = file.includes('*') ? file : `**/${file}`; + const matches = fg.sync(globPattern, { + cwd: installDir, + deep: 3, + ignore: IGNORE_DIRS, + }); + for (const match of matches) { + const content = fs.readFileSync(path.join(installDir, match), 'utf-8'); + if (pattern.test(content)) return match; } } catch { continue; @@ -160,20 +156,26 @@ function detectStripePackage( return null; } +/** + * Extract Stripe SDK version from lockfiles / dependency declarations. + * Searches relative to the directory where the package file was found, + * supporting monorepos where the package file may be in a subdirectory. + */ function extractStripeVersion( installDir: string, language: Language, + packageFile: string, ): string | null { + const packageDir = path.join(installDir, path.dirname(packageFile)); try { switch (language) { case 'node': { - // Try package-lock.json first for (const lockfile of [ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', ]) { - const lockPath = path.join(installDir, lockfile); + const lockPath = path.join(packageDir, lockfile); if (!fs.existsSync(lockPath)) continue; const content = fs.readFileSync(lockPath, 'utf-8'); @@ -191,9 +193,8 @@ function extractStripeVersion( if (match) return match[1]; } } - // Fallback to package.json version range const pkgJson = JSON.parse( - fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'), + fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'), ); const ver = pkgJson.dependencies?.stripe ?? pkgJson.devDependencies?.stripe; @@ -201,7 +202,7 @@ function extractStripeVersion( } case 'python': { for (const lockfile of ['requirements.txt', 'poetry.lock', 'uv.lock']) { - const lockPath = path.join(installDir, lockfile); + const lockPath = path.join(packageDir, lockfile); if (!fs.existsSync(lockPath)) continue; const content = fs.readFileSync(lockPath, 'utf-8'); const match = content.match(/stripe[=~>=<]+([0-9][0-9.]*)/i); @@ -210,7 +211,7 @@ function extractStripeVersion( return null; } case 'ruby': { - const lockPath = path.join(installDir, 'Gemfile.lock'); + const lockPath = path.join(packageDir, 'Gemfile.lock'); if (fs.existsSync(lockPath)) { const content = fs.readFileSync(lockPath, 'utf-8'); const match = content.match(/stripe\s+\(([^)]+)\)/); @@ -219,7 +220,7 @@ function extractStripeVersion( return null; } case 'php': { - const lockPath = path.join(installDir, 'composer.lock'); + const lockPath = path.join(packageDir, 'composer.lock'); if (fs.existsSync(lockPath)) { const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); const pkg = parsed.packages?.find( @@ -230,7 +231,7 @@ function extractStripeVersion( return null; } case 'go': { - const sumPath = path.join(installDir, 'go.sum'); + const sumPath = path.join(packageDir, 'go.sum'); if (fs.existsSync(sumPath)) { const content = fs.readFileSync(sumPath, 'utf-8'); const match = content.match( @@ -246,7 +247,7 @@ function extractStripeVersion( 'build.gradle.kts', 'pom.xml', ]) { - const filePath = path.join(installDir, buildFile); + const filePath = path.join(packageDir, buildFile); if (!fs.existsSync(filePath)) continue; const content = fs.readFileSync(filePath, 'utf-8'); const match = content.match(/stripe-java[:'"\s]+([0-9][0-9.]*)/); @@ -312,7 +313,7 @@ export function detectStripe( const packageFile = detectStripePackage(installDir, language); if (!packageFile) return null; - const sdkVersion = extractStripeVersion(installDir, language); + const sdkVersion = extractStripeVersion(installDir, language, packageFile); const customerCreationCalls = scanForPatterns( installDir, From f4c33b7325721f5d43acfa74bd5d3183e18c5029 Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 16:50:24 -0300 Subject: [PATCH 07/14] fix(revenue-analytics): Detect Stripe SDK from uv.lock and poetry.lock Python projects using uv or Poetry may not have requirements.txt. Add uv.lock and poetry.lock to the Stripe package detection sources, matching the `name = "stripe"` TOML pattern used by both lockfiles. --- .../__tests__/stripe-detection.test.ts | 31 +++++++++++++++++++ .../stripe-detection.ts | 2 ++ 2 files changed, 33 insertions(+) diff --git a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts index e46f1bf..bb8e183 100644 --- a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts +++ b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts @@ -139,6 +139,37 @@ const session = await stripe.checkout.sessions.create({ } }); + test('detects Stripe from uv.lock', () => { + const { dir, cleanup } = createFixture({ + 'uv.lock': `[[package]] +name = "stripe" +version = "11.4.1" +source = { registry = "https://pypi.org/simple" }`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('uv.lock'); + } finally { + cleanup(); + } + }); + + test('detects Stripe from poetry.lock', () => { + const { dir, cleanup } = createFixture({ + 'poetry.lock': `[[package]] +name = "stripe" +version = "11.4.1"`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('poetry.lock'); + } finally { + cleanup(); + } + }); + test('finds Python customer creation', () => { const { dir, cleanup } = createFixture({ 'requirements.txt': 'stripe>=5.0.0', diff --git a/src/setup-revenue-analytics/stripe-detection.ts b/src/setup-revenue-analytics/stripe-detection.ts index e9c00da..14677f0 100644 --- a/src/setup-revenue-analytics/stripe-detection.ts +++ b/src/setup-revenue-analytics/stripe-detection.ts @@ -18,6 +18,8 @@ const STRIPE_PACKAGES: Record = { { file: 'requirements.txt', pattern: /^stripe([>=~!<\s]|$)/m }, { file: 'pyproject.toml', pattern: /["']stripe["']/ }, { file: 'Pipfile', pattern: /stripe/ }, + { file: 'uv.lock', pattern: /name\s*=\s*["']stripe["']/ }, + { file: 'poetry.lock', pattern: /name\s*=\s*["']stripe["']/ }, ], ruby: [{ file: 'Gemfile', pattern: /['"]stripe['"]/ }], php: [{ file: 'composer.json', pattern: /"stripe\/stripe-php"/ }], From a64173ae5f397f2890a886e04b756eca6581801d Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 17:21:27 -0300 Subject: [PATCH 08/14] fix(revenue-analytics): Add guiding tenets to agent prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incorporate four safety tenets into the prompt: 1. Never fabricate — don't substitute wrong identifiers 2. Thread the value — propagate as optional param, skip if impossible 3. Minimize API calls — deduplicate Customer.modify, don't add before every charge 4. Follow abstractions — modify Stripe utility layers, not business logic --- .../__tests__/prompt-builder.test.ts | 40 ++++++++++++--- src/setup-revenue-analytics/prompt-builder.ts | 50 +++++++++++++------ 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts index cf61964..8fe5183 100644 --- a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts +++ b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts @@ -74,18 +74,44 @@ describe('buildRevenueAnalyticsPrompt', () => { expect(prompt).toContain('FIRST ARGUMENT is the distinct_id'); }); - test('warns not to invent properties', () => { - const prompt = buildRevenueAnalyticsPrompt(makeContext()); - expect(prompt).toContain('Do NOT invent'); - expect(prompt).toContain('user.posthogDistinctId'); - }); - test('uses placeholder in code examples', () => { const prompt = buildRevenueAnalyticsPrompt(makeContext()); expect(prompt).toContain(''); expect(prompt).toContain('Replace `` with the actual'); }); + describe('guiding tenets', () => { + test('includes never-fabricate tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Never fabricate the value'); + expect(prompt).toContain('A wrong value is worse than no value'); + }); + + test('includes thread-the-value tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain("Thread the value, don't invent it"); + expect(prompt).toContain('optional parameter'); + }); + + test('includes minimize-api-calls tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Minimize extra API calls'); + expect(prompt).toContain('network round-trip'); + }); + + test('includes follow-abstractions tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Follow existing Stripe abstraction patterns'); + expect(prompt).toContain('utility/service layer'); + }); + + test('warns against fabricating user.posthogDistinctId', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('user.posthogDistinctId'); + expect(prompt).toContain('does not exist'); + }); + }); + test('includes customer creation locations', () => { const prompt = buildRevenueAnalyticsPrompt(makeContext()); expect(prompt).toContain('src/billing.ts:10'); @@ -136,7 +162,7 @@ describe('buildRevenueAnalyticsPrompt', () => { test('includes constraints section', () => { const prompt = buildRevenueAnalyticsPrompt(makeContext()); - expect(prompt).toContain('Do NOT modify the charge/payment logic'); + expect(prompt).toContain('Do NOT modify charge/payment logic'); expect(prompt).toContain('Do NOT remove any existing code'); }); diff --git a/src/setup-revenue-analytics/prompt-builder.ts b/src/setup-revenue-analytics/prompt-builder.ts index de214f0..5e27af5 100644 --- a/src/setup-revenue-analytics/prompt-builder.ts +++ b/src/setup-revenue-analytics/prompt-builder.ts @@ -55,6 +55,18 @@ Tip: Set client_reference_id to the internal user ID when creating Checkout Sess Your goal: Modify the codebase so that every Stripe customer has a \`posthog_person_distinct_id\` metadata field. This connects Stripe revenue data to PostHog persons. +## Guiding Tenets + +Follow these tenets for every decision you make: + +1. **Never fabricate the value.** If the PostHog distinct_id is not available in the current scope, do NOT substitute another identifier (Stripe customer ID, internal user ID, org ID, etc.). A wrong value is worse than no value — it corrupts metadata and blocks correct identification downstream. + +2. **Thread the value, don't invent it.** If a function needs the distinct_id but doesn't have it, add it as an optional parameter propagated from a caller that does. If no caller in the chain has it, skip that call site entirely and leave a TODO comment. + +3. **Minimize extra API calls.** Each \`stripe.Customer.modify\` / \`stripe.customers.update\` is a network round-trip with rate-limit cost. Don't add one before every payment intent, subscription, or invoice creation. If multiple charge calls share the same customer in a single flow, update once at the top, not before each call. + +4. **Follow existing Stripe abstraction patterns.** If the codebase wraps Stripe calls behind a utility/service layer, modify that layer. Don't call the Stripe API directly from business logic just to set metadata. + ## Context - Language: ${language} @@ -86,11 +98,9 @@ How to find it: 2. Search for \`posthog.capture(\` or \`client.capture(\` — look for \`distinctId\` or \`distinct_id\` in the arguments. 3. Search for \`posthog.get_distinct_id()\` — the variable it's assigned to tells you what holds the distinct_id. -Once you know WHAT value is the distinct_id (e.g. \`email\`, \`user.id\`, \`userId\`), determine HOW to access that same value at each Stripe call site. The variable name may differ between files — trace the data flow. For example: -- Frontend calls \`posthog.identify(email)\` → backend receives email as a parameter → use that parameter at the Stripe call site -- If the value isn't directly available, you may need to pass it through or look it up +Once you know WHAT value is the distinct_id (e.g. \`email\`, \`user.id\`, \`userId\`), determine HOW to access that same value at each Stripe call site. The variable name may differ between files — trace the data flow. -Do NOT invent properties like \`user.posthogDistinctId\` — this field does not exist. Use the actual value from the codebase. +If the distinct_id is not available at a Stripe call site, apply Tenet 2: add it as an optional parameter from a caller that has it. If no caller has it, skip that site and add a TODO. ## Instructions @@ -100,6 +110,8 @@ Follow these steps IN ORDER: For each Stripe Customer.create call, add \`posthog_person_distinct_id\` to the metadata. +Before modifying: check if the codebase wraps Stripe calls behind a utility or service layer. If so, modify that layer — don't add direct API calls in business logic (Tenet 4). + **API pattern (replace \`\` with the actual value):** \`\`\` ${stripeDocs.customerCreate.fullExample} @@ -107,13 +119,18 @@ ${stripeDocs.customerCreate.fullExample} Rules: - Replace \`\` with the actual distinct_id expression available at this call site. +- If the distinct_id is not in scope, thread it as an optional parameter from the caller (Tenet 2). If no caller has it, skip this site and add \`// TODO: pass PostHog distinct_id here\`. - If the call already has a metadata object, ADD the \`posthog_person_distinct_id\` key to it. Do NOT overwrite existing metadata. -- If the call does not have a metadata object, add one. - Preserve all existing arguments and code structure. -### STEP 2: Add Customer Update Before Charges +### STEP 2: Add Customer Update for Existing Customers -For each charge/payment call (PaymentIntent.create, Subscription.create, Invoice.create, checkout.Session.create), add a Stripe Customer.update/modify call BEFORE the charge. +This step handles customers created before this setup — they won't have the metadata from Step 1. + +Add a \`Customer.update\` / \`Customer.modify\` call to tag existing customers with the PostHog distinct_id. But apply Tenet 3 — minimize API calls: +- Do NOT add an update before every single charge call. Instead, find the earliest point where both the customer ID and the distinct_id are available. +- If multiple charge calls share the same customer in a single function or flow, update once at the top. +- If the codebase already has a "get or ensure customer" pattern, add the metadata there. **API pattern (replace \`\` with the actual value):** \`\`\` @@ -121,11 +138,8 @@ ${stripeDocs.customerUpdate.fullExample} \`\`\` Rules: -- Replace \`\` with the actual distinct_id expression available at this call site. -- Add the update call immediately before the charge call. -- Extract the customer ID from the charge call's arguments (it's usually a \`customer\` field). -- The update only sets metadata — do not modify the charge/payment logic itself. -- If the customer ID is not available at that point, trace back to find it. +- Same as Step 1: if the distinct_id is not in scope, thread it or skip with a TODO (Tenet 2). Never substitute a different identifier (Tenet 1). +- Respect existing Stripe abstraction layers (Tenet 4). ${checkoutNote} ### STEP 3: Verify @@ -133,17 +147,21 @@ ${checkoutNote} After making all changes, read each modified file to verify: - No syntax errors - Existing code logic is preserved -- The metadata field uses the correct distinct_id value (NOT a made-up property) +- The metadata field uses the correct distinct_id value — not a fabricated property +- No unnecessary duplicate API calls (Tenet 3) +- Changes respect existing abstraction patterns (Tenet 4) - Imports are present if needed ## Constraints -- Do NOT modify the charge/payment logic itself — only add metadata to customer creation and add customer.update calls before charges. +- **Never fabricate.** Do not invent properties or values. \`user.posthogDistinctId\` does not exist — use only values from the codebase. +- **Thread, don't invent.** If the distinct_id isn't in scope, propagate it as a parameter. If impossible, skip the site with a TODO. +- **Minimize API calls.** Don't add a Customer.modify before every charge — deduplicate. +- **Respect abstractions.** If the codebase wraps Stripe, modify the wrapper. +- Do NOT modify charge/payment logic — only add metadata to customer creation and customer update calls. - Do NOT remove any existing code. - Do NOT add new packages or dependencies. -- Do NOT invent new properties or fields. Use only values that already exist in the codebase. - Preserve all imports and error handling. -- If you truly cannot determine the distinct_id after searching, use \`"TODO_POSTHOG_DISTINCT_ID"\` as a string placeholder. When done, emit: ${ AgentSignals.STATUS From 517d58d3e0ac0facdd6c57894e12a40a940b454b Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 17:25:04 -0300 Subject: [PATCH 09/14] fix(revenue-analytics): Add [STATUS] emissions for spinner progress updates The agent now emits status messages at each milestone so the user sees progress instead of a static "Setting up revenue analytics..." spinner: identifying distinct_id, updating customer creation, adding metadata for existing customers, verifying changes. --- src/setup-revenue-analytics/prompt-builder.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/setup-revenue-analytics/prompt-builder.ts b/src/setup-revenue-analytics/prompt-builder.ts index 5e27af5..719e52f 100644 --- a/src/setup-revenue-analytics/prompt-builder.ts +++ b/src/setup-revenue-analytics/prompt-builder.ts @@ -102,9 +102,15 @@ Once you know WHAT value is the distinct_id (e.g. \`email\`, \`user.id\`, \`user If the distinct_id is not available at a Stripe call site, apply Tenet 2: add it as an optional parameter from a caller that has it. If no caller has it, skip that site and add a TODO. +Once determined, emit: ${ + AgentSignals.STATUS + } Identified distinct_id — updating Stripe customer creation... + ## Instructions -Follow these steps IN ORDER: +Follow these steps IN ORDER. Emit a \`${ + AgentSignals.STATUS + }\` message at the start of each step so the user can see progress. ### STEP 1: Update Stripe Customer Creation @@ -123,6 +129,10 @@ Rules: - If the call already has a metadata object, ADD the \`posthog_person_distinct_id\` key to it. Do NOT overwrite existing metadata. - Preserve all existing arguments and code structure. +After modifying customer creation, emit: ${ + AgentSignals.STATUS + } Customer creation updated — adding metadata for existing customers... + ### STEP 2: Add Customer Update for Existing Customers This step handles customers created before this setup — they won't have the metadata from Step 1. @@ -142,6 +152,10 @@ Rules: - Respect existing Stripe abstraction layers (Tenet 4). ${checkoutNote} +After modifying existing customer handling, emit: ${ + AgentSignals.STATUS + } Verifying changes... + ### STEP 3: Verify After making all changes, read each modified file to verify: From 611b6a54b8fa1417deef870b5cfab78347185ca6 Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Thu, 2 Apr 2026 17:43:33 -0300 Subject: [PATCH 10/14] fix: Deduplicate status logs in LoggingUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove explicit pushStatus call from handleSDKMessage — spinner.message already calls pushStatus internally in both InkUI and LoggingUI, so the [STATUS] line was appearing twice. --- src/lib/agent-interface.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 1dca0cc..860b880 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -1188,7 +1188,6 @@ function handleSDKMessage( const statusMatch = block.text.match(statusRegex); if (statusMatch) { const statusText = statusMatch[1].trim(); - getUI().pushStatus(statusText); spinner.message(statusText); } } From 6cc29aaaff8ac561ba0df1e243373aefa420dfc6 Mon Sep 17 00:00:00 2001 From: Arthur Moreira de Deus Date: Fri, 3 Apr 2026 08:58:41 -0300 Subject: [PATCH 11/14] fix(revenue-analytics): Strip HTML tags before decoding entities --- .../__tests__/stripe-docs-fetcher.test.ts | 66 +++++++++++++++++++ .../stripe-docs-fetcher.ts | 8 ++- 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts diff --git a/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts b/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts new file mode 100644 index 0000000..30e5d59 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts @@ -0,0 +1,66 @@ +/** + * We only test decodeHtmlEntities here — the fetch logic is tested + * via integration tests against the live Stripe docs site. + */ + +// decodeHtmlEntities is not exported, so we reach it through the module internals. +// Re-implement as a standalone to test the logic in isolation. +// This mirrors the implementation in stripe-docs-fetcher.ts exactly. +function decodeHtmlEntities(text: string): string { + const stripped = text.replace(/<[^>]+>/g, ''); + return stripped + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +describe('decodeHtmlEntities', () => { + test('decodes basic HTML entities', () => { + expect(decodeHtmlEntities('a & b')).toBe('a & b'); + expect(decodeHtmlEntities('a < b')).toBe('a < b'); + expect(decodeHtmlEntities('a > b')).toBe('a > b'); + expect(decodeHtmlEntities('"hello"')).toBe('"hello"'); + }); + + test('decodes apostrophe entities', () => { + expect(decodeHtmlEntities('it's')).toBe("it's"); + expect(decodeHtmlEntities('it's')).toBe("it's"); + }); + + test('strips HTML tags', () => { + expect(decodeHtmlEntities('hello')).toBe('hello'); + expect(decodeHtmlEntities('link')).toBe('link'); + expect(decodeHtmlEntities('before
after')).toBe('beforeafter'); + }); + + test('strips tags before decoding entities to prevent injection', () => { + // <script> should decode to ', + ); + }); + + test('handles double-encoded entities', () => { + // &lt; → strip tags (none present) → decode & → < → decode < → < + // Double-encoded content fully decodes. This is fine because the output + // is used as plain text in the agent prompt, not rendered as HTML. + expect(decodeHtmlEntities('&lt;script&gt;')).toBe(''); + expect(result).not.toContain(' { // <script> should decode to