mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
1b56cb1992
`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>
183 lines
7.4 KiB
TypeScript
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([]);
|
|
});
|
|
});
|