diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a06e4d..fbbb08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes - VBA: a call written with the `Call` keyword, or with an argument list, is now reported as a call rather than as an ambiguous bare-identifier read, so a genuinely missing procedure is no longer filtered out by the constant-lookup rules meant for plain identifier reads. A bare name with no `Call` keyword and no arguments still counts as an identifier read, because it really can be a constant. (#265) +- A form or report whose file was saved under a different name than the module itself carries now gets its event handlers and control references wired up, instead of quietly coming through with none of them. (#249) - Calls into Access and VBA built-ins no longer create thousands of phantom symbols that show up in search results and node counts. (#245) - Class setup and teardown routines in Access class modules are now recognised; the previous check looked for a constructor spelling that only exists in VB.NET, so it never matched real VBA code. (#248) - VBA references mentioned only inside messages, logs, and other string literals no longer create false form, query, or temporary-variable relationships. (#209) diff --git a/__tests__/extraction-vba-vbname-binding.test.ts b/__tests__/extraction-vba-vbname-binding.test.ts new file mode 100644 index 0000000..5174241 --- /dev/null +++ b/__tests__/extraction-vba-vbname-binding.test.ts @@ -0,0 +1,310 @@ +/** + * extraction-vba-vbname-binding.test.ts + * + * Acceptance tests for issue #249 — Access code-behind binding must follow + * the module, not the filename. + * + * Event-handler synthesis and the `Me.` sweep both gated on the FILE + * BASENAME starting with `Form_` / `Report_`. When the filename and the + * module's `Attribute VB_Name` disagree, the file still parsed and still + * emitted its procedures, but the form came out with no event wiring and no + * control references — and nothing errored or warned. + * + * The fixtures make that concrete. `Form_Expediente.cls` and `sample2.cls` + * under `__tests__/fixtures/vba-vbname-binding/` are BYTE-IDENTICAL; the only + * difference between them is the name on disk. So every difference these + * tests find between the two extractions is the bug. + * + * The other half of the file is the guard that must NOT move. A plain + * service class (`InformeRiesgoPDFServicio.cls`) whose methods end in real + * Access event names must still produce zero control stubs — that guard is + * what stopped ~550 spurious nodes in real projects, and widening it is the + * failure mode this change had to avoid. + * + * Real files, real extractors, no mocking. + */ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { VbaExtractor } from '../src/extraction/vba-extractor'; +import { VbaFormExtractor } from '../src/extraction/vba-form-extractor'; +import type { + Edge, + ExtractionResult, + Node, + UnresolvedReference, +} from '../src/types'; + +const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'vba-vbname-binding'); +const NAMED_CLS = path.join(FIXTURE_DIR, 'Form_Expediente.cls'); +const RENAMED_CLS = path.join(FIXTURE_DIR, 'sample2.cls'); +const NAMED_TXT = path.join(FIXTURE_DIR, 'Form_Expediente.form.txt'); +const REPORT_CLS = path.join(FIXTURE_DIR, 'sample_report.cls'); +const SERVICE_CLS = path.join(FIXTURE_DIR, 'InformeRiesgoPDFServicio.cls'); +const NEAR_MISS_CLS = path.join(FIXTURE_DIR, 'FormularioVentas.cls'); + +function extractCode(p: string): ExtractionResult { + return new VbaExtractor(p, fs.readFileSync(p, 'utf8')).extract(); +} + +function fn(result: ExtractionResult, name: string): Node | undefined { + return result.nodes.find((n) => n.kind === 'function' && n.name === name); +} + +/** Every `event-handler` edge leaving the named Sub. */ +function handlerEdges(result: ExtractionResult, subName: string): Edge[] { + const sub = fn(result, subName); + if (!sub) return []; + return result.edges.filter( + (e) => e.kind === 'event-handler' && e.source === sub.id, + ); +} + +/** + * The comparable shape of an event-handler edge: its event, its scope, and + * the NAME + kind of whatever it points at. The node ids themselves are + * deliberately excluded — they hash the sibling layout path, which differs + * between the two files by design (the sibling lives beside the FILE). + */ +function handlerShape(result: ExtractionResult, subName: string) { + return handlerEdges(result, subName) + .map((e) => { + const target = result.nodes.find((n) => n.id === e.target); + return { + eventName: e.metadata?.eventName, + scope: e.metadata?.scope ?? null, + targetKind: target?.kind, + targetName: target?.name, + }; + }) + .sort((a, b) => String(a.targetName).localeCompare(String(b.targetName))); +} + +function meControlRefs(result: ExtractionResult): UnresolvedReference[] { + return result.unresolvedReferences.filter( + (r) => r.metadata?.synthesizedBy === 'vba-me-control', + ); +} + +const named = extractCode(NAMED_CLS); +const renamed = extractCode(RENAMED_CLS); +const report = extractCode(REPORT_CLS); +const service = extractCode(SERVICE_CLS); +const nearMiss = extractCode(NEAR_MISS_CLS); + +// ============================================================================= +// The premise: the two .cls fixtures really are the same bytes. If this ever +// stops holding, every comparison below is measuring the wrong thing. +// ============================================================================= + +describe('issue #249 — fixture premise', () => { + it('Form_Expediente.cls and sample2.cls are byte-identical', () => { + expect(fs.readFileSync(RENAMED_CLS)).toEqual(fs.readFileSync(NAMED_CLS)); + }); +}); + +// ============================================================================= +// AC #1 — identical source under either filename produces the same set of +// event-handler edges, control-level and form-level alike. +// ============================================================================= + +describe('issue #249 — event-handler edges follow VB_Name', () => { + it('the renamed file binds cmdGuardar_Click exactly as the named one does', () => { + const expected = [ + { + eventName: 'Click', + scope: null, + targetKind: 'form-instance-control', + targetName: 'cmdGuardar', + }, + ]; + expect(handlerShape(named, 'cmdGuardar_Click')).toEqual(expected); + expect(handlerShape(renamed, 'cmdGuardar_Click')).toEqual(expected); + }); + + it('the renamed file binds the Form_Load lifecycle handler to a form-layout node', () => { + expect(handlerShape(named, 'Form_Load')).toEqual([ + { + eventName: 'Load', + scope: 'form', + targetKind: 'form-layout', + targetName: 'Form_Expediente', + }, + ]); + // The layout NAME still comes from the file, because that is the sibling + // that exists on disk next to it. + expect(handlerShape(renamed, 'Form_Load')).toEqual([ + { + eventName: 'Load', + scope: 'form', + targetKind: 'form-layout', + targetName: 'sample2', + }, + ]); + }); + + it('both files emit the same number of event-handler edges', () => { + const count = (r: ExtractionResult) => + r.edges.filter((e) => e.kind === 'event-handler').length; + expect(count(renamed)).toBe(count(named)); + expect(count(named)).toBe(2); + }); +}); + +// ============================================================================= +// AC #1 (second half) — the `Me.` sweep. +// ============================================================================= + +describe('issue #249 — Me. references follow VB_Name', () => { + it('the renamed file emits the same control references as the named one', () => { + const names = (r: ExtractionResult) => + meControlRefs(r) + .map((ref) => ref.referenceName) + .sort(); + expect(names(named)).toEqual(['cboUsuario', 'txtNombre']); + expect(names(renamed)).toEqual(['cboUsuario', 'txtNombre']); + }); + + it('the sibling layout path is derived from the FILE, not from VB_Name', () => { + const siblingOf = (r: ExtractionResult) => + new Set(meControlRefs(r).map((ref) => ref.metadata?.siblingPath)); + expect(siblingOf(named)).toEqual( + new Set([NAMED_TXT.replace(/\\/g, '/')]), + ); + expect(siblingOf(renamed)).toEqual( + new Set([ + path.join(FIXTURE_DIR, 'sample2.form.txt').replace(/\\/g, '/'), + ]), + ); + }); +}); + +// ============================================================================= +// AC #2 — the mismatch is diagnosable: `bindingSource: 'vb-name'`, and only +// on the mismatch. +// ============================================================================= + +describe('issue #249 — bindingSource marks the mismatch', () => { + it('every synthesized edge from the renamed file carries bindingSource', () => { + const edges = renamed.edges.filter((e) => e.kind === 'event-handler'); + expect(edges.length).toBeGreaterThan(0); + for (const e of edges) { + expect(e.metadata?.bindingSource).toBe('vb-name'); + } + }); + + it('every control reference from the renamed file carries bindingSource', () => { + const refs = meControlRefs(renamed); + expect(refs.length).toBeGreaterThan(0); + for (const r of refs) { + expect(r.metadata?.bindingSource).toBe('vb-name'); + } + }); + + it('the filename fast path stays unmarked', () => { + for (const e of named.edges.filter((e) => e.kind === 'event-handler')) { + expect(e.metadata?.bindingSource).toBeUndefined(); + } + for (const r of meControlRefs(named)) { + expect(r.metadata?.bindingSource).toBeUndefined(); + } + }); +}); + +// ============================================================================= +// The filename fast path is untouched: the stub the code-behind emits still +// carries the id `VbaFormExtractor` produces for the real layout node, so the +// INSERT OR REPLACE convergence keeps working. Not hardcoded — the real form +// extractor runs on the real sibling file. +// ============================================================================= + +describe('issue #249 — the filename fast path still converges on the real layout node', () => { + it('the Form_Load stub id matches the real form-layout node id', () => { + const layout = new VbaFormExtractor( + NAMED_TXT, + fs.readFileSync(NAMED_TXT, 'utf8'), + ).extract(); + const real = layout.nodes.find((n) => n.kind === 'form-layout'); + expect(real).toBeDefined(); + const edge = handlerEdges(named, 'Form_Load')[0]; + expect(edge?.target).toBe(real!.id); + }); +}); + +// ============================================================================= +// The report half of the fallback: `Report_*` must pick `.report.txt`. +// ============================================================================= + +describe('issue #249 — the report fallback picks the report sibling', () => { + it('binds Report_Open to a report-layout node beside the file', () => { + expect(handlerShape(report, 'Report_Open')).toEqual([ + { + eventName: 'Open', + scope: 'form', + targetKind: 'report-layout', + targetName: 'sample_report', + }, + ]); + const edge = handlerEdges(report, 'Report_Open')[0]; + const target = report.nodes.find((n) => n.id === edge?.target); + expect(target?.filePath).toBe( + path.join(FIXTURE_DIR, 'sample_report.report.txt'), + ); + }); + + it('still binds the report control handler', () => { + expect(handlerShape(report, 'txtTotal_Click')).toEqual([ + { + eventName: 'Click', + scope: null, + targetKind: 'form-instance-control', + targetName: 'txtTotal', + }, + ]); + }); +}); + +// ============================================================================= +// AC #3 — the guard that must not move. A plain service class binds to +// nothing, even though `Documento_Print` and `Cabecera_Format` end in real +// Access event names and would otherwise become control stubs. +// ============================================================================= + +describe('issue #249 — a plain service class still binds to nothing', () => { + for (const [label, result] of [ + ['InformeRiesgoPDFServicio', service], + ['FormularioVentas (Form-without-underscore near miss)', nearMiss], + ] as const) { + describe(label, () => { + it('produces zero form-instance-control stubs', () => { + expect( + result.nodes.filter((n) => n.kind === 'form-instance-control'), + ).toEqual([]); + }); + + it('produces zero layout stubs', () => { + expect( + result.nodes.filter( + (n) => n.kind === 'form-layout' || n.kind === 'report-layout', + ), + ).toEqual([]); + }); + + it('produces zero event-handler edges', () => { + expect(result.edges.filter((e) => e.kind === 'event-handler')).toEqual( + [], + ); + }); + + it('produces zero Me. references', () => { + expect(meControlRefs(result)).toEqual([]); + }); + + it('still emits its procedures', () => { + expect( + result.nodes.filter((n) => n.kind === 'function').length, + ).toBeGreaterThan(0); + }); + }); + } +}); diff --git a/__tests__/fixtures/vba-vbname-binding/Form_Expediente.cls b/__tests__/fixtures/vba-vbname-binding/Form_Expediente.cls new file mode 100644 index 0000000..0401b0e --- /dev/null +++ b/__tests__/fixtures/vba-vbname-binding/Form_Expediente.cls @@ -0,0 +1,29 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "Form_Expediente" +Option Compare Database +Option Explicit + +' ============================================================================= +' Form_Expediente — fixture for issue #249 (code-behind binding keys on the +' filename instead of on the module's own name). +' +' `sample2.cls` in this same folder is a BYTE-IDENTICAL copy of this file. +' The only difference between the two is the name on disk, so any difference +' in what the extractor produces for them is the bug under test. +' +' Contents, one of each shape the binding decision has to reach: +' cmdGuardar_Click — a control handler; needs a form-instance-control target. +' Form_Load — a form-level lifecycle handler; needs the layout target. +' Me.cboUsuario / Me.txtNombre — control references the Me sweep must emit. +' ============================================================================= + +Private Sub Form_Load() + Me.cboUsuario.Requery +End Sub + +Private Sub cmdGuardar_Click() + Me.txtNombre.SetFocus +End Sub diff --git a/__tests__/fixtures/vba-vbname-binding/Form_Expediente.form.txt b/__tests__/fixtures/vba-vbname-binding/Form_Expediente.form.txt new file mode 100644 index 0000000..f1307b7 --- /dev/null +++ b/__tests__/fixtures/vba-vbname-binding/Form_Expediente.form.txt @@ -0,0 +1,30 @@ +Version =21 +VersionRequired =20 +Checksum =-987654321 +Begin Form + Caption ="Expediente" + Width =7000 + Height =4000 + Begin CommandButton + Name ="cmdGuardar" + Caption ="Guardar" + Left =500 + Top =1200 + Width =1500 + Height =400 + End + Begin ComboBox + Name ="cboUsuario" + Left =500 + Top =600 + Width =2000 + Height =300 + End + Begin TextBox + Name ="txtNombre" + Left =500 + Top =100 + Width =2000 + Height =300 + End +End diff --git a/__tests__/fixtures/vba-vbname-binding/FormularioVentas.cls b/__tests__/fixtures/vba-vbname-binding/FormularioVentas.cls new file mode 100644 index 0000000..782477a --- /dev/null +++ b/__tests__/fixtures/vba-vbname-binding/FormularioVentas.cls @@ -0,0 +1,19 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "FormularioVentas" +Option Compare Database +Option Explicit + +' ============================================================================= +' The near-miss guard for issue #249: a VB_Name that starts with the letters +' `Form` but has no separating underscore. The trailing `_` is the whole +' discriminator of the Access code-behind convention, so this class is an +' ordinary one and must bind to nothing — even though `Detalle_Print` ends +' in a real Access event name. +' ============================================================================= + +Public Sub Detalle_Print() + Me.txtImporte = 0 +End Sub diff --git a/__tests__/fixtures/vba-vbname-binding/InformeRiesgoPDFServicio.cls b/__tests__/fixtures/vba-vbname-binding/InformeRiesgoPDFServicio.cls new file mode 100644 index 0000000..13c94dd --- /dev/null +++ b/__tests__/fixtures/vba-vbname-binding/InformeRiesgoPDFServicio.cls @@ -0,0 +1,39 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "InformeRiesgoPDFServicio" +Option Compare Database +Option Explicit + +' ============================================================================= +' InformeRiesgoPDFServicio — the regression guard for issue #249. +' +' A plain service class: neither its filename nor its VB_Name carries a +' `Form_` / `Report_` prefix. Its methods are full of underscores, and +' `Documento_Print` / `Cabecera_Format` end in real Access event names, so +' the ONLY thing standing between this class and a pile of invented control +' stubs is the prefix guard. Letting that guard go synthesized roughly 550 +' spurious stubs across real projects, so widening it is the failure mode +' #249 had to avoid: this fixture must keep producing nothing. +' ============================================================================= + +Private Sub Class_Initialize() + Me.Estado = "listo" +End Sub + +Public Sub Documento_Print() + Me.txtCuerpo = GenerarHTML_Principal() +End Sub + +Public Sub Cabecera_Format() + Me.txtTitulo = "Informe de riesgo" +End Sub + +Public Function GenerarHTML_Principal() As String + GenerarHTML_Principal = GetEstilosCSS_PDF() +End Function + +Public Function GetEstilosCSS_PDF() As String + GetEstilosCSS_PDF = "body { margin: 0 }" +End Function diff --git a/__tests__/fixtures/vba-vbname-binding/sample2.cls b/__tests__/fixtures/vba-vbname-binding/sample2.cls new file mode 100644 index 0000000..0401b0e --- /dev/null +++ b/__tests__/fixtures/vba-vbname-binding/sample2.cls @@ -0,0 +1,29 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "Form_Expediente" +Option Compare Database +Option Explicit + +' ============================================================================= +' Form_Expediente — fixture for issue #249 (code-behind binding keys on the +' filename instead of on the module's own name). +' +' `sample2.cls` in this same folder is a BYTE-IDENTICAL copy of this file. +' The only difference between the two is the name on disk, so any difference +' in what the extractor produces for them is the bug under test. +' +' Contents, one of each shape the binding decision has to reach: +' cmdGuardar_Click — a control handler; needs a form-instance-control target. +' Form_Load — a form-level lifecycle handler; needs the layout target. +' Me.cboUsuario / Me.txtNombre — control references the Me sweep must emit. +' ============================================================================= + +Private Sub Form_Load() + Me.cboUsuario.Requery +End Sub + +Private Sub cmdGuardar_Click() + Me.txtNombre.SetFocus +End Sub diff --git a/__tests__/fixtures/vba-vbname-binding/sample_report.cls b/__tests__/fixtures/vba-vbname-binding/sample_report.cls new file mode 100644 index 0000000..daaa20c --- /dev/null +++ b/__tests__/fixtures/vba-vbname-binding/sample_report.cls @@ -0,0 +1,21 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "Report_Resumen" +Option Compare Database +Option Explicit + +' ============================================================================= +' Report_Resumen under a filename that lost the prefix — the report half of +' issue #249. The sibling extension the fallback picks must be `.report.txt`, +' not `.form.txt`. +' ============================================================================= + +Private Sub Report_Open(Cancel As Integer) + Me.txtTotal.Visible = True +End Sub + +Private Sub txtTotal_Click() + Me.txtTotal.Visible = False +End Sub diff --git a/src/extraction/vba/controls.ts b/src/extraction/vba/controls.ts index 18bb99e..2567b33 100644 --- a/src/extraction/vba/controls.ts +++ b/src/extraction/vba/controls.ts @@ -5,6 +5,7 @@ * node) the resolver later binds to the form's controls. */ import { VbaExtractorContext, ProcInfo } from './context'; +import { codeBehindExtFromVbName } from './text-utils'; /** * `Me.` / `Me!` reference capture — hole 1 @@ -46,16 +47,62 @@ const ACCESS_FORM_MEMBER_BLACKLIST = new Set([ const seenMeControls = new WeakMap>(); -function siblingLayoutPath(filePath: string): string | null { - const normalized = filePath.replace(/\\/g, '/'); +/** + * The sibling layout file this code-behind's `Me.` references + * resolve against, plus where the `Form_` / `Report_` prefix that authorised + * the binding was found. + */ +interface SiblingLayoutBinding { + /** Path to the sibling `.form.txt` / `.report.txt`, slash-normalized. */ + siblingPath: string; + /** + * `null` on the filename fast path; `'vb-name'` when only the module's + * resolved `Attribute VB_Name` carried the prefix, so consumers can see + * that the file and the module disagree (issue #249). + */ + bindingSource: 'vb-name' | null; +} + +/** + * Resolve the sibling layout binding for the file being extracted. + * + * The BASENAME is the fast path and is checked first, unchanged. Issue #249 + * adds the fallback below it: a code-behind class exported under a filename + * that lost the `Form_` / `Report_` prefix used to produce no control + * references at all — the sweep simply bailed and nothing warned — so the + * decision to bind now falls back to the module's resolved `VB_Name`. + * + * The sibling PATH is always derived from the FILE path either way: that is + * where the `.form.txt` / `.report.txt` actually sits on disk, whatever the + * module calls itself. Only the decision to bind consults `VB_Name`. + * + * `ctx.classNamePrefix` is `null` for `.bas` modules, and the `.cls` guard + * below keeps the fallback on the same file kind the fast path accepts. + */ +function siblingLayoutBinding( + ctx: VbaExtractorContext, +): SiblingLayoutBinding | null { + const normalized = ctx.filePath.replace(/\\/g, '/'); const basename = normalized.slice(normalized.lastIndexOf('/') + 1); if (/^Form_.+\.cls$/i.test(basename)) { - return normalized.replace(/\.cls$/i, '.form.txt'); + return { + siblingPath: normalized.replace(/\.cls$/i, '.form.txt'), + bindingSource: null, + }; } if (/^Report_.+\.cls$/i.test(basename)) { - return normalized.replace(/\.cls$/i, '.report.txt'); + return { + siblingPath: normalized.replace(/\.cls$/i, '.report.txt'), + bindingSource: null, + }; } - return null; + if (!/\.cls$/i.test(basename)) return null; + const ext = codeBehindExtFromVbName(ctx.classNamePrefix); + if (!ext) return null; + return { + siblingPath: normalized.replace(/\.cls$/i, ext), + bindingSource: 'vb-name', + }; } /** @@ -105,7 +152,13 @@ export function scanMeControlReferences( // `m.index + ME_PREFIX_LEN`. const operator = line.charAt(m.index + 2); // '.' | '!' const isBang = operator === '!'; - const siblingPath = siblingLayoutPath(ctx.filePath); + const binding = siblingLayoutBinding(ctx); + const siblingPath = binding?.siblingPath ?? null; + // Present only when the filename and the module's VB_Name disagree + // (issue #249), so the fast path's reference metadata is unchanged. + const bindingSourceMeta = binding?.bindingSource + ? { bindingSource: binding.bindingSource } + : {}; // Issue #140 is deliberately a separate, dot-only sweep for Access // form/report code-behind. Keeping `Me` in the generic runtime receiver @@ -130,7 +183,12 @@ export function scanMeControlReferences( column: m.index + ME_PREFIX_LEN, filePath: ctx.filePath, language: 'vba', - metadata: { synthesizedBy: 'vba-me-control', siblingPath, builtIn: true }, + metadata: { + synthesizedBy: 'vba-me-control', + siblingPath, + builtIn: true, + ...bindingSourceMeta, + }, }); continue; } @@ -158,6 +216,7 @@ export function scanMeControlReferences( synthesizedBy: 'vba-me-control', siblingPath, access: isDirectAssignment(before, after) ? 'write' : 'read', + ...bindingSourceMeta, }, }); continue; diff --git a/src/extraction/vba/procedures.ts b/src/extraction/vba/procedures.ts index d9cdc79..0efc90e 100644 --- a/src/extraction/vba/procedures.ts +++ b/src/extraction/vba/procedures.ts @@ -11,8 +11,10 @@ import { Node, NodeKind, Edge } from '../../types'; import { generateNodeId } from '../tree-sitter-helpers'; import { PROC_RE, PRIMITIVE_TYPES } from './constants'; import { + codeBehindExtFromVbName, parseEventHandlerName, parseFormLevelEventHandlerName, + type CodeBehindExt, } from './text-utils'; import { ProcInfo, VbaClassifier } from './context'; import { defineRule, runRules, VbaExtractionRule } from './rules'; @@ -83,7 +85,7 @@ export const RULES: readonly VbaExtractionRule[] = [ defineRule({ id: 'procedure', description: - 'Match a `Sub` / `Function` / `Property Get|Let|Set (...) [As ]` declaration; emit a function node, register the proc in `localProcs` / `functionNodeByName` / `functionNodeByStartLine` / `functionReturnTypes`, and (for `Form_*.cls` / `Report_*.cls`) synthesize an `event-handler` edge — to a `form-instance-control` stub for a control handler, or to the sibling `form-layout` / `report-layout` stub for a form-level lifecycle handler.', + 'Match a `Sub` / `Function` / `Property Get|Let|Set (...) [As ]` declaration; emit a function node, register the proc in `localProcs` / `functionNodeByName` / `functionNodeByStartLine` / `functionReturnTypes`, and (for code-behind whose filename or `VB_Name` carries the `Form_` / `Report_` prefix) synthesize an `event-handler` edge — to a `form-instance-control` stub for a control handler, or to the sibling `form-layout` / `report-layout` stub for a form-level lifecycle handler.', pattern: PROC_RE, emit: (m, ctx, line, lineNum) => { const visibilityRaw = (m[1] ?? '').trim(); @@ -233,21 +235,37 @@ export const RULES: readonly VbaExtractionRule[] = [ // Prefix-driven sibling binding (issue #41). Both `Form_*.cls` and // `Report_*.cls` Dysflow code-behind files share the same code path; // only the sibling extension differs (`.form.txt` vs `.report.txt`). - // The check is on the BASENAME prefix so a class called - // `FormularioVentas.cls` or `ReportingHelper.cls` (no trailing - // underscore) does not match — the trailing `_` is the discriminator. - // Any other `.cls` (e.g. `InformeRiesgoPDFServicio.cls` with methods - // like `GenerarHTML_Principal`) gets `codeBehindExt === null` and is - // skipped, preserving the original Form_-only guard's behaviour for - // non-form classes. + // The prefix test rejects a class called `FormularioVentas.cls` or + // `ReportingHelper.cls` (no trailing underscore) — the trailing `_` is + // the discriminator. A class that matches on NEITHER its filename nor + // its VB_Name (e.g. `InformeRiesgoPDFServicio.cls` with methods like + // `GenerarHTML_Principal`) gets `codeBehindExt === null` and is skipped, + // preserving the original Form_-only guard's behaviour for non-form + // classes. const basename = path.basename(ctx.filePath).toLowerCase(); - const codeBehindExt = basename.startsWith('report_') + const basenameExt: CodeBehindExt | null = basename.startsWith('report_') ? '.report.txt' : basename.startsWith('form_') ? '.form.txt' : null; + // Issue #249: the basename above stays the fast path, but it is not the + // module's identity — `Attribute VB_Name` is. When a code-behind class + // is exported (or hand-renamed) to a filename that drops the prefix, the + // file still parses and still emits its procedures, so the form used to + // come out with no event wiring at all and nothing to show for it. Fall + // back to the resolved VB_Name (`ctx.classNamePrefix`) so the decision to + // bind follows the module, not the filename. + const codeBehindExt: CodeBehindExt | null = + basenameExt ?? codeBehindExtFromVbName(ctx.classNamePrefix); + // Non-null only when the two disagree, so the fast path's edge metadata + // is unchanged and a mismatch is diagnosable instead of invisible. + const bindingSource: 'vb-name' | null = + basenameExt === null && codeBehindExt !== null ? 'vb-name' : null; const isFormCodeBehind = codeBehindExt !== null; if (isFormCodeBehind) { + // The sibling path is still derived from the FILE path — that is where + // the `.form.txt` / `.report.txt` actually lives on disk, whatever the + // module calls itself. Only the decision to bind falls back to VB_Name. const siblingPath = ctx.filePath.replace(/\.cls$/i, codeBehindExt!); if (formLevelHandler) { // ---- Form-LEVEL event (issue #247). -------------------------- @@ -302,7 +320,13 @@ export const RULES: readonly VbaExtractionRule[] = [ provenance: 'heuristic', // `scope: 'form'` lets consumers separate form-level from // control-level handlers without re-parsing the Sub name. - metadata: { eventName: formLevelHandler.eventName, scope: 'form' }, + // `bindingSource` appears only when the filename and the module's + // VB_Name disagree (issue #249). + metadata: { + eventName: formLevelHandler.eventName, + scope: 'form', + ...(bindingSource ? { bindingSource } : {}), + }, line: lineNum, column: 0, }); @@ -336,7 +360,10 @@ export const RULES: readonly VbaExtractionRule[] = [ target: controlNodeId, kind: 'event-handler', provenance: 'heuristic', - metadata: { eventName: handler.eventName }, + metadata: { + eventName: handler.eventName, + ...(bindingSource ? { bindingSource } : {}), + }, line: lineNum, column: 0, }); diff --git a/src/extraction/vba/text-utils.ts b/src/extraction/vba/text-utils.ts index c6018c8..0f3ca6a 100644 --- a/src/extraction/vba/text-utils.ts +++ b/src/extraction/vba/text-utils.ts @@ -230,3 +230,43 @@ export function parseFormLevelEventHandlerName( if (!isAccessEventName(eventName)) return null; return { ownerName, eventName }; } + +/** + * Sibling layout extension implied by an Access code-behind prefix. + * `Form_*` binds to a `.form.txt`; `Report_*` binds to a `.report.txt`. + */ +export type CodeBehindExt = '.form.txt' | '.report.txt'; + +/** + * Issue #249 helper: the `Form_` / `Report_` code-behind prefix carried by a + * module's RESOLVED `Attribute VB_Name`, used as the fallback when the file + * on disk does not carry the prefix in its basename. + * + * Both binding sites (the event-handler synthesis in `procedures.ts` and the + * `Me.` sweep in `controls.ts`) keep their own basename check as the + * fast path and consult this only on a basename miss, so the common case — + * a Dysflow export whose filename and `VB_Name` agree — is byte-for-byte + * unchanged. + * + * `classNamePrefix` is `null` for `.bas` modules and holds the resolved + * `VB_Name` (or the extension-stripped basename when the module carries no + * `VB_Name`) for `.cls` modules, so this fallback can only ever fire for a + * class module. That is deliberate: the guard that keeps a plain service + * class such as `InformeRiesgoPDFServicio.cls` — whose methods + * (`GenerarHTML_Principal`, `GetEstilosCSS_PDF`) look like event handlers to + * a naive `_` split — from synthesizing hundreds of spurious + * `form-instance-control` stubs is that NEITHER its filename NOR its + * `VB_Name` starts with `Form_` / `Report_`. Widening the prefix test would + * reopen exactly that hole, so the test stays the same canonical Access + * naming convention; only the string it is applied to widens. + * + * The trailing `.+` is load-bearing: a bare `Form_` names no form. + */ +export function codeBehindExtFromVbName( + classNamePrefix: string | null, +): CodeBehindExt | null { + if (!classNamePrefix) return null; + if (/^Report_.+$/i.test(classNamePrefix)) return '.report.txt'; + if (/^Form_.+$/i.test(classNamePrefix)) return '.form.txt'; + return null; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1492015..9891ba5 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1121,6 +1121,12 @@ export class ReferenceResolver { synthesizedBy: 'vba-me-control', siblingPath: ref.original.metadata.siblingPath, access: ref.original.metadata.access, + // Issue #249: only set when the code-behind's filename and + // its `Attribute VB_Name` disagree, so the resulting edge + // says why it bound instead of hiding the mismatch. + ...(ref.original.metadata.bindingSource + ? { bindingSource: ref.original.metadata.bindingSource } + : {}), } : {}), },