From 3eb0b950b91e1be1f6bd16ac317473a74b35ac91 Mon Sep 17 00:00:00 2001 From: andres Date: Sat, 4 Jul 2026 11:10:57 +0200 Subject: [PATCH] feat(vba-extractor): model DoCmd.OpenReport and DoCmd.OpenQuery like OpenForm (closes #48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hueco 6 (DoCmd.OpenForm "X" -> opens-form edge) was always labelled follow-up work for OpenReport and OpenQuery. This lands both. The OpenForm pipeline is preserved byte-identically (existing hueco-6 + Const-fallback + W4-guard tests stay green without modification) while OpenReport mirrors it through a small dispatch table and OpenQuery opts for an UnresolvedReference so the resolver binds to the REAL query node emitted by SqlQueryExtractor. Changes: * `NodeKind` extended with `'report-layout'`; `EdgeKind` extended with `'opens-report'` (`src/types.ts`). Distinct from `form-layout` / `opens-form` so downstream tooling can filter form vs report without inspecting qualifiedName. * `OPEN_FORM_ARG_RE` retained byte-identical. New `OPEN_REPORT_ARG_RE` mirrors it for `DoCmd.OpenReport "X"`; new `OPEN_QUERY_ARG_RE` covers `DoCmd.OpenQuery "X"` (Issue #48 spec acceptance). * `DOCMD_OPEN_DISPATCH` table introduced (`{ method, re, edgeKind, stubKind, syntheticPrefix, syntheticExtension, moduleNamePrefix, cacheKey, metadataTargetKey, synthesizedBy }`). OpenForm and OpenReport share the entire scanner + emitter pipeline; only the per-method metadata differs. OpenQuery intentionally is NOT in this dispatch because it emits an UnresolvedReference, not a stub + edge. * `scanOpenFormCalls` generalized into `scanDoCmdOpenCalls` — iterates the dispatch table; OpenForm behavior byte-identical (same regex, same arg resolution, same stub id formula). * `emitOpensFormEdge` generalized into `emitOpensStubEdge` — parametric on the dispatch entry. Cache key is now `${cacheKey}:${loweredName}` so OpenForm and OpenReport de-dup buckets stay disjoint within a file. Stub synthetic path uses `${syntheticPrefix}/${Name}${syntheticExtension}` — `.form.txt` for OpenForm, `.report.txt` for OpenReport (mirrors the real file extension per Issue #48 spec). * New `scanDoCmdOpenQuery` — pushes ONE UnresolvedReference per match (`referenceName` = resolved query name, `referenceKind` = 'references', `metadata.synthesizedBy` = 'vba-opens-query'). Falls back to bare identifier when a local Const isn't found. The reference resolves to the REAL `query` node SqlQueryExtractor emits for `queries/.sql` — no synthetic stub, no synthetic `query` node, no synthetic function node (W4 graph-pollution invariant preserved). * Wiring updated in the line-scanner: `scanDoCmdOpenCalls` replaces the old `scanOpenFormCalls` call; `scanDoCmdOpenQuery` runs alongside it on the original unmasked line (consistent with OpenForm — the literal `"X"` form has the form/query name in a string literal that masking would destroy). * 9 regression tests in `__tests__/extraction-vba.test.ts` (Issue #48 describe block): - OpenReport literal + Const-resolved + Const-fallback (mirrors OpenForm's Const-fallback test at line ~2268) - OpenReport deterministic stub id across re-index (asserted against `generateNodeId('synthetic:opensReportStub/.report.txt', ...)`) - OpenReport W4 no-synthetic-fn guard - OpenReport de-dup: two calls produce exactly ONE stub - OpenQuery literal + Const-resolved + Const-fallback - OpenQuery W4 no-synthetic-fn guard Existing OpenForm tests (hueco-6 in `__tests__/extraction-vba-control-modeling.test.ts`; the "DoCmd.OpenForm does not emit a synthetic function node" and the "resolves local string constants in DoCmd.OpenForm..." tests in extraction-vba.test.ts) stay green without modification — the dispatch refactor preserves OpenForm emission exactly. Disjointness note: OpenForm and OpenReport live in separate ID spaces (different synthetic prefixes, different stub kinds, different qualifiedName prefixes `Form_` vs `Report_`). The cache key prefix `OpenForm:` vs `OpenReport:` further guarantees the two stubs in the same file don't collide. Validation: * `pnpm exec vitest run __tests__/extraction-vba.test.ts -t "Issue #48"` -> 9/9 pass in 681 ms * Full VBA suite (6 files): **236/236 pass** in 5.82 s — zero regressions in OpenForm or any other extractor path * `pnpm run build` -> exit 0, no TS errors --- __tests__/extraction-vba.test.ts | 236 ++++++++++++++++++++++ src/extraction/vba-extractor.ts | 333 ++++++++++++++++++++++++------- src/types.ts | 12 ++ 3 files changed, 512 insertions(+), 69 deletions(-) diff --git a/__tests__/extraction-vba.test.ts b/__tests__/extraction-vba.test.ts index e57e1745..d917d094 100644 --- a/__tests__/extraction-vba.test.ts +++ b/__tests__/extraction-vba.test.ts @@ -29,6 +29,7 @@ */ import { describe, it, expect } from 'vitest'; import { VbaExtractor } from '../src/extraction/vba-extractor'; +import { generateNodeId } from '../src/extraction/tree-sitter-helpers'; function extract(filePath: string, source: string) { return new VbaExtractor(filePath, source).extract(); @@ -895,6 +896,241 @@ End Sub`; }); }); +// --------------------------------------------------------------------------- +// Issue #48: DoCmd.OpenReport & DoCmd.OpenQuery built-in modeling — sibling +// coverage to the OpenForm hueco-6 implementation. OpenReport mirrors the +// OpenForm stub+edge pattern (Report_ qualifiedName, report-layout +// stub kind, opens-report edge kind, targetReportName metadata key, +// vba-opens-report synthesizedBy). OpenQuery is structurally different: it +// emits a single UnresolvedReference (not a stub + edge) so the resolver +// binds to the REAL `query` node emitted by SqlQueryExtractor for +// `queries/.sql` — same shape as vba-me-control / vba-forms-bang. +// +// Acceptance criteria covered (one test each): +// 1. Literal target emits opens-report edge + report-layout stub +// 2. Const-resolved target emits opens-report edge with resolved name +// 3. Unresolved Const falls back to bare identifier (no edge skipped) +// 4. Stub id is deterministic across re-index (id formula check) +// 5. DoCmd.OpenReport does NOT emit a synthetic `function` node (W4) +// 6. Two DoCmd.OpenReport "SameName" calls → exactly ONE stub (de-dup) +// 7. Literal target emits one UnresolvedReference with synthesizedBy +// `vba-opens-query`, no synthetic `function` / `query` node +// 8. Const-resolved query emits UnresolvedReference with resolved name +// 9. Unresolved Const falls back to bare identifier +// 10. DoCmd.OpenQuery does NOT emit a synthetic `function` node (W4) +// --------------------------------------------------------------------------- + +describe('VbaExtractor — DoCmd.OpenReport built-in modeling (Issue #48)', () => { + it('DoCmd.OpenReport "InformeMensual" (literal) emits an opens-report edge to a report-layout stub', () => { + const src = `Public Sub PrintIt() + DoCmd.OpenReport "InformeMensual" +End Sub`; + const r = extract('src/modules/modReports.bas', src); + + const edge = r.edges.find((e) => e.kind === 'opens-report'); + expect(edge).toBeDefined(); + expect(edge?.provenance).toBe('heuristic'); + expect(edge?.metadata?.synthesizedBy).toBe('vba-opens-report'); + expect(edge?.metadata?.targetReportName).toBe('InformeMensual'); + // OpenForm's key MUST NOT leak into OpenReport metadata. + expect(edge?.metadata?.targetFormName).toBeUndefined(); + + const stub = r.nodes.find((n) => n.id === edge?.target); + expect(stub).toBeDefined(); + expect(stub?.kind).toBe('report-layout'); + expect(stub?.name).toBe('InformeMensual'); + expect(stub?.qualifiedName).toBe('Report_InformeMensual'); + expect(stub?.metadata?.stub).toBe(true); + }); + + it('DoCmd.OpenReport REPORT_MENSUAL (Const-resolved) emits the resolved name "InformeMensual"', () => { + // Mirrors the OpenForm Const-fallback test shape — known Const is + // resolved to its literal value, unknown Const falls back to the bare + // identifier. + const src = [ + 'Const REPORT_MENSUAL As String = "InformeMensual"', + 'Sub PrintKnown()', + ' DoCmd.OpenReport REPORT_MENSUAL', + ' DoCmd.OpenReport REPORT_UNKNOWN', + 'End Sub', + ].join('\n'); + const r = extract('src/modules/modReports.bas', src); + + const reportEdges = r.edges.filter((e) => e.kind === 'opens-report'); + expect(reportEdges.length).toBe(2); + + const targets = reportEdges + .map((e) => r.nodes.find((n) => n.id === e.target)?.name) + .sort(); + expect(targets).toEqual(['InformeMensual', 'REPORT_UNKNOWN']); + + // Both edges must carry the resolved targetReportName in metadata. + const toInformeMensual = reportEdges.find( + (e) => e.metadata?.targetReportName === 'InformeMensual', + ); + expect(toInformeMensual).toBeDefined(); + expect(toInformeMensual?.metadata?.synthesizedBy).toBe('vba-opens-report'); + + // Unknown Const: edge still emitted, falls back to bare identifier. + const toUnknown = reportEdges.find( + (e) => e.metadata?.targetReportName === 'REPORT_UNKNOWN', + ); + expect(toUnknown).toBeDefined(); + const unknownStub = r.nodes.find((n) => n.id === toUnknown?.target); + expect(unknownStub?.qualifiedName).toBe('Report_REPORT_UNKNOWN'); + }); + + it('stub id for DoCmd.OpenReport "InformeMensual" is deterministic across re-index (matches generateNodeId formula)', () => { + // Deterministic-id invariant: the stub id is computed from a synthetic + // file path (`synthetic:opensReportStub/.form.txt`) and the + // kind/name/line tuple. Re-indexing the same source MUST produce the + // SAME id (so per-file INSERT OR REPLACE collapses to a no-op and the + // graph stays stable). The synthetic path uses `.form.txt` for both + // OpenForm and OpenReport (see `emitOpensStubEdge` comment); the + // dispatch table's `moduleNamePrefix` is what carries the + // `Report_` vs `Form_` qualifiedName convention. + const src = `Public Sub PrintIt() + DoCmd.OpenReport "InformeMensual" +End Sub`; + + const expectedStubId = generateNodeId( + 'synthetic:opensReportStub/InformeMensual.report.txt', + 'report-layout', + 'InformeMensual', + 0, + ); + + const r1 = extract('src/modules/modReports.bas', src); + const r2 = extract('src/modules/modReports.bas', src); + const edge1 = r1.edges.find((e) => e.kind === 'opens-report'); + const edge2 = r2.edges.find((e) => e.kind === 'opens-report'); + expect(edge1?.target).toBe(expectedStubId); + expect(edge2?.target).toBe(expectedStubId); + expect(edge1?.target).toBe(edge2?.target); + }); + + it('DoCmd.OpenReport does not emit a synthetic function node (W4 invariant)', () => { + // Mirrors the W4 guard for OpenForm (line ~647). `DoCmd` is in + // RUNTIME_RECEIVER_BLACKLIST, so the generic CALL_RE path skips it; + // the dedicated dispatch must NOT regress to emitting a synthetic + // `DoCmd.OpenReport` `function` node (would pollute the graph per W4). + const src = `Public Sub X() + DoCmd.OpenReport "InformeMensual" +End Sub`; + const r = extract('src/modules/X.bas', src); + const synthFns = r.nodes.filter( + (n) => n.kind === 'function' && n.name.includes('DoCmd.OpenReport'), + ); + expect(synthFns).toHaveLength(0); + }); + + it('two DoCmd.OpenReport "SameName" calls in one file produce exactly ONE report-layout stub (de-dup invariant)', () => { + // The opensStubIdsByKey cache (keyed by `${cacheKey}:${lowerName}`) + // must collapse N call sites to a single stub. Verifies the Issue #48 + // refactor preserved the de-dup contract while moving from a name-keyed + // cache to a (method, name)-keyed cache. + const src = [ + 'Sub PrintTwice()', + ' DoCmd.OpenReport "SameName"', + ' DoCmd.OpenReport "SameName"', + 'End Sub', + ].join('\n'); + const r = extract('src/modules/modReports.bas', src); + + const stubs = r.nodes.filter( + (n) => n.kind === 'report-layout' && n.name === 'SameName', + ); + expect(stubs).toHaveLength(1); + + const edges = r.edges.filter((e) => e.kind === 'opens-report'); + expect(edges.length).toBe(2); + // Both edges MUST point at the SAME stub id (the de-dup invariant). + const uniqueTargets = new Set(edges.map((e) => e.target)); + expect(uniqueTargets.size).toBe(1); + }); +}); + +describe('VbaExtractor — DoCmd.OpenQuery built-in modeling (Issue #48)', () => { + it('DoCmd.OpenQuery "Consulta1" (literal) emits ONE UnresolvedReference with synthesizedBy vba-opens-query', () => { + // OpenQuery is structurally different from OpenForm/OpenReport: it + // emits an UnresolvedReference (NOT a stub + edge) so the resolver + // binds to the REAL `query` node emitted by SqlQueryExtractor for + // `queries/.sql`. Same emission shape as vba-me-control / + // vba-forms-bang. + const src = `Public Sub OpenIt() + DoCmd.OpenQuery "Consulta1" +End Sub`; + const r = extract('src/modules/modQueries.bas', src); + + const refs = r.unresolvedReferences.filter( + (u) => u.metadata?.synthesizedBy === 'vba-opens-query', + ); + expect(refs.length).toBe(1); + expect(refs[0]?.referenceName).toBe('Consulta1'); + expect(refs[0]?.referenceKind).toBe('references'); + + // No stub nodes (OpenQuery doesn't synthesize one). + const stubs = r.nodes.filter((n) => n.kind === 'report-layout' || n.kind === 'form-layout'); + expect(stubs.some((s) => s.name === 'Consulta1')).toBe(false); + // No opens-form / opens-report edges either (the resolver binds the + // UnresolvedReference when the matching .sql is indexed). + const openEdges = r.edges.filter( + (e) => e.kind === 'opens-form' || e.kind === 'opens-report', + ); + expect(openEdges).toHaveLength(0); + }); + + it('DoCmd.OpenQuery CONSULTA_DEPURACION (Const-resolved) emits UnresolvedReference with resolved name', () => { + const src = [ + 'Const CONSULTA_DEPURACION As String = "qDepuracion"', + 'Sub OpenDepuration()', + ' DoCmd.OpenQuery CONSULTA_DEPURACION', + 'End Sub', + ].join('\n'); + const r = extract('src/modules/modQueries.bas', src); + + const refs = r.unresolvedReferences.filter( + (u) => u.metadata?.synthesizedBy === 'vba-opens-query', + ); + expect(refs.length).toBe(1); + expect(refs[0]?.referenceName).toBe('qDepuracion'); + expect(refs[0]?.referenceKind).toBe('references'); + }); + + it('DoCmd.OpenQuery CONSULTA_UNKNOWN (Const not defined) falls back to bare identifier', () => { + const src = `Public Sub OpenIt() + DoCmd.OpenQuery CONSULTA_UNKNOWN +End Sub`; + const r = extract('src/modules/modQueries.bas', src); + + const refs = r.unresolvedReferences.filter( + (u) => u.metadata?.synthesizedBy === 'vba-opens-query', + ); + expect(refs.length).toBe(1); + expect(refs[0]?.referenceName).toBe('CONSULTA_UNKNOWN'); + }); + + it('DoCmd.OpenQuery does not emit a synthetic function node (W4 invariant)', () => { + // Mirrors the W4 guard for OpenForm / OpenReport. The dedicated + // OpenQuery scanner emits UnresolvedReferences, NOT synthetic + // `function` / `query` nodes (the real `query` node already exists in + // the index once `queries/.sql` is processed, and creating + // stubs would compete with the binding). + const src = `Public Sub X() + DoCmd.OpenQuery "Consulta1" +End Sub`; + const r = extract('src/modules/X.bas', src); + const synthFns = r.nodes.filter( + (n) => n.kind === 'function' && n.name.includes('DoCmd.OpenQuery'), + ); + expect(synthFns).toHaveLength(0); + // VbaExtractor must not synthesize a `query` node either — that kind + // belongs to SqlQueryExtractor's output, not the VBA scanner. + const synthQueries = r.nodes.filter((n) => n.kind === 'query'); + expect(synthQueries).toHaveLength(0); + }); +}); + /** * S3 invariant — `Dim x As SomeType` (unqualified, no dot) MUST emit a * `references` edge to `SomeType` when `SomeType` is not a primitive. diff --git a/src/extraction/vba-extractor.ts b/src/extraction/vba-extractor.ts index 11fba352..b122c121 100644 --- a/src/extraction/vba-extractor.ts +++ b/src/extraction/vba-extractor.ts @@ -1180,6 +1180,101 @@ export class VbaExtractor { private static readonly OPEN_FORM_ARG_RE = /\bDoCmd\.OpenForm\s+("(?:(?:[^"]|"")*)"|\p{L}[\p{L}\p{N}_]*)/gu; + /** + * Issue #48: `DoCmd.OpenReport ""` modelling regex — sibling + * of `OPEN_FORM_ARG_RE` (hueco 6 expanded). Same literal-or-bare-id argument + * capture (group 1) and same trailing positional-args drop. The dispatch + * table `DOCMD_OPEN_DISPATCH` (below) carries the per-method metadata so + * OpenForm and OpenReport share the same scan/emit pipeline while their + * edge kinds (`opens-form` vs `opens-report`), stub node kinds + * (`form-layout` vs `report-layout`), synthetic file-path prefixes, and + * qualifiedName prefixes (`Form_` vs `Report_`) stay + * distinct. + */ + private static readonly OPEN_REPORT_ARG_RE = + /\bDoCmd\.OpenReport\s+("(?:(?:[^"]|"")*)"|\p{L}[\p{L}\p{N}_]*)/gu; + + /** + * Issue #48 dispatch table — shared literal-or-Const argument resolution + * for `DoCmd.OpenForm` and `DoCmd.OpenReport`. Each entry is everything + * `scanDoCmdOpenCalls` + `emitOpensStubEdge` need to share the pipeline + * between methods while keeping the per-method names distinct. + * + * OpenQuery is intentionally NOT in this dispatch — it emits an + * `UnresolvedReference` (not a stub + edge), resolution to the REAL + * `query` node emitted by `SqlQueryExtractor`. See + * `OPEN_QUERY_ARG_RE` + `scanDoCmdOpenQuery`. + * + * Why a separate dispatch: `DoCmd` is in `RUNTIME_RECEIVER_BLACKLIST` + * (R4 invariant), so ALL of these methods are intentionally SKIPPED by + * the generic `CALL_RE` path that would otherwise emit a junk `calls` + * edge to a synthetic `function` node for `DoCmd.OpenX`. This dispatch + * matches BEFORE the call-site scan and uses its own emission path. + */ + private static readonly DOCMD_OPEN_DISPATCH: ReadonlyArray<{ + method: 'OpenForm' | 'OpenReport'; + re: RegExp; + edgeKind: 'opens-form' | 'opens-report'; + stubKind: 'form-layout' | 'report-layout'; + syntheticPrefix: 'synthetic:opensFormStub' | 'synthetic:opensReportStub'; + /** Synthetic file extension. OpenForm uses `.form.txt` to mirror the + * real `.form.txt` extension a `Form_` module exports; OpenReport + * uses `.report.txt` to mirror the real `.report.txt` extension a + * `Report_` module exports. Per Issue #48 spec: the synthetic + * path is `synthetic:opensReportStub/.report.txt`. */ + syntheticExtension: '.form.txt' | '.report.txt'; + moduleNamePrefix: 'Form_' | 'Report_'; + /** Cache key prefix (e.g. `OpenForm`, `OpenReport`) — keeps the two + * stubs in disjoint de-dup buckets within a file. */ + cacheKey: 'OpenForm' | 'OpenReport'; + /** Edge metadata key (`targetFormName` for OpenForm, `targetReportName` + * for OpenReport). OpenForm's literal key value MUST stay `targetFormName` + * because the existing B4 test (hueco 6) and + * `DoCmd.OpenForm CONST-fallback` regression assert on it. */ + metadataTargetKey: 'targetFormName' | 'targetReportName'; + synthesizedBy: 'vba-opens-form' | 'vba-opens-report'; + }> = [ + { + method: 'OpenForm', + re: VbaExtractor.OPEN_FORM_ARG_RE, + edgeKind: 'opens-form', + stubKind: 'form-layout', + syntheticPrefix: 'synthetic:opensFormStub', + syntheticExtension: '.form.txt', + moduleNamePrefix: 'Form_', + cacheKey: 'OpenForm', + metadataTargetKey: 'targetFormName', + synthesizedBy: 'vba-opens-form', + }, + { + method: 'OpenReport', + re: VbaExtractor.OPEN_REPORT_ARG_RE, + edgeKind: 'opens-report', + stubKind: 'report-layout', + syntheticPrefix: 'synthetic:opensReportStub', + syntheticExtension: '.report.txt', + moduleNamePrefix: 'Report_', + cacheKey: 'OpenReport', + metadataTargetKey: 'targetReportName', + synthesizedBy: 'vba-opens-report', + }, + ]; + + /** + * Issue #48: `DoCmd.OpenQuery ""` modelling regex. Emits an + * `UnresolvedReference` (NOT a stub + edge) so the resolver binds to the + * REAL `query` node `SqlQueryExtractor` emits for `queries/.sql` + * — the same shape as `vba-me-control` and `vba-forms-bang`. The query + * may not yet exist in the index when the .bas is parsed; the resolver + * does the binding when the .sql is later indexed. + * + * Argument shape: identical to OpenForm/OpenReport — literal `"..."` or + * bare identifier resolved against local `Const` declarations, falling + * back to the bare identifier when unknown. + */ + private static readonly OPEN_QUERY_ARG_RE = + /\bDoCmd\.OpenQuery\s+("(?:(?:[^"]|"")*)"|\p{L}[\p{L}\p{N}_]*)/gu; + /** SQL assigned to a local variable, e.g. `m_SQL = "SELECT ..." & ...`. */ private static readonly SQL_VAR_ASSIGN_RE = /^\s*(\p{L}[\p{L}\p{N}_]*)\s*=\s*(.*)$/iu; @@ -1758,9 +1853,16 @@ export class VbaExtractor { // node id (the same pattern the cross-file incoming-edges // snapshot already uses at `index.ts:getCrossFileIncomingEdges`). const caller2 = stack[stack.length - 1]!; - this.scanOpenFormCalls(line, caller2, lineNum); + // Issue #48: shared OpenForm/OpenReport dispatch via + // `scanDoCmdOpenCalls` (formerly `scanOpenFormCalls`, refactored to + // iterate the `DOCMD_OPEN_DISPATCH` table — OpenForm behavior is + // byte-identical to pre-#48). The OpenQuery scanner emits an + // `UnresolvedReference` and stays separate from the dispatch since + // its emission shape (no stub + edge) differs. + this.scanDoCmdOpenCalls(line, caller2, lineNum); + this.scanDoCmdOpenQuery(line, caller2, lineNum); // Issue #44: cross-form bang references (`Forms!X` / `Forms("X")!Y`). - // Same line context as `scanOpenFormCalls` — the form name lives in + // Same line context as `scanDoCmdOpenCalls` — the form name lives in // a string literal in the paren form, so we MUST scan the unmasked // line. The scanner is independent of `scanCallSites` because // `Forms` is in `RUNTIME_RECEIVER_BLACKLIST` and would otherwise be @@ -1926,12 +2028,15 @@ export class VbaExtractor { private callDedupe = new Set(); private synthFunctionNodeIds = new Set(); /** - * B4 (hueco 6): cache of stub `form-layout` node ids we've already emitted - * for a given target form name in this file. Avoids emitting duplicate - * stubs when `DoCmd.OpenForm "FormTest"` shows up N times across N calls. - * Keyed by the lowercased form name so `FormTest` / `formtest` collapse. + * B4 (hueco 6) extended by Issue #48: cache of stub node ids we've already + * emitted for a given (method, target name) pair in this file. Avoids + * emitting duplicate stubs when `DoCmd.OpenForm "FormTest"` or + * `DoCmd.OpenReport "InformeMensual"` shows up N times across N calls. + * Keyed by `${cacheKey}:${lowerName}` so the OpenForm and OpenReport + * de-dup buckets stay disjoint — `OpenForm:Form1` ≠ `OpenReport:Form1`. + * The name part is lowercased so `FormTest` / `formtest` collapse. */ - private opensFormStubIdsByName = new Map(); + private opensStubIdsByKey = new Map(); /** * Hueco 1: scan a line for `Me.` / `Me!` @@ -2332,100 +2437,135 @@ export class VbaExtractor { } /** - * B4 (hueco 6): scan one line of VBA source for `DoCmd.OpenForm "X"` - * calls. For each match, emit: - * - a stub `form-layout` node for the target form (cached by name so - * the same form referenced from N sites emits exactly ONE stub), - * - an `opens-form` heuristic edge from the calling Sub to that stub. + * B4 (hueco 6) extended by Issue #48: scan one line of VBA source for + * `DoCmd.OpenX "Target"` calls where X ∈ {Form, Report} (see the + * `DOCMD_OPEN_DISPATCH` table). For each match, emit: + * - a stub node (form-layout / report-layout) for the target, cached + * per-(method, name) so the same target referenced from N sites + * emits exactly ONE stub, + * - an `opens-form` / `opens-report` heuristic edge from the calling + * Sub to that stub. * * Both endpoints are pushed into `this.nodes` / `this.edges`, so the * per-file edge filter at `index.ts:insertedIds.has(source) && * insertedIds.has(target)` passes the edge naturally without any * exemption to the filter. * - * Why a stub and not a direct lookup: the target form lives in a - * DIFFERENT file (its own `.form.txt`), and the extractor doesn't have - * DB access at parse time. The stub's synthetic file path - * (`synthetic:opensFormStub/.form.txt`) guarantees a - * deterministic node id so re-indexes collapse to the same stub. - * When the consumer's `.form.txt` is later indexed, the real - * `form-layout` node carries a different id (it uses the real file - * path); the stub and the real coexist harmlessly. The orchestrator - * flagged this as acceptable for B4 — only `OpenForm` is in scope. - * `OpenReport`, `OpenQuery`, `OpenTable`, … are follow-up work. + * Why a stub and not a direct lookup: the target form/report lives in a + * DIFFERENT file (its own `.form.txt` / `.report.txt`), and the extractor + * doesn't have DB access at parse time. The stub's synthetic file path + * (`synthetic:opensFormStub/.form.txt` / + * `synthetic:opensReportStub/.form.txt`) guarantees a deterministic + * node id so re-indexes collapse to the same stub. When the consumer's + * `.form.txt` / `.report.txt` is later indexed, the real + * `form-layout` / `report-layout` node carries a different id (it uses + * the real file path); the stub and the real coexist harmlessly. * - * Scope note: this regex matches literal-string and bare-identifier forms. - * Bare identifiers are resolved only through local `Const` declarations; - * arbitrary variable data-flow remains intentionally out of scope. + * Why a separate dispatch from CALL_RE: `DoCmd` is in + * `RUNTIME_RECEIVER_BLACKLIST` (R4 invariant), so `DoCmd.OpenForm` / + * `DoCmd.OpenReport` are intentionally SKIPPED by the generic CALL_RE + * path that would otherwise emit a junk `calls` edge to a synthetic + * `function` node for `DoCmd.OpenX`. The dispatch below matches BEFORE + * the call-site scan and uses its own emission path. + * + * Scope note: literal-string and bare-identifier argument forms are + * supported. Bare identifiers resolve only through local `Const` + * declarations; arbitrary variable data-flow remains intentionally + * out of scope. */ - private scanOpenFormCalls( + private scanDoCmdOpenCalls( line: string, caller: ProcInfo, lineNum: number, ): void { - // Each regex has /g so we MUST reset `lastIndex` before use; cloning - // the regex is the simplest way to avoid leaking state across lines. - const localRe = new RegExp( - VbaExtractor.OPEN_FORM_ARG_RE.source, - VbaExtractor.OPEN_FORM_ARG_RE.flags, - ); - let m: RegExpExecArray | null; - while ((m = localRe.exec(line)) !== null) { - const rawArg = (m[1] ?? '').trim(); - const targetFormName = rawArg.startsWith('"') - ? unwrapVbaStringLiteral(rawArg) - : (this.localConstants.get(rawArg.toLowerCase()) ?? rawArg); - if (!targetFormName) continue; - this.emitOpensFormEdge(caller, targetFormName, lineNum, m.index); + for (const dispatch of VbaExtractor.DOCMD_OPEN_DISPATCH) { + // Each regex has /g so we MUST reset `lastIndex` before use; cloning + // the regex is the simplest way to avoid leaking state across lines + // AND across dispatch iterations. + const localRe = new RegExp(dispatch.re.source, dispatch.re.flags); + let m: RegExpExecArray | null; + while ((m = localRe.exec(line)) !== null) { + const rawArg = (m[1] ?? '').trim(); + const targetName = rawArg.startsWith('"') + ? unwrapVbaStringLiteral(rawArg) + : (this.localConstants.get(rawArg.toLowerCase()) ?? rawArg); + if (!targetName) continue; + this.emitOpensStubEdge( + dispatch, + caller, + targetName, + lineNum, + m.index, + ); + } } } /** - * B4 (hueco 6): emit a stub `form-layout` node for `targetFormName` - * (cached so duplicates collapse) and an `opens-form` heuristic edge + * B4 (hueco 6) extended by Issue #48: emit a stub `form-layout` / + * `report-layout` node for `targetName` (cached per dispatch entry so + * duplicates collapse and OpenForm/OpenReport de-dup buckets stay + * disjoint) and a single `opens-form` / `opens-report` heuristic edge * from `caller` to that stub. * * The edge carries: - * - `kind: 'opens-form'` — new cross-file edge kind - * - `provenance: 'heuristic'` — synthesized, not parsed - * - `metadata.targetFormName` — the captured literal - * - `metadata.synthesizedBy: 'vba-opens-form'` — distinguishes this - * synthesis from the dim/sql/event-handler families + * - `kind` — dispatch-specific (`opens-form` / `opens-report`) + * - `provenance: 'heuristic'` — synthesized, not parsed + * - `metadata.` (e.g. `targetFormName`) — the resolved name + * - `metadata.synthesizedBy` — dispatch-specific (`vba-opens-form` / + * `vba-opens-report`); distinguishes this synthesis from the + * dim/sql/event-handler families * * The stub's `metadata.stub: true` flag lets downstream UI render - * unresolved references distinctly (e.g. with a dashed border) and - * gives later re-resolution pass a hook for collapse. The stub is + * stubs distinctly (e.g. with a dashed border) and gives later + * re-resolution pass a hook for collapse. The stub is * line-independent (`line = 0`) so re-indexes produce identical ids. */ - private emitOpensFormEdge( + private emitOpensStubEdge( + dispatch: { + method: 'OpenForm' | 'OpenReport'; + edgeKind: 'opens-form' | 'opens-report'; + stubKind: 'form-layout' | 'report-layout'; + syntheticPrefix: 'synthetic:opensFormStub' | 'synthetic:opensReportStub'; + syntheticExtension: '.form.txt' | '.report.txt'; + moduleNamePrefix: 'Form_' | 'Report_'; + cacheKey: 'OpenForm' | 'OpenReport'; + metadataTargetKey: 'targetFormName' | 'targetReportName'; + synthesizedBy: 'vba-opens-form' | 'vba-opens-report'; + }, caller: ProcInfo, - targetFormName: string, + targetName: string, lineNum: number, column: number, ): void { - const key = targetFormName.toLowerCase(); - let stubId = this.opensFormStubIdsByName.get(key); + const key = `${dispatch.cacheKey}:${targetName.toLowerCase()}`; + let stubId = this.opensStubIdsByKey.get(key); if (!stubId) { // Synthetic file path keeps the stub's id deterministic AND - // disambiguates it from any real `.form.txt` indexed later. - // The directory prefix (`synthetic:opensFormStub/`) is intentionally - // not a real filesystem path — it just namespaces the id space. - const syntheticFilePath = `synthetic:opensFormStub/${targetFormName}.form.txt`; + // disambiguates it from any real `.form.txt` / `.report.txt` + // indexed later. The directory prefix (`synthetic:opensFormStub/` + // or `synthetic:opensReportStub/`) is intentionally not a real + // filesystem path — it just namespaces the id space. The file + // extension (`syntheticExtension`) DOES mirror the real form/report + // file extension so a reader of the synthetic path can tell the + // stub's intent at a glance. + const syntheticFilePath = `${dispatch.syntheticPrefix}/${targetName}${dispatch.syntheticExtension}`; stubId = generateNodeId( syntheticFilePath, - 'form-layout', - targetFormName, + dispatch.stubKind, + targetName, 0, ); - this.opensFormStubIdsByName.set(key, stubId); + this.opensStubIdsByKey.set(key, stubId); this.nodes.push({ id: stubId, - kind: 'form-layout', - name: targetFormName, - // Convention: form module names in Access are `Form_`. - // We follow the same convention in the synthetic stub's - // qualifiedName so cross-file lookups can find it consistently. - qualifiedName: `Form_${targetFormName}`, + kind: dispatch.stubKind, + name: targetName, + // Convention: form module names in Access are `Form_` and + // report module names are `Report_`. We follow the same + // convention in the synthetic stub's qualifiedName so cross-file + // lookups can find it consistently. + qualifiedName: `${dispatch.moduleNamePrefix}${targetName}`, filePath: syntheticFilePath, language: 'vba', startLine: lineNum, @@ -2439,17 +2579,72 @@ export class VbaExtractor { this.edges.push({ source: this.findOrCreateFunctionNodeId(caller), target: stubId, - kind: 'opens-form', + kind: dispatch.edgeKind, provenance: 'heuristic', metadata: { - synthesizedBy: 'vba-opens-form', - targetFormName, + synthesizedBy: dispatch.synthesizedBy, + [dispatch.metadataTargetKey]: targetName, }, line: lineNum, column, }); } + /** + * Issue #48: scan one line of VBA source for `DoCmd.OpenQuery "X"` calls. + * Each match emits ONE `UnresolvedReference` (NOT a stub + edge) so the + * resolver binds to the REAL `query` node that `SqlQueryExtractor` + * produces for `queries/.sql` (dysflow exports every saved QueryDef + * + `queries.json` manifest). Falls back to silent when the .sql is not + * yet in the index — the resolver does the binding when it's later + * indexed, exactly like `vba-me-control` and `vba-forms-bang`. + * + * Companion to `scanDoCmdOpenCalls` but intentionally NOT in the + * dispatch table — OpenQuery's emission shape (`UnresolvedReference`) + * is structurally different from OpenForm/OpenReport's (synthetic node + * + heuristic edge). The two pipelines share the literal-vs-Const + * argument resolution pattern via `localConstants.get(...)` but emit + * via two different branches of `unresolvedReferences` vs + * `nodes`/`edges`. + * + * UnresolvedReference shape (per Issue #48 spec — must match + * SqlQueryExtractor's query node name exactly): + * - `referenceName` = resolved query name + * - `referenceKind: 'references'` = same kind the resolver binds + * - `metadata.synthesizedBy: 'vba-opens-query'` + * - NO synthetic `function` node (W4 graph-pollution invariant — the + * real `query` node already exists in the index once `.sql` is + * processed, and creating stubs would compete with the binding). + */ + private scanDoCmdOpenQuery( + line: string, + caller: ProcInfo, + lineNum: number, + ): void { + const localRe = new RegExp( + VbaExtractor.OPEN_QUERY_ARG_RE.source, + VbaExtractor.OPEN_QUERY_ARG_RE.flags, + ); + let m: RegExpExecArray | null; + while ((m = localRe.exec(line)) !== null) { + const rawArg = (m[1] ?? '').trim(); + const targetName = rawArg.startsWith('"') + ? unwrapVbaStringLiteral(rawArg) + : (this.localConstants.get(rawArg.toLowerCase()) ?? rawArg); + if (!targetName) continue; + this.unresolvedReferences.push({ + fromNodeId: this.findOrCreateFunctionNodeId(caller), + referenceName: targetName, + referenceKind: 'references', + line: lineNum, + column: m.index, + filePath: this.filePath, + language: 'vba', + metadata: { synthesizedBy: 'vba-opens-query' }, + }); + } + } + /** * Regex matching the chained `& "..."` literals that may follow a * wrapper's first literal on the same physical line. Captures the diff --git a/src/types.ts b/src/types.ts index 0c26c909..5be39249 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,8 +54,14 @@ export const NODE_KINDS = [ // CommandButton, etc.) and is the bridge target for `event-handler` // edges synthesized from `_` handler Subs in the // sibling `.cls`. + // 'report-layout' — Issue #48: the report-level stub node synthesized + // for `DoCmd.OpenReport ""`. Mirrors `form-layout` but is keyed + // to the report naming convention (`Report_` qualifiedName). + // Distinct kind so downstream tooling can filter form-vs-report stubs + // without inspecting qualifiedName. 'form-layout', 'form-instance-control', + 'report-layout', ] as const; export type NodeKind = (typeof NODE_KINDS)[number]; @@ -85,8 +91,14 @@ export type EdgeKind = // the calling function to a target form module. Carries // `metadata.targetFormName` until the target `.cls`/`.form.txt` is // indexed and resolved. + // 'opens-report' — Issue #48: `DoCmd.OpenReport ""` + // modeled as an edge from the calling function to a target report + // module. Carries `metadata.targetReportName`. Symmetric to + // `opens-form` (different edge kind, different stub kind, different + // qualifiedName prefix `Report_` vs `Form_`). | 'event-handler' | 'opens-form' + | 'opens-report' | 'raises-event' | 'subscribes-event' | 'type-member';