feat: add webhook/event handler detection

Add Webhook node type and TRIGGERS edge to the knowledge graph.
Detects Stripe webhooks, Supabase Edge Functions, Supabase Realtime
subscriptions, and generic webhook handlers. Adds webhook_map MCP tool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test 2026-03-25 18:30:52 +01:00
parent 969b4623ca
commit a6faefdfd3
10 changed files with 187 additions and 15 deletions

View file

@ -44,7 +44,8 @@ export type NodeLabel =
| 'Template'
| 'Section'
| 'Route'
| 'Tool';
| 'Tool'
| 'Webhook';
export type NodeProperties = {
name: string;
@ -89,6 +90,9 @@ export type NodeProperties = {
responseKeys?: string[];
errorKeys?: string[];
middleware?: string[];
// Webhook/event handler
kind?: string;
eventTypes?: string[];
// Extensible
[key: string]: unknown;
};
@ -115,7 +119,10 @@ export type RelationshipType =
| 'HANDLES_TOOL'
| 'ENTRY_POINT_OF'
| 'WRAPS'
| 'QUERIES';
| 'QUERIES'
| 'TRIGGERS'
| 'ENQUEUES'
| 'PROCESSES';
export interface GraphNode {
id: string;

View file

@ -40,6 +40,7 @@ export const NODE_TABLES = [
'Module',
'Route',
'Tool',
'Webhook',
] as const;
export type NodeTableName = (typeof NODE_TABLES)[number];
@ -67,6 +68,9 @@ export const REL_TYPES = [
'ENTRY_POINT_OF',
'WRAPS',
'QUERIES',
'TRIGGERS',
'ENQUEUES',
'PROCESSES',
] as const;
export type RelType = (typeof REL_TYPES)[number];

View file

@ -42,6 +42,7 @@ import type {
ExtractedFetchCall,
ExtractedDecoratorRoute,
ExtractedToolDef,
ExtractedWebhook,
FileConstructorBindings,
FileScopeBindings,
ExtractedORMQuery,
@ -60,6 +61,7 @@ export interface WorkerExtractedData {
decoratorRoutes: ExtractedDecoratorRoute[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
webhooks: ExtractedWebhook[];
constructorBindings: FileConstructorBindings[];
fileScopeBindings: FileScopeBindings[];
}
@ -94,6 +96,7 @@ const processParsingWithWorkers = async (
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
webhooks: [],
constructorBindings: [],
fileScopeBindings: [],
};
@ -118,6 +121,7 @@ const processParsingWithWorkers = async (
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
const allWebhooks: ExtractedWebhook[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
const fileScopeBindingsByFile: FileScopeBindings[] = [];
for (const result of chunkResults) {
@ -145,18 +149,18 @@ const processParsingWithWorkers = async (
});
}
for (const item of result.imports) allImports.push(item);
for (const item of result.calls) allCalls.push(item);
for (const item of result.assignments) allAssignments.push(item);
for (const item of result.heritage) allHeritage.push(item);
for (const item of result.routes) allRoutes.push(item);
for (const item of result.fetchCalls) allFetchCalls.push(item);
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
for (const item of result.toolDefs) allToolDefs.push(item);
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
for (const item of result.constructorBindings) allConstructorBindings.push(item);
if (result.fileScopeBindings)
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
allImports.push(...result.imports);
allCalls.push(...result.calls);
allAssignments.push(...result.assignments);
allHeritage.push(...result.heritage);
allRoutes.push(...result.routes);
allFetchCalls.push(...result.fetchCalls);
allDecoratorRoutes.push(...result.decoratorRoutes);
allToolDefs.push(...result.toolDefs);
if (result.ormQueries) allORMQueries.push(...result.ormQueries);
allWebhooks.push(...result.webhooks);
allConstructorBindings.push(...result.constructorBindings);
if (result.fileScopeBindings) fileScopeBindingsByFile.push(...result.fileScopeBindings);
}
// Merge and log skipped languages from workers
@ -185,6 +189,7 @@ const processParsingWithWorkers = async (
decoratorRoutes: allDecoratorRoutes,
toolDefs: allToolDefs,
ormQueries: allORMQueries,
webhooks: allWebhooks,
constructorBindings: allConstructorBindings,
fileScopeBindings: fileScopeBindingsByFile,
};

View file

@ -16,7 +16,7 @@
*/
import { createKnowledgeGraph } from '../graph/graph.js';
import { type PipelineProgress } from 'gitnexus-shared';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import { PipelineResult } from '../../types/pipeline.js';
import {
runPipeline,

View file

@ -217,6 +217,23 @@ export interface ExtractedORMQuery {
lineNumber: number;
}
export interface ExtractedWebhook {
filePath: string;
name: string;
kind: 'stripe' | 'bullmq-consumer' | 'bullmq-producer' | 'edge-function' | 'realtime' | 'generic';
eventTypes: string[];
lineNumber: number;
}
export interface ExtractedQueuePattern {
filePath: string;
role: 'producer' | 'consumer';
queueName: string;
method?: string;
handlerName?: string;
lineNumber: number;
}
/** Constructor bindings keyed by filePath for cross-file type resolution */
export interface FileConstructorBindings {
filePath: string;
@ -266,6 +283,8 @@ export interface ParseWorkerResult {
decoratorRoutes: ExtractedDecoratorRoute[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
webhooks: ExtractedWebhook[];
queuePatterns: ExtractedQueuePattern[];
constructorBindings: FileConstructorBindings[];
/** All-scope type bindings from TypeEnv for BindingAccumulator (includes function-local). */
fileScopeBindings: FileScopeBindings[];
@ -709,6 +728,7 @@ const processBatch = (
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
webhooks: [],
constructorBindings: [],
fileScopeBindings: [],
skippedLanguages: {},
@ -1332,6 +1352,48 @@ export function extractORMQueries(
}
}
// ============================================================================
// Webhook/Event Handler Extraction
// ============================================================================
function extractWebhooks(filePath: string, content: string, out: ExtractedWebhook[]): void {
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 });
}
}
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');
if (hasSigVerify) {
const pathParts = filePath.split('/');
const name = pathParts[pathParts.length - 1].replace(/\.(ts|js|tsx|jsx)$/, '');
out.push({ filePath, name: `webhook:${name}`, kind: 'generic', eventTypes: [], lineNumber: 0 });
}
}
}
const processFileGroup = (
files: ParseWorkerInput[],
language: SupportedLanguages,
@ -2246,6 +2308,8 @@ const processFileGroup = (
// Extract ORM queries (Prisma, Supabase)
extractORMQueries(file.path, parseContent, result.ormQueries);
extractWebhooks(file.path, file.content, result.webhooks);
extractQueuePatterns(file.path, file.content, result.queuePatterns);
// Vue: emit CALLS edges for components used in <template>
if (language === SupportedLanguages.Vue) {
@ -2280,6 +2344,8 @@ let accumulated: ParseWorkerResult = {
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
webhooks: [],
queuePatterns: [],
constructorBindings: [],
fileScopeBindings: [],
skippedLanguages: {},
@ -2307,8 +2373,11 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
appendAll(target.decoratorRoutes, src.decoratorRoutes);
appendAll(target.toolDefs, src.toolDefs);
appendAll(target.ormQueries, src.ormQueries);
appendAll(target.webhooks, src.webhooks);
appendAll(target.queuePatterns, src.queuePatterns);
appendAll(target.constructorBindings, src.constructorBindings);
appendAll(target.fileScopeBindings, src.fileScopeBindings);
appendAll(target.typeEnvBindings, src.typeEnvBindings);
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
}
@ -2358,6 +2427,8 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
webhooks: [],
queuePatterns: [],
constructorBindings: [],
fileScopeBindings: [],
skippedLanguages: {},

View file

@ -277,6 +277,10 @@ export const streamAllCSVsToDisk = async (
path.join(csvDir, 'tool.csv'),
'id,name,filePath,description',
);
const webhookWriter = new BufferedCSVWriter(
path.join(csvDir, 'webhook.csv'),
'id,name,filePath,kind,eventTypes',
);
// Multi-language node types share the same CSV shape (no isExported column)
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
@ -447,6 +451,20 @@ export const streamAllCSVsToDisk = async (
].join(','),
);
break;
case 'Webhook': {
const eventTypes = node.properties.eventTypes as string[] || [];
const eventTypesStr = `[${eventTypes.map((e: string) => `'${e.replace(/'/g, "''")}'`).join(',')}]`;
await webhookWriter.addRow(
[
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
escapeCSVField(node.properties.filePath || ''),
escapeCSVField(node.properties.kind as string || ''),
escapeCSVField(eventTypesStr),
].join(','),
);
break;
}
default: {
// Code element nodes (Function, Class, Interface, CodeElement)
const writer = codeWriterMap[node.label];
@ -501,6 +519,7 @@ export const streamAllCSVsToDisk = async (
sectionWriter,
routeWriter,
toolWriter,
webhookWriter,
...multiLangWriters.values(),
];
await Promise.all(allWriters.map((w) => w.finish()));
@ -537,6 +556,7 @@ export const streamAllCSVsToDisk = async (
['Section' as NodeTableName, sectionWriter],
['Route' as NodeTableName, routeWriter],
['Tool' as NodeTableName, toolWriter],
['Webhook' as NodeTableName, webhookWriter],
...Array.from(multiLangWriters.entries()).map(
([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter],
),

View file

@ -587,6 +587,9 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => {
if (table === 'Tool') {
return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Webhook') {
return `COPY ${t}(id, name, filePath, kind, eventTypes) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Method') {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}

View file

@ -196,6 +196,17 @@ CREATE NODE TABLE Tool (
PRIMARY KEY (id)
)`;
// Webhook/event handler endpoints
export const WEBHOOK_SCHEMA = `
CREATE NODE TABLE Webhook (
id STRING,
name STRING,
filePath STRING,
kind STRING,
eventTypes STRING[],
PRIMARY KEY (id)
)`;
// Markdown heading sections
export const SECTION_SCHEMA = `
CREATE NODE TABLE Section (
@ -318,6 +329,14 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM File TO Tool,
FROM Function TO Tool,
FROM Method TO Tool,
FROM File TO Webhook,
FROM Webhook TO Webhook,
FROM Webhook TO Function,
FROM Webhook TO Method,
FROM File TO Webhook,
FROM Webhook TO Webhook,
FROM Webhook TO Function,
FROM Webhook TO Method,
FROM CodeElement TO Community,
FROM Interface TO Community,
FROM Interface TO Function,
@ -420,6 +439,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM CodeElement TO Process,
FROM Route TO Process,
FROM Tool TO Process,
FROM Webhook TO Process,
FROM Webhook TO Process,
type STRING,
confidence DOUBLE,
reason STRING,
@ -510,6 +531,8 @@ export const NODE_SCHEMA_QUERIES = [
ROUTE_SCHEMA,
// MCP tools
TOOL_SCHEMA,
// Webhooks/event handlers
WEBHOOK_SCHEMA,
];
export const REL_SCHEMA_QUERIES = [RELATION_SCHEMA];

View file

@ -495,6 +495,8 @@ export class LocalBackend {
return this.shapeCheck(repo, params);
case 'tool_map':
return this.toolMap(repo, params);
case 'webhook_map':
return this.webhookMap(repo, params);
case 'api_impact':
return this.apiImpact(repo, params);
default:
@ -3219,6 +3221,30 @@ export class LocalBackend {
};
}
private async webhookMap(repo: RepoHandle, params: { kind?: string; name?: string }): Promise<any> {
const kindFilter = params.kind ? `AND n.kind = $kind` : '';
const nameFilter = params.name ? `AND n.name CONTAINS $name` : '';
const queryParams: Record<string, any> = {};
if (params.kind) queryParams.kind = params.kind;
if (params.name) queryParams.name = params.name;
const rows = await executeParameterized(repo.id, `
MATCH (n:Webhook)
WHERE n.id STARTS WITH 'Webhook:' ${kindFilter} ${nameFilter}
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
`, {});
const webhooks = rows.map((r: any) => ({
id: r.id, name: r.name, filePath: r.filePath, kind: r.kind, eventTypes: r.eventTypes || [],
}));
const triggers = triggerRows.map((r: any) => ({
source: r.source, target: r.target, reason: r.reason,
}));
return { webhooks, triggers, total: webhooks.length };
}
async disconnect(): Promise<void> {
await closeLbug(); // close all connections
// Note: we intentionally do NOT call disposeEmbedder() here.

View file

@ -335,6 +335,19 @@ Returns: tool nodes with their handler files and descriptions.`,
required: [],
},
},
{
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.`,
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string', description: 'Filter by webhook kind. Omit for all.' },
name: { type: 'string', description: 'Filter by webhook name. Omit for all.' },
repo: { type: 'string', description: 'Repository name or path.' },
},
required: [],
},
},
{
name: 'shape_check',
description: `Check response shapes for API routes against their consumers' property accesses.