Parcourir la source

feat(extraction): add COBOL language support (.cbl/.cob/.cpy) (#590, #648) (#1161)

Programs, sections/paragraphs (reconstructed extents over the grammar's
flat header stream), PERFORM/THRU/GO TO/CALL call edges, COPY copybook
imports incl. standalone .cpy fragments, DATA DIVISION records/fields/
88-levels with write-site impact references, and CICS flows: EXEC
LINK/XCTL program targets (literal + same-file VALUE deref), EXEC SQL
INCLUDE, and pseudo-conversational RETURN/START TRANSID hops resolved
to the owning program via a CICS framework resolver. Fixed and free
source format (free format via a scanner wide-mode sentinel).

Grammar: vendored wasm built from a patched yutaro-sakamoto/
tree-sitter-cobol (EXEC blocks as an external-scanner token, copybook
fragment entry point, single-quote continuation, COPY REPLACING
pseudo-text, NOT=, CALL GIVING, ENTRY, FREE, bitwise ops, abbreviated
relations, COBOL-2002 usages, and more). Patch + provenance + upstream
PR draft in docs/grammars/. Parse health: AWS CardDemo 43/44 native
(upstream: 9/31), 44/44 through preParse; copybooks 28/29; CobolCraft
free-format 17/17 (upstream: 0); NIST COBOL85 unchanged at 373/382.

Copybook members resolve to files like C includes (basename index,
name-matcher short-circuit so compiler-supplied members stay honestly
unresolved): CardDemo imports 5 -> 285. Impact proof: ACCT-CURR-BAL
(CVACT01Y copybook) surfaces its 4 writer programs cross-file.

Also: run-all.sh now neutralizes the ambient prompt-hook in both A/B
arms (CODEGRAPH_NO_PROMPT_HOOK=1); COBOL corpus entries for agent-eval.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry il y a 2 mois
Parent
commit
41620c60fa

+ 25 - 2
.claude/skills/agent-eval/corpus.json

@@ -421,7 +421,30 @@
       "repo": "https://github.com/tidyverse/ggplot2",
       "size": "Large",
       "files": "~1150",
-      "question": "When a ggplot object is printed, how does the plot actually get built and drawn \u2014 trace the path from print/plot to where geoms render. Name the key functions in order."
+      "question": "When a ggplot object is printed, how does the plot actually get built and drawn — trace the path from print/plot to where geoms render. Name the key functions in order."
+    }
+  ],
+  "COBOL": [
+    {
+      "name": "cics-genapp",
+      "repo": "https://github.com/cicsdev/cics-genapp",
+      "size": "Small",
+      "files": "~60",
+      "question": "When a new insurance policy is added through the LGTESTP1 CICS test harness, which programs handle the request on the way to the DB2 insert? Trace the path and name the programs in order."
+    },
+    {
+      "name": "carddemo",
+      "repo": "https://github.com/aws-samples/aws-mainframe-modernization-carddemo",
+      "size": "Medium",
+      "files": "~75",
+      "question": "How does the online bill-payment flow work — from the COBIL00C screen program to the transaction record being written? Name the paragraphs performed in order and the copybooks that define the records involved."
+    },
+    {
+      "name": "CobolCraft",
+      "repo": "https://github.com/meyfa/CobolCraft",
+      "size": "Medium",
+      "files": "~270",
+      "question": "How does an incoming player chat message travel from packet handling to being broadcast to the other connected players? Name the programs on the path in order."
     }
   ]
-}
+}

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- CodeGraph now indexes **COBOL** (`.cbl`, `.cob`, `.cpy`) — programs, sections and paragraphs with `PERFORM`/`GO TO` call edges, `CALL` cross-program calls, `COPY` copybook imports (standalone copybooks included), and DATA DIVISION records with 88-level condition names, in both fixed and free source format. Impact queries work on data items: every `MOVE`/`ADD`/`COMPUTE`/`SUBTRACT` write-site links back to the field it changes, so "what touches this copybook field" answers across programs. CICS flows connect too: `EXEC CICS LINK`/`XCTL` program targets, `EXEC SQL INCLUDE` copybooks, and pseudo-conversational `RETURN TRANSID(...)` hops resolve to the program owning the transaction id. (#590, #648)
 - CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (`<cfcomponent>`/`<cffunction>`) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `<cfscript>` blocks (at any nesting depth, including inside `<cfif>`/`<cfloop>`/`<cftry>`), call edges, and calls embedded in `#hash#` expressions inside `<cfquery>` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118)
 - CFML inheritance written as a component path now links to the right component. `extends="coldbox.system.web.Controller"` names its supertype by dotted path and `extends="../base"` by relative path (the FW/1 style) — both previously produced no inheritance edge at all, which on framework-style CFML apps hid most of the type hierarchy from impact and blast-radius analysis (on ColdBox's own core, over 90% of inheritance was invisible). Resolution is deliberately conservative: the target's directory layout must corroborate the declared path — so a supertype that lives in an out-of-repo library (testbox, mxunit, an installed framework) correctly stays unlinked rather than being guessed at, and an ambiguous path produces no edge rather than a wrong one. (#1152)
 - CFML method calls made through a local variable, typed argument, or component property now resolve to the right method — the same receiver-type inference the other object-oriented languages already had. `var svc = new UserService(); svc.save()`, `createObject("component", "path.UserService")`, a typed `<cfargument>` or cfscript parameter, and `variables.`/`this.`-scoped fields — including the pseudoconstructor pattern (`variables.svc = new UserService()` in `init()`) and WireBox-injected properties (`property name="svc" inject="UserService"`) — all now link the call to the declared component's method, with methods inherited from a supertype resolved through the inheritance links above. This makes callers, impact/blast-radius, and `codegraph_explore` flow traces follow CFML service calls instead of dropping them or guessing among same-named methods.

+ 2 - 1
README.md

@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
 | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
-| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, CFML, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, CFML, COBOL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
 | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
@@ -716,6 +716,7 @@ is written):
 | R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) |
 | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
 | CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based `<cfcomponent>`/`<cffunction>` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `<cfscript>` delegation, call edges) |
+| COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
 
 ## Measured cross-file coverage
 

+ 210 - 0
__tests__/extraction.test.ts

@@ -8326,3 +8326,213 @@ import foo.cfm;
     });
   });
 });
