mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-12 03:48:38 +00:00
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:
parent
969b4623ca
commit
ec9464224e
14 changed files with 752 additions and 8 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
8
gitnexus/package-lock.json
generated
8
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
283
gitnexus/src/core/ingestion/guard-extractor.ts
Normal file
283
gitnexus/src/core/ingestion/guard-extractor.ts
Normal 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;
|
||||
}
|
||||
132
gitnexus/src/core/ingestion/pipeline-phases/guards.ts
Normal file
132
gitnexus/src/core/ingestion/pipeline-phases/guards.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(','),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)`;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
27
gitnexus/test/fixtures/guard-clauses/route-handler.ts
vendored
Normal file
27
gitnexus/test/fixtures/guard-clauses/route-handler.ts
vendored
Normal 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);
|
||||
}
|
||||
37
gitnexus/test/integration/guard-detection.test.ts
Normal file
37
gitnexus/test/integration/guard-detection.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
158
gitnexus/test/unit/guard-extractor.test.ts
Normal file
158
gitnexus/test/unit/guard-extractor.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
gitnexus/test/unit/guard-schema.test.ts
Normal file
39
gitnexus/test/unit/guard-schema.test.ts
Normal 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
9
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue