From 83f79729b26c027e2c93c4af846dba1f86030a9f Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 29 Mar 2026 11:34:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20workflow/state=20machine=20detection=20?= =?UTF-8?q?=E2=80=94=20StatusType=20nodes,=20TRANSITIONS=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gitnexus-shared/src/graph/types.ts | 11 +- gitnexus-shared/src/lbug/schema-constants.ts | 6 +- .../core/ingestion/pipeline-phases/index.ts | 1 + .../ingestion/pipeline-phases/workflows.ts | 162 +++++++++ gitnexus/src/core/ingestion/pipeline.ts | 2 + .../src/core/ingestion/workflow-detector.ts | 333 ++++++++++++++++++ gitnexus/src/core/lbug/csv-generator.ts | 301 +++++++++------- gitnexus/src/core/lbug/lbug-adapter.ts | 269 ++++++++------ gitnexus/src/core/lbug/schema.ts | 39 +- gitnexus/test/unit/schema.test.ts | 172 ++++----- gitnexus/test/unit/workflow-detector.test.ts | 254 +++++++++++++ 11 files changed, 1218 insertions(+), 332 deletions(-) create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/workflows.ts create mode 100644 gitnexus/src/core/ingestion/workflow-detector.ts create mode 100644 gitnexus/test/unit/workflow-detector.test.ts diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 49762d145..f4f41c3f9 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -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; } diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 656ffe552..c3f0535cc 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -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"; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index c05264de1..c8ffbadfc 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/workflows.ts b/gitnexus/src/core/ingestion/pipeline-phases/workflows.ts new file mode 100644 index 000000000..dc103954a --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/workflows.ts @@ -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 = { + name: 'workflows', + deps: ['parse'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { allPaths } = getPhaseOutput(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(); + const valueToType = new Map(); + const entityToType = new Map(); + + 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 }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e1f226289..d4693bd30 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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, ]; diff --git a/gitnexus/src/core/ingestion/workflow-detector.ts b/gitnexus/src/core/ingestion/workflow-detector.ts new file mode 100644 index 000000000..3bbbf8b48 --- /dev/null +++ b/gitnexus/src/core/ingestion/workflow-detector.ts @@ -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(); + 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; +} diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 616a00cac..8e8f88c47 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -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 { - 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 => { +const extractContent = async ( + node: GraphNode, + contentCache: FileContentCache, +): Promise => { 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 { 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(); 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) { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index f019960c5..88ca2f10c 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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 (operation: () => Promise): Promise => { +const runWithSessionLock = async ( + operation: () => Promise, +): Promise => { const previous = sessionLock; let release: (() => void) | null = null; sessionLock = new Promise((resolve) => { @@ -196,7 +198,8 @@ const runWithSessionLock = async (operation: () => Promise): Promise => } }; -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 (dbPath: string, operation: () => Promise): Promise => { +export const withLbugDb = async ( + dbPath: string, + operation: () => Promise, +): Promise => { let lastError: unknown; for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) { try { @@ -243,7 +249,9 @@ export const withLbugDb = async (dbPath: string, operation: () => Promise) 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(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([ - '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 => { 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, ): Promise => { 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>, ): Promise => { 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 => { 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 => { 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 => { 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 => { +export const dropFTSIndex = async ( + tableName: string, + indexName: string, +): Promise => { if (!conn) { - throw new Error('LadybugDB not initialized. Call initLbug first.'); + throw new Error("LadybugDB not initialized. Call initLbug first."); } try { diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 5579036f1..c7a795d9a 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -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, +]; diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index cab80984a..db1a63eee 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -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], diff --git a/gitnexus/test/unit/workflow-detector.test.ts b/gitnexus/test/unit/workflow-detector.test.ts new file mode 100644 index 000000000..5f02735a1 --- /dev/null +++ b/gitnexus/test/unit/workflow-detector.test.ts @@ -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", + }); + }); +});