+
+describe('COBOL Extraction', () => {
+  it('should detect .cbl/.cob/.cpy as cobol (case-insensitive)', () => {
+    expect(detectLanguage('app/cbl/CBACT01C.cbl')).toBe('cobol');
+    expect(detectLanguage('app/cbl/CBSTM03A.CBL')).toBe('cobol');
+    expect(detectLanguage('prog.cob')).toBe('cobol');
+    expect(detectLanguage('app/cpy/CVACT01Y.cpy')).toBe('cobol');
+    expect(isSourceFile('CBACT01C.cbl')).toBe(true);
+  });
+
+  const FIXED = (body: string) =>
+    body
+      .split('\n')
+      .map((l) => (l.length > 0 ? '       ' + l : l))
+      .join('\n');
+
+  const PROGRAM = FIXED(`IDENTIFICATION DIVISION.
+PROGRAM-ID. TESTPROG.
+DATA DIVISION.
+WORKING-STORAGE SECTION.
+01  WS-TOTALS.
+    05  WS-COUNT            PIC 9(4) VALUE ZERO.
+    88  WS-DONE             VALUE 'Y'.
+77  WS-FLAG                 PIC X.
+COPY CVACT01Y.
+PROCEDURE DIVISION.
+MAIN-SECTION SECTION.
+0000-MAIN.
+    PERFORM 1000-INIT
+    PERFORM 2000-PROCESS THRU 2000-EXIT
+    CALL 'CBACT01C' USING WS-TOTALS
+    CALL WS-FLAG
+    GO TO 9999-END
+    .
+1000-INIT.
+    MOVE ZERO TO WS-COUNT.
+2000-PROCESS.
+    EXEC CICS LINK PROGRAM('COCOM01C') COMMAREA(WS-TOTALS)
+    END-EXEC.
+2000-EXIT.
+    EXIT.
+9999-END.
+    GOBACK.
+`);
+
+  it('should extract the program as a module node', () => {
+    const result = extractFromSource('TESTPROG.cbl', PROGRAM);
+    const moduleNode = result.nodes.find((n) => n.kind === 'module');
+    expect(moduleNode).toBeDefined();
+    expect(moduleNode?.name).toBe('TESTPROG');
+  });
+
+  it('should extract sections and paragraphs as functions with reconstructed extents', () => {
+    const result = extractFromSource('TESTPROG.cbl', PROGRAM);
+    const fns = result.nodes.filter((n) => n.kind === 'function');
+    const names = fns.map((f) => f.name);
+    expect(names).toContain('MAIN-SECTION');
+    expect(names).toContain('0000-MAIN');
+    expect(names).toContain('2000-PROCESS');
+    // A paragraph spans from its header to the next header, not just one line.
+    const main = fns.find((f) => f.name === '0000-MAIN');
+    expect(main).toBeDefined();
+    expect(main!.endLine).toBeGreaterThan(main!.startLine + 3);
+    // Paragraphs are contained in their section (qualified name includes it).
+    expect(main!.qualifiedName).toContain('MAIN-SECTION');
+  });
+
+  it('should extract PERFORM, PERFORM THRU, GO TO, and CALL literal as calls references', () => {
+    const result = extractFromSource('TESTPROG.cbl', PROGRAM);
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+    const targets = calls.map((c) => c.referenceName);
+    expect(targets).toContain('1000-INIT');
+    expect(targets).toContain('2000-PROCESS'); // PERFORM ... THRU start
+    expect(targets).toContain('2000-EXIT'); // PERFORM ... THRU end
+    expect(targets).toContain('9999-END'); // GO TO
+    expect(targets).toContain('CBACT01C'); // CALL 'literal'
+    expect(targets).toContain('COCOM01C'); // EXEC CICS LINK PROGRAM('...')
+    // Dynamic CALL through a data name is skipped — announce, don't guess.
+    expect(targets).not.toContain('WS-FLAG');
+  });
+
+  it('should extract COPY as an import node and imports reference', () => {
+    const result = extractFromSource('TESTPROG.cbl', PROGRAM);
+    const importNode = result.nodes.find((n) => n.kind === 'import');
+    expect(importNode).toBeDefined();
+    expect(importNode?.name).toBe('CVACT01Y');
+    const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
+    expect(importRefs.map((r) => r.referenceName)).toContain('CVACT01Y');
+  });
+
+  it('should extract data items as variables, fields, and 88-level constants', () => {
+    const result = extractFromSource('TESTPROG.cbl', PROGRAM);
+    const group = result.nodes.find((n) => n.name === 'WS-TOTALS');
+    expect(group?.kind).toBe('variable');
+    const nested = result.nodes.find((n) => n.name === 'WS-COUNT');
+    expect(nested?.kind).toBe('field');
+    expect(nested?.qualifiedName).toContain('WS-TOTALS');
+    const condition = result.nodes.find((n) => n.name === 'WS-DONE');
+    expect(condition?.kind).toBe('constant');
+    const standalone = result.nodes.find((n) => n.name === 'WS-FLAG');
+    expect(standalone?.kind).toBe('variable');
+  });
+
+  it('should extract EXEC SQL INCLUDE as an import', () => {
+    const code = FIXED(`IDENTIFICATION DIVISION.
+PROGRAM-ID. SQLPROG.
+DATA DIVISION.
+WORKING-STORAGE SECTION.
+EXEC SQL INCLUDE SQLCA END-EXEC.
+01  WS-X                    PIC X.
+PROCEDURE DIVISION.
+P1.
+    GOBACK.
+`);
+    const result = extractFromSource('SQLPROG.cbl', code);
+    const importNode = result.nodes.find((n) => n.kind === 'import' && n.name === 'SQLCA');
+    expect(importNode).toBeDefined();
+    // The EXEC block must not break the rest of the file.
+    expect(result.nodes.find((n) => n.name === 'WS-X')).toBeDefined();
+  });
+
+  it('should extract a standalone data copybook (.cpy fragment)', () => {
+    const code = FIXED(`01  ACCOUNT-RECORD.
+    05  ACCT-ID             PIC 9(11).
+    05  ACCT-CURR-BAL       PIC S9(10)V99.
+`);
+    const result = extractFromSource('CVACT01Y.cpy', code);
+    const record = result.nodes.find((n) => n.name === 'ACCOUNT-RECORD');
+    expect(record?.kind).toBe('variable');
+    const field = result.nodes.find((n) => n.name === 'ACCT-ID');
+    expect(field?.kind).toBe('field');
+  });
+
+  it('should extract a procedure copybook (.cpy fragment with paragraphs)', () => {
+    const code = FIXED(`EDIT-DATE.
+    MOVE 1 TO WS-X
+    PERFORM VALIDATE-YEAR.
+VALIDATE-YEAR.
+    CONTINUE.
+`);
+    const result = extractFromSource('CSUTLDPY.cpy', code);
+    const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(fns).toContain('EDIT-DATE');
+    expect(fns).toContain('VALIDATE-YEAR');
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+    expect(calls.map((c) => c.referenceName)).toContain('VALIDATE-YEAR');
+  });
+
+  it('should emit write-site references for MOVE/ADD/COMPUTE targets', () => {
+    const code = FIXED(`IDENTIFICATION DIVISION.
+PROGRAM-ID. WRITEREF.
+DATA DIVISION.
+WORKING-STORAGE SECTION.
+01  WS-TOTAL                 PIC S9(7)V99.
+01  WS-COUNT                 PIC 9(4).
+PROCEDURE DIVISION.
+P1.
+    MOVE ZERO TO WS-TOTAL
+    ADD 1 TO WS-COUNT
+    COMPUTE WS-TOTAL = WS-TOTAL + 1
+    SUBTRACT 1 FROM WS-COUNT
+    MOVE 1 TO RETURN-CODE.
+`);
+    const result = extractFromSource('WRITEREF.cbl', code);
+    const writes = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
+    const names = writes.map((w) => w.referenceName);
+    expect(names.filter((n) => n === 'WS-TOTAL').length).toBeGreaterThanOrEqual(2); // MOVE + COMPUTE
+    expect(names).toContain('WS-COUNT'); // ADD and SUBTRACT targets
+    // Special registers carry no declaration — never referenced.
+    expect(names).not.toContain('RETURN-CODE');
+  });
+
+  it('should emit cics-transid references for RETURN TRANSID, literal and via same-file VALUE', () => {
+    const code = FIXED(`IDENTIFICATION DIVISION.
+PROGRAM-ID. TXPROG.
+DATA DIVISION.
+WORKING-STORAGE SECTION.
+01  WS-TRANID                PIC X(04) VALUE 'CB00'.
+PROCEDURE DIVISION.
+P1.
+    EXEC CICS RETURN TRANSID('CC00') COMMAREA(WS-X) END-EXEC
+    .
+P2.
+    EXEC CICS RETURN TRANSID(WS-TRANID) END-EXEC
+    .
+P3.
+    EXEC CICS XCTL PROGRAM(WS-UNKNOWN-VAR) END-EXEC
+    .
+`);
+    const result = extractFromSource('TXPROG.cbl', code);
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+    const names = calls.map((c) => c.referenceName);
+    expect(names).toContain('cics-transid:CC00'); // literal
+    expect(names).toContain('cics-transid:CB00'); // dereferenced through WS-TRANID
+    // Un-derefable program variable: dynamic dispatch, no guessed edge.
+    expect(names.filter((n) => n.startsWith('cics-transid:')).length).toBe(2);
+  });
+
+  it('should shift free-format source so it still extracts (preParse)', () => {
+    const code = `IDENTIFICATION DIVISION.
+PROGRAM-ID. FREEPROG.
+PROCEDURE DIVISION.
+DO-WORK.
+    DISPLAY 'HI'.
+`;
+    const result = extractFromSource('freeprog.cbl', code);
+    expect(result.nodes.find((n) => n.kind === 'module')?.name).toBe('FREEPROG');
+    expect(result.nodes.find((n) => n.kind === 'function')?.name).toBe('DO-WORK');
+  });
+});

+ 128 - 0
docs/grammars/tree-sitter-cobol.md

