GitNexus/gitnexus/test/unit/jcl-parser.test.ts
Gergo Magyar 4af677e637 feat: add COBOL language support with regex extraction pipeline
Standalone COBOL processor following the markdown-processor.ts pattern:
- No LanguageProvider modification — COBOL uses regex, not tree-sitter
- No SupportedLanguages enum change — standalone processor pattern

New files:
- cobol-processor.ts — orchestrator (processCobol, isCobolFile, isJclFile)
- cobol/cobol-preprocessor.ts — regex state machine extraction (~888 LOC)
- cobol/cobol-copy-expander.ts — COPY statement expansion with circular detection
- cobol/jcl-parser.ts — JCL job/step/DD extraction
- cobol/jcl-processor.ts — JCL graph node creation

Extraction produces:
- Module nodes (PROGRAM-ID)
- Function nodes (paragraphs)
- Namespace nodes (sections)
- Property nodes (data items)
- CALLS edges (PERFORM intra-file, CALL cross-program)
- IMPORTS edges (COPY statements)
- CONTAINS edges (section → paragraph hierarchy)

Pipeline integration: single processCobol() call in Phase 2.6

54 new tests (33 COBOL + 21 JCL), all 3889 tests pass.
2026-03-24 14:39:25 +00:00

338 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { parseJcl } from '../../src/core/ingestion/cobol/jcl-parser.js';
import type { JclParseResults } from '../../src/core/ingestion/cobol/jcl-parser.js';
describe('parseJcl', () => {
// ── JOB statements ──────────────────────────────────────────────────
describe('JOB statements', () => {
it('extracts job name', () => {
const jcl = `//MYJOB JOB (ACCT),'MY JOB'`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.jobs).toHaveLength(1);
expect(r.jobs[0].name).toBe('MYJOB');
expect(r.jobs[0].line).toBe(1);
});
it('extracts CLASS and MSGCLASS parameters', () => {
const jcl = `//PAYJOB JOB (ACCT),'PAYROLL',CLASS=A,MSGCLASS=X`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.jobs).toHaveLength(1);
expect(r.jobs[0].name).toBe('PAYJOB');
expect(r.jobs[0].class).toBe('A');
expect(r.jobs[0].msgclass).toBe('X');
});
it('handles job with no CLASS or MSGCLASS', () => {
const jcl = `//BAREJOB JOB (ACCT),'BARE'`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.jobs).toHaveLength(1);
expect(r.jobs[0].name).toBe('BAREJOB');
expect(r.jobs[0].class).toBeUndefined();
expect(r.jobs[0].msgclass).toBeUndefined();
});
});
// ── EXEC statements ─────────────────────────────────────────────────
describe('EXEC statements', () => {
it('extracts step with PGM=program', () => {
const jcl = [
'//MYJOB JOB (ACCT)',
'//STEP1 EXEC PGM=IEFBR14',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.steps).toHaveLength(1);
expect(r.steps[0].name).toBe('STEP1');
expect(r.steps[0].program).toBe('IEFBR14');
expect(r.steps[0].proc).toBeUndefined();
});
it('extracts step with proc name (no PGM= keyword)', () => {
const jcl = [
'//MYJOB JOB (ACCT)',
'//STEP1 EXEC MYPROC',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.steps).toHaveLength(1);
expect(r.steps[0].name).toBe('STEP1');
expect(r.steps[0].program).toBeUndefined();
expect(r.steps[0].proc).toBe('MYPROC');
});
it('associates step with current job', () => {
const jcl = [
'//JOB1 JOB (ACCT)',
'//STEPA EXEC PGM=PROG1',
'//JOB2 JOB (ACCT)',
'//STEPB EXEC PGM=PROG2',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.steps).toHaveLength(2);
expect(r.steps[0].jobName).toBe('JOB1');
expect(r.steps[1].jobName).toBe('JOB2');
});
});
// ── DD statements ───────────────────────────────────────────────────
describe('DD statements', () => {
it('extracts DD name and dataset (DSN=)', () => {
const jcl = [
'//MYJOB JOB (ACCT)',
'//STEP1 EXEC PGM=IEFBR14',
'//INPUT DD DSN=MY.DATA.SET,DISP=SHR',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.ddStatements).toHaveLength(1);
expect(r.ddStatements[0].ddName).toBe('INPUT');
expect(r.ddStatements[0].dataset).toBe('MY.DATA.SET');
});
it('extracts DISP parameter', () => {
const jcl = [
'//MYJOB JOB (ACCT)',
'//STEP1 EXEC PGM=IEFBR14',
'//OUTPUT DD DSN=MY.OUT,DISP=(NEW,CATLG,DELETE)',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.ddStatements).toHaveLength(1);
expect(r.ddStatements[0].disp).toBe('NEW');
});
it('associates DD with current step', () => {
const jcl = [
'//MYJOB JOB (ACCT)',
'//STEP1 EXEC PGM=PROG1',
'//DD1 DD DSN=DS1,DISP=SHR',
'//STEP2 EXEC PGM=PROG2',
'//DD2 DD DSN=DS2,DISP=SHR',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.ddStatements).toHaveLength(2);
expect(r.ddStatements[0].stepName).toBe('STEP1');
expect(r.ddStatements[1].stepName).toBe('STEP2');
});
});
// ── PROC definitions ────────────────────────────────────────────────
describe('PROC definitions', () => {
it('extracts in-stream PROC with name', () => {
const jcl = [
'//MYPROC PROC',
'//STEP1 EXEC PGM=IEFBR14',
'// PEND',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.procs).toHaveLength(1);
expect(r.procs[0].name).toBe('MYPROC');
expect(r.procs[0].isInStream).toBe(true);
});
it('handles PROC/PEND pairs', () => {
const jcl = [
'//PROC1 PROC',
'//S1 EXEC PGM=PROG1',
'// PEND',
'//PROC2 PROC',
'//S2 EXEC PGM=PROG2',
'// PEND',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.procs).toHaveLength(2);
expect(r.procs[0].name).toBe('PROC1');
expect(r.procs[1].name).toBe('PROC2');
});
});
// ── INCLUDE / SET ───────────────────────────────────────────────────
describe('INCLUDE and SET', () => {
it('extracts INCLUDE MEMBER=name', () => {
const jcl = `// INCLUDE MEMBER=MYINCL`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.includes).toHaveLength(1);
expect(r.includes[0].member).toBe('MYINCL');
expect(r.includes[0].line).toBe(1);
});
it('extracts SET variable=value', () => {
const jcl = `// SET ENV=PROD`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.sets).toHaveLength(1);
expect(r.sets[0].variable).toBe('ENV');
expect(r.sets[0].value).toBe('PROD');
});
});
// ── Conditionals ────────────────────────────────────────────────────
describe('Conditionals', () => {
it('extracts IF condition THEN', () => {
const jcl = `// IF STEP1.RC = 0 THEN`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.conditionals).toHaveLength(1);
expect(r.conditionals[0].type).toBe('IF');
expect(r.conditionals[0].condition).toBe('STEP1.RC = 0');
});
it('extracts ELSE and ENDIF', () => {
const jcl = [
'// IF STEP1.RC = 0 THEN',
'//GOOD EXEC PGM=GOODPGM',
'// ELSE',
'//BAD EXEC PGM=BADPGM',
'// ENDIF',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.conditionals).toHaveLength(3);
expect(r.conditionals[0].type).toBe('IF');
expect(r.conditionals[1].type).toBe('ELSE');
expect(r.conditionals[1].condition).toBeUndefined();
expect(r.conditionals[2].type).toBe('ENDIF');
expect(r.conditionals[2].condition).toBeUndefined();
});
});
// ── JCLLIB ──────────────────────────────────────────────────────────
describe('JCLLIB', () => {
it('extracts JCLLIB ORDER=(lib1,lib2)', () => {
const jcl = `// JCLLIB ORDER=(SYS1.PROCLIB,USER.PROCLIB)`;
const r = parseJcl(jcl, 'test.jcl');
expect(r.jcllib).toHaveLength(1);
expect(r.jcllib[0].order).toEqual(['SYS1.PROCLIB', 'USER.PROCLIB']);
expect(r.jcllib[0].line).toBe(1);
});
});
// ── Continuation lines ──────────────────────────────────────────────
describe('Continuation lines', () => {
it('joins continuation lines (col 72 non-blank + next line starts with //)', () => {
// Build a DD line that is exactly 72 chars with non-blank at col 72 (index 71).
// The continuation line provides the DISP parameter.
// "//DD1 DD DSN=MY.VERY.LONG.DATASET.NAME.THAT.KEEPS.GOING," is 60 chars.
// Pad to 71 then add non-blank at col 72.
const base = '//DD1 DD DSN=MY.VERY.LONG.DATASET.NAME.THAT.KEEPS.GOING,';
const padding = ' '.repeat(71 - base.length);
const line1 = base + padding + 'X'; // col 72 is 'X' (non-blank) -> continuation
const line2 = '// DISP=SHR';
const jcl = [
'//MYJOB JOB (ACCT)',
'//STEP1 EXEC PGM=IEFBR14',
line1,
line2,
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
// The continuation should join the DD line so both DSN and DISP are parsed
expect(r.ddStatements).toHaveLength(1);
expect(r.ddStatements[0].ddName).toBe('DD1');
expect(r.ddStatements[0].dataset).toBe('MY.VERY.LONG.DATASET.NAME.THAT.KEEPS.GOING');
expect(r.ddStatements[0].disp).toBe('SHR');
});
});
// ── Edge cases ──────────────────────────────────────────────────────
describe('Edge cases', () => {
it('skips JCL comments (//*)', () => {
const jcl = [
'//* This is a comment',
'//MYJOB JOB (ACCT)',
'//* Another comment',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.jobs).toHaveLength(1);
expect(r.jobs[0].name).toBe('MYJOB');
});
it('skips non-JCL lines', () => {
const jcl = [
'This is not a JCL line',
'//MYJOB JOB (ACCT)',
' Some data',
'//STEP1 EXEC PGM=IEFBR14',
].join('\n');
const r = parseJcl(jcl, 'test.jcl');
expect(r.jobs).toHaveLength(1);
expect(r.steps).toHaveLength(1);
});
it('empty input returns empty results', () => {
const r = parseJcl('', 'test.jcl');
expect(r.jobs).toEqual([]);
expect(r.steps).toEqual([]);
expect(r.ddStatements).toEqual([]);
expect(r.procs).toEqual([]);
expect(r.includes).toEqual([]);
expect(r.sets).toEqual([]);
expect(r.jcllib).toEqual([]);
expect(r.conditionals).toEqual([]);
});
it('complete JCL job with multiple steps and DDs', () => {
const jcl = [
'//* Complete payroll job',
'//PAYJOB JOB (ACCT123),\'PAYROLL RUN\',CLASS=A,MSGCLASS=X',
'// JCLLIB ORDER=(PAY.PROCLIB,SYS1.PROCLIB)',
'// SET ENV=PROD',
'// INCLUDE MEMBER=STDPARMS',
'//*',
'// IF 1 = 1 THEN',
'//STEP01 EXEC PGM=PAYEXT',
'//INPUT DD DSN=PAY.MASTER,DISP=SHR',
'//OUTPUT DD DSN=PAY.EXTRACT,DISP=(NEW,CATLG,DELETE)',
'//SYSPRINT DD SYSOUT=*',
'//*',
'//STEP02 EXEC PAYCALC',
'//INFILE DD DSN=PAY.EXTRACT,DISP=SHR',
'// ELSE',
'//STEP03 EXEC PGM=IEFBR14',
'// ENDIF',
].join('\n');
const r = parseJcl(jcl, 'payroll.jcl');
// Jobs
expect(r.jobs).toHaveLength(1);
expect(r.jobs[0]).toEqual({
name: 'PAYJOB',
line: 2,
class: 'A',
msgclass: 'X',
});
// JCLLIB
expect(r.jcllib).toHaveLength(1);
expect(r.jcllib[0].order).toEqual(['PAY.PROCLIB', 'SYS1.PROCLIB']);
// SET
expect(r.sets).toHaveLength(1);
expect(r.sets[0]).toEqual({ variable: 'ENV', value: 'PROD', line: 4 });
// INCLUDE
expect(r.includes).toHaveLength(1);
expect(r.includes[0].member).toBe('STDPARMS');
// Conditionals
expect(r.conditionals).toHaveLength(3);
expect(r.conditionals[0].type).toBe('IF');
expect(r.conditionals[1].type).toBe('ELSE');
expect(r.conditionals[2].type).toBe('ENDIF');
// Steps
expect(r.steps).toHaveLength(3);
expect(r.steps[0]).toMatchObject({ name: 'STEP01', program: 'PAYEXT', jobName: 'PAYJOB' });
expect(r.steps[1]).toMatchObject({ name: 'STEP02', proc: 'PAYCALC', jobName: 'PAYJOB' });
expect(r.steps[2]).toMatchObject({ name: 'STEP03', program: 'IEFBR14', jobName: 'PAYJOB' });
// DD statements
expect(r.ddStatements).toHaveLength(4);
expect(r.ddStatements[0]).toMatchObject({ ddName: 'INPUT', stepName: 'STEP01', dataset: 'PAY.MASTER', disp: 'SHR' });
expect(r.ddStatements[1]).toMatchObject({ ddName: 'OUTPUT', stepName: 'STEP01', disp: 'NEW' });
expect(r.ddStatements[2]).toMatchObject({ ddName: 'SYSPRINT', stepName: 'STEP01' });
expect(r.ddStatements[3]).toMatchObject({ ddName: 'INFILE', stepName: 'STEP02', dataset: 'PAY.EXTRACT' });
});
});
});