Files
codegraph/__tests__/explore-cobol-copybook.test.ts
Colby Mchenry 1b56cb1992 fix(explore): find COBOL copybooks by name, with every include site (#2342) (#2358)
`codegraph explore MYCOPYBOOK`, the `codegraph_explore` MCP tool and
`codegraph context` answered "No relevant code found" for a COBOL copybook
named in the query, while `query`/`node` found its include at once.

Cause: the COBOL extractor records `COPY X` and `EXEC SQL INCLUDE X` as
`import` nodes, and findRelevantContext filters every channel by
HIGH_VALUE_NODE_KINDS, which leaves imports out on purpose (a JS/TS/Python
import statement is noise in explore). With the include out of reach the
text search fell through to its fuzzy fallback, so a member with a digit in
it (never extracted as a symbol) came back as an unrelated program one edit
away: CVACT01Y -> CBACT01C, CVCUS01Y -> CBCUS01C on CardDemo. Making COBOL
imports "high value" alone would not have helped either: explore's renderer
skips import nodes, and the import-to-definition step drops a COBOL include
because its `imports` edge leaves the enclosing scope, not the import node.

Change:
- src/graph/cobol-copybooks.ts (new, shared derivation): resolves the COBOL
  words of a query to copybooks - every COPY / EXEC SQL INCLUDE of the member
  (case-insensitive) and the indexed copybook file (.cpy, or a file an
  include resolved to; a same-named program is not one). Gated on the
  project having COBOL files at all (one seek on idx_files_language), so no
  other language's query does extra work or changes. Plain lowercase words
  in a multi-word query are English, not member names.
- findRelevantContext: a named copybook's top-level declarations, then its
  include statements, become entry points ahead of the kind-filtered
  channels, like an exact filename (at most half the slots when the query
  matched other things, include sites in those files first). They are not
  walked (BFS from a record layout spent the answer on whichever program
  MOVEs into it), the member is not re-searched as text (the fuzzy decoy),
  and an explicit nodeKinds filter without `import` is honored.
- codegraph_explore: the copybook file is pinned like a path in the query;
  an include statement that is an entry point is grouped and rendered (a
  window around the COPY line) instead of skipped; a new "COBOL copybook"
  section lists every include site (path:line, COPY vs EXEC SQL INCLUDE,
  25 lines then a count) or says the member has no indexed source (DB2
  DCLGEN, compiler-supplied such as SQLCA/DFHAID).
- QueryBuilder: three indexed lookups; CodeGraph.findNamedCopybooks exposes
  the derivation so explore and the context builder share one answer.
No extraction change; no re-index needed.

Verification: __tests__/explore-cobol-copybook.test.ts (8 tests, through
ToolHandler codegraph_explore and buildContext): with src/ reverted to
origin/main 6 fail (the 2 guard cases pass on both), 8/8 pass with the fix.
tsc clean, npm run build OK. Issue repro fixed on the built CLI (explore +
context) and through a real `serve --mcp` tools/call. Full suite on a heavily
loaded box: 5716 passed, 25 failed in 15 files; 12 of those files pass alone,
and the other 3 (function-ref, git-index-currency, worktree-detection) are
5 s / 60 s timeouts that fail on clean main as well in interleaved runs.

Validation (both builds on one index per repo; query-side change only):
- expressjs/express: 14 explore + 4 context queries byte-identical.
- aws-mainframe-modernization-carddemo: 10 non-copybook queries
  byte-identical; 15 copybook queries all now return the copybook (when
  indexed) and every include site - before: 9 "No relevant code found",
  4 unrelated programs, 2 without the copybook.
- cicsdev/cics-genapp: 6 non-copybook queries byte-identical; 6 copybook
  queries fixed (LGCMAREA's 25 sites incl. multi-line EXEC SQL INCLUDE,
  lowercase lgpolicy, SSMAP with no indexed source).
- In-process explore latency on express unchanged within noise.

Thanks @popolusiak for the report.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:39:03 +00:00

183 lines
7.4 KiB
TypeScript

/**
* COBOL copybooks are findable by name in explore (#2342).
*
* `COPY X` and `EXEC SQL INCLUDE X` are indexed as `import` nodes, and the
* context builder's default kind filter drops imports (for JavaScript an
* import statement is noise). So `codegraph_explore "MYCOPYBOOK"` — and the
* CLI `explore`, which runs the same handler — answered "No relevant code
* found" while `query`/`node` found the include at once. A copybook name with
* a digit in it (`CVACT01Y`) fared worse: the text search fell through to its
* fuzzy fallback and answered with an unrelated program one edit away.
*
* Now a query that names a copybook gets the copybook's own source (pinned,
* first) and every statement that includes it; a member whose source is not
* indexed says so. Other languages' imports stay out of explore.
*/
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
let dir: string;
let cg: CodeGraph;
async function index(files: Record<string, string>): Promise<void> {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cobol-copybook-'));
for (const [name, source] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
fs.writeFileSync(path.join(dir, name), source);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
}
async function explore(query: string): Promise<string> {
const result = await new ToolHandler(cg).execute('codegraph_explore', { query });
expect(result.isError).toBeFalsy();
return result.content[0]!.text;
}
afterEach(() => {
cg?.destroy();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
const lines = (...l: string[]): string => l.join('\n') + '\n';
/** The issue's reproduction, verbatim: an EXEC SQL INCLUDE whose member is not indexed. */
const TESTPROG = lines(
' IDENTIFICATION DIVISION.',
' PROGRAM-ID. TESTPROG.',
' PROCEDURE DIVISION.',
' UP-INCLUDE-COPYBOOK SECTION.',
' UP-INCLUDE-COPYBOOK-10.',
' EXEC SQL INCLUDE MYCOPYBOOK END-EXEC.',
);
/** An indexed data copybook, COPY'd by two programs. */
const COPYBOOK_PROJECT = {
'cpy/CVACT01Y.cpy': lines(
' 01 ACCOUNT-RECORD.',
' 05 ACCT-ID PIC 9(11).',
' 05 ACCT-CURR-BAL PIC S9(10)V99.',
),
'cbl/ACCTUPDT.cbl': lines(
' IDENTIFICATION DIVISION.',
' PROGRAM-ID. ACCTUPDT.',
' DATA DIVISION.',
' WORKING-STORAGE SECTION.',
' COPY CVACT01Y.',
' 01 WS-COUNT PIC 9(4) VALUE 0.',
' PROCEDURE DIVISION.',
' MAIN-PARA.',
' MOVE 1 TO ACCT-ID',
' STOP RUN.',
),
'cbl/ACCTVIEW.cbl': lines(
' IDENTIFICATION DIVISION.',
' PROGRAM-ID. ACCTVIEW.',
' DATA DIVISION.',
' WORKING-STORAGE SECTION.',
' COPY CVACT01Y.',
' PROCEDURE DIVISION.',
' MAIN-PARA.',
' DISPLAY ACCT-CURR-BAL',
' STOP RUN.',
),
// One edit away from the copybook's name, and includes nothing: the
// program the fuzzy fallback used to answer `CVACT01Y` with.
'cbl/CBACT01C.cbl': lines(
' IDENTIFICATION DIVISION.',
' PROGRAM-ID. CBACT01C.',
' PROCEDURE DIVISION.',
' MAIN-PARA.',
' DISPLAY "BATCH"',
' STOP RUN.',
),
};
describe('explore finds COBOL copybooks by name (#2342)', () => {
it('surfaces an EXEC SQL INCLUDE whose copybook is not indexed (the issue repro)', async () => {
await index({ 'TESTPROG.cbl': TESTPROG });
const text = await explore('MYCOPYBOOK');
expect(text).not.toContain('No relevant code found');
// The include site, listed and rendered.
expect(text).toContain('`TESTPROG.cbl:6` — EXEC SQL INCLUDE');
expect(text).toMatch(/6\t\s+EXEC SQL INCLUDE MYCOPYBOOK END-EXEC\./);
// And the honest note that there is no copybook source here to look for.
expect(text).toContain('no indexed source');
});
it('pins an indexed copybook ahead of every include site, and lists all of them', async () => {
await index(COPYBOOK_PROJECT);
const text = await explore('CVACT01Y');
expect(text).not.toContain('No relevant code found');
expect(text).toContain('source `cpy/CVACT01Y.cpy`; included at 2 sites');
expect(text).toContain('`cbl/ACCTUPDT.cbl:5` — COPY');
expect(text).toContain('`cbl/ACCTVIEW.cbl:5` — COPY');
// The copybook's own source renders, before any including program.
const copybookAt = text.indexOf('**`cpy/CVACT01Y.cpy`**');
expect(copybookAt).toBeGreaterThan(-1);
expect(text).toContain('01 ACCOUNT-RECORD.');
for (const program of ['cbl/ACCTUPDT.cbl', 'cbl/ACCTVIEW.cbl']) {
const at = text.indexOf(`**\`${program}\`**`);
expect(at).toBeGreaterThan(copybookAt);
}
expect(text).toContain('COPY CVACT01Y.');
expect(text).toContain('1 file pinned from the query');
// Not the program one edit away from the copybook's name.
expect(text).not.toContain('CBACT01C');
});
it('feeds the same entry points to codegraph context (buildContext)', async () => {
await index({ 'TESTPROG.cbl': TESTPROG, ...COPYBOOK_PROJECT });
const missing = await cg.buildContext('MYCOPYBOOK', { format: 'markdown' });
expect(missing).toContain('EXEC SQL INCLUDE MYCOPYBOOK END-EXEC');
const indexed = await cg.buildContext('CVACT01Y', { format: 'markdown' });
expect(indexed).toContain('ACCOUNT-RECORD');
expect(indexed).toContain('COPY CVACT01Y.');
expect(indexed).not.toContain('CBACT01C');
});
it('leaves the copybook out when an explicit kind filter excludes imports', async () => {
await index({ 'TESTPROG.cbl': TESTPROG });
const subgraph = await cg.findRelevantContext('MYCOPYBOOK', { nodeKinds: ['function'] });
expect([...subgraph.nodes.values()].some((n) => n.kind === 'import')).toBe(false);
});
it('keeps imports of other languages out of explore', async () => {
await index({
'main.ts': "import { CVACT01Y } from './lib';\nexport function run() { return CVACT01Y; }\n",
'lib.ts': 'export const CVACT01Y = 1;\n',
});
const subgraph = await cg.findRelevantContext('CVACT01Y ./lib');
expect([...subgraph.nodes.values()].some((n) => n.kind === 'import')).toBe(false);
const text = await explore('CVACT01Y');
expect(text).not.toContain('COBOL copybook');
expect(text).toContain('export const CVACT01Y = 1;');
});
});
describe('copybook member tokens (#2342)', () => {
// Imported lazily so the behavioural cases above load (and fail) on a build
// that predates the module.
const tokens = async (query: string): Promise<string[]> =>
(await import('../src/graph/cobol-copybooks')).copybookMemberTokens(query);
it('keeps COBOL-shaped words and skips plain lowercase English', async () => {
expect(await tokens('where is CVACT01Y included from cust-rec and dfh$aid'))
.toEqual(['CVACT01Y', 'cust-rec', 'dfh$aid']);
});
it('dedupes case-insensitively and drops short or digit-only tokens', async () => {
expect(await tokens('SQLCA sqlca IO 0042 Sqlca')).toEqual(['SQLCA']);
});
it('takes a one-word query as a name whatever its case', async () => {
expect(await tokens('lgpolicy')).toEqual(['lgpolicy']);
expect(await tokens('the lgpolicy')).toEqual([]);
});
});