@@ -0,0 +1,128 @@
+# tree-sitter-cobol.wasm — provenance & rebuild
+
+`src/extraction/wasm/tree-sitter-cobol.wasm` is built from
+[yutaro-sakamoto/tree-sitter-cobol](https://github.com/yutaro-sakamoto/tree-sitter-cobol)
+at commit `e99dbdc3d800d5fa2796476efd60af91f6b43d93` with the patch in
+`tree-sitter-cobol.patch` applied (grammar.js + src/scanner.c; everything else
+is regenerated by `tree-sitter generate`).
+
+## What the patch adds
+
+The upstream grammar (COBOL85, derived from opensource-cobol, NIST-tested)
+parses batch COBOL well but chokes on the constructs that dominate real
+mainframe and GnuCOBOL code:
+
+1. **`EXEC ... END-EXEC` blocks** (CICS / SQL / DLI). Upstream has no EXEC
+   support at all — the tokens get absorbed into the preceding statement until
+   one breaks the parse, cascading hundreds of lines into one ERROR. The patch
+   adds an `EXEC_BLOCK` external-scanner token that consumes the whole block,
+   surfaced as a single `exec_statement` node (valid as a procedure statement
+   and as a data-division entry, for `EXEC SQL INCLUDE`/`DECLARE`).
+2. **`NOT=`** without a space (IBM COBOL accepts it).
+3. **`FD <name>.` followed by `COPY`** for the record layout — upstream
+   required a literal record description after every FD (also fixed for
+   LINKAGE SECTION).
+4. **`COPY ... REPLACING ==pseudo-text== BY ==pseudo-text==`** with multiple
+   replacement pairs. (Upstream's `replacing_clause` never consumed the
+   REPLACING keyword and allowed only one WORD/string pair.)
+5. **Single-quote string continuation lines** (hyphen in the indicator
+   column). Upstream's scanner handled continuation only for double-quoted
+   strings; the patch generalizes the quote character and handles doubled-
+   quote escapes (`'DON''T'`).
+6. **`FUNCTION <intrinsic>(refmod)`** — upstream's generic FUNCTION fallback
+   took arguments but not a reference-modification suffix
+   (`FUNCTION CURRENT-DATE(1:4)`; the dedicated intrinsic tokens like
+   `CURRENT-DATE-FUNC` only match opensource-cobol's *preprocessed* names).
+7. **Standalone copybook entry point** — a `copybook_fragment` start
+   alternative so `.cpy` files (data records or procedure paragraphs, no
+   IDENTIFICATION DIVISION) parse without a wrapper. A file is either
+   programs or one fragment, keeping program suffixes unambiguous.
+8. **Wide mode for free-format source** — the extractor converts free-format
+   COBOL to fixed by left-padding 7 columns and plants a `CGWIDE` sentinel in
+   the first line's sequence area; the scanner then relaxes the column-72
+   right margin (free-format lines routinely exceed it). One byte of scanner
+   state, carried through serialize/deserialize. Fixed-format files are
+   untouched.
+9. **COBOL-2002 / GnuCOBOL surface**: `BINARY-LONG-LONG`, `FLOAT-LONG`,
+   `FLOAT-SHORT` usages; `PROGRAM-ID. X IS RECURSIVE.`; relational `WHEN`
+   objects (`WHEN > 0`); abbreviated combined relations
+   (`WHEN X < 0 OR > Y`); bitwise operators (`B-AND`, `B-OR`, `B-XOR`,
+   `B-NOT`, `B-SHIFT-L/-LC`, `B-SHIFT-R/-RC`); the `FREE` statement;
+   `VALUE <constant-name>`; `PIC X(CONSTANT-NAME)`; `>>` compiler
+   directives as comments; `ENTRY 'literal' USING ...` (IMS batch
+   alternate entry points); empty `DATE-COMPILED.` / `DATE-WRITTEN.` /
+   `SECURITY.` headers.
+10. **`CALL ... GIVING`** — upstream *intended* to support it but a misnested
+    `field()` call swallowed the GIVING alternative entirely.
+
+## Measured parse health (at vendoring time)
+
+| Corpus | Clean parses |
+|---|---|
+| AWS CardDemo, all programs incl. DB2/IMS/MQ variants (44 `.cbl`) | 43/44 — upstream: 9/31 on the base set alone. The one residual (a period-less `EXEC SQL INCLUDE` between paragraphs) is repaired by the extractor's preParse, which terminates the single-line form with a period. |
+| AWS CardDemo copybooks (29 `.cpy`) | 28/29 — upstream: 0/29 (the failure is a `COPY REPLACING` template containing `(TESTVAR1)` placeholders, not valid COBOL pre-substitution) |
+| NIST COBOL85 suite (382 programs) | 373/382 — unchanged from upstream |
+| CobolCraft (17 free-format GnuCOBOL programs, via wide mode) | 17/17 — upstream: unparseable (free format) |
+| Upstream corpus tests | 1 pre-existing failure (`comment`), no new failures |
+
+
+## Rebuild
+
+```bash
+git clone https://github.com/yutaro-sakamoto/tree-sitter-cobol
+cd tree-sitter-cobol
+git checkout e99dbdc3d800d5fa2796476efd60af91f6b43d93
+git apply path/to/tree-sitter-cobol.patch
+# tree-sitter 0.24.x needs a tree-sitter.json; grammar name must stay COBOL
+# (the C symbols are tree_sitter_COBOL*):
+cat > tree-sitter.json <<'JSON'
+{
+  "grammars": [
+    { "name": "COBOL", "camelcase": "COBOL", "scope": "source.cobol",
+      "path": ".", "file-types": ["cbl", "cob", "cpy"] }
+  ],
+  "metadata": { "version": "0.1.1", "license": "MIT",
+    "description": "COBOL grammar for tree-sitter",
+    "links": { "repository": "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } }
+}
+JSON
+npm install tree-sitter-cli@0.24.5
+npx tree-sitter generate
+npx tree-sitter build --wasm -o tree-sitter-cobol.wasm   # needs emscripten or Docker
+```
+
+The patches are written to be upstreamable — each is independent and comes
+with the failing construct documented above.
+
+## Upstreaming (prepared, not sent)
+
+`git apply tree-sitter-cobol.patch` on upstream commit `e99dbdc3` reproduces
+the fork exactly, so the patch file IS the prepared series. Draft PR body,
+ready to post to yutaro-sakamoto/tree-sitter-cobol once approved:
+
+> **Parse real-world CICS/DB2 and GnuCOBOL sources**
+>
+> This adds the constructs that block the grammar on production COBOL, found
+> while integrating it into a code-indexing tool. Measured on public corpora:
+> AWS CardDemo goes from 9/31 to 43/44 clean parses, CobolCraft (free-format
+> GnuCOBOL) from 0 to 17/17, NIST COBOL85 unchanged at 373/382, and the
+> existing corpus tests keep their single pre-existing failure (`comment`).
+>
+> - `EXEC ... END-EXEC` blocks as an external-scanner token (`exec_statement`)
+> - Fixed-format single-quote string continuation + doubled-quote escapes
+> - `COPY ... REPLACING ==pseudo-text==` with multiple pairs
+> - `FD`/`LINKAGE SECTION` record descriptions supplied via `COPY`
+> - `NOT=`, `CALL ... GIVING` (a misnested `field()` dropped it), `FREE`,
+>   `ENTRY`, `PROGRAM-ID ... IS RECURSIVE`, empty `DATE-COMPILED.` headers
+> - `FUNCTION <name>(refmod)`, `VALUE <constant>`, `PIC X(CONSTANT)`
+> - Relational and abbreviated-combined `WHEN` objects, bitwise `B-*` ops,
+>   `>>` directives-as-comments, COBOL-2002 usages (`BINARY-LONG-LONG`, ...)
+> - A `copybook_fragment` entry point so standalone `.cpy` files parse
+> - An opt-in wide mode (sentinel in the first line's sequence area) that a
+>   free-format preprocessor can use to relax the column-72 margin
+>
+> Happy to split any of these out or adjust naming/style. Each item is
+> independent; `tree-sitter test` passes minus the one pre-existing failure.
+
+The maintainer may prefer separate PRs per item — the diff hunks split
+cleanly along the list above.

+ 513 - 0
docs/grammars/tree-sitter-cobol.patch

@@ -0,0 +1,513 @@
+diff --git a/grammar.js b/grammar.js
+index 6d48b47..e700a18 100644
+--- a/grammar.js
++++ b/grammar.js
+@@ -19,6 +19,7 @@ module.exports = grammar({
+     $._LINE_COMMENT,
+     $.comment_entry,
+     $._multiline_string,
++    $._exec_block,
+   ],
+ 
+   extras: $ => [
+@@ -29,20 +30,30 @@ module.exports = grammar({
+     $._LINE_COMMENT_ALIAS,
+     $.copy_statement,
+     $.comment,
++    $.directive,
+   ],
+ 
+   rules: {
+-    start: $ => repeat(
+-      choice(
+-        $.program_definition,
+-        //optional($.function_definition) //todo
+-      )
++    start: $ => optional(choice(
++      repeat1($.program_definition),
++      $.copybook_fragment,
++      //optional($.function_definition) //todo
++    )),
++
++    // A standalone copybook (.cpy): data descriptions or procedure code
++    // without a program header.
++    copybook_fragment: $ => choice(
++      $.record_description_list,
++      $._procedure_division_contenet
+     ),
+ 
+     _LINE_COMMENT_ALIAS: $ => alias($._LINE_COMMENT, $.comment),
+ 
+     comment: $ => /\*>[^\n]*/,
+ 
++    // GnuCOBOL / COBOL-2002 compiler directives: >>SOURCE, >>IF, >>DEFINE, ...
++    directive: $ => />>[^\n]*/,
++
+     program_definition: $ => prec.right(seq(
+       $.identification_division,
+       optional($.environment_division),
+@@ -60,7 +71,8 @@ module.exports = grammar({
+           optional(choice(
+             $.as_literal,
+             $.is_initial,
+-            $.is_common)),
++            $.is_common,
++            $.is_recursive)),
+           '.')),
+       repeat(choice(
+         $.author_section,
+@@ -90,6 +102,11 @@ module.exports = grammar({
+       $._COMMON
+     ),
+ 
++    is_recursive: $ => seq(
++      optional($._IS),
++      $._RECURSIVE
++    ),
++
+     author_section: $ => seq(
+       $._AUTHOR, '.',
+       field('comment', repeat1($.comment_entry)),
+@@ -102,17 +119,17 @@ module.exports = grammar({
+ 
+     date_written_section: $ => seq(
+       $._DATE_WRITTEN, '.',
+-      field('comment', repeat1($.comment_entry)),
++      field('comment', repeat($.comment_entry)),
+     ),
+ 
+     date_compiled_section: $ => seq(
+       $._DATE_COMPILED, '.',
+-      field('comment', repeat1($.comment_entry)),
++      field('comment', repeat($.comment_entry)),
+     ),
+ 
+     security_section: $ => seq(
+       $._SECURITY, '.',
+-      field('comment', repeat1($.comment_entry)),
++      field('comment', repeat($.comment_entry)),
+     ),
+ 
+     function_definition: $ => seq(
+@@ -734,7 +751,7 @@ module.exports = grammar({
+     file_description: $ => seq(
+       $.file_type,
+       $.file_description_entry,
+-      $.record_description_list
++      optional($.record_description_list)
+     ),
+ 
+     file_type: $ => choice(
+@@ -870,12 +887,12 @@ module.exports = grammar({
+     ),
+ 
+     record_description_list: $ => seq(
+-      repeat1(seq($.data_description, repeat1('.')))
++      repeat1(choice(seq($.data_description, repeat1('.')), prec(1, seq($.exec_statement, optional('.')))))
+     ),
+ 
+     working_storage_section: $ => seq(
+       $._WORKING_STORAGE, $._SECTION, '.',
+-      repeat(seq($.data_description, repeat1('.')))
++      repeat(choice(seq($.data_description, repeat1('.')), seq($.exec_statement, optional('.'))))
+     ),
+ 
+     data_description: $ => choice(
+@@ -1070,7 +1087,7 @@ module.exports = grammar({
+       $.picture_edit,
+     ),
+ 
+-    picture_x: $ => /([xX](\([0-9]+\))?)+/,
++    picture_x: $ => /([xX](\(([0-9]+|[a-zA-Z][a-zA-Z0-9-]*)\))?)+/,
+ 
+     picture_n: $ => /([nN](\([0-9]+\))?)+/,
+ 
+@@ -1119,6 +1136,11 @@ module.exports = grammar({
+       seq($.BINARY_SHORT, $.SIGNED),
+       seq($.BINARY_SHORT, $.UNSIGNED),
+       $.BINARY_SHORT,
++      seq($.BINARY_LONG_LONG, $.SIGNED),
++      seq($.BINARY_LONG_LONG, $.UNSIGNED),
++      $.BINARY_LONG_LONG,
++      $.FLOAT_LONG,
++      $.FLOAT_SHORT,
+       seq($.BINARY_LONG, $.SIGNED),
+       seq($.BINARY_LONG, $.UNSIGNED),
+       $.BINARY_LONG,
+@@ -1197,7 +1219,7 @@ module.exports = grammar({
+     )),
+ 
+     value_item: $ => seq(
+-      $._literal,
++      choice($._literal, $.qualified_word),
+       optional(seq(
+         $.THRU,
+         $._literal
+@@ -1227,7 +1249,7 @@ module.exports = grammar({
+ 
+     linkage_section: $ => seq(
+       $._LINKAGE, $._SECTION, '.',
+-      $.record_description_list
++      optional($.record_description_list)
+     ),
+ 
+     report_section: $ => /report_section/,
+@@ -1357,6 +1379,19 @@ module.exports = grammar({
+       '.'
+     ),
+ 
++    exec_statement: $ => $._exec_block,
++
++    free_statement: $ => prec.right(seq(
++      $._FREE,
++      repeat1($._target_x)
++    )),
++
++    entry_statement: $ => prec.right(seq(
++      $._ENTRY,
++      field('name', $.string),
++      optional(seq($._USING, repeat1($._call_param)))
++    )),
++
+     end_program: $ => prec(1, seq(
+       $._END_PROGRAM,
+       $.program_name,
+@@ -1364,6 +1399,9 @@ module.exports = grammar({
+     )),
+ 
+     _statement: $ => choice(
++      $.exec_statement,
++      $.entry_statement,
++      $.free_statement,
+       $.accept_statement,
+       $.add_statement,
+       $.allocate_statement,
+@@ -1465,12 +1503,19 @@ module.exports = grammar({
+     ),
+ 
+     replacing_clause: $ => seq(
++      $._REPLACING,
++      repeat1($.replacing_pair)
++    ),
++
++    replacing_pair: $ => seq(
+       field('leading_or_trailing', optional(choice($.LEADING, $.TRAILING))),
+-      field('x', choice($.WORD, $.string)),
+-      optional($._BY),
+-      field('by', choice($.WORD, $.string)),
++      field('x', choice($.pseudo_text, $.WORD, $.string)),
++      $._BY,
++      field('by', choice($.pseudo_text, $.WORD, $.string)),
+     ),
+ 
++    pseudo_text: $ => /==([^=\n]|=[^=\n])*==/,
++
+     start_statement: $ => seq(
+       $._START,
+       field('file_name', $.WORD),
+@@ -1670,9 +1715,9 @@ module.exports = grammar({
+         repeat1($._call_param)
+       ))),
+       optional(choice(
+-        field('returning', seq($._RETURNING, $._identifier),
+-          field('giving', seq($._GIVING, $._identifier)),
+-        )))
++        field('returning', seq($._RETURNING, $._identifier)),
++        field('giving', seq($._GIVING, $._identifier))
++      ))
+     ),
+ 
+     _call_param: $ => choice(
+@@ -1902,6 +1947,7 @@ module.exports = grammar({
+ 
+     _evaluate_object: $ => choice(
+       $.expr,
++      prec.right(seq(choice('=', '>', '<', '>=', '<=', $._NOT_EQUAL), $.expr)),
+       $.ANY,
+       $.TRUE,
+       $.FALSE
+@@ -1951,6 +1997,10 @@ module.exports = grammar({
+     expr: $ => prec.left(choice(
+       seq($.NOT, $.expr),
+       seq($.expr, choice($.AND, $.OR), $.expr),
++      // Abbreviated combined relation: `X < 0 OR > Y` — the subject of the
++      // second comparison is implied. (The AND_LT/OR_GT combined tokens the
++      // grammar carries for this never win against keyword lexing.)
++      seq($.expr, choice($.AND, $.OR), $._comparator, $._expr_calc),
+       $._expr_bool,
+       seq("(", $.expr, ")")
+     )),
+@@ -1965,6 +2015,11 @@ module.exports = grammar({
+     )),
+ 
+     _expr_calc_binary: $ => choice(
++      prec.left(1, seq($._expr_calc, $.B_AND, $._expr_calc)),
++      prec.left(1, seq($._expr_calc, $.B_OR, $._expr_calc)),
++      prec.left(1, seq($._expr_calc, $.B_XOR, $._expr_calc)),
++      prec.left(1, seq($._expr_calc, $.B_SHIFT_L, $._expr_calc)),
++      prec.left(1, seq($._expr_calc, $.B_SHIFT_R, $._expr_calc)),
+       prec.left(1, seq($._expr_calc, '+', $._expr_calc)),
+       prec.left(1, seq($._expr_calc, '-', $._expr_calc)),
+       prec.left(2, seq($._expr_calc, '**', $._expr_calc)),
+@@ -1974,6 +2029,7 @@ module.exports = grammar({
+     ),
+ 
+     _expr_calc_unary: $ => prec(4, choice(
++      seq($.B_NOT, $._expr_calc),
+       seq('+', $._expr_calc),
+       seq('-', $._expr_calc),
+       seq('^', $._expr_calc),
+@@ -2753,7 +2809,7 @@ module.exports = grammar({
+       seq($.TRIM_FUNCTION, '(', $._trim_args, ')', optional($.func_refmod)),
+       seq($.NUMVALC_FUNC, '(', $._numvalc_args, ')'),
+       seq($.LOCALE_DT_FUNC, '(', $._locale_dt_args, ')', optional($.func_refmod)),
+-      seq($.WORD, optional($.func_args)),
++      seq($.WORD, optional($.func_args), optional($.func_refmod)),
+     ))),
+ 
+     func_refmod: $ => choice(
+@@ -2868,6 +2924,16 @@ module.exports = grammar({
+     _BINARY_CHAR: $ => /[bB][iI][nN][aA][rR][yY]-[cC][hH][aA][rR]/,
+     _BINARY_DOUBLE: $ => /[bB][iI][nN][aA][rR][yY]-[dD][oO][uU][bB][lL][eE]/,
+     _BINARY_LONG: $ => /[bB][iI][nN][aA][rR][yY]-[lL][oO][nN][gG]/,
++    _BINARY_LONG_LONG: $ => /[bB][iI][nN][aA][rR][yY]-[lL][oO][nN][gG]-[lL][oO][nN][gG]/,
++    _FREE: $ => /[fF][rR][eE][eE]/,
++    B_AND: $ => /[bB]-[aA][nN][dD]/,
++    B_OR: $ => /[bB]-[oO][rR]/,
++    B_XOR: $ => /[bB]-[xX][oO][rR]/,
++    B_NOT: $ => /[bB]-[nN][oO][tT]/,
++    B_SHIFT_L: $ => /[bB]-[sS][hH][iI][fF][tT]-[lL][cC]?/,
++    B_SHIFT_R: $ => /[bB]-[sS][hH][iI][fF][tT]-[rR][cC]?/,
++    _FLOAT_LONG: $ => /[fF][lL][oO][aA][tT]-[lL][oO][nN][gG]/,
++    _FLOAT_SHORT: $ => /[fF][lL][oO][aA][tT]-[sS][hH][oO][rR][tT]/,
+     _BINARY_SHORT: $ => /[bB][iI][nN][aA][rR][yY]-[sS][hH][oO][rR][tT]/,
+     _BLANK: $ => /[bB][lL][aA][nN][kK]/,
+     _BLANK_LINE: $ => /[bB][lL][aA][nN][kK]-[lL][iI][nN][eE]/,
+@@ -3335,6 +3401,9 @@ module.exports = grammar({
+     BINARY_CHAR: $ => $._BINARY_CHAR,
+     BINARY_DOUBLE: $ => $._BINARY_DOUBLE,
+     BINARY_LONG: $ => $._BINARY_LONG,
++    BINARY_LONG_LONG: $ => $._BINARY_LONG_LONG,
++    FLOAT_LONG: $ => $._FLOAT_LONG,
++    FLOAT_SHORT: $ => $._FLOAT_SHORT,
+     BINARY_SHORT: $ => $._BINARY_SHORT,
+     //BLANK: $ => $._BLANK,
+     BLANK_LINE: $ => $._BLANK_LINE,
+@@ -3754,7 +3823,7 @@ module.exports = grammar({
+ 
+     COMPUTATIONAL: $ => $._COMPUTATIONAL,
+     _COMPUTATIONAL: $ => /[cC][oO][mM][pP][uU][tT][aA][tT][iI][oO][nN][aA][lL]/,
+-    _NOT_EQUAL: $ => /(!=)|([nN][oO][tT][ \t]+(([eE][qQ][uU][aA][lL])|=))/,
++    _NOT_EQUAL: $ => /(!=)|([nN][oO][tT]([ \t]+[eE][qQ][uU][aA][lL]|[ \t]*=))/,
+     _NOT_LESS: $ => /([nN][oO][tT][ \t]+(<|[lL][eE][sS][sS]))/,
+     _NOT_GREATER: $ => /([nN][oO][tT][ \t]+(>|[gG][rR][eE][aA][tT][eE][rR]))/,
+ 
+diff --git a/src/scanner.c b/src/scanner.c
+index 6c1ae90..b69cf53 100644
+--- a/src/scanner.c
++++ b/src/scanner.c
+@@ -1,4 +1,5 @@
+ #include <tree_sitter/parser.h>
++#include <stdlib.h>
+ #include <wctype.h>
+ 
+ enum TokenType {
+@@ -8,10 +9,21 @@ enum TokenType {
+     LINE_COMMENT,
+     COMMENT_ENTRY,
+     multiline_string,
++    EXEC_BLOCK,
+ };
+ 
++// Wide mode: a preprocessor that converts free-format COBOL to fixed format
++// by left-padding every line 7 columns writes the sentinel "CGWIDE" into the
++// sequence area (columns 1-6) of the FIRST line. Free-format lines routinely
++// run past column 72, so when the sentinel is seen the fixed-format right
++// margin (column 73+ = ignored identification area) is pushed out of reach.
++// The one-byte flag is scanner state, carried through serialize/deserialize.
++#define CG_FIXED_WIDTH 72
++#define CG_WIDE_WIDTH 4096
++static const char CG_WIDE_SENTINEL[6] = {'C', 'G', 'W', 'I', 'D', 'E'};
++
+ void *tree_sitter_COBOL_external_scanner_create() {
+-    return NULL;
++    return calloc(1, 1);
+ }
+ 
+ static bool is_white_space(int c) {
+@@ -31,7 +43,7 @@ char* any_content_keyword[] = {
+     "procedure division",
+ };
+ 
+-static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words) {
++static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words, int width) {
+     while(lexer->lookahead == ' ' || lexer->lookahead == '\t') {
+         lexer->advance(lexer, true);
+     }
+@@ -45,7 +57,7 @@ static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words)
+ 
+     while(true) {
+         // At the end of the line
+-        if(lexer->get_column(lexer) > 71 || lexer->lookahead == '\n' || lexer->lookahead == 0) {
++        if(lexer->get_column(lexer) > width - 1 || lexer->lookahead == '\n' || lexer->lookahead == 0) {
+             return false;
+         }
+ 
+@@ -58,7 +70,7 @@ static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words)
+         }
+ 
+         if(all_match_failed) {
+-            for(; lexer->get_column(lexer) < 71 && lexer->lookahead != '\n' && lexer->lookahead != 0;
++            for(; lexer->get_column(lexer) < width - 1 && lexer->lookahead != '\n' && lexer->lookahead != 0;
+             lexer->advance(lexer, true)) {
+             }
+             return false;
+@@ -94,6 +106,9 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+         return false;
+     }
+ 
++    char *wide = (char *)payload;
++    const int width = (wide && *wide) ? CG_WIDE_WIDTH : CG_FIXED_WIDTH;
++
+     if(valid_symbols[WHITE_SPACES]) {
+         if(is_white_space(lexer->lookahead)) {
+             while(is_white_space(lexer->lookahead)) {
+@@ -106,9 +121,20 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+     }
+ 
+     if(valid_symbols[LINE_PREFIX_COMMENT] && lexer->get_column(lexer) <= 5) {
++        // The sequence area is ignored content — but the free-format
++        // preprocessor plants the CGWIDE sentinel here on the first line.
++        int match = 0;
+         while(lexer->get_column(lexer) <= 5) {
++            if(match >= 0 && match < 6 && lexer->lookahead == CG_WIDE_SENTINEL[match]) {
++                match++;
++            } else {
++                match = -1;
++            }
+             lexer->advance(lexer, true);
+         }
++        if(match == 6 && wide) {
++            *wide = 1;
++        }
+         lexer->result_symbol = LINE_PREFIX_COMMENT;
+         lexer->mark_end(lexer);
+         return true;
+@@ -132,7 +158,7 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+     }
+ 
+     if(valid_symbols[LINE_SUFFIX_COMMENT]) {
+-        if(lexer->get_column(lexer) >= 72) {
++        if(lexer->get_column(lexer) >= width) {
+             while(lexer->lookahead != '\n' && lexer->lookahead != 0) {
+                 lexer->advance(lexer, true);
+             }
+@@ -143,7 +169,7 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+     }
+ 
+     if(valid_symbols[COMMENT_ENTRY]) {
+-        if(!start_with_word(lexer, any_content_keyword, number_of_comment_entry_keywords)) {
++        if(!start_with_word(lexer, any_content_keyword, number_of_comment_entry_keywords, width)) {
+             lexer->mark_end(lexer);
+             lexer->result_symbol = COMMENT_ENTRY;
+             return true;
+@@ -152,18 +178,66 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+         }
+     }
+ 
++    if(valid_symbols[EXEC_BLOCK]) {
++        // EXEC (CICS|SQL|DLI|...) ... END-EXEC embedded block. Match the word
++        // EXEC (case-insensitive) followed by whitespace, then consume through
++        // the next END-EXEC. On any mismatch return false so the internal
++        // lexer re-reads the same characters as an ordinary WORD.
++        if(lexer->lookahead == 'e' || lexer->lookahead == 'E') {
++            const char *kw = "exec";
++            int ki = 0;
++            while(ki < 4 && (lexer->lookahead == towupper(kw[ki]) || lexer->lookahead == towlower(kw[ki]))) {
++                lexer->advance(lexer, false);
++                ki++;
++            }
++            if(ki == 4 && (lexer->lookahead == ' ' || lexer->lookahead == '\t' ||
++                           lexer->lookahead == '\n' || lexer->lookahead == '\r')) {
++                char ring[8] = {0,0,0,0,0,0,0,0};
++                while(lexer->lookahead != 0) {
++                    for(int i = 0; i < 7; ++i) ring[i] = ring[i+1];
++                    ring[7] = (char)towlower(lexer->lookahead);
++                    lexer->advance(lexer, false);
++                    if(ring[0]=='e' && ring[1]=='n' && ring[2]=='d' && ring[3]=='-' &&
++                       ring[4]=='e' && ring[5]=='x' && ring[6]=='e' && ring[7]=='c') {
++                        lexer->result_symbol = EXEC_BLOCK;
++                        lexer->mark_end(lexer);
++                        return true;
++                    }
++                }
++            }
++            return false;
++        }
++    }
++
+     if(valid_symbols[multiline_string]) {
++        int quote = lexer->lookahead;
++        if(quote != '"' && quote != '\'') {
++            return false;
++        }
+         while(true) {
+-            if(lexer->lookahead != '"') {
++            if(lexer->lookahead != quote) {
+                 return false;
+             }
+             lexer->advance(lexer, false);
+-            while(lexer->lookahead != '"' && lexer->lookahead != 0 && lexer->get_column(lexer) < 72) {
++            bool closed = false;
++            while(true) {
++                while(lexer->lookahead != quote && lexer->lookahead != 0 && lexer->get_column(lexer) < width) {
++                    lexer->advance(lexer, false);
++                }
++                if(lexer->lookahead != quote) {
++                    break;
++                }
+                 lexer->advance(lexer, false);
++                if(lexer->lookahead == quote) {
++                    // doubled quote = escaped quote inside the literal
++                    lexer->advance(lexer, false);
++                    continue;
++                }
++                closed = true;
++                break;
+             }
+-            if(lexer->lookahead == '"') {
++            if(closed) {
+                 lexer->result_symbol = multiline_string;
+-                lexer->advance(lexer, false);
+                 lexer->mark_end(lexer);
+                 return true;
+             }
+@@ -187,7 +261,7 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+             }
+ 
+             lexer->advance(lexer, true);
+-            while(lexer->lookahead == ' ' && lexer->get_column(lexer) < 72) {
++            while(lexer->lookahead == ' ' && lexer->get_column(lexer) < width) {
+                 lexer->advance(lexer, true);
+             }
+         }
+@@ -197,11 +271,19 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
+ }
+ 
+ unsigned tree_sitter_COBOL_external_scanner_serialize(void *payload, char *buffer) {
++    if(payload && buffer) {
++        buffer[0] = *(char *)payload;
++        return 1;
++    }
+     return 0;
+ }
+ 
+ void tree_sitter_COBOL_external_scanner_deserialize(void *payload, const char *buffer, unsigned length) {
++    if(payload) {
++        *(char *)payload = (buffer && length >= 1) ? buffer[0] : 0;
++    }
+ }
+ 
+ void tree_sitter_COBOL_external_scanner_destroy(void *payload) {
++    free(payload);
+ }

+ 6 - 0
scripts/agent-eval/run-all.sh

@@ -19,6 +19,12 @@ OUT="${AGENT_EVAL_OUT:-/tmp/agent-eval}"
 HARNESS="$(cd "$(dirname "$0")" && pwd)"
 mkdir -p "$OUT"
 
+# Neutralize any ambient CodeGraph prompt-hook (~/.claude) in BOTH arms:
+# the hook injects codegraph context into every prompt, which contaminates
+# the without-arm (free structural context) and double-counts the with-arm.
+# The A/B's only variable must be the MCP server wired below.
+export CODEGRAPH_NO_PROMPT_HOOK=1
+
 [ -n "$CG_BIN" ] || { echo "no codegraph binary on PATH (set CG_BIN)"; exit 1; }
 [ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO — index it first"; exit 1; }
 case "$MODE" in headless|tmux|all) ;; *) echo "mode must be headless|tmux|all (got '$MODE')"; exit 1;; esac

+ 10 - 1
src/extraction/grammars.ts

@@ -42,6 +42,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
   cfml: 'tree-sitter-cfml.wasm',
   cfscript: 'tree-sitter-cfscript.wasm',
   cfquery: 'tree-sitter-cfquery.wasm',
+  cobol: 'tree-sitter-cobol.wasm',
 };
 
 /**
@@ -123,6 +124,13 @@ export const EXTENSION_MAP: Record<string, Language> = {
   // XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
   // shape and emits SQL-statement nodes (other XML returns empty).
   '.xml': 'xml',
+  // COBOL: programs (.cbl/.cob) and copybooks (.cpy). Vendored grammar
+  // (patched yutaro-sakamoto/tree-sitter-cobol) handles fixed-format column
+  // rules, EXEC CICS/SQL blocks, and standalone copybook fragments.
+  '.cbl': 'cobol',
+  '.cob': 'cobol',
+  '.cobol': 'cobol',
+  '.cpy': 'cobol',
   // Spring config: `application.properties` / `application-*.properties`. Same
   // shape as the `.yml` variants — the YAML/properties extractor emits one node
   // per leaf key, and the Spring resolver links `@Value("${k}")` references.
@@ -241,7 +249,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
       // `class Foo(...)` as an ERROR that swallows the whole class (#237); we
       // vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
       // primary constructors natively.
-      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery')
+      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol')
         ? path.join(__dirname, 'wasm', wasmFile)
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
       const language = await WasmLanguage.load(wasmPath);
@@ -459,6 +467,7 @@ export function getLanguageDisplayName(language: Language): string {
     cfml: 'CFML',
     cfscript: 'CFScript',
     cfquery: 'CFQuery (SQL)',
+    cobol: 'COBOL',
     unknown: 'Unknown',
   };
   return names[language] || language;

+ 527 - 0
src/extraction/languages/cobol.ts

@@ -0,0 +1,527 @@
+/**
+ * COBOL Language Extractor
+ *
+ * COBOL's AST (vendored, patched build of yutaro-sakamoto/tree-sitter-cobol)
+ * is fundamentally different from block-structured languages, so extraction
+ * runs almost entirely through the custom visitNode hook (the Pascal pattern):
+ *
+ * - A program (PROGRAM-ID) becomes a `module` node.
+ * - PROCEDURE DIVISION sections and paragraphs become `function` nodes. The
+ *   grammar emits them FLAT — a section_header/paragraph_header followed by
+ *   sibling statements — so extents are reconstructed here: a paragraph runs
+ *   from its header to the next header, a section to the next section header.
+ * - PERFORM (including THRU ranges), GO TO, and CALL 'literal' become `calls`
+ *   references. A dynamic CALL through a data name is skipped — announce,
+ *   don't guess. EXEC CICS LINK/XCTL PROGRAM('X') with a literal target also
+ *   becomes a `calls` reference; EXEC SQL INCLUDE X becomes an `imports`
+ *   reference (DB2's COPY).
+ * - COPY statements become `import` nodes + `imports` references.
+ * - DATA DIVISION entries become `variable` (01/77 levels), `field` (nested
+ *   levels, contained in their group item), or `constant` (88-level condition
+ *   names) nodes, so impact queries on working-storage names work.
+ * - Standalone copybooks (.cpy) parse via the grammar's copybook_fragment
+ *   entry point: data copybooks yield their record structure, procedure
+ *   copybooks yield paragraphs.
+ *
+ * The grammar is fixed-format (code area columns 8-72). preParse detects a
+ * free-format file (division header or level number starting before column 8)
+ * and indents every line by 7 spaces: line numbers are preserved, columns
+ * drift by 7 — acceptable for line-oriented consumers.
+ */
+
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import { getNodeText, getChildByField } from '../tree-sitter-helpers';
+import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
+
+/** EXEC CICS LINK/XCTL — program target, quoted literal or data name. */
+const EXEC_CICS_PROGRAM_RE =
+  /\b(?:LINK|XCTL)\b[\s\S]*?\bPROGRAM\s*\(\s*(?:['"]([A-Za-z0-9$#@-]+)['"]|([A-Za-z0-9-]+))\s*\)/i;
+/** EXEC CICS RETURN/START — next transaction, quoted literal or data name. */
+const EXEC_CICS_TRANSID_RE =
+  /\b(?:RETURN|START)\b[\s\S]*?\bTRANSID\s*\(\s*(?:['"]([A-Za-z0-9$#@]{1,4})['"]|([A-Za-z0-9-]+))\s*\)/i;
+/** EXEC SQL INCLUDE <member> — the SQL flavor of COPY. */
+const EXEC_SQL_INCLUDE_RE = /\bSQL\b[\s\S]*?\bINCLUDE\s+([A-Za-z0-9$#@-]+)/i;
+/** The VALUE literal in a data item's signature ("PIC X(04) VALUE 'CB00'"). */
+const VALUE_LITERAL_RE = /\bVALUE\s+['"]([A-Za-z0-9$#@-]+)['"]/i;
+
+function line(node: SyntaxNode): number {
+  return node.startPosition.row + 1;
+}
+
+function endLineOf(node: SyntaxNode): number {
+  return node.endPosition.row + 1;
+}
+
+/** Collapse whitespace runs so multi-line clauses read as one signature. */
+function collapse(text: string, cap = 120): string {
+  const flat = text.replace(/\s+/g, ' ').trim();
+  return flat.length > cap ? flat.slice(0, cap - 1) + '…' : flat;
+}
+
+function currentScope(ctx: ExtractorContext): string | undefined {
+  return ctx.nodeStack[ctx.nodeStack.length - 1];
+}
+
+/**
+ * DB2 convention writes `EXEC SQL INCLUDE member END-EXEC` with no sentence
+ * period, sitting between paragraphs like a COPY — which derails the
+ * grammar's sentence machinery. Terminate the single-line form by writing a
+ * period into the character right after END-EXEC (a space), or appending one
+ * at end of line. Both are offset-preserving for every other character.
+ */
+function terminateSqlIncludes(source: string): string {
+  const lineRe = /^([ \t]*(?:[0-9]{6})?[ \t]+EXEC\s+SQL\s+INCLUDE\s+[A-Za-z0-9$#@-]+\s+END-EXEC)([ \t]|$)/i;
+  return source
+    .split('\n')
+    .map((l) => {
+      if (!/END-EXEC/i.test(l) || /END-EXEC\s*\./i.test(l)) return l;
+      const m = lineRe.exec(l);
+      if (!m) return l;
+      const head = m[1]!;
+      return m[2] === '' ? head + '.' : head + '.' + l.slice(head.length + 1);
+    })
+    .join('\n');
+}
+
+function addRef(
+  ctx: ExtractorContext,
+  fromNodeId: string | undefined,
+  referenceName: string,
+  referenceKind: 'calls' | 'imports' | 'references',
+  at: SyntaxNode
+): void {
+  if (!fromNodeId || !referenceName) return;
+  ctx.addUnresolvedReference({
+    fromNodeId,
+    referenceName,
+    referenceKind,
+    line: line(at),
+    column: at.startPosition.column,
+  });
+}
+
+/** COPY <book> [IN/OF lib] — import node + imports reference. */
+function handleCopy(node: SyntaxNode, ctx: ExtractorContext): void {
+  const book = getChildByField(node, 'book');
+  if (!book) return;
+  const name = getNodeText(book, ctx.source)
+    .trim()
+    .replace(/^['"]|['"]$/g, '');
+  if (!name) return;
+  ctx.createNode('import', name, node, {
+    signature: collapse(getNodeText(node, ctx.source)),
+  });
+  addRef(ctx, currentScope(ctx), name, 'imports', node);
+}
+
+/**
+ * A CICS option can name its target through a data item instead of a
+ * literal (`TRANSID(WS-TRANID)` with `05 WS-TRANID ... VALUE 'CB00'`).
+ * Dereference it against the SAME FILE's already-extracted data items —
+ * the DATA DIVISION precedes the PROCEDURE DIVISION, so they are in
+ * ctx.nodes by the time EXEC blocks are mined. Anything else (copybook
+ * arrays, computed names) is dynamic dispatch: skipped, not guessed.
+ */
+function derefSameFileValue(name: string, ctx: ExtractorContext): string | undefined {
+  const upper = name.toUpperCase();
+  for (const node of ctx.nodes) {
+    if (node.filePath !== ctx.filePath) continue;
+    if (node.kind !== 'variable' && node.kind !== 'field' && node.kind !== 'constant') continue;
+    if (node.name.toUpperCase() !== upper) continue;
+    const value = node.signature ? VALUE_LITERAL_RE.exec(node.signature) : null;
+    return value?.[1];
+  }
+  return undefined;
+}
+
+/**
+ * EXEC ... END-EXEC blocks are opaque single nodes. Mine the text for the
+ * statically-resolvable shapes: CICS LINK/XCTL to a program (cross-program
+ * call), CICS RETURN/START TRANSID (the pseudo-conversational hop — emitted
+ * as a `cics-transid:XXXX` reference the CICS framework resolver maps to
+ * the owning program), and SQL INCLUDE (a copybook import).
+ */
+function handleExec(
+  node: SyntaxNode,
+  ctx: ExtractorContext,
+  fromNodeId: string | undefined
+): void {
+  const text = getNodeText(node, ctx.source);
+  const cics = EXEC_CICS_PROGRAM_RE.exec(text);
+  if (cics) {
+    const program = cics[1] ?? (cics[2] ? derefSameFileValue(cics[2], ctx) : undefined);
+    if (program) addRef(ctx, fromNodeId, program, 'calls', node);
+  }
+  const transid = EXEC_CICS_TRANSID_RE.exec(text);
+  if (transid) {
+    const tx = transid[1] ?? (transid[2] ? derefSameFileValue(transid[2], ctx) : undefined);
+    if (tx && /^[A-Za-z0-9$#@]{1,4}$/.test(tx)) {
+      addRef(ctx, fromNodeId, `cics-transid:${tx.toUpperCase()}`, 'calls', node);
+    }
+  }
+  const include = EXEC_SQL_INCLUDE_RE.exec(text);
+  if (include?.[1]) {
+    ctx.createNode('import', include[1], node, {
+      signature: collapse(text),
+    });
+    addRef(ctx, fromNodeId ?? currentScope(ctx), include[1], 'imports', node);
+  }
+}
+
+/**
+ * Walk a run of DATA DIVISION entries (working-storage section children or a
+ * record_description_list). Level numbers drive nesting: an entry closes all
+ * open entries with level >= its own. 88-level condition names attach to the
+ * open item as constants and never open a scope.
+ */
+function walkDataEntries(entries: SyntaxNode[], ctx: ExtractorContext): void {
+  interface Item {
+    node: SyntaxNode;
+    level: number;
+    name: string | null;
+  }
+  const items: (Item | null)[] = entries.map((e) => {
+    if (e.type !== 'data_description') return null;
+    const levelNode = e.namedChildren.find((c: SyntaxNode) => c?.type === 'level_number');
+    const nameNode = e.namedChildren.find((c: SyntaxNode) => c?.type === 'entry_name');
+    const level = levelNode ? parseInt(getNodeText(levelNode, ctx.source), 10) : NaN;
+    const name = nameNode ? getNodeText(nameNode, ctx.source).trim() : null;
+    return { node: e, level: Number.isFinite(level) ? level : 1, name };
+  });
+
+  /** Levels 01, 66, and 77 always open at top level, whatever came before. */
+  const isTopLevel = (level: number): boolean => level === 1 || level === 66 || level === 77;
+
+  /** A group item extends to the last entry before the next level <= its own. */
+  const groupEnd = (i: number): number => {
+    const self = items[i]!;
+    let end = endLineOf(self.node);
+    for (let j = i + 1; j < entries.length; j++) {
+      const it = items[j];
+      if (!it) continue;
+      if (it.level !== 88 && (it.level <= self.level || isTopLevel(it.level))) break;
+      end = Math.max(end, endLineOf(it.node));
+    }
+    return end;
+  };
+
+  const open: { level: number }[] = [];
+  let pushed = 0;
+  const closeTo = (level: number) => {
+    while (open.length > 0 && open[open.length - 1]!.level >= level) {
+      open.pop();
+      ctx.popScope();
+      pushed--;
+    }
+  };
+
+  for (let i = 0; i < entries.length; i++) {
+    const entry = entries[i]!;
+    if (entry.type === 'copy_statement') {
+      handleCopy(entry, ctx);
+      continue;
+    }
+    if (entry.type === 'exec_statement') {
+      handleExec(entry, ctx, currentScope(ctx));
+      continue;
+    }
+    const item = items[i];
+    if (!item) continue;
+
+    const isCondition = item.level === 88;
+    if (!isCondition) closeTo(isTopLevel(item.level) ? 0 : item.level);
+
+    // FILLER / unnamed entries carry no symbol; they only occupy layout space.
+    if (!item.name || /^FILLER$/i.test(item.name)) continue;
+
+    const kind = isCondition ? 'constant' : open.length === 0 ? 'variable' : 'field';
+    const created = ctx.createNode(kind, item.name, item.node, {
+      endLine: isCondition ? endLineOf(item.node) : groupEnd(i),
+      signature: collapse(getNodeText(item.node, ctx.source)),
+    });
+    if (created && !isCondition) {
+      ctx.pushScope(created.id);
+      open.push({ level: item.level });
+      pushed++;
+    }
+  }
+  while (pushed > 0) {
+    ctx.popScope();
+    pushed--;
+  }
+}
+
+/**
+ * Special registers and CICS/SQL communication areas — writes to these are
+ * runtime plumbing, not project data flow; referencing them would only mint
+ * dangling refs (they have no declaration node).
+ */
+const SPECIAL_REGISTER_RE = /^(RETURN-CODE|SQLCODE|SQLSTATE|TALLY|EIB[A-Z-]+|DFH[A-Z-]+|WHEN-COMPILED|LENGTH|ADDRESS)$/i;
+
+/** The base data name of an assignment target (first WORD, ignoring subscripts/OF-qualifiers). */
+function targetBaseName(target: SyntaxNode, source: string): SyntaxNode | null {
+  if (target.type === 'WORD') return target;
+  for (const child of target.namedChildren) {
+    if (!child) continue;
+    const found = targetBaseName(child, source);
+    if (found) return found;
+  }
+  return null;
+}
+
+/**
+ * Emit a `references` ref for every data item a statement WRITES (MOVE TO,
+ * ADD TO/GIVING, SUBTRACT FROM/GIVING, COMPUTE =). Write-sites are what an
+ * impact query on a working-storage or copybook field needs: "what changes
+ * WS-TOTAL" is the COBOL analogue of value-reference edges. Read operands
+ * are deliberately not tracked — they would multiply edge volume for little
+ * impact value.
+ */
+function emitWriteRefs(
+  statement: SyntaxNode,
+  fields: string[],
+  fromNodeId: string | undefined,
+  ctx: ExtractorContext
+): void {
+  for (const field of fields) {
+    for (const target of statement.childrenForFieldName(field)) {
+      if (!target) continue;
+      const word = targetBaseName(target, ctx.source);
+      if (!word) continue;
+      const name = getNodeText(word, ctx.source).trim();
+      if (!name || SPECIAL_REGISTER_RE.test(name)) continue;
+      addRef(ctx, fromNodeId, name, 'references', word);
+    }
+  }
+}
+
+/**
+ * Collect call/import references from a statement subtree, attributed to the
+ * enclosing paragraph/section (or the program when no paragraph is open).
+ */
+function collectRefs(
+  node: SyntaxNode,
+  fromNodeId: string | undefined,
+  ctx: ExtractorContext
+): void {
+  switch (node.type) {
+    case 'move_statement':
+      emitWriteRefs(node, ['dst'], fromNodeId, ctx);
+      return;
+    case 'add_statement':
+      emitWriteRefs(node, ['to', 'giving'], fromNodeId, ctx);
+      return;
+    case 'compute_statement':
+      emitWriteRefs(node, ['left'], fromNodeId, ctx);
+      return;
+    case 'subtract_statement': {
+      // SUBTRACT x FROM t — `from` is the target; with GIVING, `giving` is.
+      const hasGiving = node.childrenForFieldName('giving').length > 0;
+      emitWriteRefs(node, [hasGiving ? 'giving' : 'from'], fromNodeId, ctx);
+      return;
+    }
+    case 'perform_statement_call_proc': {
+      // PERFORM A [THRU B] — every label is a paragraph/section call target.
+      const proc = getChildByField(node, 'procedure');
+      if (proc) {
+        for (const label of proc.namedChildren) {
+          if (label?.type !== 'label') continue;
+          const name = getNodeText(label, ctx.source).trim();
+          addRef(ctx, fromNodeId, name, 'calls', label);
+        }
+      }
+      return;
+    }
+    case 'call_statement': {
+      // CALL 'PROG' — static cross-program call. CALL data-name is dynamic
+      // dispatch through a variable: skipped (announce, don't guess).
+      const x = getChildByField(node, 'x');
+      if (x?.type === 'string') {
+        const name = getNodeText(x, ctx.source).replace(/^['"]|['"]$/g, '').trim();
+        addRef(ctx, fromNodeId, name, 'calls', x);
+      }
+      return;
+    }
+    case 'goto_statement': {
+      const to = getChildByField(node, 'to');
+      if (to) {
+        addRef(ctx, fromNodeId, getNodeText(to, ctx.source).trim(), 'calls', to);
+      }
+      return;
+    }
+    case 'exec_statement':
+      handleExec(node, ctx, fromNodeId);
+      return;
+    case 'copy_statement':
+      handleCopy(node, ctx);
+      return;
+    default:
+      for (const child of node.namedChildren) {
+        if (child) collectRefs(child, fromNodeId, ctx);
+      }
+  }
+}
+
+/** "PARA-NAME." → "PARA-NAME"; "SEC-NAME SECTION." → "SEC-NAME". */
+function headerName(header: SyntaxNode, source: string): string {
+  const text = getNodeText(header, source).trim().replace(/\.$/, '').trim();
+  return text.split(/\s+/)[0] ?? text;
+}
+
+/**
+ * Walk the flat PROCEDURE DIVISION (or a procedure copybook fragment):
+ * reconstruct section/paragraph extents from header positions and attribute
+ * the sibling statements between headers to the open paragraph.
+ */
+function walkProcedureChildren(
+  children: SyntaxNode[],
+  divisionEndLine: number,
+  ctx: ExtractorContext
+): void {
+  /** 1-based last line of the region started at children[i]. */
+  const regionEnd = (i: number, sectionsOnly: boolean): number => {
+    for (let j = i + 1; j < children.length; j++) {
+      const c = children[j]!;
+      if (c.type === 'section_header' || (!sectionsOnly && c.type === 'paragraph_header')) {
+        // Header starts at 0-based row R → the region ends on 1-based line R.
+        return Math.max(c.startPosition.row, line(children[i]!));
+      }
+    }
+    return divisionEndLine;
+  };
+
+  let currentFnId = currentScope(ctx);
+  let sectionPushed = false;
+
+  for (let i = 0; i < children.length; i++) {
+    const child = children[i]!;
+    if (child.type === 'section_header') {
+      if (sectionPushed) {
+        ctx.popScope();
+        sectionPushed = false;
+      }
+      const created = ctx.createNode('function', headerName(child, ctx.source), child, {
+        endLine: regionEnd(i, true),
+        signature: 'SECTION',
+      });
+      if (created) {
+        ctx.pushScope(created.id);
+        sectionPushed = true;
+        currentFnId = created.id;
+      }
+    } else if (child.type === 'paragraph_header') {
+      const created = ctx.createNode('function', headerName(child, ctx.source), child, {
+        endLine: regionEnd(i, false),
+      });
+      if (created) currentFnId = created.id;
+    } else {
+      collectRefs(child, currentFnId, ctx);
+    }
+  }
+  if (sectionPushed) ctx.popScope();
+}
+
+/** Program name from identification_division > program_name. */
+function programName(programNode: SyntaxNode, source: string): string | null {
+  const idDiv = programNode.namedChildren.find(
+    (c: SyntaxNode) => c?.type === 'identification_division'
+  );
+  const nameNode = idDiv?.namedChildren.find((c: SyntaxNode) => c?.type === 'program_name');
+  if (!nameNode) return null;
+  return getNodeText(nameNode, source).trim().replace(/^['"]|['"]$/g, '').replace(/\.$/, '');
+}
+
+export const cobolExtractor: LanguageExtractor = {
+  // All extraction flows through the visitNode hook — COBOL's flat,
+  // column-oriented AST doesn't fit the generic type-list dispatch.
+  functionTypes: [],
+  classTypes: [],
+  methodTypes: [],
+  interfaceTypes: [],
+  structTypes: [],
+  enumTypes: [],
+  typeAliasTypes: [],
+  importTypes: [],
+  callTypes: [],
+  variableTypes: [],
+  nameField: 'name',
+  bodyField: 'body',
+  paramsField: 'parameters',
+
+  preParse: (source: string) => {
+    // The grammar implements fixed-format column rules (sequence area 1-6,
+    // indicator 7, code 8-72). A free-format file — where real code starts
+    // before column 8 — would have its first characters eaten as sequence
+    // area, corrupting the whole parse. Scan for a decisive marker line
+    // (division header, PROGRAM-ID, or a level-number entry) preceded only
+    // by whitespace: if it opens before column 8 the file is free-format,
+    // and every line is shifted right by 7 spaces. Line numbers are
+    // preserved; columns drift by 7 (consumers are line-oriented). A
+    // sequence-numbered fixed file never matches — its columns 1-6 hold
+    // digits, not whitespace.
+    const marker =
+      /^([ \t]*)(IDENTIFICATION\s+DIVISION|ID\s+DIVISION|PROGRAM-ID\b|\d{2}[ \t]+[A-Za-z])/i;
+    let freeFormat = false;
+    for (const l of source.split(/\r?\n/)) {
+      const m = marker.exec(l);
+      if (!m) continue;
+      freeFormat = m[1]!.length < 7;
+      break;
+    }
+    if (!freeFormat) return terminateSqlIncludes(source);
+    // "CGWIDE" in the first line's sequence area tells the scanner to relax
+    // the column-72 right margin — free-format lines routinely exceed it,
+    // and truncating them there would corrupt strings and statements.
+    return terminateSqlIncludes(
+      source
+        .split('\n')
+        .map((l, i) => {
+          if (i === 0) return 'CGWIDE ' + l;
+          return l.length > 0 ? '       ' + l : l;
+        })
+        .join('\n')
+    );
+  },
+
+  visitNode: (node: SyntaxNode, ctx: ExtractorContext): boolean => {
+    switch (node.type) {
+      case 'program_definition': {
+        const name = programName(node, ctx.source);
+        const moduleNode = name ? ctx.createNode('module', name, node) : null;
+        if (moduleNode) ctx.pushScope(moduleNode.id);
+        for (const child of node.namedChildren) {
+          if (child) ctx.visitNode(child);
+        }
+        if (moduleNode) ctx.popScope();
+        return true;
+      }
+      case 'procedure_division': {
+        walkProcedureChildren(node.namedChildren.filter(Boolean) as SyntaxNode[], endLineOf(node), ctx);
+        return true;
+      }
+      case 'working_storage_section':
+      case 'record_description_list': {
+        walkDataEntries(node.namedChildren.filter(Boolean) as SyntaxNode[], ctx);
+        return true;
+      }
+      case 'copybook_fragment': {
+        const children = node.namedChildren.filter(Boolean) as SyntaxNode[];
+        if (children.some((c) => c.type === 'record_description_list')) {
+          for (const child of children) ctx.visitNode(child);
+        } else {
+          // Procedure copybook: paragraphs + statements, flat under the fragment.
+          walkProcedureChildren(children, endLineOf(node), ctx);
+        }
+        return true;
+      }
+      case 'copy_statement':
+        handleCopy(node, ctx);
+        return true;
+      case 'exec_statement':
+        handleExec(node, ctx, currentScope(ctx));
+        return true;
+      default:
+        return false;
+    }
+  },
+};

+ 2 - 0
src/extraction/languages/index.ts

@@ -29,6 +29,7 @@ import { luauExtractor } from './luau';
 import { objcExtractor } from './objc';
 import { cfscriptExtractor } from './cfscript';
 import { cfqueryExtractor } from './cfquery';
+import { cobolExtractor } from './cobol';
 
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   typescript: typescriptExtractor,
@@ -55,4 +56,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   objc: objcExtractor,
   cfscript: cfscriptExtractor,
   cfquery: cfqueryExtractor,
+  cobol: cobolExtractor,
 };

BIN
src/extraction/wasm/tree-sitter-cobol.wasm


+ 91 - 0
src/resolution/frameworks/cics.ts

@@ -0,0 +1,91 @@
+/**
+ * CICS Framework Resolver (COBOL)
+ *
+ * Resolves the pseudo-conversational transaction hop: a program ends with
+ * `EXEC CICS RETURN TRANSID('CB00')` (or START), and CICS re-invokes the
+ * program that OWNS transaction CB00 on the next attention key. The
+ * transaction→program mapping lives in the CICS CSD, which is never in the
+ * repo — but by near-universal convention each program declares its own
+ * transaction id as a working-storage constant:
+ *
+ *     05 WS-TRANID    PIC X(04) VALUE 'CB00'.
+ *
+ * The COBOL extractor emits `cics-transid:CB00` call references for literal
+ * (or same-file-dereferenced) TRANSID options; this resolver maps the id to
+ * the program module whose TRAN*-named data item declares that VALUE. No
+ * match (an id owned by a program outside the repo) stays unresolved.
+ */
+
+import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
+import { Node } from '../../types';
+
+const TRANSID_REF_PREFIX = 'cics-transid:';
+/** Data items that name a transaction id by convention. */
+const TRANID_NAME_RE = /TRAN/i;
+const VALUE_LITERAL_RE = /\bVALUE\s+['"]([A-Za-z0-9$#@]{1,4})['"]/i;
+
+/**
+ * transaction id → owning program module, built once per resolution context.
+ * A WeakMap so a per-ref scan of every data node can't go quadratic on
+ * copybook-heavy repos.
+ */
+const transidIndexes = new WeakMap<ResolutionContext, Map<string, string>>();
+
+function buildIndex(context: ResolutionContext): Map<string, string> {
+  const index = new Map<string, string>();
+  const dataNodes: Node[] = [
+    ...context.getNodesByKind('variable'),
+    ...context.getNodesByKind('field'),
+    ...context.getNodesByKind('constant'),
+  ];
+  for (const node of dataNodes) {
+    if (node.language !== 'cobol') continue;
+    if (!TRANID_NAME_RE.test(node.name)) continue;
+    const value = node.signature ? VALUE_LITERAL_RE.exec(node.signature) : null;
+    if (!value?.[1]) continue;
+    const tx = value[1].toUpperCase();
+    if (index.has(tx)) continue; // first declaration wins; collisions are rare and ambiguous
+    const moduleNode = context
+      .getNodesInFile(node.filePath)
+      .find((n) => n.kind === 'module' && n.language === 'cobol');
+    if (moduleNode) index.set(tx, moduleNode.id);
+  }
+  return index;
+}
+
+export const cicsResolver: FrameworkResolver = {
+  name: 'cics',
+  languages: ['cobol'],
+
+  detect(context: ResolutionContext): boolean {
+    // Any indexed COBOL program qualifies — the resolver only ever acts on
+    // cics-transid: references, which only the COBOL extractor emits.
+    return context.getNodesByKind('module').some((n) => n.language === 'cobol');
+  },
+
+  // cics-transid:XXXX matches no symbol name — opt it past the
+  // name-exists pre-filter so it reaches resolve().
+  claimsReference(name: string): boolean {
+    return name.startsWith(TRANSID_REF_PREFIX);
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    if (!ref.referenceName.startsWith(TRANSID_REF_PREFIX)) return null;
+    const tx = ref.referenceName.slice(TRANSID_REF_PREFIX.length).toUpperCase();
+
+    let index = transidIndexes.get(context);
+    if (!index) {
+      index = buildIndex(context);
+      transidIndexes.set(context, index);
+    }
+
+    const targetNodeId = index.get(tx);
+    if (!targetNodeId) return null;
+    return {
+      original: ref,
+      targetNodeId,
+      confidence: 0.85,
+      resolvedBy: 'framework',
+    };
+  },
+};

+ 3 - 0
src/resolution/frameworks/index.ts

@@ -27,6 +27,7 @@ import { swiftObjcBridgeResolver } from './swift-objc';
 import { reactNativeBridgeResolver } from './react-native';
 import { expoModulesResolver } from './expo-modules';
 import { fabricViewResolver } from './fabric';
+import { cicsResolver } from './cics';
 
 /**
  * All registered framework resolvers
@@ -70,6 +71,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   expoModulesResolver,
   // React Native Fabric / Codegen view components — TS spec → component nodes
   fabricViewResolver,
+  // CICS pseudo-conversational TRANSID hops (COBOL)
+  cicsResolver,
 ];
 
 /**

+ 91 - 0
src/resolution/import-resolver.ts

@@ -46,6 +46,14 @@ export function resolveImportPath(
   language: Language,
   context: ResolutionContext
 ): string | null {
+  // COBOL COPY/EXEC SQL INCLUDE names a copybook member, not a path — the
+  // compiler searches a library, so we match against indexed file basenames.
+  // Must run before isExternalImport: a bare member name would otherwise be
+  // misclassified as an external package.
+  if (language === 'cobol') {
+    return resolveCobolCopybook(importPath, fromFile, context);
+  }
+
   // Skip external/npm packages — but pass the context so the
   // bare-specifier heuristic can consult the project's tsconfig
   // alias map first (custom prefixes like `@components/*` would
@@ -76,6 +84,57 @@ export function resolveImportPath(
   return null;
 }
 
+/**
+ * COBOL copybook lookup: `COPY CVACT01Y` (or `EXEC SQL INCLUDE X`) names a
+ * library member resolved by the compiler's copybook search path, so we match
+ * the member against indexed file basenames, case-insensitively. `.cpy` wins
+ * over a same-named program; a same-directory hit wins within a tier. The
+ * stem index is built once per resolution context (a per-ref scan of every
+ * file node would go quadratic on copybook-heavy repos).
+ */
+const cobolCopybookIndexes = new WeakMap<ResolutionContext, Map<string, string[]>>();
+
+function resolveCobolCopybook(
+  member: string,
+  fromFile: string,
+  context: ResolutionContext
+): string | null {
+  let index = cobolCopybookIndexes.get(context);
+  if (!index) {
+    index = new Map();
+    for (const fileNode of context.getNodesByKind('file')) {
+      const normalized = fileNode.filePath.replace(/\\/g, '/');
+      const base = normalized.split('/').pop() ?? '';
+      const dot = base.lastIndexOf('.');
+      const stem = (dot > 0 ? base.slice(0, dot) : base).toLowerCase();
+      const paths = index.get(stem);
+      if (paths) paths.push(fileNode.filePath);
+      else index.set(stem, [fileNode.filePath]);
+    }
+    cobolCopybookIndexes.set(context, index);
+  }
+
+  const candidates = index.get(member.toLowerCase());
+  if (!candidates || candidates.length === 0) return null;
+
+  const fromDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
+  let best: string | null = null;
+  let bestScore = -1;
+  for (const candidate of candidates) {
+    const normalized = candidate.replace(/\\/g, '/');
+    const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase();
+    let score = 0;
+    if (ext === '.cpy') score += 4;
+    else if (ext === '.cbl' || ext === '.cob' || ext === '.cobol') score += 2;
+    if (normalized.split('/').slice(0, -1).join('/') === fromDir) score += 1;
+    if (score > bestScore) {
+      bestScore = score;
+      best = candidate;
+    }
+  }
+  return best;
+}
+
 /**
  * C and C++ standard library header names (without delimiters).
  * Used by isExternalImport to filter system includes from resolution.
@@ -547,6 +606,15 @@ export function isPhpIncludePathRef(ref: UnresolvedRef): boolean {
   );
 }
 
+/**
+ * Is this a COBOL COPY / EXEC SQL INCLUDE copybook reference? These resolve
+ * to files only (or stay unresolved for compiler-supplied members) — never
+ * to a same-named symbol via the name-matcher.
+ */
+export function isCobolCopybookRef(ref: UnresolvedRef): boolean {
+  return ref.language === 'cobol' && ref.referenceKind === 'imports';
+}
+
 /**
  * Resolve a PHP include/require path to a project-relative file path.
  *
@@ -1165,6 +1233,29 @@ export function resolveViaImport(
     return null;
   }
 
+  // COBOL COPY / EXEC SQL INCLUDE — resolve the copybook member to a
+  // file→file edge, mirroring the C/C++ include branch above. A member that
+  // matches no indexed file (compiler-supplied copybooks like SQLCA/DFHAID)
+  // stays unresolved — callers must not fall back to the symbol name-matcher,
+  // which would connect it to a same-named import symbol elsewhere.
+  if (isCobolCopybookRef(ref)) {
+    const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language!, context);
+    if (!resolvedPath) return null;
+    const basename = resolvedPath.split('/').pop()!;
+    const fileNode = context
+      .getNodesByName(basename)
+      .find((n) => n.kind === 'file' && n.filePath === resolvedPath);
+    if (fileNode) {
+      return {
+        original: ref,
+        targetNodeId: fileNode.id,
+        confidence: 0.9,
+        resolvedBy: 'import',
+      };
+    }
+    return null;
+  }
+
   // PHP include/require — resolve the static string path to a file→file
   // edge, mirroring the C/C++ branch above. Distinguish include PATHS from
   // namespace `use` symbols by shape: an include path contains a slash or a

+ 2 - 2
src/resolution/index.ts

@@ -17,7 +17,7 @@ import {
   ImportMapping,
 } from './types';
 import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
-import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef } from './import-resolver';
+import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef } from './import-resolver';
 import { detectFrameworks } from './frameworks';
 import { synthesizeCallbackEdges } from './callback-synthesizer';
 import { createYielder, type MaybeYield } from './cooperative-yield';
@@ -815,7 +815,7 @@ export class ReferenceResolver {
     // If that didn't find the file, do NOT fall back to the symbol
     // name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
     // db.php elsewhere in the tree (a wrong edge is worse than none, #660).
-    if (isPhpIncludePathRef(ref)) {
+    if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref)) {
       return candidates.length > 0
         ? candidates.reduce((best, curr) =>
             curr.confidence > best.confidence ? curr : best

+ 1 - 0
src/types.ts

@@ -98,6 +98,7 @@ export const LANGUAGES = [
   'cfml',
   'cfscript',
   'cfquery',
+  'cobol',
   'unknown',
 ] as const;