feat: guard clause and guarded call detection

Detect early-return guard clauses (auth/permission checks) and conditional
function calls in route handlers. Enriches Function/Method nodes with
guardClauses metadata and CALLS edges with guard conditions.

- New guard-extractor.ts with tree-sitter AST parsing
- Supports TypeScript, JavaScript, Python, PHP
- Detects return-based and throw/raise-based guards
- Pipeline Phase 3.55 after route creation
- Schema: guardClauses STRING on Function/Method, guard STRING on relations
- CSV generator updated for guard serialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
marxo126 2026-03-28 11:15:36 +01:00 • committed by Test
parent 969b4623ca
commit ec9464224e
14 changed files with 752 additions and 8 deletions

View file

@ -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;
}

View file

@ -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"
}

View file

@ -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<string, any> = {
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<string>): 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;
}

View file

@ -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<GuardsOutput> = {
name: 'guards',
deps: ['crossFile'],
async execute(
ctx: PipelineContext,
_deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<GuardsOutput> {
// Build file → nodes map for Function/Method nodes
const fileGroups = new Map<string, { label: string; id: string; name: string }[]>();
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<string, unknown>).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<string, unknown>).guard = gc.guard;
guardedCallCount++;
}
}
}
}
if (isDev && (guardCount > 0 || guardedCallCount > 0)) {
console.log(
`🛡️ Guard detection: ${guardCount} guard clauses, ${guardedCallCount} guarded call edges`,
);
}
return { guardCount, guardedCallCount };
},
};

View file

@ -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';

View file

@ -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) {

View file

@ -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<NodeTableName, { csvPath: string; rows: number }>;
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<string, BufferedCSVWriter> = {
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(','),
);
}

View file

@ -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)) {

View file

@ -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
)`;
// ============================================================================

View file

@ -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);
}

View file

@ -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
});
});

View file

@ -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();
});
});

View file

@ -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');
});
});

9
package-lock.json generated
View file

@ -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"
}