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

* 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

* bench(cobol): update depth budget for copybook-dir preference (#2967)

COBOL resolver now prefers well-known copybook directories (copybooks/,
cpy/, copy/, plus importer dir) over vendor paths when resolving COPY
statements. This intentional behavior change moves the resolver from
depth-free (prior measured ~0.885) to depth-sensitive (measured 1.751
on CI run 34394116972), because the new preferredCopybookDirs check
walks path components.

- Raise depth_budget from 1.6 to 2.4 (~1.37x the measured ratio)
- Update _measured.depth_ratio from 0.885 to 1.751
- Add _cobol_copybook_dir_preference_2967 note documenting the change

The COBOL fingerprints already reflect the new target set behavior
(vendor/EXTERNAL.cpy correctly returns null when a copybook dir is
present) per commit fc9e8270.

Co-authored-by: Kevin Rajan <kvnloo@users.noreply.github.com>

* bench(cobol): update baselines for copybook-dir preference (#2967)

COBOL preferred-dir filtering now affects resolution outcomes. The
unique-arm layouts (mixed copybooks/src dirs) drop from 1153 to 442
resolved as files outside preferred directories are correctly filtered.
The collide arm (all files in svc${d}/copybooks) keeps 1153 resolved
because ALL files remain in the preferred class.

- Update small/deep resolved: 1153 → 442
- Update small/large/deep fingerprints for new target set
- Raise heap_bound_bytes.cobol: 3500000 → 6200000 (1.5x measured 4112464 B)
- Add measure.mjs exception: collide legitimately differs from small
- Update _heap_bound_note with new cobol measurement context
- Expand _cobol_copybook_dir_preference_2967 note to explain collide delta

The collide arm now measures collision behavior within the preferred
class rather than across mixed layouts — an intentional outcome of the
preferred-dir semantics rather than a corpus defect.

Refs #2967

Co-authored-by: Kevin Rajan <kvnloo@users.noreply.github.com>

* fix(cobol): P1-A stem key with uppercase extensions, P1-B polyglot preferred-class latch

P1-A: Use raw extension for path.basename so CUSTREC.CPY keys as CUSTREC
  - Before: path.basename('CUSTREC.CPY', '.cpy') -> 'CUSTREC.CPY' (no strip)
  - After: path.basename('CUSTREC.CPY', '.CPY') -> 'CUSTREC' (stripped)
  - Processor used raw extension; census now matches

P1-B: Latch preferred-class only from copybook-tier paths
  - Before: docs/copy/README.md triggered hasPreferredDir = true
  - After: check preferred-dir only after extension filter
  - Processor receives polyglot allPathSet; test added

Test coverage:
  - cobol-copy-external-imports.test.ts: processor pins for both fixes
  - cobol-import-target-parity.test.ts: resolver parity updated to new behavior
  - All existing COBOL tests pass

Co-authored-by: Kevin Rajan <kvnloo@users.noreply.github.com>

* test(cobol): fix Mixed.CPY integration test for P1-A behavior

The integration test in cobol-import-index-reuse.test.ts had outdated
expectations from before P1-A. With P1-A, path.basename uses the raw
extension, so Mixed.CPY is now keyed as MIXED (extension stripped).

Before (pre-P1-A):
- Mixed.CPY → keyed as MIXED.CPY (uppercase ext not stripped)
- COPY MIXED.CPY → found, COPY MIXED → null

After (P1-A):
- Mixed.CPY → keyed as MIXED (raw ext stripped)
- COPY MIXED → found, COPY MIXED.CPY → null

Updated test expectations to match P1-A behavior. All three validation
tests now pass:
- cobol-import-index-reuse.test.ts (integration, index reuse)
- cobol-copy-external-imports.test.ts (processor P1-A/P1-B pins)
- cobol-import-target-parity.test.ts (resolver parity)

Fixes PER-994 CI failure on abhigyanpatwari/GitNexus#3240.

Co-authored-by: Kevin Rajan <kvnloo@users.noreply.github.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kevin Rajan <kvnloo@users.noreply.github.com>
This commit is contained in:
Kevin Rajan 2026-09-10 09:42:59 -05:00 • committed by GitHub
parent 16307a11be
commit 3236e2fbcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 338 additions and 130 deletions

File diff suppressed because one or more lines are too long

View file

@ -2717,6 +2717,9 @@ for (const lang of LANGS) {
}
for (const arm of ['deep', 'collide']) {
if (got[arm].resolved !== got.small.resolved) {
// COBOL #2967 exception: the collide arm (all files in copybook dirs) legitimately
// resolves MORE than the unique arm (mixed layouts) after preferred-dir filtering.
if (lang === 'cobol' && arm === 'collide') continue;
failures.push(
`${lang}: ${arm} arm resolved ${got[arm].resolved} vs small ${got.small.resolved} — the ` +
`${arm} arm was supposed to change ${arm === 'deep' ? 'path depth' : 'directory and file NAMING'} ` +

View file

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

View file

@ -0,0 +1,114 @@
/**
* 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) {
const extRaw = path.extname(fp);
const extLower = extRaw.toLowerCase();
const tier = COPYBOOK_EXTENSIONS.has(extLower)
? copybooks
: COBOL_SOURCE_EXTENSIONS.has(extLower)
? sources
: undefined;
if (tier === undefined) continue;
if (!hasPreferredDir && pathHasPreferredDir(fp)) hasPreferredDir = true;
const basename = path.basename(fp, extRaw).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`.
*/
import path from 'node:path';
import type { ParsedFile } 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 type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cobolProvider } from '../cobol.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 };
});
import { resolveCobolCopyTarget } from './copy-target.js';
const cobolScopeResolver: ScopeResolver = {
language: SupportedLanguages.Cobol,
@ -84,11 +22,9 @@ const cobolScopeResolver: ScopeResolver = {
importEdgeReason: 'cobol-scope: copy',
// ── Resolve COPY bookname to file path ─────────────────────────────
resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => {
const upper = targetRaw.toUpperCase();
const index = getCobolCopyIndex(allFilePaths);
// Copybooks first, then COBOL sources — the tier order IS the tie-break.
return index.copybooks.get(upper) ?? index.sources.get(upper) ?? null;
// Shared with the regex processor's resolveCopy (#2967 lockstep).
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
return resolveCobolCopyTarget(targetRaw, fromFile, allFilePaths);
},
// COBOL has no binding-merge rules beyond the default (local-first-then-imports).

View file

@ -118,11 +118,9 @@ describe('COBOL COPY resolution — index reuse across imports (#2908)', () => {
// Source tier, reached only after every copybook missed.
expect(resolveImportTarget('PAYROLL', FROM_FILE, files, undefined)).toBe('src/PAYROLL.cob');
expect(resolveImportTarget('TAXCALC', FROM_FILE, files, undefined)).toBe('src/TAXCALC.cobol');
// `path.basename(fp, '.cpy')` will not strip `.CPY`, so the stem keeps it.
expect(resolveImportTarget('MIXED.CPY', FROM_FILE, files, undefined)).toBe(
'copybooks/Mixed.CPY',
);
expect(resolveImportTarget('MIXED', FROM_FILE, files, undefined)).toBeNull();
// P1-A: raw extension stripped, so `Mixed.CPY` keys as `MIXED`.
expect(resolveImportTarget('MIXED', FROM_FILE, files, undefined)).toBe('copybooks/Mixed.CPY');
expect(resolveImportTarget('MIXED.CPY', FROM_FILE, files, undefined)).toBeNull();
// Neither tier: a `.txt`, a file with no extension, and an absent member.
expect(resolveImportTarget('NOEXT', FROM_FILE, files, undefined)).toBeNull();
expect(resolveImportTarget('ABSENT', FROM_FILE, files, undefined)).toBeNull();

View file

@ -0,0 +1,111 @@
/**
* 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'));
});
it('P1-A: uppercase .CPY extension does not break stem extraction', () => {
const files = [
{ path: 'copybooks/CUSTREC.CPY', content: CUSTREC },
{ 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'));
});
it('P1-B: polyglot allPathSet with copy/cpy segments does not latch preferred-class', () => {
const files = [
{ path: 'copybooks/CUSTREC.cpy', content: CUSTREC },
{ path: 'vendor/EXTERNAL.cpy', content: EXTERNAL },
{ path: 'src/PROG.cbl', content: PROG },
];
const polyglotPaths = new Set([
'docs/copy/README.md',
'src/copy/clipboard.ts',
...files.map((f) => f.path),
]);
const graph = createKnowledgeGraph();
seedFiles(graph, files);
processCobol(graph, files, polyglotPaths);
const targets = cobolCopyTargets(graph);
expect(targets).toContain(generateId('File', 'copybooks/CUSTREC.cpy'));
expect(targets).not.toContain(generateId('File', 'vendor/EXTERNAL.cpy'));
});
});

View file

@ -66,17 +66,19 @@ function legacyResolveCobolImportTarget(
const upper = targetRaw.toUpperCase();
// Check copybook files first
for (const fp of allFilePaths) {
const ext = path.extname(fp).toLowerCase();
if (!LEGACY_COPYBOOK_EXTENSIONS.has(ext)) continue;
const basename = path.basename(fp, ext).toUpperCase();
const extRaw = path.extname(fp);
const extLower = extRaw.toLowerCase();
if (!LEGACY_COPYBOOK_EXTENSIONS.has(extLower)) continue;
const basename = path.basename(fp, extRaw).toUpperCase();
if (basename === upper) return fp;
}
// Also search COBOL source files (.cbl, .cob, .cobol)
const COBOL_SOURCE_EXTS = new Set(['.cbl', '.cob', '.cobol']);
for (const fp of allFilePaths) {
const ext = path.extname(fp).toLowerCase();
if (!COBOL_SOURCE_EXTS.has(ext)) continue;
const basename = path.basename(fp, ext).toUpperCase();
const extRaw = path.extname(fp);
const extLower = extRaw.toLowerCase();
if (!COBOL_SOURCE_EXTS.has(extLower)) continue;
const basename = path.basename(fp, extRaw).toUpperCase();
if (basename === upper) return fp;
}
return null;
@ -119,13 +121,16 @@ const STEMS = ['CUSTREC', 'custrec', 'AcctRec', 'PAYROLL', 'BOOK', 'COMMON', 'TA
*/
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>();
for (let i = 0; i < fileCount; i++) {
const a = mix(seed * 7919 + i);
const b = mix(a ^ 0x9e3779b9);
const c = mix(b ^ 0x85ebca6b);
const dir = DIRS[a % DIRS.length];
const dir = dirs[a % dirs.length];
const stem = STEMS[b % STEMS.length];
const rel = `${stem}${EXTS[c % EXTS.length]}`;
files.add(dir === '' ? rel : `${dir}/${rel}`);
@ -137,6 +142,10 @@ function corpus(seed: number, fileCount: number): Set<string> {
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
* corpus's awkward files. Lower-case entries are what breaks if the target
@ -166,11 +175,31 @@ const TARGETS = [
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)', () => {
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;
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) {
expect(resolve(target, files), `cobol "${target}" repo=${repo}`).toEqual(
legacyResolveCobolImportTarget(target, files),
@ -181,6 +210,34 @@ describe('COBOL COPY-target index hoist — output parity with the pre-change sc
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('P1-A: uppercase .CPY extension does not break stem extraction', () => {
const files = new Set(['copybooks/CUSTREC.CPY', 'src/PROG.cbl']);
expect(resolve('CUSTREC', files)).toBe('copybooks/CUSTREC.CPY');
});
it('P1-B: polyglot file set with copy/cpy segments does not latch preferred-class', () => {
const files = new Set([
'docs/copy/README.md',
'src/copy/clipboard.ts',
'copybooks/CUSTREC.cpy',
'vendor/EXTERNAL.cpy',
'src/PROG.cbl',
]);
expect(resolve('CUSTREC', files)).toBe('copybooks/CUSTREC.cpy');
expect(resolve('EXTERNAL', files)).toBeNull();
});
it('the corpus actually resolves things (the parity arm is not vacuous)', () => {
// A corpus that resolved nothing would make the arm above pass on
// `null === null` forever. Measured on this corpus: 390 hits.
@ -264,27 +321,21 @@ const HANDBUILT: readonly HandBuilt[] = [
expected: 'copybooks/CUSTREC.cpy',
},
{
why: 'the extension is matched LOWER-cased, so `Foo.CPY` is a copybook at all',
why: 'the extension is matched LOWER-cased for tier, stem stripped with lowercase ext, so `Foo.CPY` is reachable as FOO',
files: ['copybooks/Foo.CPY'],
target: 'FOO.CPY',
target: 'FOO',
expected: 'copybooks/Foo.CPY',
},
{
why: '`path.basename(fp, ext)` strips case-SENSITIVELY, so `Foo.CPY` is NOT reachable as FOO',
files: ['copybooks/Foo.CPY'],
target: 'FOO',
expected: null,
},
{
why: 'a `.CPY` file keyed with its suffix loses `BOOK` to a `.cbl` in the later tier',
why: 'a `.CPY` file (uppercase ext) is keyed without suffix, hits in tier 1',
files: ['x/BOOK.cbl', 'y/BOOK.CPY'],
target: 'BOOK',
expected: 'x/BOOK.cbl',
expected: 'y/BOOK.CPY',
},
{
why: 'an uppercase source extension is a source file (`.CBL` → tier 2, keyed with its suffix)',
why: 'an uppercase source extension is a source file (`.CBL` → tier 2, keyed without suffix)',
files: ['src/Pay.CBL'],
target: 'PAY.CBL',
target: 'PAY',
expected: 'src/Pay.CBL',
},
{

View file

@ -358,7 +358,11 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
fromFile: 'src/PROG.cbl',
resolutionConfig: undefined,
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',
},
],
@ -405,7 +409,6 @@ const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLan
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
[SupportedLanguages.Cobol, '`EXTERNAL` -> `vendor/EXTERNAL.cpy`'],
]);
/**