mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
969b4623ca
commit
a6faefdfd3
10 changed files with 187 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue