mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
feat: workflow/state machine detection — StatusType nodes, TRANSITIONS edges
Detect status type definitions (union types, enums) and status transition patterns (Prisma .update, direct assignments, setters) across TS/JS/Python/Java. Creates StatusType nodes with statusValues/statusKind properties and TRANSITIONS edges with fromStatus/toStatus/entityType/isTransactional metadata. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
969b4623ca
commit
83f79729b2
11 changed files with 1218 additions and 332 deletions
|
|
@ -44,7 +44,8 @@ export type NodeLabel =
|
|||
| 'Template'
|
||||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool';
|
||||
| 'Tool'
|
||||
| 'StatusType';
|
||||
|
||||
export type NodeProperties = {
|
||||
name: string;
|
||||
|
|
@ -115,7 +116,8 @@ export type RelationshipType =
|
|||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES';
|
||||
| 'QUERIES'
|
||||
| 'TRANSITIONS';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
|
|
@ -131,4 +133,9 @@ export interface GraphRelationship {
|
|||
confidence: number;
|
||||
reason: string;
|
||||
step?: number;
|
||||
/** TRANSITIONS-specific: from/to status values and entity context */
|
||||
fromStatus?: string;
|
||||
toStatus?: string;
|
||||
entityType?: string;
|
||||
isTransactional?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ export const NODE_TABLES = [
|
|||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
'StatusType',
|
||||
] as const;
|
||||
|
||||
export type NodeTableName = (typeof NODE_TABLES)[number];
|
||||
|
||||
export const REL_TABLE_NAME = 'CodeRelation';
|
||||
export const REL_TABLE_NAME = "CodeRelation";
|
||||
|
||||
export const REL_TYPES = [
|
||||
'CONTAINS',
|
||||
|
|
@ -67,8 +68,9 @@ export const REL_TYPES = [
|
|||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
'TRANSITIONS',
|
||||
] as const;
|
||||
|
||||
export type RelType = (typeof REL_TYPES)[number];
|
||||
|
||||
export const EMBEDDING_TABLE_NAME = 'CodeEmbedding';
|
||||
export const EMBEDDING_TABLE_NAME = "CodeEmbedding";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export { parsePhase, type ParseOutput } from './parse.js';
|
|||
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 { workflowsPhase, type WorkflowsOutput } from './workflows.js';
|
||||
export { crossFilePhase, type CrossFileOutput } from './cross-file.js';
|
||||
export { mroPhase, type MROOutput } from './mro.js';
|
||||
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
|
||||
|
|
|
|||
162
gitnexus/src/core/ingestion/pipeline-phases/workflows.ts
Normal file
162
gitnexus/src/core/ingestion/pipeline-phases/workflows.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Phase: workflows
|
||||
*
|
||||
* Detects workflow / state machine status types and transitions.
|
||||
* Creates StatusType nodes and TRANSITIONS edges.
|
||||
*
|
||||
* @deps parse
|
||||
* @reads allPaths (from parse)
|
||||
* @writes graph (StatusType nodes, DEFINES + TRANSITIONS edges)
|
||||
* @output statusTypeCount, transitionCount
|
||||
*/
|
||||
|
||||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import {
|
||||
extractStatusTypes,
|
||||
extractStatusTransitions,
|
||||
type DetectedStatusType,
|
||||
} from '../workflow-detector.js';
|
||||
|
||||
export interface WorkflowsOutput {
|
||||
statusTypeCount: number;
|
||||
transitionCount: number;
|
||||
}
|
||||
|
||||
export const workflowsPhase: PipelinePhase<WorkflowsOutput> = {
|
||||
name: 'workflows',
|
||||
deps: ['parse'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
deps: ReadonlyMap<string, PhaseResult<unknown>>,
|
||||
): Promise<WorkflowsOutput> {
|
||||
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
|
||||
const tsCandidates = allPaths.filter(
|
||||
(p) =>
|
||||
(p.endsWith('.ts') || p.endsWith('.tsx')) &&
|
||||
!p.includes('node_modules') &&
|
||||
!p.includes('.test.') &&
|
||||
!p.includes('.spec.'),
|
||||
);
|
||||
|
||||
const tsContents = await readFileContents(ctx.repoPath, tsCandidates);
|
||||
const allStatusTypes: DetectedStatusType[] = [];
|
||||
|
||||
for (const [filePath, content] of tsContents) {
|
||||
const detected = extractStatusTypes(content, 'typescript', filePath);
|
||||
allStatusTypes.push(...detected);
|
||||
}
|
||||
|
||||
// Create StatusType nodes + lookup maps for transition matching
|
||||
const statusTypeNodeIds = new Map<string, string>();
|
||||
const valueToType = new Map<string, DetectedStatusType>();
|
||||
const entityToType = new Map<string, DetectedStatusType>();
|
||||
|
||||
for (const st of allStatusTypes) {
|
||||
const nodeId = generateId('StatusType', st.name);
|
||||
statusTypeNodeIds.set(st.name, nodeId);
|
||||
const entityName = st.name
|
||||
.replace(/Status$|State$|Phase$|Stage$/i, '')
|
||||
.toLowerCase();
|
||||
if (entityName) entityToType.set(entityName, st);
|
||||
for (const v of st.values) {
|
||||
if (!valueToType.has(v)) valueToType.set(v, st);
|
||||
}
|
||||
ctx.graph.addNode({
|
||||
id: nodeId,
|
||||
label: 'StatusType',
|
||||
properties: {
|
||||
name: st.name,
|
||||
filePath: st.filePath,
|
||||
statusValues: st.values,
|
||||
statusKind: st.kind,
|
||||
},
|
||||
});
|
||||
|
||||
const fileId = generateId('File', st.filePath);
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('DEFINES', `${fileId}->${nodeId}`),
|
||||
sourceId: fileId,
|
||||
targetId: nodeId,
|
||||
type: 'DEFINES',
|
||||
confidence: 1.0,
|
||||
reason: `status-type-${st.kind}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Scan TS files for status transitions (Prisma .update patterns)
|
||||
let transitionCount = 0;
|
||||
if (allStatusTypes.length > 0) {
|
||||
for (const [filePath, content] of tsContents) {
|
||||
const transitions = extractStatusTransitions(content, 'typescript', filePath);
|
||||
for (const t of transitions) {
|
||||
let matchingType: DetectedStatusType | undefined;
|
||||
if (t.entityType) {
|
||||
matchingType = entityToType.get(t.entityType.toLowerCase());
|
||||
}
|
||||
if (!matchingType) {
|
||||
matchingType =
|
||||
valueToType.get(t.toStatus) ??
|
||||
(t.fromStatus ? valueToType.get(t.fromStatus) : undefined);
|
||||
}
|
||||
if (!matchingType) continue;
|
||||
|
||||
const statusNodeId = statusTypeNodeIds.get(matchingType.name);
|
||||
if (!statusNodeId) continue;
|
||||
|
||||
let sourceId: string | undefined;
|
||||
if (t.functionName) {
|
||||
const funcNode =
|
||||
ctx.graph.getNode(
|
||||
generateId('Function', `${filePath}::${t.functionName}`),
|
||||
) ??
|
||||
ctx.graph.getNode(
|
||||
generateId('Method', `${filePath}::${t.functionName}`),
|
||||
);
|
||||
if (funcNode) sourceId = funcNode.id;
|
||||
}
|
||||
if (!sourceId) {
|
||||
sourceId = generateId('File', filePath);
|
||||
}
|
||||
|
||||
const fromPart = t.fromStatus || '*';
|
||||
const reasonStr = t.isTransactional
|
||||
? `transactional-update:${t.entityType || ''}:${fromPart}->${t.toStatus}`
|
||||
: `direct-update:${t.entityType || ''}:${fromPart}->${t.toStatus}`;
|
||||
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId(
|
||||
'TRANSITIONS',
|
||||
`${sourceId}->${statusNodeId}:${t.toStatus}`,
|
||||
),
|
||||
sourceId,
|
||||
targetId: statusNodeId,
|
||||
type: 'TRANSITIONS',
|
||||
confidence: 0.9,
|
||||
reason: reasonStr,
|
||||
fromStatus: t.fromStatus,
|
||||
toStatus: t.toStatus,
|
||||
entityType: t.entityType,
|
||||
isTransactional: t.isTransactional,
|
||||
});
|
||||
|
||||
transitionCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev && (allStatusTypes.length > 0 || transitionCount > 0)) {
|
||||
console.log(
|
||||
`Workflow detection: ${allStatusTypes.length} status types, ${transitionCount} transitions`,
|
||||
);
|
||||
}
|
||||
|
||||
return { statusTypeCount: allStatusTypes.length, transitionCount };
|
||||
},
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
routesPhase,
|
||||
toolsPhase,
|
||||
ormPhase,
|
||||
workflowsPhase,
|
||||
crossFilePhase,
|
||||
mroPhase,
|
||||
communitiesPhase,
|
||||
|
|
@ -79,6 +80,7 @@ function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
|
|||
routesPhase,
|
||||
toolsPhase,
|
||||
ormPhase,
|
||||
workflowsPhase,
|
||||
crossFilePhase,
|
||||
];
|
||||
|
||||
|
|
|
|||
333
gitnexus/src/core/ingestion/workflow-detector.ts
Normal file
333
gitnexus/src/core/ingestion/workflow-detector.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* Workflow / State Machine Detection
|
||||
*
|
||||
* Detects:
|
||||
* 1. Status type definitions (union types, enums with status-like values)
|
||||
* 2. Status transition patterns (ORM update calls, direct assignments, setters)
|
||||
*
|
||||
* Supports TypeScript, JavaScript, Python, and Java patterns.
|
||||
*/
|
||||
|
||||
export interface DetectedStatusType {
|
||||
/** Type name (e.g., 'GrantStatus', 'ApprovalInstanceStatus') */
|
||||
name: string;
|
||||
/** All possible status values */
|
||||
values: string[];
|
||||
/** Source file */
|
||||
filePath: string;
|
||||
/** Line number of the type definition */
|
||||
line: number;
|
||||
/** Whether it's a union type or enum */
|
||||
kind: "union" | "enum";
|
||||
}
|
||||
|
||||
export interface DetectedTransition {
|
||||
/** Status value being set (e.g., 'ACTIVE', 'approved') */
|
||||
toStatus: string;
|
||||
/** Previous status if detectable from surrounding if-condition */
|
||||
fromStatus?: string;
|
||||
/** Entity/model being updated (e.g., 'grant', 'approvalStepInstance') */
|
||||
entityType?: string;
|
||||
/** Enclosing function name */
|
||||
functionName?: string;
|
||||
/** Source file */
|
||||
filePath: string;
|
||||
/** Line number */
|
||||
line: number;
|
||||
/** Whether inside a $transaction block */
|
||||
isTransactional?: boolean;
|
||||
}
|
||||
|
||||
// Keywords that indicate a type is status/state-related
|
||||
const STATUS_NAME_PATTERNS =
|
||||
/status|state|phase|stage|step|workflow|lifecycle/i;
|
||||
|
||||
// ORM patterns: prisma.model.update({ data: { status: 'value' } })
|
||||
// Also matches: tx.model.update (transactional)
|
||||
const PRISMA_UPDATE_PATTERN =
|
||||
/(?:prisma|tx)\.(\w+)\.update\s*\(\s*\{[\s\S]*?data\s*:\s*\{[\s\S]*?status\s*:\s*['"](\w+)['"]/g;
|
||||
|
||||
// Direct assignment: x.status = 'value' / this.status = 'approved' / self.status = 'active'
|
||||
const STATUS_ASSIGNMENT_PATTERN = /(\w+)\.status\s*=\s*['"](\w+)['"]/g;
|
||||
|
||||
// Setter: setStatus('value') / set_status('value') / updateStatus('value') / changeStatus('value')
|
||||
const STATUS_SETTER_PATTERN =
|
||||
/(?:(\w+)\.)?(?:set_?[Ss]tatus|updateStatus|changeStatus)\s*\(\s*['"](\w+)['"]/g;
|
||||
|
||||
// Generic .update() with status field: obj.update({ status: 'value' })
|
||||
const GENERIC_UPDATE_PATTERN =
|
||||
/(\w+)\.update\s*\(\s*\{[\s\S]*?status\s*:\s*['"](\w+)['"]/g;
|
||||
|
||||
// Transaction wrapper detection
|
||||
const TRANSACTION_PATTERN = /\$transaction\s*\(/;
|
||||
|
||||
// Function enclosure detection (simplified — finds nearest function name above a line)
|
||||
|
||||
/**
|
||||
* Extract status type definitions from file content.
|
||||
* Detects: `type XStatus = 'a' | 'b' | 'c'` and `enum XStatus { A = 'a', B = 'b' }`
|
||||
*/
|
||||
export function extractStatusTypes(
|
||||
content: string,
|
||||
language: string,
|
||||
filePath: string,
|
||||
): DetectedStatusType[] {
|
||||
const supportedLanguages = ["typescript", "javascript", "python", "java"];
|
||||
if (!supportedLanguages.includes(language)) return [];
|
||||
|
||||
const results: DetectedStatusType[] = [];
|
||||
let match;
|
||||
|
||||
// ── TypeScript / JavaScript patterns ──
|
||||
|
||||
if (language === "typescript" || language === "javascript") {
|
||||
// Pattern 1: Union types — handles both single-line and multi-line:
|
||||
// type FooStatus = 'a' | 'b' | 'c';
|
||||
// type FooStatus =
|
||||
// | 'a'
|
||||
// | 'b';
|
||||
const unionPattern = /(?:export\s+)?type\s+(\w+)\s*=\s*([\s\S]*?);/g;
|
||||
while ((match = unionPattern.exec(content)) !== null) {
|
||||
const name = match[1];
|
||||
const valuesStr = match[2];
|
||||
const values = [...valuesStr.matchAll(/'([^']*)'/g)].map((m) => m[1]);
|
||||
// Skip if no string literal values (not a string union)
|
||||
if (values.length < 2) continue;
|
||||
|
||||
// Filter: must have status-related name OR status-like values
|
||||
if (!STATUS_NAME_PATTERNS.test(name) && !looksLikeStatusValues(values))
|
||||
continue;
|
||||
|
||||
const line = content.slice(0, match.index).split("\n").length;
|
||||
results.push({ name, values, filePath, line, kind: "union" });
|
||||
}
|
||||
|
||||
// Pattern 2: TS/JS Enums — enum FooStatus { A = 'a', B = 'b' }
|
||||
const enumPattern = /(?:export\s+)?enum\s+(\w+)\s*\{([^}]+)\}/g;
|
||||
while ((match = enumPattern.exec(content)) !== null) {
|
||||
const name = match[1];
|
||||
const body = match[2];
|
||||
|
||||
if (!STATUS_NAME_PATTERNS.test(name)) continue;
|
||||
|
||||
const values = [...body.matchAll(/=\s*['"]([^'"]+)['"]/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
if (values.length === 0) continue;
|
||||
|
||||
const line = content.slice(0, match.index).split("\n").length;
|
||||
results.push({ name, values, filePath, line, kind: "enum" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Python patterns ──
|
||||
// class OrderStatus(Enum):
|
||||
// PENDING = 'pending'
|
||||
// SHIPPED = 'shipped'
|
||||
|
||||
if (language === "python") {
|
||||
const pyEnumPattern =
|
||||
/class\s+(\w+)\s*\(\s*(?:str\s*,\s*)?Enum\s*\)\s*:([\s\S]*?)(?=\nclass\s|\n[a-zA-Z]|$)/g;
|
||||
while ((match = pyEnumPattern.exec(content)) !== null) {
|
||||
const name = match[1];
|
||||
const body = match[2];
|
||||
|
||||
if (!STATUS_NAME_PATTERNS.test(name) && !looksLikeStatusValues([])) {
|
||||
// Check if enum member names look like status values
|
||||
const memberNames = [...body.matchAll(/^\s+(\w+)\s*=/gm)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
if (
|
||||
!STATUS_NAME_PATTERNS.test(name) &&
|
||||
!looksLikeStatusValues(memberNames.map((n) => n.toLowerCase()))
|
||||
)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract string values: PENDING = 'pending'
|
||||
const stringValues = [
|
||||
...body.matchAll(/\w+\s*=\s*['"]([^'"]+)['"]/g),
|
||||
].map((m) => m[1]);
|
||||
// If no string values, use the constant names themselves (e.g., auto())
|
||||
const values =
|
||||
stringValues.length > 0
|
||||
? stringValues
|
||||
: [...body.matchAll(/^\s+(\w+)\s*=/gm)].map((m) => m[1]);
|
||||
|
||||
if (values.length === 0) continue;
|
||||
|
||||
const line = content.slice(0, match.index).split("\n").length;
|
||||
results.push({ name, values, filePath, line, kind: "enum" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Java patterns ──
|
||||
// enum Status { PENDING, ACTIVE, COMPLETED }
|
||||
|
||||
if (language === "java") {
|
||||
const javaEnumPattern = /(?:public\s+)?enum\s+(\w+)\s*\{([^}]+)\}/g;
|
||||
while ((match = javaEnumPattern.exec(content)) !== null) {
|
||||
const name = match[1];
|
||||
const body = match[2];
|
||||
|
||||
if (!STATUS_NAME_PATTERNS.test(name)) continue;
|
||||
|
||||
// Java enums: extract comma-separated constant names (before any '(' or ';')
|
||||
const constantSection = body.split(";")[0];
|
||||
const values = constantSection
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => /^\w+$/.test(v) && v.length > 0);
|
||||
|
||||
if (values.length === 0) continue;
|
||||
|
||||
const line = content.slice(0, match.index).split("\n").length;
|
||||
results.push({ name, values, filePath, line, kind: "enum" });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract status transitions from file content.
|
||||
* Detects ORM updates, direct assignments, setter calls, and generic .update() patterns.
|
||||
*/
|
||||
export function extractStatusTransitions(
|
||||
content: string,
|
||||
language: string,
|
||||
filePath: string,
|
||||
): DetectedTransition[] {
|
||||
const supportedLanguages = ["typescript", "javascript", "python", "java"];
|
||||
if (!supportedLanguages.includes(language)) return [];
|
||||
|
||||
const results: DetectedTransition[] = [];
|
||||
|
||||
// Helper to build a transition from a match
|
||||
const addTransition = (
|
||||
entityType: string,
|
||||
toStatus: string,
|
||||
matchIndex: number,
|
||||
) => {
|
||||
const line = content.slice(0, matchIndex).split("\n").length;
|
||||
const precedingContent = content.slice(
|
||||
Math.max(0, matchIndex - 500),
|
||||
matchIndex,
|
||||
);
|
||||
const isTransactional = TRANSACTION_PATTERN.test(precedingContent);
|
||||
const fromStatus = findPrecedingStatusCheck(content, matchIndex);
|
||||
const functionName = findEnclosingFunction(content, matchIndex);
|
||||
|
||||
results.push({
|
||||
toStatus,
|
||||
fromStatus: fromStatus || undefined,
|
||||
entityType,
|
||||
functionName: functionName || undefined,
|
||||
filePath,
|
||||
line,
|
||||
isTransactional: isTransactional || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Track matched status values by line to avoid duplicates (e.g., generic .update overlapping with Prisma)
|
||||
const matchedLines = new Set<string>();
|
||||
const addUnique = (
|
||||
entityType: string,
|
||||
toStatus: string,
|
||||
matchIndex: number,
|
||||
) => {
|
||||
const line = content.slice(0, matchIndex).split("\n").length;
|
||||
const key = `${line}:${toStatus}`;
|
||||
if (matchedLines.has(key)) return;
|
||||
matchedLines.add(key);
|
||||
addTransition(entityType, toStatus, matchIndex);
|
||||
};
|
||||
|
||||
let match;
|
||||
|
||||
// ── Prisma ORM pattern ──
|
||||
PRISMA_UPDATE_PATTERN.lastIndex = 0;
|
||||
while ((match = PRISMA_UPDATE_PATTERN.exec(content)) !== null) {
|
||||
addUnique(match[1], match[2], match.index);
|
||||
}
|
||||
|
||||
// ── Direct assignment: x.status = 'value' ──
|
||||
STATUS_ASSIGNMENT_PATTERN.lastIndex = 0;
|
||||
while ((match = STATUS_ASSIGNMENT_PATTERN.exec(content)) !== null) {
|
||||
addUnique(match[1], match[2], match.index);
|
||||
}
|
||||
|
||||
// ── Setter pattern: obj.setStatus('value') ──
|
||||
STATUS_SETTER_PATTERN.lastIndex = 0;
|
||||
while ((match = STATUS_SETTER_PATTERN.exec(content)) !== null) {
|
||||
const entityType = match[1] || "unknown";
|
||||
addUnique(entityType, match[2], match.index);
|
||||
}
|
||||
|
||||
// ── Generic .update({ status: 'value' }) — skip prisma/tx to avoid duplicates ──
|
||||
GENERIC_UPDATE_PATTERN.lastIndex = 0;
|
||||
while ((match = GENERIC_UPDATE_PATTERN.exec(content)) !== null) {
|
||||
// Skip if it's a prisma chained call (already caught above)
|
||||
const preceding = content.slice(Math.max(0, match.index - 50), match.index);
|
||||
if (/(?:prisma|tx)\.\w+\.$/.test(preceding)) continue;
|
||||
addUnique(match[1], match[2], match.index);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function looksLikeStatusValues(values: string[]): boolean {
|
||||
if (values.length < 2) return false;
|
||||
const statusKeywords =
|
||||
/^(draft|active|pending|approved|rejected|completed|cancelled|closed|on.?hold|in.?progress|not.?started|failed|running|paused|blocked|todo|done|shipped|delivered|processing|escalated|skipped)/i;
|
||||
const matchCount = values.filter((v) => statusKeywords.test(v)).length;
|
||||
return matchCount >= 2; // At least 2 values look like statuses
|
||||
}
|
||||
|
||||
function findPrecedingStatusCheck(
|
||||
content: string,
|
||||
beforeIndex: number,
|
||||
): string | null {
|
||||
// Look back up to 500 chars but don't cross function boundaries
|
||||
const windowStart = Math.max(0, beforeIndex - 500);
|
||||
let window = content.slice(windowStart, beforeIndex);
|
||||
|
||||
// Trim window at the last function declaration to stay within scope
|
||||
const funcBoundary =
|
||||
/(?:async\s+)?function\s+\w+|(?:const|let)\s+\w+\s*=\s*(?:async\s*)?\(/g;
|
||||
let lastBoundaryIndex = -1;
|
||||
let m;
|
||||
while ((m = funcBoundary.exec(window)) !== null) {
|
||||
lastBoundaryIndex = m.index;
|
||||
}
|
||||
if (lastBoundaryIndex > 0) {
|
||||
window = window.slice(lastBoundaryIndex);
|
||||
}
|
||||
|
||||
const checks = [
|
||||
...window.matchAll(
|
||||
/\.status\s*===?\s*['"](\w+)['"]|status\s*===?\s*['"](\w+)['"]/g,
|
||||
),
|
||||
];
|
||||
if (checks.length === 0) return null;
|
||||
// Return the last (closest) check
|
||||
const last = checks[checks.length - 1];
|
||||
return last[1] || last[2];
|
||||
}
|
||||
|
||||
function findEnclosingFunction(
|
||||
content: string,
|
||||
beforeIndex: number,
|
||||
): string | null {
|
||||
const window = content.slice(Math.max(0, beforeIndex - 2000), beforeIndex);
|
||||
const pattern =
|
||||
/(?:async\s+)?function\s+(\w+)|(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/g;
|
||||
let lastMatch: string | null = null;
|
||||
let match;
|
||||
while ((match = pattern.exec(window)) !== null) {
|
||||
lastMatch = match[1] || match[2];
|
||||
}
|
||||
return lastMatch;
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@
|
|||
* - All fields are consistently quoted for safety with code content
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { createWriteStream, WriteStream } from 'fs';
|
||||
import path from 'path';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { NodeTableName } from './schema.js';
|
||||
import fs from "fs/promises";
|
||||
import { createWriteStream, WriteStream } from "fs";
|
||||
import path from "path";
|
||||
import type { GraphNode } from "gitnexus-shared";
|
||||
import { KnowledgeGraph } from "../graph/types.js";
|
||||
import { NodeTableName } from "./schema.js";
|
||||
|
||||
/** Flush buffered rows to disk every N rows */
|
||||
const FLUSH_EVERY = 500;
|
||||
|
|
@ -28,14 +28,16 @@ const FLUSH_EVERY = 500;
|
|||
|
||||
export const sanitizeUTF8 = (str: string): string => {
|
||||
return str
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
||||
.replace(/[\uD800-\uDFFF]/g, '')
|
||||
.replace(/[\uFFFE\uFFFF]/g, '');
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
||||
.replace(/[\uD800-\uDFFF]/g, "")
|
||||
.replace(/[\uFFFE\uFFFF]/g, "");
|
||||
};
|
||||
|
||||
export const escapeCSVField = (value: string | number | undefined | null): string => {
|
||||
export const escapeCSVField = (
|
||||
value: string | number | undefined | null,
|
||||
): string => {
|
||||
if (value === undefined || value === null) return '""';
|
||||
let str = String(value);
|
||||
str = sanitizeUTF8(str);
|
||||
|
|
@ -82,7 +84,7 @@ class FileContentCache {
|
|||
}
|
||||
|
||||
async get(relativePath: string): Promise<string> {
|
||||
if (!relativePath) return '';
|
||||
if (!relativePath) return "";
|
||||
const cached = this.cache.get(relativePath);
|
||||
if (cached !== undefined) {
|
||||
// Move to end of accessOrder (LRU promotion)
|
||||
|
|
@ -95,12 +97,12 @@ class FileContentCache {
|
|||
}
|
||||
try {
|
||||
const fullPath = path.join(this.repoPath, relativePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
const content = await fs.readFile(fullPath, "utf-8");
|
||||
this.set(relativePath, content);
|
||||
return content;
|
||||
} catch {
|
||||
this.set(relativePath, '');
|
||||
return '';
|
||||
this.set(relativePath, "");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,31 +116,34 @@ class FileContentCache {
|
|||
}
|
||||
}
|
||||
|
||||
const extractContent = async (node: GraphNode, contentCache: FileContentCache): Promise<string> => {
|
||||
const extractContent = async (
|
||||
node: GraphNode,
|
||||
contentCache: FileContentCache,
|
||||
): Promise<string> => {
|
||||
const filePath = node.properties.filePath;
|
||||
const content = await contentCache.get(filePath);
|
||||
if (!content) return '';
|
||||
if (node.label === 'Folder') return '';
|
||||
if (isBinaryContent(content)) return '[Binary file - content not stored]';
|
||||
if (!content) return "";
|
||||
if (node.label === "Folder") return "";
|
||||
if (isBinaryContent(content)) return "[Binary file - content not stored]";
|
||||
|
||||
if (node.label === 'File') {
|
||||
if (node.label === "File") {
|
||||
const MAX_FILE_CONTENT = 10000;
|
||||
return content.length > MAX_FILE_CONTENT
|
||||
? content.slice(0, MAX_FILE_CONTENT) + '\n... [truncated]'
|
||||
? content.slice(0, MAX_FILE_CONTENT) + "\n... [truncated]"
|
||||
: content;
|
||||
}
|
||||
|
||||
const startLine = node.properties.startLine;
|
||||
const endLine = node.properties.endLine;
|
||||
if (startLine === undefined || endLine === undefined) return '';
|
||||
if (startLine === undefined || endLine === undefined) return "";
|
||||
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split("\n");
|
||||
const start = Math.max(0, startLine - 2);
|
||||
const end = Math.min(lines.length - 1, endLine + 2);
|
||||
const snippet = lines.slice(start, end + 1).join('\n');
|
||||
const snippet = lines.slice(start, end + 1).join("\n");
|
||||
const MAX_SNIPPET = 5000;
|
||||
return snippet.length > MAX_SNIPPET
|
||||
? snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]'
|
||||
? snippet.slice(0, MAX_SNIPPET) + "\n... [truncated]"
|
||||
: snippet;
|
||||
};
|
||||
|
||||
|
|
@ -152,7 +157,7 @@ class BufferedCSVWriter {
|
|||
rows = 0;
|
||||
|
||||
constructor(filePath: string, header: string) {
|
||||
this.ws = createWriteStream(filePath, 'utf-8');
|
||||
this.ws = createWriteStream(filePath, "utf-8");
|
||||
// Large repos flush many times — raise listener cap to avoid MaxListenersExceededWarning
|
||||
this.ws.setMaxListeners(50);
|
||||
this.buffer.push(header);
|
||||
|
|
@ -169,17 +174,17 @@ class BufferedCSVWriter {
|
|||
|
||||
flush(): Promise<void> {
|
||||
if (this.buffer.length === 0) return Promise.resolve();
|
||||
const chunk = this.buffer.join('\n') + '\n';
|
||||
const chunk = this.buffer.join("\n") + "\n";
|
||||
this.buffer.length = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws.once('error', reject);
|
||||
this.ws.once("error", reject);
|
||||
const ok = this.ws.write(chunk);
|
||||
if (ok) {
|
||||
this.ws.removeListener('error', reject);
|
||||
this.ws.removeListener("error", reject);
|
||||
resolve();
|
||||
} else {
|
||||
this.ws.once('drain', () => {
|
||||
this.ws.removeListener('error', reject);
|
||||
this.ws.once("drain", () => {
|
||||
this.ws.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
|
@ -190,7 +195,7 @@ class BufferedCSVWriter {
|
|||
await this.flush();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws.end(() => resolve());
|
||||
this.ws.on('error', reject);
|
||||
this.ws.on("error", reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -230,56 +235,73 @@ export const streamAllCSVsToDisk = async (
|
|||
|
||||
// Create writers for every node type up-front
|
||||
const fileWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'file.csv'),
|
||||
'id,name,filePath,content',
|
||||
path.join(csvDir, "file.csv"),
|
||||
"id,name,filePath,content",
|
||||
);
|
||||
const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath');
|
||||
const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description';
|
||||
const folderWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, "folder.csv"),
|
||||
"id,name,filePath",
|
||||
);
|
||||
const codeElementHeader =
|
||||
"id,name,filePath,startLine,endLine,isExported,content,description";
|
||||
const functionWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'function.csv'),
|
||||
path.join(csvDir, "function.csv"),
|
||||
codeElementHeader,
|
||||
);
|
||||
const classWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, "class.csv"),
|
||||
codeElementHeader,
|
||||
);
|
||||
const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader);
|
||||
const interfaceWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'interface.csv'),
|
||||
path.join(csvDir, "interface.csv"),
|
||||
codeElementHeader,
|
||||
);
|
||||
const methodHeader =
|
||||
'id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType';
|
||||
const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), methodHeader);
|
||||
"id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType";
|
||||
const methodWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, "method.csv"),
|
||||
methodHeader,
|
||||
);
|
||||
const codeElemWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'codeelement.csv'),
|
||||
path.join(csvDir, "codeelement.csv"),
|
||||
codeElementHeader,
|
||||
);
|
||||
const communityWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'community.csv'),
|
||||
'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount',
|
||||
path.join(csvDir, "community.csv"),
|
||||
"id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount",
|
||||
);
|
||||
const processWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'process.csv'),
|
||||
'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId',
|
||||
path.join(csvDir, "process.csv"),
|
||||
"id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId",
|
||||
);
|
||||
|
||||
// Section nodes have an extra 'level' column
|
||||
const sectionWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'section.csv'),
|
||||
'id,name,filePath,startLine,endLine,level,content,description',
|
||||
path.join(csvDir, "section.csv"),
|
||||
"id,name,filePath,startLine,endLine,level,content,description",
|
||||
);
|
||||
|
||||
// Route nodes for API endpoint mapping
|
||||
const routeWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'route.csv'),
|
||||
'id,name,filePath,responseKeys,errorKeys,middleware',
|
||||
path.join(csvDir, "route.csv"),
|
||||
"id,name,filePath,responseKeys,errorKeys,middleware",
|
||||
);
|
||||
|
||||
// Tool nodes for MCP tool definitions
|
||||
const toolWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, 'tool.csv'),
|
||||
'id,name,filePath,description',
|
||||
path.join(csvDir, "tool.csv"),
|
||||
"id,name,filePath,description",
|
||||
);
|
||||
|
||||
// StatusType nodes for workflow/state machine detection
|
||||
const statusTypeWriter = new BufferedCSVWriter(
|
||||
path.join(csvDir, "statustype.csv"),
|
||||
"id,name,filePath,statusValues,statusKind",
|
||||
);
|
||||
|
||||
// Multi-language node types share the same CSV shape (no isExported column)
|
||||
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
|
||||
const multiLangHeader =
|
||||
"id,name,filePath,startLine,endLine,content,description";
|
||||
const MULTI_LANG_TYPES = [
|
||||
'Struct',
|
||||
'Enum',
|
||||
|
|
@ -305,7 +327,10 @@ export const streamAllCSVsToDisk = async (
|
|||
for (const t of MULTI_LANG_TYPES) {
|
||||
multiLangWriters.set(
|
||||
t,
|
||||
new BufferedCSVWriter(path.join(csvDir, `${t.toLowerCase()}.csv`), multiLangHeader),
|
||||
new BufferedCSVWriter(
|
||||
path.join(csvDir, `${t.toLowerCase()}.csv`),
|
||||
multiLangHeader,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -332,121 +357,135 @@ export const streamAllCSVsToDisk = async (
|
|||
await fileWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVField(content),
|
||||
].join(','),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Folder':
|
||||
case "Folder":
|
||||
await folderWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
case 'Community': {
|
||||
case "Community": {
|
||||
const keywords = node.properties.keywords || [];
|
||||
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/,/g, '\\,')}'`).join(',')}]`;
|
||||
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/\\/g, "\\\\").replace(/'/g, "''").replace(/,/g, "\\,")}'`).join(",")}]`;
|
||||
await communityWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.heuristicLabel || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.heuristicLabel || ""),
|
||||
keywordsStr,
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
escapeCSVField(node.properties.enrichedBy || 'heuristic'),
|
||||
escapeCSVField(node.properties.description || ""),
|
||||
escapeCSVField(node.properties.enrichedBy || "heuristic"),
|
||||
escapeCSVNumber(node.properties.cohesion, 0),
|
||||
escapeCSVNumber(node.properties.symbolCount, 0),
|
||||
].join(','),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Process': {
|
||||
case "Process": {
|
||||
const communities = node.properties.communities || [];
|
||||
const communitiesStr = `[${communities.map((c: string) => `'${c.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
const communitiesStr = `[${communities.map((c: string) => `'${c.replace(/'/g, "''")}'`).join(",")}]`;
|
||||
await processWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.heuristicLabel || ''),
|
||||
escapeCSVField(node.properties.processType || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.heuristicLabel || ""),
|
||||
escapeCSVField(node.properties.processType || ""),
|
||||
escapeCSVNumber(node.properties.stepCount, 0),
|
||||
escapeCSVField(communitiesStr),
|
||||
escapeCSVField(node.properties.entryPointId || ''),
|
||||
escapeCSVField(node.properties.terminalId || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.entryPointId || ""),
|
||||
escapeCSVField(node.properties.terminalId || ""),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Method': {
|
||||
case "Method": {
|
||||
const content = await extractContent(node, contentCache);
|
||||
await methodWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
node.properties.isExported ? 'true' : 'false',
|
||||
node.properties.isExported ? "true" : "false",
|
||||
escapeCSVField(content),
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
escapeCSVField(node.properties.description || ""),
|
||||
escapeCSVNumber(node.properties.parameterCount, 0),
|
||||
escapeCSVField(node.properties.returnType || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.returnType || ""),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Section': {
|
||||
case "Section": {
|
||||
const content = await extractContent(node, contentCache);
|
||||
await sectionWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVNumber(node.properties.level, 1),
|
||||
escapeCSVField(content),
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.description || ""),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Route': {
|
||||
case "Route": {
|
||||
const responseKeys = node.properties.responseKeys || [];
|
||||
// LadybugDB array literal inside a quoted CSV field: escapeCSVField wraps in "..."
|
||||
// and the array uses single-quoted elements
|
||||
const keysStr = `[${responseKeys.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
const keysStr = `[${responseKeys.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(",")}]`;
|
||||
const errorKeys = node.properties.errorKeys || [];
|
||||
const errorKeysStr = `[${errorKeys.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
const errorKeysStr = `[${errorKeys.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(",")}]`;
|
||||
const middleware = node.properties.middleware || [];
|
||||
const middlewareStr = `[${middleware.map((m: string) => `'${m.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
const middlewareStr = `[${middleware.map((m: string) => `'${m.replace(/'/g, "''")}'`).join(",")}]`;
|
||||
await routeWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVField(keysStr),
|
||||
escapeCSVField(errorKeysStr),
|
||||
escapeCSVField(middlewareStr),
|
||||
].join(','),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'Tool':
|
||||
case "Tool":
|
||||
await toolWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVField(node.properties.description || ""),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
case "StatusType": {
|
||||
const statusValues = (node.properties as any).statusValues || [];
|
||||
const statusValuesStr = `[${statusValues.map((v: string) => `'${v.replace(/'/g, "''")}'`).join(",")}]`;
|
||||
await statusTypeWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVField(statusValuesStr),
|
||||
escapeCSVField((node.properties as any).statusKind || ""),
|
||||
].join(","),
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Code element nodes (Function, Class, Interface, CodeElement)
|
||||
const writer = codeWriterMap[node.label];
|
||||
|
|
@ -455,14 +494,14 @@ export const streamAllCSVsToDisk = async (
|
|||
await writer.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
node.properties.isExported ? 'true' : 'false',
|
||||
node.properties.isExported ? "true" : "false",
|
||||
escapeCSVField(content),
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.description || ""),
|
||||
].join(","),
|
||||
);
|
||||
} else {
|
||||
// Multi-language node types (Struct, Impl, Trait, Macro, etc.)
|
||||
|
|
@ -472,13 +511,13 @@ export const streamAllCSVsToDisk = async (
|
|||
await mlWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(node.properties.name || ""),
|
||||
escapeCSVField(node.properties.filePath || ""),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVField(content),
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
].join(','),
|
||||
escapeCSVField(node.properties.description || ""),
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -501,13 +540,17 @@ export const streamAllCSVsToDisk = async (
|
|||
sectionWriter,
|
||||
routeWriter,
|
||||
toolWriter,
|
||||
statusTypeWriter,
|
||||
...multiLangWriters.values(),
|
||||
];
|
||||
await Promise.all(allWriters.map((w) => w.finish()));
|
||||
|
||||
// --- Stream relationship CSV ---
|
||||
const relCsvPath = path.join(csvDir, 'relations.csv');
|
||||
const relWriter = new BufferedCSVWriter(relCsvPath, 'from,to,type,confidence,reason,step');
|
||||
const relCsvPath = path.join(csvDir, "relations.csv");
|
||||
const relWriter = new BufferedCSVWriter(
|
||||
relCsvPath,
|
||||
"from,to,type,confidence,reason,step,fromStatus,toStatus,entityType,isTransactional",
|
||||
);
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
await relWriter.addRow(
|
||||
[
|
||||
|
|
@ -517,7 +560,11 @@ export const streamAllCSVsToDisk = async (
|
|||
escapeCSVNumber(rel.confidence, 1.0),
|
||||
escapeCSVField(rel.reason),
|
||||
escapeCSVNumber((rel as any).step, 0),
|
||||
].join(','),
|
||||
escapeCSVField(rel.fromStatus || ""),
|
||||
escapeCSVField(rel.toStatus || ""),
|
||||
escapeCSVField(rel.entityType || ""),
|
||||
rel.isTransactional ? "true" : "false",
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
await relWriter.finish();
|
||||
|
|
@ -525,20 +572,22 @@ export const streamAllCSVsToDisk = async (
|
|||
// Build result map — only include tables that have rows
|
||||
const nodeFiles = new Map<NodeTableName, { csvPath: string; rows: number }>();
|
||||
const tableMap: [NodeTableName, BufferedCSVWriter][] = [
|
||||
['File', fileWriter],
|
||||
['Folder', folderWriter],
|
||||
['Function', functionWriter],
|
||||
['Class', classWriter],
|
||||
['Interface', interfaceWriter],
|
||||
['Method', methodWriter],
|
||||
['CodeElement', codeElemWriter],
|
||||
['Community', communityWriter],
|
||||
['Process', processWriter],
|
||||
['Section' as NodeTableName, sectionWriter],
|
||||
['Route' as NodeTableName, routeWriter],
|
||||
['Tool' as NodeTableName, toolWriter],
|
||||
["File", fileWriter],
|
||||
["Folder", folderWriter],
|
||||
["Function", functionWriter],
|
||||
["Class", classWriter],
|
||||
["Interface", interfaceWriter],
|
||||
["Method", methodWriter],
|
||||
["CodeElement", codeElemWriter],
|
||||
["Community", communityWriter],
|
||||
["Process", processWriter],
|
||||
["Section" as NodeTableName, sectionWriter],
|
||||
["Route" as NodeTableName, routeWriter],
|
||||
["Tool" as NodeTableName, toolWriter],
|
||||
["StatusType" as NodeTableName, statusTypeWriter],
|
||||
...Array.from(multiLangWriters.entries()).map(
|
||||
([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter],
|
||||
([name, w]) =>
|
||||
[name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter],
|
||||
),
|
||||
];
|
||||
for (const [name, writer] of tableMap) {
|
||||
|
|
|
|||
|
|
@ -174,14 +174,16 @@ const DB_LOCK_RETRY_DELAY_MS = 500;
|
|||
export const isDbBusyError = (err: unknown): boolean => {
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
msg.includes('busy') ||
|
||||
msg.includes('lock') ||
|
||||
msg.includes('already in use') ||
|
||||
msg.includes('could not set lock')
|
||||
msg.includes("busy") ||
|
||||
msg.includes("lock") ||
|
||||
msg.includes("already in use") ||
|
||||
msg.includes("could not set lock")
|
||||
);
|
||||
};
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const runWithSessionLock = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
sessionLock = new Promise<void>((resolve) => {
|
||||
|
|
@ -196,7 +198,8 @@ const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> =>
|
|||
}
|
||||
};
|
||||
|
||||
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
const normalizeCopyPath = (filePath: string): string =>
|
||||
filePath.replace(/\\/g, "/");
|
||||
|
||||
export const initLbug = async (dbPath: string) => {
|
||||
return runWithSessionLock(() => ensureLbugInitialized(dbPath));
|
||||
|
|
@ -210,7 +213,10 @@ export const initLbug = async (dbPath: string) => {
|
|||
* database is busy (e.g. `gitnexus analyze` holds the write lock).
|
||||
* Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
|
||||
*/
|
||||
export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>): Promise<T> => {
|
||||
export const withLbugDb = async <T>(
|
||||
dbPath: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
|
|
@ -243,7 +249,9 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
|
|||
vectorExtensionLoaded = false;
|
||||
});
|
||||
// Sleep outside the lock — no need to block others while waiting
|
||||
await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt),
|
||||
);
|
||||
}
|
||||
}
|
||||
// This line is unreachable — the loop either returns or throws inside,
|
||||
|
|
@ -288,7 +296,10 @@ const doInitLbug = async (dbPath: string) => {
|
|||
const realPath = await fs.realpath(dbPath);
|
||||
const parentDir = path.dirname(dbPath);
|
||||
const realParent = await fs.realpath(parentDir);
|
||||
if (!realPath.startsWith(realParent + path.sep) && realPath !== realParent) {
|
||||
if (
|
||||
!realPath.startsWith(realParent + path.sep) &&
|
||||
realPath !== realParent
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to delete ${dbPath}: resolved path ${realPath} is outside storage directory`,
|
||||
);
|
||||
|
|
@ -314,7 +325,7 @@ const doInitLbug = async (dbPath: string) => {
|
|||
} catch (err) {
|
||||
// Only ignore "already exists" errors - log everything else
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('already exists')) {
|
||||
if (!msg.includes("already exists")) {
|
||||
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -336,21 +347,21 @@ export const loadGraphToLbug = async (
|
|||
onProgress?: LbugProgressCallback,
|
||||
) => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
|
||||
const log = onProgress || (() => {});
|
||||
|
||||
const csvDir = path.join(storagePath, 'csv');
|
||||
const csvDir = path.join(storagePath, "csv");
|
||||
|
||||
log('Streaming CSVs to disk...');
|
||||
log("Streaming CSVs to disk...");
|
||||
const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir);
|
||||
|
||||
const validTables = new Set<string>(NODE_TABLES as readonly string[]);
|
||||
const getNodeLabel = (nodeId: string): string => {
|
||||
if (nodeId.startsWith('comm_')) return 'Community';
|
||||
if (nodeId.startsWith('proc_')) return 'Process';
|
||||
return nodeId.split(':')[0];
|
||||
if (nodeId.startsWith("comm_")) return "Community";
|
||||
if (nodeId.startsWith("proc_")) return "Process";
|
||||
return nodeId.split(":")[0];
|
||||
};
|
||||
|
||||
// Bulk COPY all node CSVs (sequential — LadybugDB allows only one write txn at a time)
|
||||
|
|
@ -360,7 +371,9 @@ export const loadGraphToLbug = async (
|
|||
|
||||
for (const [table, { csvPath, rows }] of nodeFiles) {
|
||||
stepsDone++;
|
||||
log(`Loading nodes ${stepsDone}/${totalSteps}: ${table} (${rows.toLocaleString()} rows)`);
|
||||
log(
|
||||
`Loading nodes ${stepsDone}/${totalSteps}: ${table} (${rows.toLocaleString()} rows)`,
|
||||
);
|
||||
|
||||
const normalizedPath = normalizeCopyPath(csvPath);
|
||||
const copyQuery = getCopyQuery(table, normalizedPath);
|
||||
|
|
@ -370,12 +383,13 @@ export const loadGraphToLbug = async (
|
|||
} catch (err) {
|
||||
try {
|
||||
const retryQuery = copyQuery.replace(
|
||||
'auto_detect=false)',
|
||||
'auto_detect=false, IGNORE_ERRORS=true)',
|
||||
"auto_detect=false)",
|
||||
"auto_detect=false, IGNORE_ERRORS=true)",
|
||||
);
|
||||
await conn.query(retryQuery);
|
||||
} catch (retryErr) {
|
||||
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
const retryMsg =
|
||||
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -419,8 +433,8 @@ export const loadGraphToLbug = async (
|
|||
} catch (err) {
|
||||
try {
|
||||
const retryQuery = copyQuery.replace(
|
||||
'auto_detect=false)',
|
||||
'auto_detect=false, IGNORE_ERRORS=true)',
|
||||
"auto_detect=false)",
|
||||
"auto_detect=false, IGNORE_ERRORS=true)",
|
||||
);
|
||||
await conn.query(retryQuery);
|
||||
} catch (retryErr) {
|
||||
|
|
@ -494,24 +508,24 @@ const COPY_CSV_OPTS = `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=
|
|||
// Multi-language table names that were created with backticks in CODE_ELEMENT_BASE
|
||||
// and must always be referenced with backticks in queries
|
||||
const BACKTICK_TABLES = new Set([
|
||||
'Struct',
|
||||
'Enum',
|
||||
'Macro',
|
||||
'Typedef',
|
||||
'Union',
|
||||
'Namespace',
|
||||
'Trait',
|
||||
'Impl',
|
||||
'TypeAlias',
|
||||
'Const',
|
||||
'Static',
|
||||
'Property',
|
||||
'Record',
|
||||
'Delegate',
|
||||
'Annotation',
|
||||
'Constructor',
|
||||
'Template',
|
||||
'Module',
|
||||
"Struct",
|
||||
"Enum",
|
||||
"Macro",
|
||||
"Typedef",
|
||||
"Union",
|
||||
"Namespace",
|
||||
"Trait",
|
||||
"Impl",
|
||||
"TypeAlias",
|
||||
"Const",
|
||||
"Static",
|
||||
"Property",
|
||||
"Record",
|
||||
"Delegate",
|
||||
"Annotation",
|
||||
"Constructor",
|
||||
"Template",
|
||||
"Module",
|
||||
]);
|
||||
|
||||
const escapeTableName = (table: string): string => {
|
||||
|
|
@ -532,7 +546,9 @@ const fallbackRelationshipInserts = async (
|
|||
for (let i = 1; i < validRelLines.length; i++) {
|
||||
const line = validRelLines[i];
|
||||
try {
|
||||
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
|
||||
const match = line.match(
|
||||
/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
|
||||
const fromLabel = getNodeLabel(fromId);
|
||||
|
|
@ -543,7 +559,11 @@ const fallbackRelationshipInserts = async (
|
|||
const step = parseInt(stepStr) || 0;
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/'/g, "''").replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
s
|
||||
.replace(/'/g, "''")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r");
|
||||
await conn.query(`
|
||||
MATCH (a:${escapeLabel(fromLabel)} {id: '${esc(fromId)}' }),
|
||||
(b:${escapeLabel(toLabel)} {id: '${esc(toId)}' })
|
||||
|
|
@ -557,37 +577,40 @@ const fallbackRelationshipInserts = async (
|
|||
|
||||
/** Tables with isExported column (TypeScript/JS-native types) */
|
||||
const TABLES_WITH_EXPORTED = new Set<string>([
|
||||
'Function',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Method',
|
||||
'CodeElement',
|
||||
"Function",
|
||||
"Class",
|
||||
"Interface",
|
||||
"Method",
|
||||
"CodeElement",
|
||||
]);
|
||||
|
||||
const getCopyQuery = (table: NodeTableName, filePath: string): string => {
|
||||
const t = escapeTableName(table);
|
||||
if (table === 'File') {
|
||||
if (table === "File") {
|
||||
return `COPY ${t}(id, name, filePath, content) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Folder') {
|
||||
if (table === "Folder") {
|
||||
return `COPY ${t}(id, name, filePath) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Community') {
|
||||
if (table === "Community") {
|
||||
return `COPY ${t}(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Process') {
|
||||
if (table === "Process") {
|
||||
return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Section') {
|
||||
if (table === "Section") {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Route') {
|
||||
if (table === "Route") {
|
||||
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Tool') {
|
||||
if (table === "Tool") {
|
||||
return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Method') {
|
||||
if (table === "StatusType") {
|
||||
return `COPY ${t}(id, name, filePath, statusValues, statusKind) 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}`;
|
||||
}
|
||||
// TypeScript/JS code element tables have isExported; multi-language tables do not
|
||||
|
|
@ -612,41 +635,43 @@ export const insertNodeToLbug = async (
|
|||
// Use provided dbPath or fall back to module-level db
|
||||
const targetDbPath = dbPath || (db ? undefined : null);
|
||||
if (!targetDbPath && !db) {
|
||||
throw new Error('LadybugDB not initialized. Provide dbPath or call initLbug first.');
|
||||
throw new Error(
|
||||
"LadybugDB not initialized. Provide dbPath or call initLbug first.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const escapeValue = (v: any): string => {
|
||||
if (v === null || v === undefined) return 'NULL';
|
||||
if (typeof v === 'number') return String(v);
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "number") return String(v);
|
||||
// Escape backslashes first (for Windows paths), then single quotes
|
||||
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`;
|
||||
return `'${String(v).replace(/\\/g, "\\\\").replace(/'/g, "''").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}'`;
|
||||
};
|
||||
|
||||
// Build INSERT query based on node type
|
||||
const t = escapeTableName(label);
|
||||
let query: string;
|
||||
|
||||
if (label === 'File') {
|
||||
query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`;
|
||||
} else if (label === 'Folder') {
|
||||
if (label === "File") {
|
||||
query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || "")}})`;
|
||||
} else if (label === "Folder") {
|
||||
query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`;
|
||||
} else if (label === 'Section') {
|
||||
} else if (label === "Section") {
|
||||
const descPart = properties.description
|
||||
? `, description: ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
: "";
|
||||
query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || "")}${descPart}})`;
|
||||
} else if (TABLES_WITH_EXPORTED.has(label)) {
|
||||
const descPart = properties.description
|
||||
? `, description: ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
: "";
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || "")}${descPart}})`;
|
||||
} else {
|
||||
// Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported
|
||||
const descPart = properties.description
|
||||
? `, description: ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
: "";
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || "")}${descPart}})`;
|
||||
}
|
||||
|
||||
// Use per-query connection if dbPath provided (avoids lock conflicts)
|
||||
|
|
@ -691,10 +716,10 @@ export const batchInsertNodesToLbug = async (
|
|||
if (nodes.length === 0) return { inserted: 0, failed: 0 };
|
||||
|
||||
const escapeValue = (v: any): string => {
|
||||
if (v === null || v === undefined) return 'NULL';
|
||||
if (typeof v === 'number') return String(v);
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "number") return String(v);
|
||||
// Escape backslashes first (for Windows paths), then single quotes, then newlines
|
||||
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`;
|
||||
return `'${String(v).replace(/\\/g, "\\\\").replace(/'/g, "''").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}'`;
|
||||
};
|
||||
|
||||
// Open a single connection for all inserts
|
||||
|
|
@ -711,25 +736,25 @@ export const batchInsertNodesToLbug = async (
|
|||
|
||||
// Use MERGE instead of CREATE for upsert behavior (handles duplicates gracefully)
|
||||
const t = escapeTableName(label);
|
||||
if (label === 'File') {
|
||||
query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`;
|
||||
} else if (label === 'Folder') {
|
||||
if (label === "File") {
|
||||
query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || "")}`;
|
||||
} else if (label === "Folder") {
|
||||
query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`;
|
||||
} else if (label === 'Section') {
|
||||
} else if (label === "Section") {
|
||||
const descPart = properties.description
|
||||
? `, n.description = ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
: "";
|
||||
query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || "")}${descPart}`;
|
||||
} else if (TABLES_WITH_EXPORTED.has(label)) {
|
||||
const descPart = properties.description
|
||||
? `, n.description = ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
: "";
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || "")}${descPart}`;
|
||||
} else {
|
||||
const descPart = properties.description
|
||||
? `, n.description = ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
: "";
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || "")}${descPart}`;
|
||||
}
|
||||
|
||||
await tempConn.query(query);
|
||||
|
|
@ -753,7 +778,7 @@ export const batchInsertNodesToLbug = async (
|
|||
|
||||
export const executeQuery = async (cypher: string): Promise<any[]> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
|
||||
const queryResult = await conn.query(cypher);
|
||||
|
|
@ -801,7 +826,7 @@ export const executePrepared = async (
|
|||
params: Record<string, any>,
|
||||
): Promise<any[]> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
|
|
@ -818,7 +843,7 @@ export const executeWithReusedStatement = async (
|
|||
paramsList: Array<Record<string, any>>,
|
||||
): Promise<void> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
if (paramsList.length === 0) return;
|
||||
|
||||
|
|
@ -836,13 +861,16 @@ export const executeWithReusedStatement = async (
|
|||
}
|
||||
} catch (e) {
|
||||
// Log the error and continue with next batch
|
||||
console.warn('Batch execution error:', e);
|
||||
console.warn("Batch execution error:", e);
|
||||
}
|
||||
// Note: LadybugDB PreparedStatement doesn't require explicit close()
|
||||
}
|
||||
};
|
||||
|
||||
export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> => {
|
||||
export const getLbugStats = async (): Promise<{
|
||||
nodes: number;
|
||||
edges: number;
|
||||
}> => {
|
||||
if (!conn) return { nodes: 0, edges: 0 };
|
||||
|
||||
let totalNodes = 0;
|
||||
|
|
@ -851,7 +879,9 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
|
|||
const queryResult = await conn.query(
|
||||
`MATCH (n:${escapeTableName(tableName)}) RETURN count(n) AS cnt`,
|
||||
);
|
||||
const nodeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const nodeResult = Array.isArray(queryResult)
|
||||
? queryResult[0]
|
||||
: queryResult;
|
||||
const nodeRows = await nodeResult.getAll();
|
||||
if (nodeRows.length > 0) {
|
||||
totalNodes += Number(nodeRows[0]?.cnt ?? nodeRows[0]?.[0] ?? 0);
|
||||
|
|
@ -866,7 +896,9 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }>
|
|||
const queryResult = await conn.query(
|
||||
`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`,
|
||||
);
|
||||
const edgeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const edgeResult = Array.isArray(queryResult)
|
||||
? queryResult[0]
|
||||
: queryResult;
|
||||
const edgeRows = await edgeResult.getAll();
|
||||
if (edgeRows.length > 0) {
|
||||
totalEdges = Number(edgeRows[0]?.cnt ?? edgeRows[0]?.[0] ?? 0);
|
||||
|
|
@ -930,7 +962,7 @@ export const loadCachedEmbeddings = async (): Promise<{
|
|||
}
|
||||
const result = Array.isArray(rows) ? rows[0] : rows;
|
||||
for (const row of await result.getAll()) {
|
||||
const nodeId = String(row.nodeId ?? row[0] ?? '');
|
||||
const nodeId = String(row.nodeId ?? row[0] ?? "");
|
||||
if (!nodeId) continue;
|
||||
embeddingNodeIds.add(nodeId);
|
||||
const embedding = row.embedding ?? row[4];
|
||||
|
|
@ -1063,7 +1095,9 @@ export const deleteNodesForFile = async (
|
|||
tempConn = new lbug.Connection(tempDb);
|
||||
targetConn = tempConn;
|
||||
} else if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Provide dbPath or call initLbug first.');
|
||||
throw new Error(
|
||||
"LadybugDB not initialized. Provide dbPath or call initLbug first.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -1074,7 +1108,7 @@ export const deleteNodesForFile = async (
|
|||
// DETACH DELETE removes the node and all its relationships
|
||||
for (const tableName of NODE_TABLES) {
|
||||
// Skip tables that don't have filePath (Community, Process)
|
||||
if (tableName === 'Community' || tableName === 'Process') continue;
|
||||
if (tableName === "Community" || tableName === "Process") continue;
|
||||
|
||||
try {
|
||||
// First count how many we'll delete
|
||||
|
|
@ -1082,7 +1116,9 @@ export const deleteNodesForFile = async (
|
|||
const countResult = await targetConn!.query(
|
||||
`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`,
|
||||
);
|
||||
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||
const result = Array.isArray(countResult)
|
||||
? countResult[0]
|
||||
: countResult;
|
||||
const rows = await result.getAll();
|
||||
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||
|
||||
|
|
@ -1136,7 +1172,7 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
|
|||
export const loadFTSExtension = async (): Promise<void> => {
|
||||
if (ftsLoaded) return;
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
try {
|
||||
// Try loading locally first (no network required)
|
||||
|
|
@ -1176,11 +1212,11 @@ export const loadVectorExtension = async (): Promise<void> => {
|
|||
await conn.query('LOAD EXTENSION VECTOR');
|
||||
vectorExtensionLoaded = true;
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '';
|
||||
const msg = err?.message || "";
|
||||
if (
|
||||
msg.includes('already loaded') ||
|
||||
msg.includes('already installed') ||
|
||||
msg.includes('already exists')
|
||||
msg.includes("already loaded") ||
|
||||
msg.includes("already installed") ||
|
||||
msg.includes("already exists")
|
||||
) {
|
||||
vectorExtensionLoaded = true;
|
||||
} else {
|
||||
|
|
@ -1199,21 +1235,21 @@ export const createFTSIndex = async (
|
|||
tableName: string,
|
||||
indexName: string,
|
||||
properties: string[],
|
||||
stemmer: string = 'porter',
|
||||
stemmer: string = "porter",
|
||||
): Promise<void> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
|
||||
await loadFTSExtension();
|
||||
|
||||
const propList = properties.map((p) => `'${p}'`).join(', ');
|
||||
const propList = properties.map((p) => `'${p}'`).join(", ");
|
||||
const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
|
||||
|
||||
try {
|
||||
await conn.query(query);
|
||||
} catch (e: any) {
|
||||
if (!e.message?.includes('already exists')) {
|
||||
if (!e.message?.includes("already exists")) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -1235,14 +1271,20 @@ export const queryFTS = async (
|
|||
limit: number = 20,
|
||||
conjunctive: boolean = false,
|
||||
): Promise<
|
||||
Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }>
|
||||
Array<{
|
||||
nodeId: string;
|
||||
name: string;
|
||||
filePath: string;
|
||||
score: number;
|
||||
[key: string]: any;
|
||||
}>
|
||||
> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
|
||||
// Escape backslashes and single quotes to prevent Cypher injection
|
||||
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
const escapedQuery = query.replace(/\\/g, "\\\\").replace(/'/g, "''");
|
||||
|
||||
const cypher = `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
|
||||
|
|
@ -1260,16 +1302,16 @@ export const queryFTS = async (
|
|||
const node = row.node || row[0] || {};
|
||||
const score = row.score ?? row[1] ?? 0;
|
||||
return {
|
||||
nodeId: node.nodeId || node.id || '',
|
||||
name: node.name || '',
|
||||
filePath: node.filePath || '',
|
||||
score: typeof score === 'number' ? score : parseFloat(score) || 0,
|
||||
nodeId: node.nodeId || node.id || "",
|
||||
name: node.name || "",
|
||||
filePath: node.filePath || "",
|
||||
score: typeof score === "number" ? score : parseFloat(score) || 0,
|
||||
...node,
|
||||
};
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Return empty if index doesn't exist yet
|
||||
if (e.message?.includes('does not exist')) {
|
||||
if (e.message?.includes("does not exist")) {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
|
|
@ -1279,9 +1321,12 @@ export const queryFTS = async (
|
|||
/**
|
||||
* Drop an FTS index
|
||||
*/
|
||||
export const dropFTSIndex = async (tableName: string, indexName: string): Promise<void> => {
|
||||
export const dropFTSIndex = async (
|
||||
tableName: string,
|
||||
indexName: string,
|
||||
): Promise<void> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
throw new Error("LadybugDB not initialized. Call initLbug first.");
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,15 @@
|
|||
*/
|
||||
|
||||
// Import from shared package (single source of truth) — used in DDL templates below
|
||||
import { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME } from 'gitnexus-shared';
|
||||
import {
|
||||
NODE_TABLES,
|
||||
REL_TABLE_NAME,
|
||||
REL_TYPES,
|
||||
EMBEDDING_TABLE_NAME,
|
||||
} from "gitnexus-shared";
|
||||
// Re-export so downstream consumers keep the same import path
|
||||
export { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME };
|
||||
export type { NodeTableName, RelType } from 'gitnexus-shared';
|
||||
export type { NodeTableName, RelType } from "gitnexus-shared";
|
||||
|
||||
// ============================================================================
|
||||
// NODE TABLE SCHEMAS
|
||||
|
|
@ -196,6 +201,17 @@ CREATE NODE TABLE Tool (
|
|||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
// Status/state type definitions (e.g., GrantStatus, OrderState)
|
||||
export const STATUS_TYPE_SCHEMA = `
|
||||
CREATE NODE TABLE StatusType (
|
||||
id STRING,
|
||||
name STRING,
|
||||
filePath STRING,
|
||||
statusValues STRING[],
|
||||
statusKind STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
// Markdown heading sections
|
||||
export const SECTION_SCHEMA = `
|
||||
CREATE NODE TABLE Section (
|
||||
|
|
@ -420,10 +436,17 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM CodeElement TO Process,
|
||||
FROM Route TO Process,
|
||||
FROM Tool TO Process,
|
||||
FROM File TO StatusType,
|
||||
FROM Function TO StatusType,
|
||||
FROM Method TO StatusType,
|
||||
type STRING,
|
||||
confidence DOUBLE,
|
||||
reason STRING,
|
||||
step INT32
|
||||
step INT32,
|
||||
fromStatus STRING,
|
||||
toStatus STRING,
|
||||
entityType STRING,
|
||||
isTransactional BOOLEAN
|
||||
)`;
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -432,7 +455,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
// ============================================================================
|
||||
|
||||
/** Embedding vector dimensions. Default 384 (snowflake-arctic-embed-xs). */
|
||||
const _rawDims = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10);
|
||||
const _rawDims = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? "384", 10);
|
||||
if (Number.isNaN(_rawDims) || _rawDims <= 0) {
|
||||
throw new Error(
|
||||
`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${process.env.GITNEXUS_EMBEDDING_DIMS}"`,
|
||||
|
|
@ -510,8 +533,14 @@ export const NODE_SCHEMA_QUERIES = [
|
|||
ROUTE_SCHEMA,
|
||||
// MCP tools
|
||||
TOOL_SCHEMA,
|
||||
// Status/state types
|
||||
STATUS_TYPE_SCHEMA,
|
||||
];
|
||||
|
||||
export const REL_SCHEMA_QUERIES = [RELATION_SCHEMA];
|
||||
|
||||
export const SCHEMA_QUERIES = [...NODE_SCHEMA_QUERIES, ...REL_SCHEMA_QUERIES, EMBEDDING_SCHEMA];
|
||||
export const SCHEMA_QUERIES = [
|
||||
...NODE_SCHEMA_QUERIES,
|
||||
...REL_SCHEMA_QUERIES,
|
||||
EMBEDDING_SCHEMA,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
NODE_TABLES,
|
||||
REL_TABLE_NAME,
|
||||
|
|
@ -19,28 +19,28 @@ import {
|
|||
RELATION_SCHEMA,
|
||||
EMBEDDING_SCHEMA,
|
||||
CREATE_VECTOR_INDEX_QUERY,
|
||||
} from '../../src/core/lbug/schema.js';
|
||||
} from "../../src/core/lbug/schema.js";
|
||||
|
||||
describe('LadybugDB Schema', () => {
|
||||
describe('NODE_TABLES', () => {
|
||||
it('includes all core node types', () => {
|
||||
describe("LadybugDB Schema", () => {
|
||||
describe("NODE_TABLES", () => {
|
||||
it("includes all core node types", () => {
|
||||
const core = [
|
||||
'File',
|
||||
'Folder',
|
||||
'Function',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Method',
|
||||
'CodeElement',
|
||||
'Community',
|
||||
'Process',
|
||||
"File",
|
||||
"Folder",
|
||||
"Function",
|
||||
"Class",
|
||||
"Interface",
|
||||
"Method",
|
||||
"CodeElement",
|
||||
"Community",
|
||||
"Process",
|
||||
];
|
||||
for (const t of core) {
|
||||
expect(NODE_TABLES).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes multi-language node types', () => {
|
||||
it("includes multi-language node types", () => {
|
||||
const multiLang = [
|
||||
'Struct',
|
||||
'Enum',
|
||||
|
|
@ -68,22 +68,22 @@ describe('LadybugDB Schema', () => {
|
|||
});
|
||||
|
||||
it('has expected total count', () => {
|
||||
// 9 core + 19 multi-language + Route + Tool = 31
|
||||
expect(NODE_TABLES).toHaveLength(31);
|
||||
// 9 core + Section + 19 multi-language + Route + Tool + StatusType = 32
|
||||
expect(NODE_TABLES).toHaveLength(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('REL_TYPES', () => {
|
||||
it('includes all expected relationship types', () => {
|
||||
describe("REL_TYPES", () => {
|
||||
it("includes all expected relationship types", () => {
|
||||
const expected = [
|
||||
'CONTAINS',
|
||||
'DEFINES',
|
||||
'IMPORTS',
|
||||
'CALLS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'MEMBER_OF',
|
||||
'STEP_IN_PROCESS',
|
||||
"CONTAINS",
|
||||
"DEFINES",
|
||||
"IMPORTS",
|
||||
"CALLS",
|
||||
"EXTENDS",
|
||||
"IMPLEMENTS",
|
||||
"MEMBER_OF",
|
||||
"STEP_IN_PROCESS",
|
||||
];
|
||||
for (const t of expected) {
|
||||
expect(REL_TYPES).toContain(t);
|
||||
|
|
@ -91,80 +91,80 @@ describe('LadybugDB Schema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('node schema DDL', () => {
|
||||
describe("node schema DDL", () => {
|
||||
it.each([
|
||||
['FILE_SCHEMA', FILE_SCHEMA, 'File'],
|
||||
['FOLDER_SCHEMA', FOLDER_SCHEMA, 'Folder'],
|
||||
['FUNCTION_SCHEMA', FUNCTION_SCHEMA, 'Function'],
|
||||
['CLASS_SCHEMA', CLASS_SCHEMA, 'Class'],
|
||||
['INTERFACE_SCHEMA', INTERFACE_SCHEMA, 'Interface'],
|
||||
['METHOD_SCHEMA', METHOD_SCHEMA, 'Method'],
|
||||
['CODE_ELEMENT_SCHEMA', CODE_ELEMENT_SCHEMA, 'CodeElement'],
|
||||
['COMMUNITY_SCHEMA', COMMUNITY_SCHEMA, 'Community'],
|
||||
['PROCESS_SCHEMA', PROCESS_SCHEMA, 'Process'],
|
||||
])('%s contains CREATE NODE TABLE for %s', (_, schema, tableName) => {
|
||||
expect(schema).toContain('CREATE NODE TABLE');
|
||||
["FILE_SCHEMA", FILE_SCHEMA, "File"],
|
||||
["FOLDER_SCHEMA", FOLDER_SCHEMA, "Folder"],
|
||||
["FUNCTION_SCHEMA", FUNCTION_SCHEMA, "Function"],
|
||||
["CLASS_SCHEMA", CLASS_SCHEMA, "Class"],
|
||||
["INTERFACE_SCHEMA", INTERFACE_SCHEMA, "Interface"],
|
||||
["METHOD_SCHEMA", METHOD_SCHEMA, "Method"],
|
||||
["CODE_ELEMENT_SCHEMA", CODE_ELEMENT_SCHEMA, "CodeElement"],
|
||||
["COMMUNITY_SCHEMA", COMMUNITY_SCHEMA, "Community"],
|
||||
["PROCESS_SCHEMA", PROCESS_SCHEMA, "Process"],
|
||||
])("%s contains CREATE NODE TABLE for %s", (_, schema, tableName) => {
|
||||
expect(schema).toContain("CREATE NODE TABLE");
|
||||
expect(schema).toContain(tableName);
|
||||
expect(schema).toContain('PRIMARY KEY');
|
||||
expect(schema).toContain("PRIMARY KEY");
|
||||
});
|
||||
|
||||
it('Function schema has startLine and endLine', () => {
|
||||
expect(FUNCTION_SCHEMA).toContain('startLine INT64');
|
||||
expect(FUNCTION_SCHEMA).toContain('endLine INT64');
|
||||
it("Function schema has startLine and endLine", () => {
|
||||
expect(FUNCTION_SCHEMA).toContain("startLine INT64");
|
||||
expect(FUNCTION_SCHEMA).toContain("endLine INT64");
|
||||
});
|
||||
|
||||
it('Function schema has isExported', () => {
|
||||
expect(FUNCTION_SCHEMA).toContain('isExported BOOLEAN');
|
||||
it("Function schema has isExported", () => {
|
||||
expect(FUNCTION_SCHEMA).toContain("isExported BOOLEAN");
|
||||
});
|
||||
|
||||
it('Community schema has heuristicLabel and cohesion', () => {
|
||||
expect(COMMUNITY_SCHEMA).toContain('heuristicLabel STRING');
|
||||
expect(COMMUNITY_SCHEMA).toContain('cohesion DOUBLE');
|
||||
it("Community schema has heuristicLabel and cohesion", () => {
|
||||
expect(COMMUNITY_SCHEMA).toContain("heuristicLabel STRING");
|
||||
expect(COMMUNITY_SCHEMA).toContain("cohesion DOUBLE");
|
||||
});
|
||||
|
||||
it('Process schema has processType and stepCount', () => {
|
||||
expect(PROCESS_SCHEMA).toContain('processType STRING');
|
||||
expect(PROCESS_SCHEMA).toContain('stepCount INT32');
|
||||
it("Process schema has processType and stepCount", () => {
|
||||
expect(PROCESS_SCHEMA).toContain("processType STRING");
|
||||
expect(PROCESS_SCHEMA).toContain("stepCount INT32");
|
||||
});
|
||||
});
|
||||
|
||||
describe('relation schema', () => {
|
||||
it('creates a single REL TABLE named CodeRelation', () => {
|
||||
describe("relation schema", () => {
|
||||
it("creates a single REL TABLE named CodeRelation", () => {
|
||||
expect(RELATION_SCHEMA).toContain(`CREATE REL TABLE ${REL_TABLE_NAME}`);
|
||||
});
|
||||
|
||||
it('has type, confidence, reason, step properties', () => {
|
||||
expect(RELATION_SCHEMA).toContain('type STRING');
|
||||
expect(RELATION_SCHEMA).toContain('confidence DOUBLE');
|
||||
expect(RELATION_SCHEMA).toContain('reason STRING');
|
||||
expect(RELATION_SCHEMA).toContain('step INT32');
|
||||
it("has type, confidence, reason, step properties", () => {
|
||||
expect(RELATION_SCHEMA).toContain("type STRING");
|
||||
expect(RELATION_SCHEMA).toContain("confidence DOUBLE");
|
||||
expect(RELATION_SCHEMA).toContain("reason STRING");
|
||||
expect(RELATION_SCHEMA).toContain("step INT32");
|
||||
});
|
||||
|
||||
it('connects Function to Function (CALLS)', () => {
|
||||
expect(RELATION_SCHEMA).toContain('FROM Function TO Function');
|
||||
it("connects Function to Function (CALLS)", () => {
|
||||
expect(RELATION_SCHEMA).toContain("FROM Function TO Function");
|
||||
});
|
||||
|
||||
it('connects File to Function (CONTAINS/DEFINES)', () => {
|
||||
expect(RELATION_SCHEMA).toContain('FROM File TO Function');
|
||||
it("connects File to Function (CONTAINS/DEFINES)", () => {
|
||||
expect(RELATION_SCHEMA).toContain("FROM File TO Function");
|
||||
});
|
||||
|
||||
it('connects symbols to Community (MEMBER_OF)', () => {
|
||||
expect(RELATION_SCHEMA).toContain('FROM Function TO Community');
|
||||
expect(RELATION_SCHEMA).toContain('FROM Class TO Community');
|
||||
it("connects symbols to Community (MEMBER_OF)", () => {
|
||||
expect(RELATION_SCHEMA).toContain("FROM Function TO Community");
|
||||
expect(RELATION_SCHEMA).toContain("FROM Class TO Community");
|
||||
});
|
||||
|
||||
it('connects symbols to Process (STEP_IN_PROCESS)', () => {
|
||||
expect(RELATION_SCHEMA).toContain('FROM Function TO Process');
|
||||
expect(RELATION_SCHEMA).toContain('FROM Method TO Process');
|
||||
it("connects symbols to Process (STEP_IN_PROCESS)", () => {
|
||||
expect(RELATION_SCHEMA).toContain("FROM Function TO Process");
|
||||
expect(RELATION_SCHEMA).toContain("FROM Method TO Process");
|
||||
});
|
||||
|
||||
it('has all FROM/TO pairs needed for HAS_METHOD edges', () => {
|
||||
it("has all FROM/TO pairs needed for HAS_METHOD edges", () => {
|
||||
// HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record
|
||||
// HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY)
|
||||
const sources = ['Class', 'Interface'];
|
||||
const backtickSources = ['Struct', 'Trait', 'Impl', 'Record'];
|
||||
const targets = ['Method'];
|
||||
const backtickTargets = ['Constructor'];
|
||||
const sources = ["Class", "Interface"];
|
||||
const backtickSources = ["Struct", "Trait", "Impl", "Record"];
|
||||
const targets = ["Method"];
|
||||
const backtickTargets = ["Constructor"];
|
||||
|
||||
// Non-backtick source → non-backtick target
|
||||
for (const src of sources) {
|
||||
|
|
@ -188,33 +188,35 @@ describe('LadybugDB Schema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('embedding schema', () => {
|
||||
it('creates CodeEmbedding table', () => {
|
||||
expect(EMBEDDING_SCHEMA).toContain(`CREATE NODE TABLE ${EMBEDDING_TABLE_NAME}`);
|
||||
expect(EMBEDDING_SCHEMA).toContain('embedding FLOAT[384]');
|
||||
describe("embedding schema", () => {
|
||||
it("creates CodeEmbedding table", () => {
|
||||
expect(EMBEDDING_SCHEMA).toContain(
|
||||
`CREATE NODE TABLE ${EMBEDDING_TABLE_NAME}`,
|
||||
);
|
||||
expect(EMBEDDING_SCHEMA).toContain("embedding FLOAT[384]");
|
||||
});
|
||||
|
||||
it('has vector index query', () => {
|
||||
expect(CREATE_VECTOR_INDEX_QUERY).toContain('CREATE_VECTOR_INDEX');
|
||||
expect(CREATE_VECTOR_INDEX_QUERY).toContain('cosine');
|
||||
it("has vector index query", () => {
|
||||
expect(CREATE_VECTOR_INDEX_QUERY).toContain("CREATE_VECTOR_INDEX");
|
||||
expect(CREATE_VECTOR_INDEX_QUERY).toContain("cosine");
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema query ordering', () => {
|
||||
it('NODE_SCHEMA_QUERIES has correct count', () => {
|
||||
expect(NODE_SCHEMA_QUERIES).toHaveLength(31);
|
||||
expect(NODE_SCHEMA_QUERIES).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('REL_SCHEMA_QUERIES has one relation table', () => {
|
||||
it("REL_SCHEMA_QUERIES has one relation table", () => {
|
||||
expect(REL_SCHEMA_QUERIES).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => {
|
||||
// 31 node + 1 rel + 1 embedding = 33
|
||||
expect(SCHEMA_QUERIES).toHaveLength(33);
|
||||
// 32 node + 1 rel + 1 embedding = 34
|
||||
expect(SCHEMA_QUERIES).toHaveLength(34);
|
||||
});
|
||||
|
||||
it('node schemas come before relation schemas in SCHEMA_QUERIES', () => {
|
||||
it("node schemas come before relation schemas in SCHEMA_QUERIES", () => {
|
||||
const relIndex = SCHEMA_QUERIES.indexOf(RELATION_SCHEMA);
|
||||
const lastNodeIndex = SCHEMA_QUERIES.indexOf(
|
||||
NODE_SCHEMA_QUERIES[NODE_SCHEMA_QUERIES.length - 1],
|
||||
|
|
|
|||
254
gitnexus/test/unit/workflow-detector.test.ts
Normal file
254
gitnexus/test/unit/workflow-detector.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractStatusTypes,
|
||||
extractStatusTransitions,
|
||||
type DetectedStatusType,
|
||||
type DetectedTransition,
|
||||
} from "../../src/core/ingestion/workflow-detector.js";
|
||||
|
||||
describe("extractStatusTypes", () => {
|
||||
it("extracts TypeScript union type status definitions", () => {
|
||||
const content = `
|
||||
export type GrantStatus = 'DRAFT' | 'ACTIVE' | 'AT_RISK' | 'ON_HOLD' | 'COMPLETED' | 'CLOSED';
|
||||
export type ProjectStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
|
||||
`;
|
||||
const types = extractStatusTypes(content, "typescript", "status-rules.ts");
|
||||
expect(types).toHaveLength(2);
|
||||
expect(types[0]).toMatchObject({
|
||||
name: "GrantStatus",
|
||||
values: ["DRAFT", "ACTIVE", "AT_RISK", "ON_HOLD", "COMPLETED", "CLOSED"],
|
||||
filePath: "status-rules.ts",
|
||||
});
|
||||
expect(types[1]).toMatchObject({
|
||||
name: "ProjectStatus",
|
||||
values: ["NOT_STARTED", "IN_PROGRESS", "COMPLETED", "CANCELLED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts lowercase approval status types", () => {
|
||||
const content = `
|
||||
export type ApprovalInstanceStatus = 'pending' | 'approved' | 'rejected' | 'escalated' | 'cancelled';
|
||||
`;
|
||||
const types = extractStatusTypes(content, "typescript", "workflow.ts");
|
||||
expect(types).toHaveLength(1);
|
||||
expect(types[0].values).toContain("pending");
|
||||
expect(types[0].values).toContain("approved");
|
||||
});
|
||||
|
||||
it("extracts enum-based status definitions", () => {
|
||||
const content = `
|
||||
enum OrderStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
SHIPPED = 'shipped',
|
||||
DELIVERED = 'delivered',
|
||||
}
|
||||
`;
|
||||
const types = extractStatusTypes(content, "typescript", "order.ts");
|
||||
expect(types).toHaveLength(1);
|
||||
expect(types[0].name).toBe("OrderStatus");
|
||||
expect(types[0].values).toEqual([
|
||||
"pending",
|
||||
"processing",
|
||||
"shipped",
|
||||
"delivered",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-status union types", () => {
|
||||
const content = `
|
||||
export type Color = 'red' | 'blue' | 'green';
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
`;
|
||||
const types = extractStatusTypes(content, "typescript", "utils.ts");
|
||||
// HttpMethod might match due to keyword heuristics, but Color should not
|
||||
expect(types.find((t) => t.name === "Color")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts Python Enum status types", () => {
|
||||
const content = `
|
||||
class OrderStatus(Enum):
|
||||
PENDING = 'pending'
|
||||
SHIPPED = 'shipped'
|
||||
DELIVERED = 'delivered'
|
||||
`;
|
||||
const types = extractStatusTypes(content, "python", "models.py");
|
||||
expect(types).toHaveLength(1);
|
||||
expect(types[0]).toMatchObject({
|
||||
name: "OrderStatus",
|
||||
values: ["pending", "shipped", "delivered"],
|
||||
kind: "enum",
|
||||
filePath: "models.py",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts Java enum status types (no string values)", () => {
|
||||
const content = `
|
||||
public enum OrderStatus { PENDING, ACTIVE, COMPLETED, CANCELLED }
|
||||
`;
|
||||
const types = extractStatusTypes(content, "java", "Order.java");
|
||||
expect(types).toHaveLength(1);
|
||||
expect(types[0]).toMatchObject({
|
||||
name: "OrderStatus",
|
||||
values: ["PENDING", "ACTIVE", "COMPLETED", "CANCELLED"],
|
||||
kind: "enum",
|
||||
filePath: "Order.java",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractStatusTransitions", () => {
|
||||
it("detects Prisma status update patterns", () => {
|
||||
const content = `
|
||||
async function approveGrant(grantId: string) {
|
||||
await prisma.grant.update({
|
||||
where: { id: grantId },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"typescript",
|
||||
"engine.ts",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "ACTIVE",
|
||||
entityType: "grant",
|
||||
functionName: "approveGrant",
|
||||
filePath: "engine.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects conditional status transitions with fromStatus", () => {
|
||||
const content = `
|
||||
async function submitGrant(grant: Grant) {
|
||||
if (grant.status === 'DRAFT') {
|
||||
await prisma.grant.update({
|
||||
where: { id: grant.id },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
}
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"typescript",
|
||||
"route.ts",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
fromStatus: "DRAFT",
|
||||
toStatus: "ACTIVE",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects transactional status updates", () => {
|
||||
const content = `
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.approvalStepInstance.update({
|
||||
where: { id: stepInstance.id },
|
||||
data: { status: 'approved' },
|
||||
});
|
||||
});
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"typescript",
|
||||
"engine.ts",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "approved",
|
||||
entityType: "approvalStepInstance",
|
||||
isTransactional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects direct assignment transitions", () => {
|
||||
const content = `
|
||||
function shipOrder(order) {
|
||||
order.status = 'shipped';
|
||||
}
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"javascript",
|
||||
"order.js",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "shipped",
|
||||
entityType: "order",
|
||||
functionName: "shipOrder",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects setter pattern transitions", () => {
|
||||
const content = `
|
||||
function approve(entity) {
|
||||
entity.setStatus('approved');
|
||||
}
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"typescript",
|
||||
"service.ts",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "approved",
|
||||
entityType: "entity",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Python self.status assignment", () => {
|
||||
const content = `
|
||||
def activate(self):
|
||||
self.status = 'active'
|
||||
`;
|
||||
const transitions = extractStatusTransitions(content, "python", "model.py");
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "active",
|
||||
entityType: "self",
|
||||
filePath: "model.py",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects generic .update() with status field", () => {
|
||||
const content = `
|
||||
function completeTask(task) {
|
||||
task.update({ status: 'completed', finishedAt: new Date() });
|
||||
}
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"javascript",
|
||||
"task.js",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "completed",
|
||||
entityType: "task",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects set_status snake_case setter", () => {
|
||||
const content = `
|
||||
def process(order):
|
||||
order.set_status('processing')
|
||||
`;
|
||||
const transitions = extractStatusTransitions(
|
||||
content,
|
||||
"python",
|
||||
"service.py",
|
||||
);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]).toMatchObject({
|
||||
toStatus: "processing",
|
||||
entityType: "order",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue