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:
Test 2026-03-29 11:34:17 +02:00
parent 969b4623ca
commit 83f79729b2
11 changed files with 1218 additions and 332 deletions

View file

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

View file

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

View file

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

View 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 };
},
};

View file

@ -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,
];

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

View file

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

View file

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

View file

@ -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,
];

View file

@ -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],

View 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",
});
});
});