diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index aa12ae1f2..4562144bc 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -729,6 +729,7 @@ const processBatch = ( toolDefs: [], ormQueries: [], webhooks: [], + queuePatterns: [], constructorBindings: [], fileScopeBindings: [], skippedLanguages: {}, @@ -1356,10 +1357,18 @@ export function extractORMQueries( // Webhook/Event Handler Extraction // ============================================================================ -function extractWebhooks(filePath: string, content: string, out: ExtractedWebhook[]): void { - // Skip test files — they often mock webhook patterns - const lp = filePath.toLowerCase(); - if (lp.includes('/test') || lp.includes('.test.') || lp.includes('.spec.') || lp.includes('__test')) return; +export function extractWebhooks(filePath: string, content: string, out: ExtractedWebhook[]): void { + const lowerPath = filePath.toLowerCase(); + + // Skip test files — they often mock webhook patterns (e.g. Stripe constructEvent in test mocks) + if (lowerPath.includes('/test') || lowerPath.includes('.test.') || lowerPath.includes('.spec.') || lowerPath.includes('__test')) return; + + // Count newlines before offset without allocating substring + split array + const lineAt = (offset: number): number => { + let n = 0; + for (let i = 0; i < offset; i++) { if (content.charCodeAt(i) === 10) n++; } + return n; + }; if (content.includes('constructEvent') || content.includes('webhooks.constructEvent')) { const eventTypes: string[] = []; @@ -1368,14 +1377,13 @@ function extractWebhooks(filePath: string, content: string, out: ExtractedWebhoo while ((m = caseRe.exec(content)) !== null) { eventTypes.push(m[1]); } const idx = content.indexOf('constructEvent'); out.push({ filePath, name: 'stripe-webhook', kind: 'stripe', eventTypes, - lineNumber: idx > -1 ? content.substring(0, idx).split('\n').length - 1 : 0 }); + lineNumber: idx > -1 ? lineAt(idx) : 0 }); return; } const edgeFnMatch = filePath.match(/supabase\/functions\/([\w-]+)\/index\.ts$/); if (edgeFnMatch && content.includes('Deno.serve')) { - const idx = content.indexOf('Deno.serve'); out.push({ filePath, name: edgeFnMatch[1], kind: 'edge-function', eventTypes: [], - lineNumber: content.substring(0, idx).split('\n').length - 1 }); + lineNumber: lineAt(content.indexOf('Deno.serve')) }); return; } if (content.includes('postgres_changes')) { @@ -1383,10 +1391,9 @@ function extractWebhooks(filePath: string, content: string, out: ExtractedWebhoo let m; while ((m = channelRe.exec(content)) !== null) { out.push({ filePath, name: `realtime:${m[1]}`, kind: 'realtime', eventTypes: [], - lineNumber: content.substring(0, m.index).split('\n').length - 1 }); + lineNumber: lineAt(m.index) }); } } - const lowerPath = filePath.toLowerCase(); if (lowerPath.includes('webhook') || lowerPath.includes('hook')) { const hasSigVerify = content.includes('x-webhook-signature') || content.includes('x-hub-signature') || content.includes('verify'); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index b85427232..90f5f54b2 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -3222,6 +3222,7 @@ export class LocalBackend { } private async webhookMap(repo: RepoHandle, params: { kind?: string; name?: string }): Promise { + await this.ensureInitialized(repo.id); const kindFilter = params.kind ? `AND n.kind = $kind` : ''; const nameFilter = params.name ? `AND n.name CONTAINS $name` : ''; const queryParams: Record = {}; @@ -3233,8 +3234,8 @@ export class LocalBackend { RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.kind AS kind, n.eventTypes AS eventTypes `, queryParams); const triggerRows = await executeParameterized(repo.id, ` - MATCH (a:Webhook)-[r:CodeRelation {type: 'TRIGGERS'}]->(b:Webhook) - RETURN a.name AS source, b.name AS target, r.reason AS reason + MATCH (f:File)-[r:CodeRelation {type: 'TRIGGERS'}]->(w:Webhook) + RETURN f.filePath AS source, w.name AS target, r.reason AS reason `, {}); const webhooks = rows.map((r: any) => ({ id: r.id, name: r.name, filePath: r.filePath, kind: r.kind, eventTypes: r.eventTypes || [], diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 5162b94f1..e6a91fc65 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -337,7 +337,11 @@ Returns: tool nodes with their handler files and descriptions.`, }, { name: 'webhook_map', - description: `Show webhook and event handler endpoints: Stripe webhooks, Supabase Edge Functions, and realtime subscriptions.\n\nWHEN TO USE: Understanding event-driven architecture, finding webhook handlers, tracing event flows.\n\nReturns: webhook nodes with handler files, kind, event types, and TRIGGERS edges.`, + description: `Show webhook and event handler endpoints: Stripe webhooks, Supabase Edge Functions, and realtime subscriptions. + +WHEN TO USE: Understanding event-driven architecture, finding webhook handlers, tracing event flows. + +Returns: webhook nodes with handler files, kind, event types, and TRIGGERS edges.`, inputSchema: { type: 'object', properties: { diff --git a/gitnexus/test/integration/webhook-detection.test.ts b/gitnexus/test/integration/webhook-detection.test.ts index 1ac6bd827..041d46178 100644 --- a/gitnexus/test/integration/webhook-detection.test.ts +++ b/gitnexus/test/integration/webhook-detection.test.ts @@ -21,7 +21,7 @@ import path from 'path'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; -import type { ExtractedWebhook } from '../../src/core/ingestion/workers/parse-worker.js'; +import { extractWebhooks, type ExtractedWebhook } from '../../src/core/ingestion/workers/parse-worker.js'; vi.mock('../../src/storage/repo-manager.js', () => ({ listRegisteredRepos: vi.fn().mockResolvedValue([]), @@ -31,11 +31,6 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ const WEBHOOK_REPO = path.resolve(__dirname, '..', 'fixtures', 'webhook-repo'); // ─── Part A: extractWebhooks detection logic against fixture files ──── -// -// We import the worker module and call extractWebhooks through a dynamic import -// since it's not exported directly. Instead, we replicate the detection logic -// inline — this is identical to what parse-worker.ts does. The fixture files -// are the canonical proof that the regex patterns match real-world code. describe('webhook detection — extraction from fixture files', () => { const stripeContent = fs.readFileSync( @@ -48,38 +43,6 @@ describe('webhook detection — extraction from fixture files', () => { path.join(WEBHOOK_REPO, 'lib/realtime.ts'), 'utf-8' ); - // Replicate extractWebhooks logic from parse-worker.ts to prove fixture detection - function extractWebhooks(filePath: string, content: string, out: ExtractedWebhook[]): void { - const lp = filePath.toLowerCase(); - if (lp.includes('/test') || lp.includes('.test.') || lp.includes('.spec.') || lp.includes('__test')) return; - - if (content.includes('constructEvent') || content.includes('webhooks.constructEvent')) { - const eventTypes: string[] = []; - const caseRe = /case\s+['"]([.\w-]+)['"]/g; - let m; - while ((m = caseRe.exec(content)) !== null) { eventTypes.push(m[1]); } - const idx = content.indexOf('constructEvent'); - out.push({ filePath, name: 'stripe-webhook', kind: 'stripe', eventTypes, - lineNumber: idx > -1 ? content.substring(0, idx).split('\n').length - 1 : 0 }); - return; - } - const edgeFnMatch = filePath.match(/supabase\/functions\/([\w-]+)\/index\.ts$/); - if (edgeFnMatch && content.includes('Deno.serve')) { - const idx = content.indexOf('Deno.serve'); - out.push({ filePath, name: edgeFnMatch[1], kind: 'edge-function', eventTypes: [], - lineNumber: content.substring(0, idx).split('\n').length - 1 }); - return; - } - if (content.includes('postgres_changes')) { - const channelRe = /\.channel\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g; - let m; - while ((m = channelRe.exec(content)) !== null) { - out.push({ filePath, name: `realtime:${m[1]}`, kind: 'realtime', eventTypes: [], - lineNumber: content.substring(0, m.index).split('\n').length - 1 }); - } - } - } - it('detects Stripe webhook with correct kind and eventTypes', () => { const out: ExtractedWebhook[] = []; extractWebhooks('app/api/stripe/webhooks/route.ts', stripeContent, out);