GitNexus/gitnexus/test/integration/resolvers/cobol.test.ts
Gergő Magyar fbffa96554
fix(lbug/mcp): exact symbol content + 0-based line storage with 1-based MCP display (#2377, #2379) (#2380)
* fix(lbug): store exact symbol content snippets

* fix(ingestion): emit 0-based line numbers for COBOL/JCL/scope/markdown nodes

COBOL/JCL processors, the scope-graph emitter, and the markdown Section
emitter stored 1-based startLine/endLine, unlike every tree-sitter node
(0-based). The exact-content slice (#2379) then dropped each symbol's
declaration line for those languages. Convert to 0-based at the graph-node
emission boundary via toZeroBasedLine — leaving parser-internal .line values,
L${line} node/edge IDs, and containment checks untouched.

Refs #2377, #2379

* refactor(lbug): single source of truth for symbol-content labels

Extract SYMBOL_NODE_LABELS so the exact-content label set can't drift the way
the inline copy did in #2379. csv-generator derives EXACT_SYMBOL_CONTENT_LABELS
from it; manifest-extractor's near-identical allowlist is left behavior-unchanged
(intentional subset, #2325-test-locked) with a documented cross-reference.

Refs #2379

* test(ingestion): cover 0-based emitter output and pin exact-content slicing

- csv-pipeline: replace the blank-buffer fixture (a +/-1 shift silently passed)
  with directly-adjacent neighbors; add one-line-symbol and Section (+/-2 fallback)
  cases.
- cobol resolver: assert COBOL Module and JCL job/step emit 0-based startLine.
- markdown CRLF: update Section startLine/endLine expectations to 0-based.

Refs #2377, #2379

* feat(mcp): present 1-based line numbers in context/query/impact tools

GraphNode startLine/endLine are stored 0-based (tree-sitter rows), which
surprised users querying them (they don't line up with editors/sed). Add
toDisplayLine and apply it at the context/query/impact response boundaries so
line numbers are editor/sed-aligned. Raw cypher stays 0-based (documented in the
schema resource); BasicBlock/PDG statement lines (already 1-based) and internal
join params are left untouched.

Refs #2377

* test(mcp): assert 1-based tool exposure with raw cypher staying 0-based

context() reports startLine+1 (editor/sed aligned); a raw cypher RETURN of the
same node keeps the stored 0-based value. Guards against double-conversion and
leaking the display shift into raw results.

Refs #2377

* fix(mcp): stop query() double-converting BM25 line numbers

bm25Search applied toDisplayLine to its result rows, and query()'s
aggregation loop applied it again, so BM25-matched symbols reported
lines shifted +2 (stored 0-based 41 read as 43, not 42) while
semantic-matched symbols were correct. bm25Search is called only from
query(); return raw 0-based rows and let the single aggregation-loop
conversion handle both retrievers.

Adds a query() BM25 regression test asserting stored 41 -> 42 (would
be 43 if double-converted), which the prior mcp-line-display test —
covering only context()+cypher — never exercised. (#2380, #2377)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): use ?? not || so first-line symbols keep their line number

`sym.startLine || sym[4]` treated a legitimate 0-based startLine of 0
as absent, so context()/query() dropped startLine/endLine for every
symbol on line 1 of its file — every COBOL Module (toZeroBasedLine(1)
= 0) and markdown h1. `??` only falls through to the positional
fallback on null/undefined, preserving a real 0. This also repairs the
rename definition-edit path, which consumes context()'s value.

Adds a context() first-line (startLine:0 -> 1) assertion. (#2380, #2377)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): make group/cross-repo trace line numbers 1-based consistently

A group/cross-repo trace presented 1-based endpoints (via
resolveSymbolForGroup) but 0-based hops (tagHops copies port.trace
output verbatim), so one response mixed bases. Wrap the trace port
adapter (traceForGroup) to convert hop lines to 1-based too, matching
the endpoints. Single-repo trace dispatches directly (not through this
port) and stays 0-based — full single-repo parity is a tracked
follow-up. core/group stays display-agnostic (no mcp import).

Extends the cross-trace e2e test to assert hops share the endpoints'
base (checkout 10 -> 11, getUsers 1 -> 2). (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): present explain/pdg_query anchor line 1-based

resolveBlockAnchor converted its ambiguous-candidate lines to 1-based
but left the resolved-target anchor raw 0-based, so the same tool
reported two bases depending on whether the target was ambiguous.
Convert the display anchor to 1-based via toDisplayLine. The BasicBlock
join param (symStart: sym.startLine + 1) is untouched — it targets the
1-based BasicBlock id space, not display.

Asserts the resolved anchor is 1-based (targetFn stored 10 -> 11). (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): bump schema + PDG result versions for the line-number change

The 0-based storage flip for COBOL/JCL/markdown/scope (#2377/#2379)
changed on-disk line semantics, and the PDG result startLine is now
1-based (#2380). Neither shipped a version bump, so an incremental
re-analyze would preserve old 1-based rows (mixed-base index rendered
one line too high) and PDG consumers got no signal.

- INCREMENTAL_SCHEMA_VERSION 5 -> 6 (forces a one-time full re-analyze)
- PDG_RESULT_VERSION 1 -> 2 (result-shape discriminator)

Updates the version-pinning tests, the pdgResultVersion result type,
and the tools.ts PDG output-contract doc. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): guard manifest label list against SYMBOL_NODE_LABELS drift

manifest-extractor's CUSTOM_CONTRACT_RESOLVE_QUERY hand-lists the
contract-resolvable labels as a deliberate subset of the shared
SYMBOL_NODE_LABELS, guarded only by a comment — the same drift class
(#2379) the shared-set refactor eliminated elsewhere. Derive the
query's label set and assert it is a strict subset whose difference is
exactly {Namespace, Variable, Module}, so adding a symbol label without
a conscious manifest decision fails. Query string stays literal
(#2325-test-locked). (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(mcp): document which tools present 1-based vs 0-based line numbers

The schema-resource note listed only context/query/impact as 1-based.
After the trace/anchor fixes it now enumerates the full set —
context, query, impact, group/cross-repo trace, and explain/pdg_query
anchors are 1-based; raw Cypher and single-repo trace stay 0-based
(full single-repo-trace parity is a tracked follow-up); BasicBlock/PDG
statement lines are separately 1-based. (#2377, #2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): pin impact() line-value display (close the coverage gap)

The prior mcp-line-display test only asserted context() + raw cypher,
which is why the query() double-conversion (#2380) shipped green. Adds
an impact() line-value assertion via the ambiguous-candidate path (the
only impact response that surfaces a per-candidate line): two same-name
symbols force ambiguity and the candidate at stored 0-based 41 must
read 42. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): fix stale rename #2283 mock after 1-based context display

rename resolves its symbol via context(), which now presents startLine
1-based (#2377), then subtracts 1 to recover the 0-based file index.
The #2283 mock stored startLine:1 but put `oldName` on the file's line
0, so after the 1-based shift the definition edit no longer matched and
the write-failure path never fired — the test read 'success' instead of
'partial'. Align the mock content to its stored line (oldName on
0-based line 1). Pre-existing failure surfaced once ubuntu/coverage
completed on this branch. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): consolidate line-display tests into one shared DB block

The query()/BM25 case had spun up a second full LadybugDB + FTS setup;
fold it into the single existing block (adding FTS + the Zqxwvbm seed
there) so the file builds one DB, not two. Trims per-file setup cost —
relevant to the Windows platform-sensitive suite's under-load 15-minute
timeout. Same five assertions, all green. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: kigland <shuaizhicheng336@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:16:45 +01:00

797 lines
30 KiB
TypeScript

/**
* COBOL: Exhaustive strict integration test.
*
* Every single node and edge produced by the COBOL/JCL pipeline is asserted
* with exact counts AND exact sorted edge-pair lists. No fuzzy assertions.
*
* Ground truth captured from the cobol-app fixture:
* CUSTUPDT.cbl, AUDITLOG.cbl, RPTGEN.cbl, NESTED.cbl,
* CUSTDAT.cpy, COPYLIB.cpy, RUNJOBS.jcl
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { cobolProvider } from '../../../src/core/ingestion/languages/cobol.js';
describe('COBOL full system extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cobol-app'), () => {}, {
skipGraphPhases: true,
});
}, 60000);
// =====================================================================
// NODE COMPLETENESS — exact count + exact sorted name list per label
// =====================================================================
describe('node completeness', () => {
it('produces exactly 5 Module nodes', () => {
const nodes = getNodesByLabel(result, 'Module');
expect(nodes.length).toBe(5);
expect(nodes).toEqual(['AUDITLOG', 'CUSTUPDT', 'INNER-PROG', 'OUTER-PROG', 'RPTGEN']);
});
it('produces exactly 21 Function nodes', () => {
const nodes = getNodesByLabel(result, 'Function');
expect(nodes.length).toBe(21);
expect(nodes).toEqual([
'ABEND-HANDLER',
'BUILD-SORT-INPUT',
'CLEANUP-PARAGRAPH',
'EXIT-PARAGRAPH',
'FETCH-DATA',
'FORMAT-REPORT',
'INIT-PARAGRAPH',
'INNER-MAIN',
'INNER-PROCESS',
'MAIN-PARAGRAPH',
'MAIN-PARAGRAPH',
'MAIN-PARAGRAPH',
'OUTER-MAIN',
'OUTER-PROCESS',
'PROCESS-PARAGRAPH',
'READ-CUSTOMER',
'SEND-SCREEN',
'UPDATE-BALANCE',
'WRITE-CUSTOMER',
'WRITE-LOG',
'WRITE-SORTED',
]);
});
it('produces exactly 2 Namespace nodes', () => {
expect(getNodesByLabel(result, 'Namespace')).toEqual(['INIT-SECTION', 'PROCESSING-SECTION']);
});
it('produces exactly 36 Property nodes', () => {
const nodes = getNodesByLabel(result, 'Property');
expect(nodes.length).toBe(36);
expect(nodes).toEqual([
'CUST-BALANCE',
'CUST-ID',
'CUST-NAME',
'CUSTOMER-RECORD',
'END-OF-FILE',
'FIELD-A',
'FIELD-B',
'LS-AMOUNT',
'LS-CUST-ID',
'LS-PARAM',
'PREMIUM-CUSTOMER',
'REGULAR-CUSTOMER',
'WS-AMOUNT',
'WS-AMT',
'WS-CODE',
'WS-COUNT',
'WS-CUST-ADDR',
'WS-CUST-CODE',
'WS-CUST-TYPE',
'WS-CUSTOMER-DATA',
'WS-CUSTOMER-NAME',
'WS-EOF',
'WS-FILE-STATUS',
'WS-INNER-CODE',
'WS-LOG-MESSAGE',
'WS-MAP-NAME',
'WS-NAME',
'WS-NEXT-PGM',
'WS-OUTER-FLAG',
'WS-PROG-NAME',
'WS-QUEUE-NAME',
'WS-RECORD',
'WS-REPORT-LINE',
'WS-SORT-FILE',
'WS-SQL-CODE',
'WS-TIMESTAMP',
]);
});
it('produces exactly 1 Record node', () => {
expect(getNodesByLabel(result, 'Record')).toEqual(['CUSTOMER-FILE']);
});
it('produces exactly 15 CodeElement nodes', () => {
const nodes = getNodesByLabel(result, 'CodeElement');
expect(nodes.length).toBe(15);
expect(nodes).toEqual([
'CALL WS-PROG-NAME',
'CICS XCTL WS-NEXT-PGM',
'CUSTJOB',
'EXEC CICS HANDLE ABEND',
'EXEC CICS LINK',
'EXEC CICS READ',
'EXEC CICS RETURN',
'EXEC CICS SEND MAP',
'EXEC CICS WRITEQ TS',
'EXEC CICS XCTL',
'EXEC CICS XCTL',
'EXEC SQL SELECT',
'PROD.CUSTOMER.MASTER',
'STEP1',
'STEP2',
]);
});
it('produces exactly 2 Constructor nodes', () => {
expect(getNodesByLabel(result, 'Constructor')).toEqual(['ALTENTRY', 'AUDITLOG-BATCH']);
});
});
// =====================================================================
// CALLS EDGES — exact count + exact sorted pairs per reason
// =====================================================================
describe('CALLS edge completeness', () => {
it('produces exactly 15 CALLS edges with reason cobol-perform', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-perform',
);
expect(edges.length).toBe(15);
expect(edgeSet(edges)).toEqual([
'FORMAT-REPORT \u2192 BUILD-SORT-INPUT',
'FORMAT-REPORT \u2192 FETCH-DATA',
'FORMAT-REPORT \u2192 MAIN-PARAGRAPH',
'FORMAT-REPORT \u2192 SEND-SCREEN',
'FORMAT-REPORT \u2192 WRITE-SORTED',
'INNER-MAIN \u2192 INNER-PROCESS',
'MAIN-PARAGRAPH \u2192 CLEANUP-PARAGRAPH',
'MAIN-PARAGRAPH \u2192 FETCH-DATA',
'MAIN-PARAGRAPH \u2192 FORMAT-REPORT',
'MAIN-PARAGRAPH \u2192 INIT-PARAGRAPH',
'MAIN-PARAGRAPH \u2192 PROCESS-PARAGRAPH',
'MAIN-PARAGRAPH \u2192 SEND-SCREEN',
'MAIN-PARAGRAPH \u2192 WRITE-LOG',
'OUTER-MAIN \u2192 OUTER-PROCESS',
'PROCESS-PARAGRAPH \u2192 READ-CUSTOMER',
]);
});
it('produces exactly 2 CALLS edges with reason cobol-perform-thru', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-perform-thru',
);
expect(edges.length).toBe(2);
expect(edgeSet(edges)).toEqual([
'FORMAT-REPORT \u2192 FORMAT-REPORT',
'PROCESS-PARAGRAPH \u2192 WRITE-CUSTOMER',
]);
});
it('produces exactly 3 CALLS edges with reason cobol-call', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.rel.reason === 'cobol-call');
expect(edges.length).toBe(3);
expect(edgeSet(edges)).toEqual([
'CUSTUPDT \u2192 AUDITLOG',
'OUTER-PROG \u2192 INNER-PROG',
'RPTGEN \u2192 CUSTUPDT',
]);
});
it('produces exactly 4 CALLS edges with reason cobol-goto', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.rel.reason === 'cobol-goto');
expect(edges.length).toBe(4);
expect(edgeSet(edges)).toEqual([
'FORMAT-REPORT \u2192 FETCH-DATA',
'FORMAT-REPORT \u2192 FORMAT-REPORT',
'FORMAT-REPORT \u2192 SEND-SCREEN',
'MAIN-PARAGRAPH \u2192 EXIT-PARAGRAPH',
]);
});
it('produces exactly 1 CALLS edge with reason cics-link', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.rel.reason === 'cics-link');
expect(edges.length).toBe(1);
expect(edgeSet(edges)).toEqual(['RPTGEN \u2192 AUDITLOG']);
});
it('produces exactly 1 CALLS edge with reason cics-xctl', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.rel.reason === 'cics-xctl');
expect(edges.length).toBe(1);
expect(edgeSet(edges)).toEqual(['RPTGEN \u2192 CUSTUPDT']);
});
it('produces exactly 1 CALLS edge with reason cics-handle-abend', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cics-handle-abend',
);
expect(edges.length).toBe(1);
expect(edgeSet(edges)).toEqual(['RPTGEN \u2192 ABEND-HANDLER']);
});
it('produces exactly 1 CALLS edge with reason cics-return-transid', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cics-return-transid',
);
expect(edges.length).toBe(1);
});
it('produces exactly 2 CALLS edges with reason jcl-exec-pgm', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'jcl-exec-pgm',
);
expect(edges.length).toBe(2);
expect(edgeSet(edges)).toEqual(['STEP1 \u2192 CUSTUPDT', 'STEP2 \u2192 RPTGEN']);
});
it('produces exactly 1 CALLS edge with reason jcl-dd:CUSTFILE', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'jcl-dd:CUSTFILE',
);
expect(edges.length).toBe(1);
expect(edgeSet(edges)).toEqual(['STEP1 \u2192 PROD.CUSTOMER.MASTER']);
});
it('produces zero unresolved CALLS edges', () => {
expect(
getRelationships(result, 'CALLS').filter((e) => e.rel.reason.endsWith('-unresolved'))
.length,
).toBe(0);
});
});
// =====================================================================
// CONTAINS EDGES — exact count + exact sorted pairs per reason
// =====================================================================
describe('CONTAINS edge completeness', () => {
it('produces exactly 4 CONTAINS edges with reason cobol-program-id', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-program-id',
);
expect(edges.length).toBe(4);
expect(edgeSet(edges)).toEqual([
'AUDITLOG.cbl \u2192 AUDITLOG',
'CUSTUPDT.cbl \u2192 CUSTUPDT',
'NESTED.cbl \u2192 OUTER-PROG',
'RPTGEN.cbl \u2192 RPTGEN',
]);
});
it('produces exactly 1 CONTAINS edge with reason cobol-nested-program', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-nested-program',
);
expect(edges.length).toBe(1);
expect(edgeSet(edges)).toEqual(['OUTER-PROG \u2192 INNER-PROG']);
});
it('produces exactly 2 CONTAINS edges with reason cobol-section', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-section',
);
expect(edges.length).toBe(2);
expect(edgeSet(edges)).toEqual([
'CUSTUPDT \u2192 INIT-SECTION',
'CUSTUPDT \u2192 PROCESSING-SECTION',
]);
});
it('produces exactly 21 CONTAINS edges with reason cobol-paragraph', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-paragraph',
);
expect(edges.length).toBe(21);
expect(edgeSet(edges)).toEqual([
'AUDITLOG \u2192 MAIN-PARAGRAPH',
'AUDITLOG \u2192 WRITE-LOG',
'INIT-SECTION \u2192 INIT-PARAGRAPH',
'INIT-SECTION \u2192 MAIN-PARAGRAPH',
'INNER-PROG \u2192 INNER-MAIN',
'INNER-PROG \u2192 INNER-PROCESS',
'OUTER-PROG \u2192 OUTER-MAIN',
'OUTER-PROG \u2192 OUTER-PROCESS',
'PROCESSING-SECTION \u2192 CLEANUP-PARAGRAPH',
'PROCESSING-SECTION \u2192 PROCESS-PARAGRAPH',
'PROCESSING-SECTION \u2192 READ-CUSTOMER',
'PROCESSING-SECTION \u2192 UPDATE-BALANCE',
'PROCESSING-SECTION \u2192 WRITE-CUSTOMER',
'RPTGEN \u2192 ABEND-HANDLER',
'RPTGEN \u2192 BUILD-SORT-INPUT',
'RPTGEN \u2192 EXIT-PARAGRAPH',
'RPTGEN \u2192 FETCH-DATA',
'RPTGEN \u2192 FORMAT-REPORT',
'RPTGEN \u2192 MAIN-PARAGRAPH',
'RPTGEN \u2192 SEND-SCREEN',
'RPTGEN \u2192 WRITE-SORTED',
]);
});
it('produces exactly 36 CONTAINS edges with reason cobol-data-item', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-data-item',
);
expect(edges.length).toBe(36);
expect(edgeSet(edges)).toEqual([
'AUDITLOG \u2192 LS-AMOUNT',
'AUDITLOG \u2192 LS-CUST-ID',
'AUDITLOG \u2192 WS-LOG-MESSAGE',
'AUDITLOG \u2192 WS-TIMESTAMP',
'CUSTUPDT \u2192 CUST-BALANCE',
'CUSTUPDT \u2192 CUST-ID',
'CUSTUPDT \u2192 CUST-NAME',
'CUSTUPDT \u2192 CUSTOMER-RECORD',
'CUSTUPDT \u2192 END-OF-FILE',
'CUSTUPDT \u2192 FIELD-A',
'CUSTUPDT \u2192 FIELD-B',
'CUSTUPDT \u2192 LS-PARAM',
'CUSTUPDT \u2192 WS-AMOUNT',
'CUSTUPDT \u2192 WS-AMT',
'CUSTUPDT \u2192 WS-CODE',
'CUSTUPDT \u2192 WS-CUSTOMER-NAME',
'CUSTUPDT \u2192 WS-EOF',
'CUSTUPDT \u2192 WS-FILE-STATUS',
'CUSTUPDT \u2192 WS-NAME',
'CUSTUPDT \u2192 WS-PROG-NAME',
'CUSTUPDT \u2192 WS-RECORD',
'INNER-PROG \u2192 WS-INNER-CODE',
'OUTER-PROG \u2192 WS-OUTER-FLAG',
'RPTGEN \u2192 PREMIUM-CUSTOMER',
'RPTGEN \u2192 REGULAR-CUSTOMER',
'RPTGEN \u2192 WS-COUNT',
'RPTGEN \u2192 WS-CUST-ADDR',
'RPTGEN \u2192 WS-CUST-CODE',
'RPTGEN \u2192 WS-CUST-TYPE',
'RPTGEN \u2192 WS-CUSTOMER-DATA',
'RPTGEN \u2192 WS-MAP-NAME',
'RPTGEN \u2192 WS-NEXT-PGM',
'RPTGEN \u2192 WS-QUEUE-NAME',
'RPTGEN \u2192 WS-REPORT-LINE',
'RPTGEN \u2192 WS-SORT-FILE',
'RPTGEN \u2192 WS-SQL-CODE',
]);
});
it('produces exactly 8 CONTAINS edges with reason cobol-exec-cics', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-exec-cics',
);
expect(edges.length).toBe(8);
expect(edgeSet(edges)).toEqual([
'RPTGEN \u2192 EXEC CICS HANDLE ABEND',
'RPTGEN \u2192 EXEC CICS LINK',
'RPTGEN \u2192 EXEC CICS READ',
'RPTGEN \u2192 EXEC CICS RETURN',
'RPTGEN \u2192 EXEC CICS SEND MAP',
'RPTGEN \u2192 EXEC CICS WRITEQ TS',
'RPTGEN \u2192 EXEC CICS XCTL',
'RPTGEN \u2192 EXEC CICS XCTL',
]);
});
it('produces exactly 1 CONTAINS edge with reason cobol-exec-sql', () => {
expect(
edgeSet(
getRelationships(result, 'CONTAINS').filter((e) => e.rel.reason === 'cobol-exec-sql'),
),
).toEqual(['RPTGEN \u2192 EXEC SQL SELECT']);
});
it('produces exactly 1 CONTAINS edge with reason cics-dynamic-program', () => {
expect(
edgeSet(
getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cics-dynamic-program',
),
),
).toEqual(['RPTGEN \u2192 CICS XCTL WS-NEXT-PGM']);
});
it('produces exactly 1 CONTAINS edge with reason cobol-dynamic-call', () => {
expect(
edgeSet(
getRelationships(result, 'CONTAINS').filter((e) => e.rel.reason === 'cobol-dynamic-call'),
),
).toEqual(['CUSTUPDT \u2192 CALL WS-PROG-NAME']);
});
it('produces exactly 2 CONTAINS edges with reason cobol-entry-point', () => {
expect(
edgeSet(
getRelationships(result, 'CONTAINS').filter((e) => e.rel.reason === 'cobol-entry-point'),
),
).toEqual(['AUDITLOG \u2192 AUDITLOG-BATCH', 'CUSTUPDT \u2192 ALTENTRY']);
});
it('produces exactly 1 CONTAINS edge with reason cobol-file-declaration', () => {
expect(
edgeSet(
getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-file-declaration',
),
),
).toEqual(['CUSTUPDT \u2192 CUSTOMER-FILE']);
});
it('produces exactly 1 CONTAINS edge with reason jcl-job', () => {
expect(
edgeSet(getRelationships(result, 'CONTAINS').filter((e) => e.rel.reason === 'jcl-job')),
).toEqual(['RUNJOBS.jcl \u2192 CUSTJOB']);
});
it('produces exactly 2 CONTAINS edges with reason jcl-step', () => {
expect(
edgeSet(getRelationships(result, 'CONTAINS').filter((e) => e.rel.reason === 'jcl-step')),
).toEqual(['CUSTJOB \u2192 STEP1', 'CUSTJOB \u2192 STEP2']);
});
});
// =====================================================================
// ACCESSES EDGES — exact count + exact sorted pairs per reason
// =====================================================================
describe('ACCESSES edge completeness', () => {
it('produces exactly 4 ACCESSES edges with reason cobol-move-read', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-move-read',
);
expect(edges.length).toBe(4);
expect(edgeSet(edges)).toEqual([
'FORMAT-REPORT \u2192 WS-CUST-CODE',
'READ-CUSTOMER \u2192 CUST-NAME',
'UPDATE-BALANCE \u2192 WS-AMOUNT',
'UPDATE-BALANCE \u2192 WS-AMT',
]);
});
it('produces exactly 5 ACCESSES edges with reason cobol-move-write', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-move-write',
);
expect(edges.length).toBe(5);
expect(edgeSet(edges)).toEqual([
'FORMAT-REPORT \u2192 WS-REPORT-LINE',
'READ-CUSTOMER \u2192 WS-CUSTOMER-NAME',
'UPDATE-BALANCE \u2192 CUST-BALANCE',
'UPDATE-BALANCE \u2192 FIELD-A',
'UPDATE-BALANCE \u2192 FIELD-B',
]);
});
it('produces exactly 1 ACCESSES edge with reason cics-file-read', () => {
expect(
getRelationships(result, 'ACCESSES').filter((e) => e.rel.reason === 'cics-file-read')
.length,
).toBe(1);
});
it('produces exactly 1 ACCESSES edge with reason cics-map', () => {
expect(
getRelationships(result, 'ACCESSES').filter((e) => e.rel.reason === 'cics-map').length,
).toBe(1);
});
it('produces exactly 1 ACCESSES edge with reason cics-queue-write', () => {
expect(
getRelationships(result, 'ACCESSES').filter((e) => e.rel.reason === 'cics-queue-write')
.length,
).toBe(1);
});
it('produces exactly 1 ACCESSES edge with reason cics-receive-into', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cics-receive-into',
);
expect(edges.length).toBe(1);
expect(edges[0].target).toBe('WS-CUSTOMER-DATA');
});
it('produces exactly 2 ACCESSES edges with reason cics-send-from', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cics-send-from',
);
expect(edges.length).toBe(2);
expect(edgeSet(edges)).toEqual([
'EXEC CICS SEND MAP \u2192 WS-REPORT-LINE',
'EXEC CICS WRITEQ TS \u2192 WS-REPORT-LINE',
]);
});
it('produces exactly 1 ACCESSES edge with reason cobol-search', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-search',
);
expect(edges.length).toBe(1);
expect(edgeSet(edges)).toEqual(['RPTGEN \u2192 WS-CUSTOMER-DATA']);
});
it('produces exactly 1 ACCESSES edge with reason sort-using', () => {
expect(
getRelationships(result, 'ACCESSES').filter((e) => e.rel.reason === 'sort-using').length,
).toBe(1);
});
it('produces exactly 1 ACCESSES edge with reason sort-giving (multi-line SORT)', () => {
expect(
getRelationships(result, 'ACCESSES').filter((e) => e.rel.reason === 'sort-giving').length,
).toBe(1);
});
it('produces exactly 2 ACCESSES edges with reason cobol-procedure-using', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-procedure-using',
);
expect(edges.length).toBe(2);
expect(edgeSet(edges)).toEqual(['AUDITLOG \u2192 LS-AMOUNT', 'AUDITLOG \u2192 LS-CUST-ID']);
});
it('produces exactly 1 ACCESSES edge with reason sql-select', () => {
expect(
getRelationships(result, 'ACCESSES').filter((e) => e.rel.reason === 'sql-select').length,
).toBe(1);
});
});
// =====================================================================
// IMPORTS EDGES — exact pairs
// =====================================================================
describe('IMPORTS edge completeness', () => {
it('produces exactly 2 IMPORTS edges with reason cobol-copy', () => {
const edges = getRelationships(result, 'IMPORTS').filter(
(e) => e.rel.reason === 'cobol-copy',
);
expect(edges.length).toBe(2);
});
});
// =====================================================================
// FEATURE-SPECIFIC ASSERTIONS — validates all review findings resolved
// =====================================================================
describe('multi-PERFORM on same line (Finding #III)', () => {
it('captures both PERFORMs in IF/ELSE on a single logical line', () => {
// IF WS-COUNT > 0 PERFORM FETCH-DATA ELSE PERFORM SEND-SCREEN
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-perform' && e.source === 'FORMAT-REPORT',
);
const targets = edges.map((e) => e.target).sort();
expect(targets).toContain('FETCH-DATA');
expect(targets).toContain('SEND-SCREEN');
});
});
describe('INPUT/OUTPUT PROCEDURE IS in SORT (Finding #iii)', () => {
it('creates CALLS edges for INPUT PROCEDURE and OUTPUT PROCEDURE targets', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-perform' && e.source === 'FORMAT-REPORT',
);
const targets = edges.map((e) => e.target).sort();
expect(targets).toContain('BUILD-SORT-INPUT');
expect(targets).toContain('WRITE-SORTED');
});
it('creates paragraph nodes for INPUT/OUTPUT PROCEDURE targets', () => {
const nodes = getNodesByLabel(result, 'Function');
expect(nodes).toContain('BUILD-SORT-INPUT');
expect(nodes).toContain('WRITE-SORTED');
});
});
describe('GO TO DEPENDING ON multi-target (Finding #iv)', () => {
it('captures all three targets from GO TO ... DEPENDING ON', () => {
// GO TO FETCH-DATA FORMAT-REPORT SEND-SCREEN DEPENDING ON WS-COUNT
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-goto' && e.source === 'FORMAT-REPORT',
);
expect(edges.length).toBe(3);
expect(edgeSet(edges)).toEqual([
'FORMAT-REPORT \u2192 FETCH-DATA',
'FORMAT-REPORT \u2192 FORMAT-REPORT',
'FORMAT-REPORT \u2192 SEND-SCREEN',
]);
});
});
describe('MOVE CORR abbreviation (Finding #IV)', () => {
it('produces ACCESSES edges for MOVE CORR with corresponding reason', () => {
const readEdges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-move-corresponding-read',
);
expect(readEdges.length).toBe(1);
expect(edgeSet(readEdges)).toEqual(['FORMAT-REPORT \u2192 WS-CUSTOMER-DATA']);
const writeEdges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-move-corresponding-write',
);
expect(writeEdges.length).toBe(1);
expect(edgeSet(writeEdges)).toEqual(['FORMAT-REPORT \u2192 WS-REPORT-LINE']);
});
});
describe('nested program CONTAINS attribution (Finding #I, #II)', () => {
it('attributes INNER-PROG paragraphs to INNER-PROG, not OUTER-PROG', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-paragraph' && e.target === 'INNER-MAIN',
);
expect(edges.length).toBe(1);
expect(edges[0].source).toBe('INNER-PROG');
});
it('attributes INNER-PROG data items to INNER-PROG, not OUTER-PROG', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-data-item' && e.target === 'WS-INNER-CODE',
);
expect(edges.length).toBe(1);
expect(edges[0].source).toBe('INNER-PROG');
});
it('attributes OUTER-PROG data items to OUTER-PROG', () => {
const edges = getRelationships(result, 'CONTAINS').filter(
(e) => e.rel.reason === 'cobol-data-item' && e.target === 'WS-OUTER-FLAG',
);
expect(edges.length).toBe(1);
expect(edges[0].source).toBe('OUTER-PROG');
});
});
describe('per-program PROCEDURE DIVISION USING (Finding #III partial)', () => {
it('creates ACCESSES edges from AUDITLOG, not from wrong program', () => {
const edges = getRelationships(result, 'ACCESSES').filter(
(e) => e.rel.reason === 'cobol-procedure-using',
);
expect(edges.length).toBe(2);
// Both edges should source from AUDITLOG (the program that declares USING)
for (const e of edges) {
expect(e.source).toBe('AUDITLOG');
}
});
});
describe('PERFORM THRU edge correctness', () => {
it('captures FORMAT-REPORT PERFORM THRU from MAIN-PARAGRAPH', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-perform-thru',
);
expect(edgeSet(edges)).toContain('FORMAT-REPORT \u2192 FORMAT-REPORT');
});
});
describe('nested program CALLS attribution', () => {
it('attributes INNER-PROG PERFORM edges to INNER-PROG paragraphs', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.rel.reason === 'cobol-perform' && e.source === 'INNER-MAIN',
);
expect(edges.length).toBe(1);
expect(edges[0].target).toBe('INNER-PROCESS');
});
});
// =====================================================================
// GRAND TOTALS — catch any unexpected edge leakage
// =====================================================================
describe('grand totals', () => {
it('produces exactly 31 total CALLS edges', () => {
// 15 perform + 2 perform-thru + 3 call + 4 goto + 1 link + 1 xctl
// + 1 handle-abend + 1 return-transid + 2 jcl-exec-pgm + 1 jcl-dd
expect(getRelationships(result, 'CALLS').length).toBe(31);
});
it('produces exactly 81 total CONTAINS edges', () => {
// 4 program-id + 1 nested-program + 2 section + 21 paragraph
// + 36 data-item + 8 exec-cics + 1 exec-sql + 1 dynamic-call
// + 1 cics-dynamic-program + 2 entry-point + 1 file-declaration
// + 1 jcl-job + 2 jcl-step
expect(getRelationships(result, 'CONTAINS').length).toBe(81);
});
it('produces exactly 2 total IMPORTS edges', () => {
expect(getRelationships(result, 'IMPORTS').length).toBe(2);
});
it('produces exactly 28 total ACCESSES edges', () => {
// Original: 4 move-read + 5 move-write + 1 move-corresponding-read + 1 move-corresponding-write
// + 1 file-read + 1 map + 1 queue-write
// + 1 receive-into + 2 send-from + 1 search + 1 sort-using + 1 sort-giving
// + 2 procedure-using + 1 sql-select + 2 call-using
// plus arithmetic: +1 arithmetic-read (WS-AMOUNT) + 1 arithmetic-write (CUST-BALANCE)
// plus ADD TO read+write: +1 arithmetic-read for CUST-BALANCE (TO operand is both read+written)
expect(getRelationships(result, 'ACCESSES').length).toBe(28);
});
});
// =====================================================================
// SCOPE-RESOLUTION MODE: the scope-resolution pipeline produces captures
// from standalone providers. These tests verify that the scope-resolution
// output matches expected capture counts for the cobol-app fixture.
// =====================================================================
describe('scope-resolution mode', () => {
it('scope-resolution pipeline produces capture output', () => {
// Standalone provider wiring in parse-worker produces scope captures
// via emitCobolScopeCaptures.
expect(result.graph).not.toBeNull();
expect(Object.keys(result.graph.nodes ?? {}).length).toBeGreaterThan(0);
});
it('extractParsedFile works for standalone COBOL provider', () => {
const source = `
IDENTIFICATION DIVISION.
PROGRAM-ID. TESTPROG.
PROCEDURE DIVISION.
DISPLAY 'hello'.
STOP RUN.
END PROGRAM TESTPROG.
`;
const parsedFile = extractParsedFile(cobolProvider, source, 'TESTPROG.cbl', () => {});
expect(parsedFile).not.toBeNull();
// Use toBe for strict equality — not.toBeNull() per DoD
expect(parsedFile!.scopes.length).toBeGreaterThan(0);
expect(typeof parsedFile!.moduleScope).toBe('string');
expect(parsedFile!.moduleScope.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------
// LINE-BASE CONVENTION — COBOL/JCL emit 0-based startLine (#2377 / #2379)
// Regex-based processors carry 1-based line numbers; they must convert to
// the 0-based GraphNode convention at emission or the exact-content slice
// drops each symbol's declaration line. These lock that in.
// ---------------------------------------------------------------------
describe('line-base convention: 0-based startLine (#2377 / #2379)', () => {
it('primary program Module starts at 0-based line 0', () => {
const custupdt = getNodesByLabelFull(result, 'Module').find((m) => m.name === 'CUSTUPDT');
expect(custupdt).toBeDefined();
expect(custupdt!.properties.startLine).toBe(0);
});
// NOTE: COBOL paragraph lines can't be cross-checked against the raw file —
// the preprocessor expands COPY statements, so `startLine` is in expanded
// coordinates (a separate, pre-existing content-alignment concern, out of
// scope for the 0-based conversion). JCL has no such expansion, so a JCL
// step gives a clean 0-based proof for a NON-line-0 symbol — ruling out a
// "conversion always yields 0" false pass.
it('JCL step CodeElement startLine is the 0-based declaration line', async () => {
const source = await fs.readFile(path.join(FIXTURES, 'cobol-app', 'RUNJOBS.jcl'), 'utf-8');
const lines = source.split('\n');
const expectedIdx = lines.findIndex((l) => l.includes('STEP1'));
expect(expectedIdx).toBeGreaterThan(0); // not line 0 — proves a real conversion
const step1 = getNodesByLabelFull(result, 'CodeElement').find((n) => n.name === 'STEP1');
expect(step1).toBeDefined();
expect(step1!.properties.startLine).toBe(expectedIdx);
expect(lines[step1!.properties.startLine]).toContain('STEP1');
});
it('JCL job CodeElement starts at 0-based line 0', async () => {
const source = await fs.readFile(path.join(FIXTURES, 'cobol-app', 'RUNJOBS.jcl'), 'utf-8');
const lines = source.split('\n');
const custjob = getNodesByLabelFull(result, 'CodeElement').find((n) => n.name === 'CUSTJOB');
expect(custjob).toBeDefined();
expect(custjob!.properties.startLine).toBe(0);
expect(lines[custjob!.properties.startLine]).toContain('CUSTJOB');
});
});
});