diff --git a/gitnexus/src/core/ingestion/cobol-processor.ts b/gitnexus/src/core/ingestion/cobol-processor.ts index 4a4bbf3b3..789c13827 100644 --- a/gitnexus/src/core/ingestion/cobol-processor.ts +++ b/gitnexus/src/core/ingestion/cobol-processor.ts @@ -183,23 +183,35 @@ export const processCobol = ( // (target = :PROGNAME) because later programs haven't // been registered in moduleNodeIds yet. Now that ALL programs are // processed, re-scan unresolved CALLS edges and patch them. + // This covers both `cobol-call-unresolved` and CICS LINK/XCTL edges + // whose targets contain `:`. graph.forEachRelationship(rel => { - if (rel.type === 'CALLS' && rel.reason?.startsWith('cobol-call-unresolved')) { - // Extract the program name from the synthetic target ID - const match = rel.targetId.match(/:(.+)/); - if (!match) return; - const resolvedId = moduleNodeIds.get(match[1]); - if (resolvedId) { - // Replace with resolved edge (can't mutate, so add new + mark old) - graph.addRelationship({ - id: rel.id + ':resolved', - type: 'CALLS', - sourceId: rel.sourceId, - targetId: resolvedId, - confidence: 0.95, - reason: 'cobol-call', - }); - } + if (rel.type !== 'CALLS') return; + const match = rel.targetId.match(/:(.+)/); + if (!match) return; + const resolvedId = moduleNodeIds.get(match[1]); + if (!resolvedId) return; + + if (rel.reason?.startsWith('cobol-call-unresolved')) { + // Replace unresolved CALL with resolved edge + graph.addRelationship({ + id: rel.id + ':resolved', + type: 'CALLS', + sourceId: rel.sourceId, + targetId: resolvedId, + confidence: 0.95, + reason: 'cobol-call', + }); + } else if (rel.reason === 'cics-link-unresolved' || rel.reason === 'cics-xctl-unresolved') { + // Replace unresolved CICS LINK/XCTL with resolved edge + graph.addRelationship({ + id: rel.id + ':resolved', + type: 'CALLS', + sourceId: rel.sourceId, + targetId: resolvedId, + confidence: 0.95, + reason: rel.reason.replace('-unresolved', ''), + }); } }); @@ -493,15 +505,17 @@ function mapToGraph( }); // LINK/XCTL -> cross-program CALLS if (cics.programName && (cics.command === 'LINK' || cics.command === 'XCTL')) { - const targetId = moduleNodeIds.get(cics.programName.toUpperCase()) + const cicsTargetModuleId = moduleNodeIds.get(cics.programName.toUpperCase()); + const targetId = cicsTargetModuleId ?? generateId('Module', `:${cics.programName.toUpperCase()}`); + const cicsReason = `cics-${cics.command.toLowerCase()}`; graph.addRelationship({ id: generateId('CALLS', `${parentId}->cics-${cics.command.toLowerCase()}->${cics.programName}:L${cics.line}`), type: 'CALLS', sourceId: parentId, targetId, - confidence: 0.95, - reason: `cics-${cics.command.toLowerCase()}`, + confidence: cicsTargetModuleId ? 0.95 : 0.5, + reason: cicsTargetModuleId ? cicsReason : `${cicsReason}-unresolved`, }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/cobol-app/AUDITLOG.cbl b/gitnexus/test/fixtures/lang-resolution/cobol-app/AUDITLOG.cbl new file mode 100644 index 000000000..34b12efa2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cobol-app/AUDITLOG.cbl @@ -0,0 +1,21 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. AUDITLOG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-LOG-MESSAGE PIC X(80). + 01 WS-TIMESTAMP PIC X(26). + + LINKAGE SECTION. + 01 LS-CUST-ID PIC 9(8). + 01 LS-AMOUNT PIC 9(7)V99. + + PROCEDURE DIVISION USING LS-CUST-ID LS-AMOUNT. + MAIN-PARAGRAPH. + PERFORM WRITE-LOG + GOBACK. + + WRITE-LOG. + STRING 'Customer ' LS-CUST-ID ' amount ' LS-AMOUNT + DELIMITED BY SIZE INTO WS-LOG-MESSAGE + DISPLAY WS-LOG-MESSAGE. diff --git a/gitnexus/test/fixtures/lang-resolution/cobol-app/CUSTDAT.cpy b/gitnexus/test/fixtures/lang-resolution/cobol-app/CUSTDAT.cpy new file mode 100644 index 000000000..52428837a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cobol-app/CUSTDAT.cpy @@ -0,0 +1,6 @@ + 01 WS-CUSTOMER-DATA. + 05 WS-CUST-CODE PIC X(10). + 05 WS-CUST-TYPE PIC X(3). + 88 PREMIUM-CUSTOMER VALUE 'PRM'. + 88 REGULAR-CUSTOMER VALUE 'REG'. + 05 WS-CUST-ADDR PIC X(50). diff --git a/gitnexus/test/fixtures/lang-resolution/cobol-app/CUSTUPDT.cbl b/gitnexus/test/fixtures/lang-resolution/cobol-app/CUSTUPDT.cbl new file mode 100644 index 000000000..5d087715b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cobol-app/CUSTUPDT.cbl @@ -0,0 +1,60 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. CUSTUPDT. + AUTHOR. TEST. + + ENVIRONMENT DIVISION. + INPUT-OUTPUT SECTION. + FILE-CONTROL. + SELECT CUSTOMER-FILE ASSIGN TO 'CUSTFILE' + ORGANIZATION IS INDEXED + ACCESS IS DYNAMIC + RECORD KEY IS CUST-ID + FILE STATUS IS WS-FILE-STATUS. + + DATA DIVISION. + FILE SECTION. + FD CUSTOMER-FILE. + 01 CUSTOMER-RECORD. + 05 CUST-ID PIC 9(8). + 05 CUST-NAME PIC X(30). + 05 CUST-BALANCE PIC 9(7)V99. + + WORKING-STORAGE SECTION. + 01 WS-FILE-STATUS PIC XX. + 01 WS-CUSTOMER-NAME PIC X(30). + 01 WS-AMOUNT PIC 9(7)V99. + 01 WS-EOF PIC 9 VALUE 0. + 88 END-OF-FILE VALUE 1. + + PROCEDURE DIVISION. + MAIN-PARAGRAPH. + PERFORM INIT-PARAGRAPH + PERFORM PROCESS-PARAGRAPH + PERFORM CLEANUP-PARAGRAPH + STOP RUN. + + INIT-PARAGRAPH. + OPEN I-O CUSTOMER-FILE + MOVE SPACES TO WS-CUSTOMER-NAME. + + PROCESS-PARAGRAPH. + PERFORM READ-CUSTOMER + PERFORM UPDATE-BALANCE + CALL "AUDITLOG" USING CUST-ID WS-AMOUNT + PERFORM WRITE-CUSTOMER. + + READ-CUSTOMER. + READ CUSTOMER-FILE + NOT AT END + MOVE CUST-NAME TO WS-CUSTOMER-NAME + END-READ. + + UPDATE-BALANCE. + ADD WS-AMOUNT TO CUST-BALANCE + MOVE WS-AMOUNT TO CUST-BALANCE. + + WRITE-CUSTOMER. + REWRITE CUSTOMER-RECORD. + + CLEANUP-PARAGRAPH. + CLOSE CUSTOMER-FILE. diff --git a/gitnexus/test/fixtures/lang-resolution/cobol-app/RPTGEN.cbl b/gitnexus/test/fixtures/lang-resolution/cobol-app/RPTGEN.cbl new file mode 100644 index 000000000..ab8213249 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cobol-app/RPTGEN.cbl @@ -0,0 +1,36 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. RPTGEN. + + DATA DIVISION. + WORKING-STORAGE SECTION. + COPY CUSTDAT. + 01 WS-REPORT-LINE PIC X(132). + 01 WS-SQL-CODE PIC S9(9) COMP. + + PROCEDURE DIVISION. + MAIN-PARAGRAPH. + PERFORM FETCH-DATA + PERFORM FORMAT-REPORT + PERFORM SEND-SCREEN + CALL "CUSTUPDT" + STOP RUN. + + FETCH-DATA. + EXEC SQL + SELECT CUST_NAME, CUST_BALANCE + FROM CUSTOMER + WHERE CUST_ID = :WS-CUST-CODE + END-EXEC. + + FORMAT-REPORT. + MOVE WS-CUST-CODE TO WS-REPORT-LINE + PERFORM MAIN-PARAGRAPH THRU FORMAT-REPORT. + + SEND-SCREEN. + EXEC CICS + SEND MAP('CUSTRPT') MAPSET('CUSTSET') + END-EXEC. + + EXEC CICS + LINK PROGRAM('AUDITLOG') + END-EXEC. diff --git a/gitnexus/test/fixtures/lang-resolution/cobol-app/RUNJOBS.jcl b/gitnexus/test/fixtures/lang-resolution/cobol-app/RUNJOBS.jcl new file mode 100644 index 000000000..b737f337a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cobol-app/RUNJOBS.jcl @@ -0,0 +1,5 @@ +//CUSTJOB JOB (ACCT),'CUSTOMER UPDATE',CLASS=A,MSGCLASS=X +//STEP1 EXEC PGM=CUSTUPDT +//CUSTFILE DD DSN=PROD.CUSTOMER.MASTER,DISP=SHR +//STEP2 EXEC PGM=RPTGEN +//SYSOUT DD SYSOUT=* diff --git a/gitnexus/test/integration/resolvers/cobol.test.ts b/gitnexus/test/integration/resolvers/cobol.test.ts new file mode 100644 index 000000000..f2c4b6e19 --- /dev/null +++ b/gitnexus/test/integration/resolvers/cobol.test.ts @@ -0,0 +1,280 @@ +/** + * COBOL: PROGRAM-ID modules, paragraph functions, data items, COPY imports, + * CALL cross-program resolution, EXEC SQL/CICS blocks, MOVE data flow, + * file declarations, JCL job/step integration + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +describe('COBOL full system extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cobol-app'), + () => {}, + { skipGraphPhases: true }, // COBOL is regex-based, not in SupportedLanguages enum + ); + }, 60000); + + // ── Node detection ────────────────────────────────────────────────── + + it('detects Module nodes for each PROGRAM-ID', () => { + const modules = getNodesByLabel(result, 'Module'); + expect(modules).toContain('CUSTUPDT'); + expect(modules).toContain('AUDITLOG'); + expect(modules).toContain('RPTGEN'); + }); + + it('detects Function nodes for paragraphs', () => { + const funcs = getNodesByLabel(result, 'Function'); + // CUSTUPDT paragraphs + expect(funcs).toContain('MAIN-PARAGRAPH'); + expect(funcs).toContain('INIT-PARAGRAPH'); + expect(funcs).toContain('PROCESS-PARAGRAPH'); + expect(funcs).toContain('READ-CUSTOMER'); + expect(funcs).toContain('UPDATE-BALANCE'); + expect(funcs).toContain('WRITE-CUSTOMER'); + expect(funcs).toContain('CLEANUP-PARAGRAPH'); + // AUDITLOG paragraphs + expect(funcs).toContain('WRITE-LOG'); + // RPTGEN paragraphs + expect(funcs).toContain('FETCH-DATA'); + expect(funcs).toContain('FORMAT-REPORT'); + expect(funcs).toContain('SEND-SCREEN'); + }); + + it('detects Property nodes for data items', () => { + const props = getNodesByLabel(result, 'Property'); + // CUSTUPDT data items + expect(props).toContain('WS-FILE-STATUS'); + expect(props).toContain('WS-CUSTOMER-NAME'); + expect(props).toContain('WS-AMOUNT'); + expect(props).toContain('CUST-ID'); + expect(props).toContain('CUST-NAME'); + expect(props).toContain('CUST-BALANCE'); + // AUDITLOG data items + expect(props).toContain('WS-LOG-MESSAGE'); + expect(props).toContain('WS-TIMESTAMP'); + expect(props).toContain('LS-CUST-ID'); + expect(props).toContain('LS-AMOUNT'); + // RPTGEN data items (from COPY expansion) + expect(props).toContain('WS-REPORT-LINE'); + expect(props).toContain('WS-SQL-CODE'); + }); + + it('detects Record nodes for file declarations', () => { + const records = getNodesByLabel(result, 'Record'); + expect(records).toContain('CUSTOMER-FILE'); + }); + + it('detects CodeElement nodes for EXEC SQL blocks', () => { + const codeElements = getNodesByLabel(result, 'CodeElement'); + const sqlElements = codeElements.filter(n => n.startsWith('EXEC SQL')); + expect(sqlElements.length).toBe(1); + expect(sqlElements.some(n => n.includes('SELECT'))).toBe(true); + }); + + it('detects CodeElement nodes for EXEC CICS blocks', () => { + const codeElements = getNodesByLabel(result, 'CodeElement'); + const cicsElements = codeElements.filter(n => n.startsWith('EXEC CICS')); + expect(cicsElements.length).toBe(2); + expect(cicsElements.some(n => n.includes('SEND'))).toBe(true); + expect(cicsElements.some(n => n.includes('LINK'))).toBe(true); + }); + + // ── Intra-program relationships ───────────────────────────────────── + + it('emits CALLS edges for PERFORM statements', () => { + const calls = getRelationships(result, 'CALLS'); + const performs = calls.filter(e => e.rel.reason === 'cobol-perform'); + // CUSTUPDT: MAIN performs INIT, PROCESS, CLEANUP + expect(performs.some(e => e.target === 'INIT-PARAGRAPH')).toBe(true); + expect(performs.some(e => e.target === 'PROCESS-PARAGRAPH')).toBe(true); + expect(performs.some(e => e.target === 'CLEANUP-PARAGRAPH')).toBe(true); + // PROCESS performs READ-CUSTOMER, UPDATE-BALANCE, WRITE-CUSTOMER + expect(performs.some(e => e.target === 'READ-CUSTOMER')).toBe(true); + expect(performs.some(e => e.target === 'UPDATE-BALANCE')).toBe(true); + expect(performs.some(e => e.target === 'WRITE-CUSTOMER')).toBe(true); + }); + + it('emits CALLS edges for PERFORM THRU ranges', () => { + const calls = getRelationships(result, 'CALLS'); + const thrus = calls.filter(e => e.rel.reason === 'cobol-perform-thru'); + // RPTGEN: PERFORM MAIN-PARAGRAPH THRU FORMAT-REPORT + expect(thrus.some(e => e.target === 'FORMAT-REPORT')).toBe(true); + }); + + it('emits CONTAINS edges for module->paragraph hierarchy', () => { + const contains = getRelationships(result, 'CONTAINS'); + // CUSTUPDT module contains its paragraphs + const custContains = contains.filter(e => + e.source === 'CUSTUPDT' && e.rel.reason === 'cobol-paragraph', + ); + const custTargets = custContains.map(e => e.target); + expect(custTargets).toContain('MAIN-PARAGRAPH'); + expect(custTargets).toContain('INIT-PARAGRAPH'); + expect(custTargets).toContain('PROCESS-PARAGRAPH'); + }); + + it('emits ACCESSES edges for MOVE statements (read/write)', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const moveReads = accesses.filter(e => + e.rel.reason === 'cobol-move-read', + ); + const moveWrites = accesses.filter(e => + e.rel.reason === 'cobol-move-write', + ); + expect(moveReads.length).toBe(3); + expect(moveWrites.length).toBe(3); + }); + + // ── Cross-program relationships ───────────────────────────────────── + + it('resolves CALL to known program as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const cobolCalls = calls.filter(e => e.rel.reason === 'cobol-call'); + expect(cobolCalls.length).toBe(2); + }); + + it('emits CALLS with reason cobol-call for static CALL', () => { + const calls = getRelationships(result, 'CALLS'); + // CUSTUPDT calls AUDITLOG + const custToAudit = calls.filter(e => + e.source === 'CUSTUPDT' && e.target === 'AUDITLOG' && e.rel.reason === 'cobol-call', + ); + expect(custToAudit.length).toBe(1); + }); + + it('emits CALLS for EXEC CICS LINK with programName', () => { + const calls = getRelationships(result, 'CALLS'); + const cicsLinks = calls.filter(e => e.rel.reason === 'cics-link'); + expect(cicsLinks.length).toBe(1); + // RPTGEN EXEC CICS LINK PROGRAM('AUDITLOG') — resolved in second pass + const link = cicsLinks.find(e => e.source === 'RPTGEN'); + expect(link).toBeDefined(); + expect(link!.target).toBe('AUDITLOG'); + }); + + it('resolves CALL AUDITLOG from both CUSTUPDT and RPTGEN', () => { + const calls = getRelationships(result, 'CALLS'); + // CUSTUPDT -> AUDITLOG via CALL (resolved in second pass) + const custToAudit = calls.filter(e => + e.source === 'CUSTUPDT' && e.target === 'AUDITLOG', + ); + expect(custToAudit.length).toBe(1); + // RPTGEN -> AUDITLOG via EXEC CICS LINK (resolved in second pass) + const rptToAudit = calls.filter(e => + e.source === 'RPTGEN' && e.target === 'AUDITLOG', + ); + expect(rptToAudit.length).toBe(1); + }); + + // ── COPY/import resolution ────────────────────────────────────────── + + it('emits IMPORTS edge for COPY statement', () => { + const imports = getRelationships(result, 'IMPORTS'); + const cobolCopies = imports.filter(e => e.rel.reason === 'cobol-copy'); + expect(cobolCopies.length).toBe(1); + }); + + it('RPTGEN imports CUSTDAT copybook', () => { + const imports = getRelationships(result, 'IMPORTS'); + const rptImports = imports.filter(e => + e.sourceFilePath.includes('RPTGEN') && e.targetFilePath.includes('CUSTDAT'), + ); + expect(rptImports.length).toBe(1); + expect(rptImports[0].rel.reason).toBe('cobol-copy'); + }); + + // ── EXEC SQL ──────────────────────────────────────────────────────── + + it('creates CodeElement for EXEC SQL SELECT', () => { + const codeElements = getNodesByLabel(result, 'CodeElement'); + expect(codeElements).toContain('EXEC SQL SELECT'); + }); + + it('creates ACCESSES edge to CUSTOMER table', () => { + const accesses = getRelationships(result, 'ACCESSES'); + // The SQL ACCESSES edge targets a synthetic Record node (:CUSTOMER) + // which is not added to the graph, so we verify by reason and source + const sqlAccesses = accesses.filter(e => + e.rel.reason === 'sql-select' && e.source === 'EXEC SQL SELECT', + ); + expect(sqlAccesses.length).toBe(1); + }); + + // ── EXEC CICS ─────────────────────────────────────────────────────── + + it('creates CodeElement for EXEC CICS SEND MAP', () => { + const codeElements = getNodesByLabel(result, 'CodeElement'); + // Two-word CICS command: SEND MAP is recognized as a single command + expect(codeElements).toContain('EXEC CICS SEND MAP'); + }); + + it('creates CodeElement for EXEC CICS LINK', () => { + const codeElements = getNodesByLabel(result, 'CodeElement'); + expect(codeElements).toContain('EXEC CICS LINK'); + }); + + // ── Data flow ─────────────────────────────────────────────────────── + + it('tracks MOVE from WS-AMOUNT to CUST-BALANCE as ACCESSES', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const writeToBalance = accesses.filter(e => + e.target === 'CUST-BALANCE' && e.rel.reason === 'cobol-move-write', + ); + expect(writeToBalance.length).toBe(1); + }); + + it('tracks MOVE from CUST-NAME to WS-CUSTOMER-NAME as ACCESSES', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const readFromName = accesses.filter(e => + e.target === 'CUST-NAME' && e.rel.reason === 'cobol-move-read', + ); + const writeToWsName = accesses.filter(e => + e.target === 'WS-CUSTOMER-NAME' && e.rel.reason === 'cobol-move-write', + ); + expect(readFromName.length).toBe(1); + expect(writeToWsName.length).toBe(1); + }); + + // ── JCL integration ───────────────────────────────────────────────── + + it('creates CodeElement for JCL job steps', () => { + const codeElements = getNodesByLabel(result, 'CodeElement'); + // JCL job node + expect(codeElements).toContain('CUSTJOB'); + // JCL step nodes + expect(codeElements).toContain('STEP1'); + expect(codeElements).toContain('STEP2'); + }); + + it('links JCL EXEC PGM=CUSTUPDT to COBOL Module', () => { + const calls = getRelationships(result, 'CALLS'); + const step1ToCust = calls.filter(e => + e.source === 'STEP1' && e.target === 'CUSTUPDT' && e.rel.reason === 'jcl-exec-pgm', + ); + expect(step1ToCust.length).toBe(1); + }); + + it('links JCL EXEC PGM=RPTGEN to COBOL Module', () => { + const calls = getRelationships(result, 'CALLS'); + const step2ToRpt = calls.filter(e => + e.source === 'STEP2' && e.target === 'RPTGEN' && e.rel.reason === 'jcl-exec-pgm', + ); + expect(step2ToRpt.length).toBe(1); + }); + + it('JCL step CALLS COBOL program', () => { + const calls = getRelationships(result, 'CALLS'); + const jclCalls = calls.filter(e => e.rel.reason === 'jcl-exec-pgm'); + expect(jclCalls.length).toBe(2); + const targets = jclCalls.map(e => e.target).sort(); + expect(targets).toEqual(['CUSTUPDT', 'RPTGEN']); + }); +});