diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 49762d145..64e64552a 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -89,6 +89,8 @@ export type NodeProperties = { responseKeys?: string[]; errorKeys?: string[]; middleware?: string[]; + // Guard clauses: early-return conditions before business logic + guardClauses?: { condition: string; returnStatus?: number; line: number }[]; // Extensible [key: string]: unknown; }; @@ -131,4 +133,6 @@ export interface GraphRelationship { confidence: number; reason: string; step?: number; + /** Condition under which this call is made (e.g., "grant.status === 'submitted'") */ + guard?: string; } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index f2605cb43..960453957 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -2975,6 +2975,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3423,6 +3424,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4324,6 +4326,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5012,6 +5015,7 @@ "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" @@ -5358,6 +5362,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5478,6 +5483,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -5556,6 +5562,7 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -5745,6 +5752,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/gitnexus/src/core/ingestion/guard-extractor.ts b/gitnexus/src/core/ingestion/guard-extractor.ts new file mode 100644 index 000000000..c8efe9d5b --- /dev/null +++ b/gitnexus/src/core/ingestion/guard-extractor.ts @@ -0,0 +1,283 @@ +/** + * Guard Clause & Guarded Call Extraction + * + * Extracts two things from function bodies: + * 1. Guard clauses: early-return if-statements with status codes (e.g., if (!session) return 401) + * 2. Guarded calls: function calls wrapped in if/switch conditions (e.g., if (status === 'x') doThing()) + * + * Uses tree-sitter AST for accurate extraction. Falls back gracefully for unsupported languages. + */ + +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import JavaScript from 'tree-sitter-javascript'; +import Python from 'tree-sitter-python'; +import PHP from 'tree-sitter-php'; + +export interface GuardClause { + /** The condition text (e.g., "!session", "!org") */ + condition: string; + /** HTTP status code if detectable (401, 403, 404, etc.) */ + returnStatus?: number; + /** Line number of the if-statement */ + line: number; + /** Enclosing function name */ + functionName?: string; + /** For throw/raise guards: the exception class name (e.g., "PermissionDenied", "UnauthorizedException") */ + throwType?: string; + /** Confidence: 1.0 = has status code or throw type, 0.7 = negation guard in route handler, 0.3 = other early return */ + confidence: number; +} + +export interface GuardedCall { + /** Name of the called function */ + calledName: string; + /** The condition text wrapping this call */ + guard: string; + /** Line number of the call */ + line: number; + /** Enclosing function name */ + functionName?: string; +} + +const LANGUAGE_MAP: Record = { + typescript: TypeScript.typescript, + javascript: JavaScript, + python: Python, + php: (PHP as any).php_only ?? PHP, +}; + +const STATUS_PATTERN = /status[:\s]*(\d{3})/; + +/** Tree-sitter node types that represent throw/raise exits across languages */ +const THROW_TYPES = new Set([ + 'throw_statement', // JS/TS, Java, C#, Kotlin + 'throw_expression', // PHP, Kotlin + 'raise_statement', // Python +]); + +/** + * Extract the exception class name from a throw/raise node. + * e.g., `throw new UnauthorizedException()` → "UnauthorizedException" + * `raise PermissionDenied()` → "PermissionDenied" + */ +function extractThrowType(node: Parser.SyntaxNode): string | undefined { + const text = node.text; + // Match: throw new ClassName(...) or raise ClassName(...) + const match = text.match(/(?:throw\s+new\s+|throw\s+|raise\s+)(\w+)/); + return match?.[1]; +} + +/** + * Extract both guard clauses and guarded calls in a single parse pass. + * Parses the file once and walks function bodies once for both extractions. + */ +export function extractGuards(content: string, language: string): { clauses: GuardClause[]; calls: GuardedCall[] } { + const lang = LANGUAGE_MAP[language]; + if (!lang) return { clauses: [], calls: [] }; + + const parser = new Parser(); + parser.setLanguage(lang); + const tree = parser.parse(content); + + const clauses: GuardClause[] = []; + const calls: GuardedCall[] = []; + + walkFunctionBodies(tree.rootNode, language, (bodyNode, funcName) => { + // Resolve the actual statement list — if the body is a single try-catch, + // look inside the try block for guards (common Next.js route handler pattern) + let stmtSource = bodyNode; + if (bodyNode.namedChildren.length <= 2) { + const tryStmt = bodyNode.namedChildren.find((c: any) => c.type === 'try_statement'); + if (tryStmt) { + const tryBody = tryStmt.childForFieldName('body'); + if (tryBody) stmtSource = tryBody; + } + } + + for (const child of stmtSource.namedChildren) { + if (child.type !== 'if_statement') { + // Allow variable declarations between guards (e.g., const session = await getSession()) + // Python uses 'expression_statement' for assignments and 'assignment' for var = ... + if (child.type === 'lexical_declaration' || child.type === 'variable_declaration' || + child.type === 'expression_statement' || child.type === 'assignment') { + continue; + } + // Any other statement type means we've passed the guard region + break; + } + + const condNode = child.childForFieldName('condition'); + const consBlock = child.childForFieldName('consequence'); + if (!condNode || !consBlock) continue; + + const condText = condNode.text.replace(/^\(/, '').replace(/\)$/, '').trim(); + + // Check if the consequent block contains a return statement or throw/raise (guard clause) + const hasReturn = findDescendantOfType(consBlock, 'return_statement'); + const hasThrow = findDescendantOfAnyType(consBlock, THROW_TYPES); + if (hasReturn || hasThrow) { + let returnStatus: number | undefined; + let throwType: string | undefined; + if (hasReturn) { + const statusMatch = hasReturn.text.match(STATUS_PATTERN); + if (statusMatch) { + returnStatus = parseInt(statusMatch[1], 10); + } + } + if (hasThrow) { + throwType = extractThrowType(hasThrow); + // Also try to extract status from throw text (e.g., throw new HttpException(..., 401)) + if (!returnStatus) { + const statusMatch = hasThrow.text.match(STATUS_PATTERN); + if (statusMatch) { + returnStatus = parseInt(statusMatch[1], 10); + } + } + } + // Compute confidence based on evidence strength + let confidence = 0.3; // default: early return without clear signal + if (returnStatus || throwType) { + confidence = 1.0; // has HTTP status code or exception type — definitely a guard + } else if (condText.startsWith('!') || condText.startsWith('not ')) { + confidence = 0.7; // negation pattern (e.g., !session, !org) — likely a guard + } + + clauses.push({ + condition: condText, + returnStatus, + confidence, + line: child.startPosition.row + 1, + functionName: funcName, + ...(throwType && { throwType }), + }); + } else { + // Not a guard clause — stop scanning for guards but still check guarded calls below + break; + } + } + + // Second pass for guarded calls — scans all if-statements (not just early-return guards) + for (const child of stmtSource.namedChildren) { + if (child.type !== 'if_statement') continue; + const condNode = child.childForFieldName('condition'); + const consBlock = child.childForFieldName('consequence'); + if (!condNode || !consBlock) continue; + + const condText = condNode.text.replace(/^\(/, '').replace(/\)$/, '').trim(); + + const callNodes = findAllDescendantsOfType(consBlock, 'call_expression'); + for (const call of callNodes) { + const funcNode = call.childForFieldName('function'); + if (!funcNode) continue; + const calledName = funcNode.type === 'member_expression' + ? funcNode.childForFieldName('property')?.text ?? funcNode.text + : funcNode.text; + + // Skip common non-interesting calls (console.log, NextResponse.json, etc.) + if (calledName === 'json' || calledName === 'log' || calledName === 'error') continue; + + calls.push({ + calledName, + guard: condText, + line: call.startPosition.row + 1, + functionName: funcName, + }); + } + } + }); + + return { clauses, calls }; +} + +/** + * Extract guard clauses (early-return if-statements) from file content. + * Convenience wrapper around extractGuards for callers that only need clauses. + */ +export function extractGuardClauses(content: string, language: string): GuardClause[] { + return extractGuards(content, language).clauses; +} + +/** + * Extract guarded calls — function calls that are wrapped in if/switch conditions. + * Convenience wrapper around extractGuards for callers that only need calls. + */ +export function extractGuardedCalls(content: string, language: string): GuardedCall[] { + return extractGuards(content, language).calls; +} + +// ── Helpers ── + +function walkFunctionBodies( + node: Parser.SyntaxNode, + language: string, + callback: (body: Parser.SyntaxNode, funcName?: string) => void, +): void { + const funcTypes = new Set([ + 'function_declaration', 'method_definition', 'arrow_function', + 'function_definition', 'method_declaration', + ]); + + // Python uses 'block' instead of 'statement_block' + const bodyTypes = language === 'python' + ? new Set(['block', 'statement_block']) + : new Set(['statement_block']); + + function walk(n: Parser.SyntaxNode): void { + if (funcTypes.has(n.type)) { + // Skip arrow functions that are callback arguments (e.g., .sort((a,b) => ...), + // .reduce((acc, x) => ...), .then((res) => ...)). These are not route handlers + // and their early-returns are not HTTP guard clauses. + if (n.type === 'arrow_function' && n.parent) { + const parentType = n.parent.type; + // If the arrow is inside an arguments list or is a direct argument to a call, skip it + if (parentType === 'arguments' || parentType === 'call_expression' || + parentType === 'member_expression') { + // Still walk children to find nested named functions + for (const child of n.namedChildren) walk(child); + return; + } + } + + const body = n.childForFieldName('body'); + const nameNode = n.childForFieldName('name'); + const funcName = nameNode?.text; + if (body && bodyTypes.has(body.type)) { + callback(body, funcName); + } + } + for (const child of n.namedChildren) { + walk(child); + } + } + + walk(node); +} + +function findDescendantOfType(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | null { + if (node.type === type) return node; + for (const child of node.namedChildren) { + const found = findDescendantOfType(child, type); + if (found) return found; + } + return null; +} + +function findDescendantOfAnyType(node: Parser.SyntaxNode, types: Set): Parser.SyntaxNode | null { + if (types.has(node.type)) return node; + for (const child of node.namedChildren) { + const found = findDescendantOfAnyType(child, types); + if (found) return found; + } + return null; +} + +function findAllDescendantsOfType(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode[] { + const results: Parser.SyntaxNode[] = []; + function walk(n: Parser.SyntaxNode) { + if (n.type === type) results.push(n); + for (const child of n.namedChildren) walk(child); + } + walk(node); + return results; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/guards.ts b/gitnexus/src/core/ingestion/pipeline-phases/guards.ts new file mode 100644 index 000000000..c13ec7895 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/guards.ts @@ -0,0 +1,132 @@ +/** + * Phase: guards + * + * Enriches Function/Method nodes with guard clause metadata and annotates + * CALLS edges with guard conditions (guarded calls). + * + * Runs after crossFile so all Function/Method nodes and CALLS edges are present. + * + * @deps crossFile + * @reads graph (Function/Method nodes, CALLS edges) + * @writes graph (node.properties.guardClauses, rel.guard) + * @output guardCount, guardedCallCount + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getLanguageFromFilename } from 'gitnexus-shared'; +import type { GraphRelationship } from 'gitnexus-shared'; +import { extractGuards } from '../guard-extractor.js'; +import { readFileContents } from '../filesystem-walker.js'; +import { isDev } from '../utils/env.js'; + +export interface GuardsOutput { + guardCount: number; + guardedCallCount: number; +} + +export const guardsPhase: PipelinePhase = { + name: 'guards', + deps: ['crossFile'], + + async execute( + ctx: PipelineContext, + _deps: ReadonlyMap>, + ): Promise { + // Build file → nodes map for Function/Method nodes + const fileGroups = new Map(); + ctx.graph.forEachNode((n) => { + if (n.label !== 'Function' && n.label !== 'Method') return; + const fp = n.properties.filePath as string | undefined; + if (!fp) return; + let group = fileGroups.get(fp); + if (!group) { + group = []; + fileGroups.set(fp, group); + } + group.push({ label: n.label, id: n.id, name: n.properties.name as string }); + }); + + if (fileGroups.size === 0) { + return { guardCount: 0, guardedCallCount: 0 }; + } + + // Pre-index CALLS edges by source file path for O(1) lookup per file + const callsBySourceFile = new Map< + string, + { rel: GraphRelationship; targetName: string; sourceName: string }[] + >(); + ctx.graph.forEachRelationship((rel) => { + if (rel.type !== 'CALLS') return; + const sourceNode = ctx.graph.getNode(rel.sourceId); + const targetNode = ctx.graph.getNode(rel.targetId); + if (!sourceNode || !targetNode) return; + const fp = sourceNode.properties.filePath as string | undefined; + if (!fp) return; + let bucket = callsBySourceFile.get(fp); + if (!bucket) { + bucket = []; + callsBySourceFile.set(fp, bucket); + } + bucket.push({ + rel, + targetName: targetNode.properties.name as string, + sourceName: sourceNode.properties.name as string, + }); + }); + + const filePaths = [...fileGroups.keys()]; + const fileContents = await readFileContents(ctx.repoPath, filePaths); + let guardCount = 0; + let guardedCallCount = 0; + + for (const [filePath, content] of fileContents) { + const language = getLanguageFromFilename(filePath); + if (!language) continue; + + // Single parse pass extracts both guard clauses and guarded calls + const { clauses, calls: guardedCalls } = extractGuards(content, language); + + if (clauses.length > 0) { + const nodes = fileGroups.get(filePath) ?? []; + for (const nodeRef of nodes) { + const nodeGuards = clauses.filter( + (g) => g.functionName === nodeRef.name && g.confidence >= 0.5, + ); + if (nodeGuards.length > 0) { + const node = ctx.graph.getNode(nodeRef.id); + if (node) { + (node.properties as Record).guardClauses = nodeGuards.map((g) => ({ + condition: g.condition, + returnStatus: g.returnStatus, + confidence: g.confidence, + line: g.line, + })); + guardCount += nodeGuards.length; + } + } + } + } + + // Match guarded calls to pre-indexed CALLS edges for this file + const fileCalls = callsBySourceFile.get(filePath); + if (fileCalls && guardedCalls.length > 0) { + for (const gc of guardedCalls) { + for (const { rel, targetName, sourceName } of fileCalls) { + if (targetName !== gc.calledName) continue; + if (gc.functionName && sourceName !== gc.functionName) continue; + (rel as Record).guard = gc.guard; + guardedCallCount++; + } + } + } + } + + if (isDev && (guardCount > 0 || guardedCallCount > 0)) { + console.log( + `🛡️ Guard detection: ${guardCount} guard clauses, ${guardedCallCount} guarded call edges`, + ); + } + + return { guardCount, guardedCallCount }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index c05264de1..63ddfe9af 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -16,6 +16,7 @@ export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js'; export { toolsPhase, type ToolsOutput, type ToolDef } from './tools.js'; export { ormPhase, type ORMOutput } from './orm.js'; export { crossFilePhase, type CrossFileOutput } from './cross-file.js'; +export { guardsPhase, type GuardsOutput } from './guards.js'; export { mroPhase, type MROOutput } from './mro.js'; export { communitiesPhase, type CommunitiesOutput } from './communities.js'; export { processesPhase, type ProcessesOutput } from './processes.js'; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e1f226289..72e326db1 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -30,6 +30,7 @@ import { toolsPhase, ormPhase, crossFilePhase, + guardsPhase, mroPhase, communitiesPhase, processesPhase, @@ -64,7 +65,7 @@ export interface PipelineOptions { * Phase dependency graph: * * scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - * → crossFile → mro → communities → processes + * → crossFile → guards → mro → communities → processes * * To add a new phase: create a file in pipeline-phases/, export the phase * object, and add it to the appropriate position in this array. @@ -80,6 +81,7 @@ function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { toolsPhase, ormPhase, crossFilePhase, + guardsPhase, ]; if (!options?.skipGraphPhases) { diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 616a00cac..448c50850 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -199,6 +199,19 @@ class BufferedCSVWriter { // STREAMING CSV GENERATION — SINGLE PASS // ============================================================================ +/** + * Serialize guard clauses array to a string for CSV storage. + * Format: JSON array of "condition:status" strings. + */ +const serializeGuardClauses = (guards: { condition: string; returnStatus?: number; line: number }[]): string => { + if (!guards || guards.length === 0) return ''; + return JSON.stringify(guards.map(g => ({ + condition: g.condition, + ...(g.returnStatus ? { status: g.returnStatus } : {}), + line: g.line, + }))); +}; + export interface StreamedCSVResult { nodeFiles: Map; relCsvPath: string; @@ -235,9 +248,11 @@ export const streamAllCSVsToDisk = async ( ); const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath'); const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description'; + const functionHeader = + 'id,name,filePath,startLine,endLine,isExported,content,description,guardClauses'; const functionWriter = new BufferedCSVWriter( path.join(csvDir, 'function.csv'), - codeElementHeader, + functionHeader, ); const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader); const interfaceWriter = new BufferedCSVWriter( @@ -245,7 +260,7 @@ export const streamAllCSVsToDisk = async ( codeElementHeader, ); const methodHeader = - 'id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType'; + 'id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType,guardClauses'; const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), methodHeader); const codeElemWriter = new BufferedCSVWriter( path.join(csvDir, 'codeelement.csv'), @@ -309,8 +324,8 @@ export const streamAllCSVsToDisk = async ( ); } + // Function and Method have dedicated cases; only Class/Interface/CodeElement use the generic writer const codeWriterMap: Record = { - Function: functionWriter, Class: classWriter, Interface: interfaceWriter, CodeElement: codeElemWriter, @@ -382,8 +397,29 @@ export const streamAllCSVsToDisk = async ( ); break; } + case 'Function': { + const content = await extractContent(node, contentCache); + const funcGuardClauses = node.properties.guardClauses || []; + const funcGuardStr = serializeGuardClauses(funcGuardClauses); + await functionWriter.addRow( + [ + escapeCSVField(node.id), + escapeCSVField(node.properties.name || ''), + escapeCSVField(node.properties.filePath || ''), + escapeCSVNumber(node.properties.startLine, -1), + escapeCSVNumber(node.properties.endLine, -1), + node.properties.isExported ? 'true' : 'false', + escapeCSVField(content), + escapeCSVField(node.properties.description || ''), + escapeCSVField(funcGuardStr), + ].join(','), + ); + break; + } case 'Method': { const content = await extractContent(node, contentCache); + const methodGuardClauses = node.properties.guardClauses || []; + const methodGuardStr = serializeGuardClauses(methodGuardClauses); await methodWriter.addRow( [ escapeCSVField(node.id), @@ -396,6 +432,7 @@ export const streamAllCSVsToDisk = async ( escapeCSVField(node.properties.description || ''), escapeCSVNumber(node.properties.parameterCount, 0), escapeCSVField(node.properties.returnType || ''), + escapeCSVField(methodGuardStr), ].join(','), ); break; @@ -448,7 +485,7 @@ export const streamAllCSVsToDisk = async ( ); break; default: { - // Code element nodes (Function, Class, Interface, CodeElement) + // Code element nodes (Class, Interface, CodeElement) const writer = codeWriterMap[node.label]; if (writer) { const content = await extractContent(node, contentCache); @@ -507,7 +544,7 @@ export const streamAllCSVsToDisk = async ( // --- Stream relationship CSV --- const relCsvPath = path.join(csvDir, 'relations.csv'); - const relWriter = new BufferedCSVWriter(relCsvPath, 'from,to,type,confidence,reason,step'); + const relWriter = new BufferedCSVWriter(relCsvPath, 'from,to,type,confidence,reason,step,guard'); for (const rel of graph.iterRelationships()) { await relWriter.addRow( [ @@ -517,6 +554,7 @@ export const streamAllCSVsToDisk = async ( escapeCSVNumber(rel.confidence, 1.0), escapeCSVField(rel.reason), escapeCSVNumber((rel as any).step, 0), + escapeCSVField(rel.guard || ''), ].join(','), ); } diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index f019960c5..d09d3c748 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -587,8 +587,11 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => { if (table === 'Tool') { return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Function') { + return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, guardClauses) 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}`; + return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType, guardClauses) FROM "${filePath}" ${COPY_CSV_OPTS}`; } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 5579036f1..bfa40a2f7 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -46,6 +46,7 @@ CREATE NODE TABLE Function ( isExported BOOLEAN, content STRING, description STRING, + guardClauses STRING, PRIMARY KEY (id) )`; @@ -87,6 +88,7 @@ CREATE NODE TABLE Method ( description STRING, parameterCount INT32, returnType STRING, + guardClauses STRING, PRIMARY KEY (id) )`; @@ -423,7 +425,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( type STRING, confidence DOUBLE, reason STRING, - step INT32 + step INT32, + guard STRING )`; // ============================================================================ diff --git a/gitnexus/test/fixtures/guard-clauses/route-handler.ts b/gitnexus/test/fixtures/guard-clauses/route-handler.ts new file mode 100644 index 000000000..216177096 --- /dev/null +++ b/gitnexus/test/fixtures/guard-clauses/route-handler.ts @@ -0,0 +1,27 @@ +// Next.js route handler with typical guard clause patterns +import { NextRequest, NextResponse } from 'next/server'; + +async function handlePOST(request: NextRequest) { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const org = await getOrg(slug); + if (!org) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + if (!hasPermission(session.role, 'grants.create')) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Business logic - only reached after all guards pass + const result = await createGrant(data); + + if (result.requiresApproval) { + initializeApprovalWorkflow(result.id); + } + + return NextResponse.json(result); +} diff --git a/gitnexus/test/integration/guard-detection.test.ts b/gitnexus/test/integration/guard-detection.test.ts new file mode 100644 index 000000000..52c3c8186 --- /dev/null +++ b/gitnexus/test/integration/guard-detection.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { extractGuardClauses, extractGuardedCalls } from '../../src/core/ingestion/guard-extractor.js'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const fixture = readFileSync( + join(__dirname, '../fixtures/guard-clauses/route-handler.ts'), + 'utf-8' +); + +describe('guard detection integration', () => { + it('detects all guard clauses in a Next.js route handler', () => { + const guards = extractGuardClauses(fixture, 'typescript'); + expect(guards.length).toBeGreaterThanOrEqual(3); + + const statuses = guards.map(g => g.returnStatus).filter(Boolean); + expect(statuses).toContain(401); + expect(statuses).toContain(403); + expect(statuses).toContain(404); + }); + + it('detects guarded calls (conditional business logic)', () => { + const guarded = extractGuardedCalls(fixture, 'typescript'); + const approvalCall = guarded.find(g => g.calledName === 'initializeApprovalWorkflow'); + expect(approvalCall).toBeDefined(); + expect(approvalCall!.guard).toContain('result.requiresApproval'); + }); + + it('does not flag unconditional calls as guarded', () => { + const guarded = extractGuardedCalls(fixture, 'typescript'); + const createCall = guarded.find(g => g.calledName === 'createGrant'); + expect(createCall).toBeUndefined(); // createGrant is unconditional + }); +}); diff --git a/gitnexus/test/unit/guard-extractor.test.ts b/gitnexus/test/unit/guard-extractor.test.ts new file mode 100644 index 000000000..9333a8314 --- /dev/null +++ b/gitnexus/test/unit/guard-extractor.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { extractGuardClauses, extractGuardedCalls, type GuardClause, type GuardedCall } from '../../src/core/ingestion/guard-extractor.js'; + +describe('extractGuardClauses', () => { + it('extracts early-return guard clauses from a route handler', () => { + const content = ` +async function handlePOST(request: NextRequest) { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!org) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + // business logic + const result = await createGrant(data); + return NextResponse.json(result); +}`; + const guards = extractGuardClauses(content, 'typescript'); + expect(guards).toHaveLength(2); + expect(guards[0]).toMatchObject({ + condition: '!session', + returnStatus: 401, + line: expect.any(Number), + }); + expect(guards[1]).toMatchObject({ + condition: '!org', + returnStatus: 404, + line: expect.any(Number), + }); + }); + + it('returns empty array for functions without guards', () => { + const content = `function add(a: number, b: number) { return a + b; }`; + const guards = extractGuardClauses(content, 'typescript'); + expect(guards).toEqual([]); + }); + + it('detects throw-based guard clauses in TypeScript/JavaScript', () => { + const content = ` +function validateUser(user: User) { + if (!user) { + throw new UnauthorizedException("User not found"); + } + if (!user.isActive) { + throw new ForbiddenException("Account disabled"); + } + return user; +}`; + const guards = extractGuardClauses(content, 'typescript'); + expect(guards).toHaveLength(2); + expect(guards[0]).toMatchObject({ + condition: '!user', + throwType: 'UnauthorizedException', + line: expect.any(Number), + }); + expect(guards[1]).toMatchObject({ + condition: '!user.isActive', + throwType: 'ForbiddenException', + line: expect.any(Number), + }); + }); + + it('detects mixed return and throw guard clauses', () => { + const content = ` +function handleRequest(req: Request) { + if (!req.auth) { + throw new UnauthorizedException(); + } + if (!req.body) { + return new Response(null, { status: 400 }); + } + return processRequest(req); +}`; + const guards = extractGuardClauses(content, 'typescript'); + expect(guards).toHaveLength(2); + expect(guards[0]).toMatchObject({ + condition: '!req.auth', + throwType: 'UnauthorizedException', + }); + expect(guards[1]).toMatchObject({ + condition: '!req.body', + returnStatus: 400, + }); + // First guard should NOT have returnStatus, second should NOT have throwType + expect(guards[0].returnStatus).toBeUndefined(); + expect(guards[1].throwType).toBeUndefined(); + }); + + it('detects Python raise-based guard clauses', () => { + const content = ` +def handle(request): + if not request.user.is_authenticated: + raise PermissionDenied() + if not request.data: + raise ValidationError("No data") + return process(request.data) +`; + const guards = extractGuardClauses(content, 'python'); + expect(guards).toHaveLength(2); + expect(guards[0]).toMatchObject({ + condition: 'not request.user.is_authenticated', + throwType: 'PermissionDenied', + line: expect.any(Number), + }); + expect(guards[1]).toMatchObject({ + condition: 'not request.data', + throwType: 'ValidationError', + line: expect.any(Number), + }); + }); + + it('extracts throwType for Java-style throw new', () => { + const content = ` +function checkPermissions(user) { + if (user === null) { + throw new NullPointerException("user is null"); + } + if (!user.hasRole("admin")) { + throw new AccessDeniedException("admin required"); + } +}`; + const guards = extractGuardClauses(content, 'javascript'); + expect(guards).toHaveLength(2); + expect(guards[0].throwType).toBe('NullPointerException'); + expect(guards[1].throwType).toBe('AccessDeniedException'); + }); +}); + +describe('extractGuardedCalls', () => { + it('extracts the condition wrapping a function call', () => { + const content = ` +function processGrant(grant: Grant) { + if (grant.status === 'submitted') { + approveGrant(grant.id); + } + if (user.role === 'admin') { + deleteGrant(grant.id); + } + alwaysCalled(); +}`; + const guarded = extractGuardedCalls(content, 'typescript'); + expect(guarded).toContainEqual( + expect.objectContaining({ + calledName: 'approveGrant', + guard: "grant.status === 'submitted'", + }) + ); + expect(guarded).toContainEqual( + expect.objectContaining({ + calledName: 'deleteGrant', + guard: "user.role === 'admin'", + }) + ); + // alwaysCalled has no guard + expect(guarded.find(g => g.calledName === 'alwaysCalled')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/guard-schema.test.ts b/gitnexus/test/unit/guard-schema.test.ts new file mode 100644 index 000000000..3a02a82ad --- /dev/null +++ b/gitnexus/test/unit/guard-schema.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { NODE_TABLES, RELATION_SCHEMA, FUNCTION_SCHEMA, METHOD_SCHEMA } from '../../src/core/lbug/schema.js'; +import type { NodeProperties, GraphRelationship } from '../../src/core/graph/types.js'; + +describe('guard clause schema', () => { + it('NodeProperties accepts guardClauses field', () => { + const props: NodeProperties = { + name: 'handlePOST', + filePath: 'route.ts', + guardClauses: [{ condition: '!session', returnStatus: 401, line: 5 }], + }; + expect(props.guardClauses).toHaveLength(1); + }); + + it('GraphRelationship accepts guard field', () => { + const rel: GraphRelationship = { + id: 'test', + sourceId: 'a', + targetId: 'b', + type: 'CALLS', + confidence: 1.0, + reason: 'guarded-call', + guard: "grant.status === 'submitted'", + }; + expect(rel.guard).toBe("grant.status === 'submitted'"); + }); + + it('RELATION_SCHEMA includes guard column', () => { + expect(RELATION_SCHEMA).toContain('guard STRING'); + }); + + it('FUNCTION_SCHEMA includes guardClauses column', () => { + expect(FUNCTION_SCHEMA).toContain('guardClauses STRING'); + }); + + it('METHOD_SCHEMA includes guardClauses column', () => { + expect(METHOD_SCHEMA).toContain('guardClauses STRING'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 65c0870ed..2ff9dd273 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -603,6 +604,7 @@ "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", @@ -632,6 +634,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -836,6 +839,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -988,6 +992,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1226,6 +1231,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2357,6 +2363,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2696,6 +2703,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2884,6 +2892,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }