fix(cobol): prefer copybook dirs so COPY EXTERNAL does not hit vendor decoys

COPY of an out-of-repo member first-won any same-named .cpy, so
vendor/EXTERNAL.cpy became a live cobol-copy IMPORTS edge. Share one
resolver between census and the regex processor: prefer copybooks/cpy/copy
plus the importer dir when present, else fail-open. Drop COBOL KNOWN_GAPS.

Fixes #2967
This commit is contained in:
lesseradmin 2026-09-07 18:26:59 -05:00
parent 0d1aed942f
commit fc9e827081
6 changed files with 255 additions and 96 deletions

View file

@ -6,8 +6,8 @@
* does its own extraction, and writes directly to the graph. * does its own extraction, and writes directly to the graph.
* *
* Pipeline: * Pipeline:
* 1. Separate programs from copybooks * 1. Separate programs from copybooks and JCL
* 2. Build copybook map (name -> content) * 2. Index file content by path for COPY expansion
* 3. For each program: expand COPY statements, then run regex extraction * 3. For each program: expand COPY statements, then run regex extraction
* 4. Map CobolRegexResults to graph nodes and relationships * 4. Map CobolRegexResults to graph nodes and relationships
* 5. Optionally process JCL files for job-step cross-references * 5. Optionally process JCL files for job-step cross-references
@ -25,6 +25,7 @@ import {
} from './cobol/cobol-preprocessor.js'; } from './cobol/cobol-preprocessor.js';
import { expandCopies } from './cobol/cobol-copy-expander.js'; import { expandCopies } from './cobol/cobol-copy-expander.js';
import { processJclFiles } from './cobol/jcl-processor.js'; import { processJclFiles } from './cobol/jcl-processor.js';
import { resolveCobolCopyTarget } from './languages/cobol/copy-target.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
@ -123,38 +124,23 @@ export const processCobol = (
// ── 1. Separate programs, copybooks, and JCL ─────────────────────── // ── 1. Separate programs, copybooks, and JCL ───────────────────────
const programs: CobolFile[] = []; const programs: CobolFile[] = [];
const copybooks: CobolFile[] = [];
const jclFiles: CobolFile[] = []; const jclFiles: CobolFile[] = [];
for (const file of files) { for (const file of files) {
const ext = path.extname(file.path).toLowerCase(); const ext = path.extname(file.path).toLowerCase();
if (JCL_EXTENSIONS.has(ext)) { if (JCL_EXTENSIONS.has(ext)) {
jclFiles.push(file); jclFiles.push(file);
} else if (isCopybook(file.path)) { } else if (COBOL_EXTENSIONS.has(ext) && !isCopybook(file.path)) {
copybooks.push(file);
} else if (COBOL_EXTENSIONS.has(ext)) {
programs.push(file); programs.push(file);
} }
} }
// ── 2. Build copybook map (uppercase name -> content) ────────────── // Path → content for every COBOL/JCL file we ingested. Resolution picks
const copybookMap = new Map<string, { content: string; path: string }>(); // the path (#2967 / census lockstep); this map only supplies the body.
for (const cb of copybooks) {
const name = path.basename(cb.path, path.extname(cb.path)).toUpperCase();
copybookMap.set(name, { content: cb.content, path: cb.path });
}
// Build reverse lookup: path -> content for O(1) readCopy
const copybookByPath = new Map<string, string>(); const copybookByPath = new Map<string, string>();
for (const [, entry] of copybookMap) { for (const f of files) {
copybookByPath.set(entry.path, entry.content); copybookByPath.set(f.path, f.content);
} }
// Resolve and read callbacks for expandCopies
const resolveCopy = (name: string): string | null => {
const entry = copybookMap.get(name.toUpperCase());
return entry ? entry.path : null;
};
// Memoize preprocessed copybook content for the duration of this // Memoize preprocessed copybook content for the duration of this
// processCobol call. A single copybook is COPYed by many programs (and at // processCobol call. A single copybook is COPYed by many programs (and at
// many COPY sites within a program); without this cache // many COPY sites within a program); without this cache
@ -196,6 +182,11 @@ export const processCobol = (
// Preprocess: clean patch markers // Preprocess: clean patch markers
const cleaned = preprocessCobolSource(file.content); const cleaned = preprocessCobolSource(file.content);
// Per-program so COPY EXTERNAL in src/PROG.cbl cannot land on
// vendor/EXTERNAL.cpy when a copybooks/ dir is present (#2967).
const resolveCopy = (name: string): string | null =>
resolveCobolCopyTarget(name, file.path, allPathSet);
// Expand COPY statements // Expand COPY statements
const { expandedContent, copyResolutions } = expandCopies( const { expandedContent, copyResolutions } = expandCopies(
cleaned, cleaned,

View file

@ -0,0 +1,113 @@
/**
* Shared COBOL `COPY` target resolution (#2967 / #2908).
*
* Both the census (`cobolScopeResolver.resolveImportTarget`) and the live
* regex processor (`resolveCopy` `cobol-copy` IMPORTS) must answer the
* same path for the same `(name, fromFile, allFilePaths)`. Greening only
* the census would leave analyze fabricating an IMPORTS edge onto a vendor
* decoy the compiler would never have searched.
*
* Well-known copybook directory segments (`copybooks`, `COPYBOOKS`, `cpy`,
* `copy`) plus the importer's own directory are the preferred class. When
* the file set contains at least one of those well-known segments, a `COPY`
* name resolves only inside that class (first in Set-iteration order).
* `vendor/EXTERNAL.cpy` therefore misses while `copybooks/CUSTREC.cpy` hits.
*
* When the file set has no such directory, fail-open to today's two-tier
* first-wins basename index shops whose copybooks *are* the tree have
* nothing to prefer. The importer directory is *not* enough on its own to
* leave fail-open (`{vendor/EXTERNAL.cpy, src/PROG.cbl}` must still answer
* `vendor/EXTERNAL.cpy`).
*
* The two-tier index is still memoized on the `allFilePaths` Set identity
* (#2908). Preferred-class filtering happens at pick time from the stored
* per-name arrays, so a pass still scans the set once.
*/
import path from 'node:path';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']);
/** Path-component names that mark a conventional copybook directory. */
const PREFERRED_DIR_NAMES = new Set(['copybooks', 'COPYBOOKS', 'cpy', 'copy']);
interface CobolCopyIndex {
/** `.cpy` / `.copybook` files — tier 1, Set-iteration order per stem. */
readonly copybooks: ReadonlyMap<string, readonly string[]>;
/** `.cbl` / `.cob` / `.cobol` files — tier 2, Set-iteration order per stem. */
readonly sources: ReadonlyMap<string, readonly string[]>;
/** True iff any file path has a well-known copybook directory segment. */
readonly hasPreferredDir: boolean;
}
function pathHasPreferredDir(fp: string): boolean {
const parts = fp.split('/');
for (let i = 0; i < parts.length - 1; i++) {
if (PREFERRED_DIR_NAMES.has(parts[i])) return true;
}
return false;
}
function pushUnique(map: Map<string, string[]>, key: string, fp: string): void {
const list = map.get(key);
if (list === undefined) {
map.set(key, [fp]);
return;
}
list.push(fp);
}
const getCobolCopyIndex = perFileSet((allFilePaths: ReadonlySet<string>): CobolCopyIndex => {
const copybooks = new Map<string, string[]>();
const sources = new Map<string, string[]>();
let hasPreferredDir = false;
for (const fp of allFilePaths) {
if (!hasPreferredDir && pathHasPreferredDir(fp)) hasPreferredDir = true;
const ext = path.extname(fp).toLowerCase();
const tier = COPYBOOK_EXTENSIONS.has(ext)
? copybooks
: COBOL_SOURCE_EXTENSIONS.has(ext)
? sources
: undefined;
if (tier === undefined) continue;
const basename = path.basename(fp, ext).toUpperCase();
pushUnique(tier, basename, fp);
}
return { copybooks, sources, hasPreferredDir };
});
function pickCopyPath(
paths: readonly string[] | undefined,
fromFile: string,
hasPreferredDir: boolean,
): string | null {
if (paths === undefined || paths.length === 0) return null;
if (!hasPreferredDir) return paths[0] ?? null;
const fromDir = path.dirname(fromFile);
for (const fp of paths) {
if (pathHasPreferredDir(fp) || path.dirname(fp) === fromDir) return fp;
}
return null;
}
/**
* Resolve a COBOL `COPY` member name against the workspace file set.
*
* `fromFile` is the importing program (census) or the program currently
* being expanded (processor). It is used only to include the importer's
* directory in the preferred class when a well-known copybook dir exists.
*/
export function resolveCobolCopyTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
const upper = targetRaw.toUpperCase();
const index = getCobolCopyIndex(allFilePaths);
return (
pickCopyPath(index.copybooks.get(upper), fromFile, index.hasPreferredDir) ??
pickCopyPath(index.sources.get(upper), fromFile, index.hasPreferredDir) ??
null
);
}

View file

@ -9,74 +9,12 @@
* Reference: `languages/python/scope-resolver.ts`. * Reference: `languages/python/scope-resolver.ts`.
*/ */
import path from 'node:path';
import type { ParsedFile } from 'gitnexus-shared'; import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cobolProvider } from '../cobol.js'; import { cobolProvider } from '../cobol.js';
import { resolveCobolCopyTarget } from './copy-target.js';
// Copybook file extensions for COPY name resolution
const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
// COBOL source files, searched only after every copybook has missed.
const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']);
/**
* Uppercased-basename first file carrying it, one map PER TIER, memoized on
* the `allFilePaths` Set identity (#2908).
*
* `resolveImportTarget` used to run two full workspace scans per `COPY` one
* for the copybook tier, one for the source tier each calling `path.extname`
* + `path.basename` + `toUpperCase` on every entry. A `COPY` of a member that
* lives outside the repo (the common case: vendor and system copybooks) missed
* in both, so both scans always ran to completion, making resolution
* O(copies × files). The orchestrator passes the SAME Set to every import in a
* pass (`pipeline/run.ts` builds it once), so a `WeakMap` keyed on that Set
* turns the scans into one build per run.
*
* Two tiers rather than one map is the tie-break, not a stylistic choice: a
* `.cpy`/`.copybook` hit beats a `.cbl`/`.cob`/`.cobol` hit even when the source
* file comes FIRST in Set-iteration order, which is exactly what collapsing the
* tiers into a single first-wins map would silently discard. Within a tier the
* first file in Set-iteration order wins, mirroring the `return` on first match
* in the scans this replaces.
*
* The per-file key is derived with the same `path.extname(fp).toLowerCase()`
* `path.basename(fp, ext)` `toUpperCase()` sequence the scans used, including
* its quirk: `path.basename` strips the suffix only on an exact, case-sensitive
* match, so `Foo.CPY` indexes under `FOO.CPY` rather than `FOO`. Node's `path`
* stays in the loop for the same reason on POSIX it does not treat `\` as a
* separator, and hand-rolled slicing on `/` would start resolving backslash
* paths the scans never resolved.
*/
interface CobolCopyIndex {
/** `.cpy` / `.copybook` files — tier 1. */
readonly copybooks: ReadonlyMap<string, string>;
/** `.cbl` / `.cob` / `.cobol` files — tier 2. */
readonly sources: ReadonlyMap<string, string>;
}
const getCobolCopyIndex = perFileSet((allFilePaths: ReadonlySet<string>): CobolCopyIndex => {
const copybooks = new Map<string, string>();
const sources = new Map<string, string>();
// One pass builds both tiers: the two scans walked the same files and
// classified each by the same extension test.
for (const fp of allFilePaths) {
const ext = path.extname(fp).toLowerCase();
const tier = COPYBOOK_EXTENSIONS.has(ext)
? copybooks
: COBOL_SOURCE_EXTENSIONS.has(ext)
? sources
: undefined;
if (tier === undefined) continue;
const basename = path.basename(fp, ext).toUpperCase();
// First in Set-iteration order wins, as the scans' first-match `return` did.
if (!tier.has(basename)) tier.set(basename, fp);
}
return { copybooks, sources };
});
const cobolScopeResolver: ScopeResolver = { const cobolScopeResolver: ScopeResolver = {
language: SupportedLanguages.Cobol, language: SupportedLanguages.Cobol,
@ -84,11 +22,9 @@ const cobolScopeResolver: ScopeResolver = {
importEdgeReason: 'cobol-scope: copy', importEdgeReason: 'cobol-scope: copy',
// ── Resolve COPY bookname to file path ───────────────────────────── // ── Resolve COPY bookname to file path ─────────────────────────────
resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => { // Shared with the regex processor's resolveCopy (#2967 lockstep).
const upper = targetRaw.toUpperCase(); resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const index = getCobolCopyIndex(allFilePaths); return resolveCobolCopyTarget(targetRaw, fromFile, allFilePaths);
// Copybooks first, then COBOL sources — the tier order IS the tie-break.
return index.copybooks.get(upper) ?? index.sources.get(upper) ?? null;
}, },
// COBOL has no binding-merge rules beyond the default (local-first-then-imports). // COBOL has no binding-merge rules beyond the default (local-first-then-imports).

View file

@ -0,0 +1,78 @@
/**
* Live regex-processor pin for COBOL COPY EXTERNAL (#2967).
*
* Census `resolveImportTarget` greening is not enough: `cobol-processor`
* emits `cobol-copy` IMPORTS from `expandCopies` + `resolveCopy`. A vendor
* decoy that the compiler would never search must not get that edge when a
* well-known copybook directory is present; fail-open without one.
*/
import { describe, expect, it } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { processCobol } from '../../src/core/ingestion/cobol-processor.js';
import { generateId } from '../../src/lib/utils.js';
const PROG = ` IDENTIFICATION DIVISION.
PROGRAM-ID. PROG.
DATA DIVISION.
WORKING-STORAGE SECTION.
COPY EXTERNAL.
COPY CUSTREC.
`;
const CUSTREC = ` 01 WS-CUSTOMER-DATA.
05 WS-CUST-CODE PIC X(10).
`;
const EXTERNAL = ` 01 WS-VENDOR-DECOY PIC X(8).
`;
function seedFiles(
graph: ReturnType<typeof createKnowledgeGraph>,
files: ReadonlyArray<{ path: string; content: string }>,
): void {
for (const f of files) {
graph.addNode({
id: generateId('File', f.path),
label: 'File',
properties: { name: f.path, filePath: f.path },
});
}
}
function cobolCopyTargets(graph: ReturnType<typeof createKnowledgeGraph>): string[] {
return graph.relationships
.filter((r) => r.type === 'IMPORTS' && r.reason === 'cobol-copy')
.map((r) => r.targetId)
.sort();
}
describe('COBOL processor COPY EXTERNAL does not fabricate vendor IMPORTS (#2967)', () => {
it('emits no cobol-copy IMPORTS onto vendor/EXTERNAL.cpy when copybooks/ is present', () => {
const files = [
{ path: 'copybooks/CUSTREC.cpy', content: CUSTREC },
{ path: 'vendor/EXTERNAL.cpy', content: EXTERNAL },
{ path: 'src/PROG.cbl', content: PROG },
];
const graph = createKnowledgeGraph();
seedFiles(graph, files);
processCobol(graph, files, new Set(files.map((f) => f.path)));
const targets = cobolCopyTargets(graph);
expect(targets).toContain(generateId('File', 'copybooks/CUSTREC.cpy'));
expect(targets).not.toContain(generateId('File', 'vendor/EXTERNAL.cpy'));
});
it('fail-open: without a copybook dir, COPY EXTERNAL still emits onto vendor/EXTERNAL.cpy', () => {
const files = [
{ path: 'vendor/EXTERNAL.cpy', content: EXTERNAL },
{ path: 'src/PROG.cbl', content: PROG },
];
const graph = createKnowledgeGraph();
seedFiles(graph, files);
processCobol(graph, files, new Set(files.map((f) => f.path)));
const targets = cobolCopyTargets(graph);
expect(targets).toContain(generateId('File', 'vendor/EXTERNAL.cpy'));
});
});

View file

@ -119,13 +119,16 @@ const STEMS = ['CUSTREC', 'custrec', 'AcctRec', 'PAYROLL', 'BOOK', 'COMMON', 'TA
*/ */
const EXTS = ['.cpy', '.copybook', '.CPY', '.cbl', '.cob', '.cobol', '.CBL', '.txt', '']; const EXTS = ['.cpy', '.copybook', '.CPY', '.cbl', '.cob', '.cobol', '.CBL', '.txt', ''];
function corpus(seed: number, fileCount: number): Set<string> { /** Directories with no well-known copybook segment — fail-open parity only. */
const FAIL_OPEN_DIRS = ['', 'src', 'jcl/proclib', 'win\\dir'];
function corpusFromDirs(seed: number, fileCount: number, dirs: readonly string[]): Set<string> {
const files = new Set<string>(); const files = new Set<string>();
for (let i = 0; i < fileCount; i++) { for (let i = 0; i < fileCount; i++) {
const a = mix(seed * 7919 + i); const a = mix(seed * 7919 + i);
const b = mix(a ^ 0x9e3779b9); const b = mix(a ^ 0x9e3779b9);
const c = mix(b ^ 0x85ebca6b); const c = mix(b ^ 0x85ebca6b);
const dir = DIRS[a % DIRS.length]; const dir = dirs[a % dirs.length];
const stem = STEMS[b % STEMS.length]; const stem = STEMS[b % STEMS.length];
const rel = `${stem}${EXTS[c % EXTS.length]}`; const rel = `${stem}${EXTS[c % EXTS.length]}`;
files.add(dir === '' ? rel : `${dir}/${rel}`); files.add(dir === '' ? rel : `${dir}/${rel}`);
@ -137,6 +140,10 @@ function corpus(seed: number, fileCount: number): Set<string> {
return files; return files;
} }
function corpus(seed: number, fileCount: number): Set<string> {
return corpusFromDirs(seed, fileCount, DIRS);
}
/** /**
* `COPY` operands as they appear in source, plus the spellings that reach the * `COPY` operands as they appear in source, plus the spellings that reach the
* corpus's awkward files. Lower-case entries are what breaks if the target * corpus's awkward files. Lower-case entries are what breaks if the target
@ -166,11 +173,31 @@ const TARGETS = [
const REPOS = 40; const REPOS = 40;
function hasPreferredCopyDir(files: ReadonlySet<string>): boolean {
for (const fp of files) {
const parts = fp.split('/');
for (let i = 0; i < parts.length - 1; i++) {
if (
parts[i] === 'copybooks' ||
parts[i] === 'COPYBOOKS' ||
parts[i] === 'cpy' ||
parts[i] === 'copy'
) {
return true;
}
}
}
return false;
}
describe('COBOL COPY-target index hoist — output parity with the pre-change scans (#2908)', () => { describe('COBOL COPY-target index hoist — output parity with the pre-change scans (#2908)', () => {
it('agrees with the verbatim pre-change resolver over the generated corpus', () => { it('agrees with the verbatim pre-change resolver over fail-open corpora (no well-known copybook dir)', () => {
let checked = 0; let checked = 0;
for (let repo = 0; repo < REPOS; repo++) { for (let repo = 0; repo < REPOS; repo++) {
const files = corpus(repo, 6 + (repo % 25)); const files = corpusFromDirs(repo, 6 + (repo % 25), FAIL_OPEN_DIRS);
expect(hasPreferredCopyDir(files), `repo=${repo} accidentally grew a copybook dir`).toBe(
false,
);
for (const target of TARGETS) { for (const target of TARGETS) {
expect(resolve(target, files), `cobol "${target}" repo=${repo}`).toEqual( expect(resolve(target, files), `cobol "${target}" repo=${repo}`).toEqual(
legacyResolveCobolImportTarget(target, files), legacyResolveCobolImportTarget(target, files),
@ -181,6 +208,17 @@ describe('COBOL COPY-target index hoist — output parity with the pre-change sc
expect(checked).toBe(REPOS * TARGETS.length); expect(checked).toBe(REPOS * TARGETS.length);
}); });
it('COPY EXTERNAL misses vendor/EXTERNAL.cpy when a copybooks/ dir is present (#2967)', () => {
const files = new Set(['copybooks/CUSTREC.cpy', 'vendor/EXTERNAL.cpy', 'src/PROG.cbl']);
expect(resolve('EXTERNAL', files)).toBeNull();
expect(resolve('CUSTREC', files)).toBe('copybooks/CUSTREC.cpy');
});
it('fail-open: without a copybook dir, COPY EXTERNAL still basename-matches (#2967)', () => {
const files = new Set(['vendor/EXTERNAL.cpy', 'src/PROG.cbl']);
expect(resolve('EXTERNAL', files)).toBe('vendor/EXTERNAL.cpy');
});
it('the corpus actually resolves things (the parity arm is not vacuous)', () => { it('the corpus actually resolves things (the parity arm is not vacuous)', () => {
// A corpus that resolved nothing would make the arm above pass on // A corpus that resolved nothing would make the arm above pass on
// `null === null` forever. Measured on this corpus: 390 hits. // `null === null` forever. Measured on this corpus: 390 hits.

View file

@ -358,7 +358,11 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
fromFile: 'src/PROG.cbl', fromFile: 'src/PROG.cbl',
resolutionConfig: undefined, resolutionConfig: undefined,
external: 'EXTERNAL', external: 'EXTERNAL',
decoy: 'vendor/EXTERNAL.cpy', // After the copybook-dir preference, vendor/EXTERNAL.cpy is intentionally
// unreachable (that is the #2967 fix). The reachable decoy is the in-repo
// copybook; vendor/EXTERNAL.cpy stays in `files` so EXTERNAL→[] is not a
// vacuous miss of an empty workspace.
decoy: 'copybooks/CUSTREC.cpy',
reachesDecoy: 'CUSTREC', reachesDecoy: 'CUSTREC',
}, },
], ],
@ -394,7 +398,6 @@ const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLan
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'], [SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'], [SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'], [SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
[SupportedLanguages.Cobol, '`EXTERNAL` -> `vendor/EXTERNAL.cpy`'],
]); ]);
/** /**