Merge pull request #25 from abhigyanpatwari/Schema_Experiments

blast radius tool implemented
This commit is contained in:
Abhigyan Patwari 2026-01-15 03:04:28 +05:30 • committed by GitHub
commit 8f004dab62
5 changed files with 293 additions and 15 deletions

View file

@ -21,6 +21,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
highlightedNodeIds,
aiCitationHighlightedNodeIds,
aiToolHighlightedNodeIds,
blastRadiusNodeIds,
isAIHighlightsEnabled,
toggleAIHighlights,
} = useAppState();
@ -31,9 +32,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
const next = new Set(highlightedNodeIds);
for (const id of aiCitationHighlightedNodeIds) next.add(id);
for (const id of aiToolHighlightedNodeIds) next.add(id);
// Note: blast radius nodes are handled separately with red color
return next;
}, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]);
// Blast radius nodes (only when AI highlights enabled)
const effectiveBlastRadiusNodeIds = useMemo(() => {
if (!isAIHighlightsEnabled) return new Set<string>();
return blastRadiusNodeIds;
}, [blastRadiusNodeIds, isAIHighlightsEnabled]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graph) return;
const node = graph.nodes.find(n => n.id === nodeId);
@ -76,6 +84,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
onNodeHover: handleNodeHover,
onStageClick: handleStageClick,
highlightedNodeIds: effectiveHighlightedNodeIds,
blastRadiusNodeIds: effectiveBlastRadiusNodeIds,
});
// Expose focusNode to parent via ref
@ -104,10 +113,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
useEffect(() => {
const sigma = sigmaRef.current;
if (!sigma) return;
const sigmaGraph = sigma.getGraph() as Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
if (sigmaGraph.order === 0) return; // Don't filter empty graph
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
sigma.refresh();
}, [visibleLabels, depthFilter, appSelectedNode, sigmaRef]);
@ -139,7 +148,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
<div className="relative w-full h-full bg-void">
{/* Background gradient */}
<div className="absolute inset-0 pointer-events-none">
<div
<div
className="absolute inset-0"
style={{
background: `
@ -151,8 +160,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
</div>
{/* Sigma container */}
<div
ref={containerRef}
<div
ref={containerRef}
className="sigma-container w-full h-full cursor-grab active:cursor-grabbing"
/>
@ -205,10 +214,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
>
<Maximize2 className="w-4 h-4" />
</button>
{/* Divider */}
<div className="h-px bg-border-subtle my-1" />
{/* Focus on selected */}
{appSelectedNode && (
<button
@ -219,7 +228,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
<Focus className="w-4 h-4" />
</button>
)}
{/* Clear selection */}
{sigmaSelectedNode && (
<button
@ -230,17 +239,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
<RotateCcw className="w-4 h-4" />
</button>
)}
{/* Divider */}
<div className="h-px bg-border-subtle my-1" />
{/* Layout control */}
<button
onClick={isLayoutRunning ? stopLayout : startLayout}
className={`
w-9 h-9 flex items-center justify-center border rounded-md transition-all
${isLayoutRunning
? 'bg-accent border-accent text-white shadow-glow animate-pulse'
${isLayoutRunning
? 'bg-accent border-accent text-white shadow-glow animate-pulse'
: 'bg-elevated border-border-subtle text-text-secondary hover:bg-hover hover:text-text-primary'
}
`}

View file

@ -73,6 +73,7 @@ You are an investigator. For each question:
- **\`grep\`** — Regex search. Best for exact strings, TODOs, error codes.
- **\`read\`** — Read file content. Always use after search/grep to see full code.
- **\`highlight\`** — Highlight nodes in the visual graph.
- **\`blastRadius\`** — Impact analysis. Output is graph-verified (trusted). Run optional grep for dynamic patterns if thoroughness needed.
## 📊 GRAPH SCHEMA
Nodes: File, Folder, Function, Class, Interface, Method, CodeElement
@ -83,6 +84,7 @@ Cypher examples:
- \`MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f.name, g.name\`
## 📝CRITICAL RULES
- **blastRadius output is trusted.** Do NOT re-validate with cypher. Optionally run the suggested grep commands for dynamic patterns.
- **Cite or retract.** Never state something you can't ground.
- **Read before concluding.** Don't guess from names alone.
- **Retry on failure.** If a tool fails, fix the input and try again.

View file

@ -1,12 +1,13 @@
/**
* Graph RAG Tools for LangChain Agent
*
* Consolidated tools (5 total):
* Consolidated tools (6 total):
* - search: Hybrid search (BM25 + semantic + RRF) with 1-hop expansion
* - cypher: Execute Cypher queries (auto-embeds {{QUERY_VECTOR}} if present)
* - grep: Regex pattern search across files
* - read: Read file content by path
* - highlight: Highlight nodes in graph UI
* - blastRadius: Impact analysis (what depends on / is affected by changes)
*/
import { tool } from '@langchain/core/tools';
@ -382,6 +383,206 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
}
);
// ============================================================================
// TOOL 6: BLAST RADIUS (Impact analysis)
// ============================================================================
const blastRadiusTool = tool(
async ({ target, direction, maxDepth }: {
target: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
}) => {
const depth = Math.min(maxDepth ?? 3, 10);
// Determine the traversal direction
const directionArrow = direction === 'upstream' ? '<-' : '->';
const directionLabel = direction === 'upstream'
? 'what depends on this (callers, importers, child classes)'
: 'what this depends on (callees, imports, parent classes)';
// Try to find the target node first
const findTargetQuery = `
MATCH (n)
WHERE n.name = '${target.replace(/'/g, "''")}'
RETURN n.id AS id, label(n) AS nodeType, n.filePath AS filePath
LIMIT 5
`;
let targetResults;
try {
targetResults = await executeQuery(findTargetQuery);
} catch (error) {
return `Error finding target "${target}": ${error}`;
}
if (!targetResults || targetResults.length === 0) {
return `Could not find "${target}" in the codebase. Try using the search tool first to find the exact name.`;
}
// Use the first match
const targetNode = targetResults[0];
const targetId = Array.isArray(targetNode) ? targetNode[0] : targetNode.id;
const targetType = Array.isArray(targetNode) ? targetNode[1] : targetNode.nodeType;
// Note: KuzuDB doesn't support [r IN relationships(path) | r.type] list comprehension
// So we query each depth level separately for accurate depth tracking
// Get depth info with separate simpler queries for each depth level
const depthQueries: Promise<any[]>[] = [];
for (let d = 1; d <= Math.min(depth, 3); d++) {
const dQuery = direction === 'upstream'
? `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (affected)-[:CodeRelation*${d}]->(target)
RETURN DISTINCT affected.id AS id, affected.name AS name, label(affected) AS nodeType, affected.filePath AS filePath, ${d} AS depth
LIMIT 100
`
: `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (target)-[:CodeRelation*${d}]->(affected)
RETURN DISTINCT affected.id AS id, affected.name AS name, label(affected) AS nodeType, affected.filePath AS filePath, ${d} AS depth
LIMIT 100
`;
depthQueries.push(executeQuery(dQuery).catch(() => []));
}
// Wait for all depth queries
const depthResults = await Promise.all(depthQueries);
// Combine results by depth
const byDepth: Map<number, any[]> = new Map();
const allNodeIds: string[] = [];
const seenIds = new Set<string>();
depthResults.forEach((results, idx) => {
const d = idx + 1;
results.forEach((row: any) => {
const nodeId = Array.isArray(row) ? row[0] : row.id;
// Avoid duplicates (a node might appear at multiple depths)
if (nodeId && !seenIds.has(nodeId)) {
seenIds.add(nodeId);
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d)!.push(row);
allNodeIds.push(nodeId);
}
});
});
const totalAffected = allNodeIds.length;
if (totalAffected === 0) {
return `No ${direction} dependencies found for "${target}". This code appears to be ${direction === 'upstream' ? 'unused (not called by anything)' : 'self-contained (no outgoing dependencies)'}.`;
}
// Build tiered output
const lines: string[] = [
`🔴 BLAST RADIUS: ${target}`,
``,
`Direction: ${direction} (${directionLabel})`,
`Total affected: ${totalAffected} components`,
``,
];
// Depth 1 - Critical
const depth1 = byDepth.get(1) || [];
if (depth1.length > 0) {
lines.push(`═══════════════════════════════════════════════`);
lines.push(`DEPTH 1 — WILL BREAK (${depth1.length} components):`);
lines.push(`═══════════════════════════════════════════════`);
depth1.slice(0, 20).forEach((r: any) => {
const name = Array.isArray(r) ? r[1] : r.name;
const nodeType = Array.isArray(r) ? r[2] : r.nodeType;
const filePath = Array.isArray(r) ? r[3] : r.filePath;
const fileName = filePath?.split('/').pop() || '';
lines.push(`• ${name} (${nodeType}) at ${fileName}`);
});
if (depth1.length > 20) lines.push(` ... and ${depth1.length - 20} more`);
lines.push(``);
}
// Depth 2 - High impact
const depth2 = byDepth.get(2) || [];
if (depth2.length > 0) {
lines.push(`═══════════════════════════════════════════════`);
lines.push(`DEPTH 2 — LIKELY AFFECTED (${depth2.length} components):`);
lines.push(`═══════════════════════════════════════════════`);
depth2.slice(0, 15).forEach((r: any) => {
const name = Array.isArray(r) ? r[1] : r.name;
const nodeType = Array.isArray(r) ? r[2] : r.nodeType;
const filePath = Array.isArray(r) ? r[3] : r.filePath;
const fileName = filePath?.split('/').pop() || '';
lines.push(`• ${name} (${nodeType}) at ${fileName}`);
});
if (depth2.length > 15) lines.push(` ... and ${depth2.length - 15} more`);
lines.push(``);
}
// Depth 3 - Transitive
const depth3 = byDepth.get(3) || [];
if (depth3.length > 0) {
lines.push(`═══════════════════════════════════════════════`);
lines.push(`DEPTH 3 — MAY NEED TESTING (${depth3.length} components):`);
lines.push(`═══════════════════════════════════════════════`);
depth3.slice(0, 5).forEach((r: any) => {
const name = Array.isArray(r) ? r[1] : r.name;
const nodeType = Array.isArray(r) ? r[2] : r.nodeType;
lines.push(`• ${name} (${nodeType})`);
});
if (depth3.length > 5) lines.push(` ... and ${depth3.length - 5} more`);
lines.push(``);
}
// Trusted analysis marker
lines.push(`═══════════════════════════════════════════════`);
lines.push(`✅ GRAPH ANALYSIS COMPLETE (trusted)`);
lines.push(`═══════════════════════════════════════════════`);
lines.push(`The above results are verified from code graph traversal.`);
lines.push(`No additional validation needed for static dependencies.`);
lines.push(``);
// Optional dynamic detection
lines.push(`═══════════════════════════════════════════════`);
lines.push(`⚠️ OPTIONAL: Dynamic Pattern Check`);
lines.push(`═══════════════════════════════════════════════`);
lines.push(`The graph cannot track: event listeners, DI, dynamic imports.`);
lines.push(`If thoroughness is needed, run:`);
lines.push(`• grep({ pattern: "${target}", fileFilter: "*.ts" })`);
lines.push(`• grep({ pattern: "${target}", fileFilter: "*.json" })`);
lines.push(``);
// Add the marker for UI highlighting
const marker = `[BLAST_RADIUS:${allNodeIds.join(',')}]`;
lines.push(marker);
return lines.join('\n');
},
{
name: 'blastRadius',
description: `Analyze the blast radius (impact) of changing a function, class, or file.
Use this when users ask:
- "What would break if I changed X?"
- "What depends on X?"
- "Impact analysis for X"
- "Blast radius of X"
Direction:
- upstream: Find what CALLS/IMPORTS/EXTENDS this target (what would break)
- downstream: Find what this target CALLS/IMPORTS/EXTENDS (dependencies)
Results are grouped by depth:
- Depth 1: Direct dependencies (will definitely break)
- Depth 2: Indirect (likely affected)
- Depth 3+: Transitive (may need testing)`,
schema: z.object({
target: z.string().describe('Name of the function, class, or file to analyze'),
direction: z.enum(['upstream', 'downstream']).describe('upstream = what depends on this; downstream = what this depends on'),
maxDepth: z.number().optional().nullable().describe('Max traversal depth (default: 3, max: 10)'),
}),
}
);
// ============================================================================
// RETURN ALL TOOLS
// ============================================================================
@ -392,5 +593,6 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
grepTool,
readTool,
highlightTool,
blastRadiusTool,
];
};

View file

@ -77,9 +77,11 @@ interface AppState {
// AI highlights (toggable)
aiCitationHighlightedNodeIds: Set<string>;
aiToolHighlightedNodeIds: Set<string>;
blastRadiusNodeIds: Set<string>;
isAIHighlightsEnabled: boolean;
toggleAIHighlights: () => void;
clearAIToolHighlights: () => void;
clearBlastRadius: () => void;
queryResult: QueryResult | null;
setQueryResult: (result: QueryResult | null) => void;
clearQueryHighlights: () => void;
@ -183,6 +185,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
// AI highlights (separate from user/query highlights)
const [aiCitationHighlightedNodeIds, setAICitationHighlightedNodeIds] = useState<Set<string>>(new Set());
const [aiToolHighlightedNodeIds, setAIToolHighlightedNodeIds] = useState<Set<string>>(new Set());
const [blastRadiusNodeIds, setBlastRadiusNodeIds] = useState<Set<string>>(new Set());
const [isAIHighlightsEnabled, setAIHighlightsEnabled] = useState(true);
const toggleAIHighlights = useCallback(() => {
@ -193,6 +196,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setAIToolHighlightedNodeIds(new Set());
}, []);
const clearBlastRadius = useCallback(() => {
setBlastRadiusNodeIds(new Set());
}, []);
const clearQueryHighlights = useCallback(() => {
setHighlightedNodeIds(new Set());
setQueryResult(null);
@ -812,6 +819,35 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setAIToolHighlightedNodeIds(new Set(rawIds));
}
}
// Parse blast radius marker from tool results
const blastMatch = tc.result.match(/\[BLAST_RADIUS:([^\]]+)\]/);
if (blastMatch) {
const rawIds = blastMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
if (rawIds.length > 0 && graph) {
const matchedIds = new Set<string>();
const graphNodeIds = graph.nodes.map(n => n.id);
for (const rawId of rawIds) {
if (graphNodeIds.includes(rawId)) {
matchedIds.add(rawId);
} else {
const found = graphNodeIds.find(gid =>
gid.endsWith(rawId) || gid.endsWith(':' + rawId)
);
if (found) {
matchedIds.add(found);
}
}
}
if (matchedIds.size > 0) {
setBlastRadiusNodeIds(matchedIds);
}
} else if (rawIds.length > 0) {
setBlastRadiusNodeIds(new Set(rawIds));
}
}
}
}
break;
@ -908,9 +944,11 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setHighlightedNodeIds,
aiCitationHighlightedNodeIds,
aiToolHighlightedNodeIds,
blastRadiusNodeIds,
isAIHighlightsEnabled,
toggleAIHighlights,
clearAIToolHighlights,
clearBlastRadius,
queryResult,
setQueryResult,
clearQueryHighlights,

View file

@ -53,6 +53,7 @@ interface UseSigmaOptions {
onNodeHover?: (nodeId: string | null) => void;
onStageClick?: () => void;
highlightedNodeIds?: Set<string>;
blastRadiusNodeIds?: Set<string>;
}
interface UseSigmaReturn {
@ -126,14 +127,16 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const layoutRef = useRef<FA2Layout | null>(null);
const selectedNodeRef = useRef<string | null>(null);
const highlightedRef = useRef<Set<string>>(new Set());
const blastRadiusRef = useRef<Set<string>>(new Set());
const layoutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
const [selectedNode, setSelectedNodeState] = useState<string | null>(null);
useEffect(() => {
highlightedRef.current = options.highlightedNodeIds || new Set();
blastRadiusRef.current = options.blastRadiusNodeIds || new Set();
sigmaRef.current?.refresh();
}, [options.highlightedNodeIds]);
}, [options.highlightedNodeIds, options.blastRadiusNodeIds]);
const setSelectedNode = useCallback((nodeId: string | null) => {
selectedNodeRef.current = nodeId;
@ -242,8 +245,32 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const currentSelected = selectedNodeRef.current;
const highlighted = highlightedRef.current;
const blastRadius = blastRadiusRef.current;
const hasHighlights = highlighted.size > 0;
const hasBlastRadius = blastRadius.size > 0;
const isQueryHighlighted = highlighted.has(node);
const isBlastRadiusNode = blastRadius.has(node);
// Blast radius takes priority (red highlighting)
if (hasBlastRadius && !currentSelected) {
if (isBlastRadiusNode) {
res.color = '#ef4444'; // Red for blast radius
res.size = (data.size || 8) * 1.8;
res.zIndex = 3;
res.highlighted = true;
} else if (isQueryHighlighted) {
// Regular cyan highlight for non-blast-radius nodes
res.color = '#06b6d4';
res.size = (data.size || 8) * 1.4;
res.zIndex = 2;
res.highlighted = true;
} else {
res.color = dimColor(data.color, 0.15);
res.size = (data.size || 8) * 0.4;
res.zIndex = 0;
}
return res;
}
if (hasHighlights && !currentSelected) {
if (isQueryHighlighted) {