From f2935c30524203aea20bc40cb9cbed76462b9a49 Mon Sep 17 00:00:00 2001 From: colbymchenry Date: Tue, 21 Jul 2026 21:15:12 +0200 Subject: [PATCH 1/3] test(vba): reproduce string-literal scanner false positives --- ...extraction-vba-string-literal-scan.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 __tests__/extraction-vba-string-literal-scan.test.ts diff --git a/__tests__/extraction-vba-string-literal-scan.test.ts b/__tests__/extraction-vba-string-literal-scan.test.ts new file mode 100644 index 0000000..b9244c2 --- /dev/null +++ b/__tests__/extraction-vba-string-literal-scan.test.ts @@ -0,0 +1,129 @@ +/** + * Issue #209 — `DoCmd.OpenForm` / `Forms!…!…` / `TempVars(…)` matched INSIDE + * a string literal must NOT emit a real graph edge. + * + * Real Access code is full of log messages, docstrings, and TODO comments + * that quote these idioms literally. The matchers in + * `src/extraction/vba/{docmd,controls,tempvars}.ts` intentionally scan the + * ORIGINAL (unmasked) line because the payload (a form name, a control + * name, a TempVars key) lives inside a `"literal"` — masking would + * obliterate the payload. The consequence is that the regex MATCHES inside + * a string literal, and a `form-layout` stub node + an `opens-form` edge + * end up in the graph even though no `DoCmd.OpenForm` was actually called. + * + * Acceptance criteria for this issue: + * 1. `MsgBox "DoCmd.OpenForm ""frmX"""` emits NO `opens-form` edge. + * 2. `Debug.Print "Forms!frmY!ctl"` emits NO control reference. + * 3. A real `DoCmd.OpenForm "frmX"` STILL emits its edge. + * 4. A real `Forms!frmY!ctl` STILL emits its reference. + * + * The fix must be local to the per-line scanners and must NOT touch the + * masked-vs-original scan strategy that the regexes depend on (the + * payload still has to come from a string literal). Per line, reject any + * match whose `m.index` falls inside a string-literal span. + */ +import { describe, it, expect } from 'vitest'; +import { VbaExtractor } from '../src/extraction/vba-extractor'; + +function extract(src: string) { + return new VbaExtractor( + 'src/modules/ModIssue209.bas', + [ + 'Attribute VB_Name = "ModIssue209"', + 'Option Explicit', + src, + ].join('\n'), + ).extract(); +} + +function heuristicEdges(r: ReturnType) { + return r.edges.filter((e) => e.provenance === 'heuristic'); +} + +describe('Issue #209 — string-literal matches emit no real graph edges', () => { + it('a MsgBox string containing `DoCmd.OpenForm "frmX"` emits no opens-form edge', () => { + const src = [ + 'Public Sub Ayuda()', + ' MsgBox "Para continuar use DoCmd.OpenForm ""frmClientes"" desde el menu"', + 'End Sub', + ].join('\n'); + const r = extract(src); + const opens = heuristicEdges(r).filter((e) => e.kind === 'opens-form'); + expect(opens, 'expected no opens-form edge from a MsgBox string').toHaveLength(0); + }); + + it('a Debug.Print string containing `Forms!frmY!ctl` emits no unresolved reference', () => { + const src = [ + 'Public Sub LogIt()', + ' Debug.Print "pendiente: Forms!frmPedidos!txtTotal"', + 'End Sub', + ].join('\n'); + const r = extract(src); + const refs = r.unresolvedReferences.filter((u) => u.referenceKind === 'bang-get'); + expect(refs, 'expected no bang-get reference from a Debug.Print string').toHaveLength(0); + }); + + it('a Debug.Print string containing `TempVars("clave")` emits no tempvar reference', () => { + const src = [ + 'Public Sub LogIt()', + ' Debug.Print "guarda TempVars(""clave"") para luego"', + 'End Sub', + ].join('\n'); + const r = extract(src); + const tempRefs = heuristicEdges(r).filter( + (e) => e.metadata?.synthesizedBy === 'vba-tempvar', + ); + expect(tempRefs, 'expected no vba-tempvar reference from a Debug.Print string').toHaveLength(0); + }); + + it('a real `DoCmd.OpenForm "frmX"` STILL emits its opens-form edge', () => { + const src = [ + 'Public Sub RealOpen()', + ' DoCmd.OpenForm "frmClientes", acNormal', + 'End Sub', + ].join('\n'); + const r = extract(src); + const opens = heuristicEdges(r).filter((e) => e.kind === 'opens-form'); + expect(opens).toHaveLength(1); + expect(opens[0]?.metadata?.targetFormName).toBe('frmClientes'); + }); + + it('a real `Forms!frmY!ctl` STILL emits its bang-get reference', () => { + const src = [ + 'Public Sub RealRead()', + ' Dim v As Variant', + ' v = Forms!frmPedidos!txtTotal', + 'End Sub', + ].join('\n'); + const r = extract(src); + const refs = r.unresolvedReferences.filter((u) => u.referenceKind === 'bang-get'); + expect(refs.length).toBeGreaterThan(0); + expect(refs.some((u) => u.referenceName === 'frmPedidos')).toBe(true); + }); + + it('a real `TempVars("k")` STILL emits a vba-tempvar reference', () => { + const src = [ + 'Public Sub RealTemp()', + ' Debug.Print TempVars("k")', + 'End Sub', + ].join('\n'); + const r = extract(src); + const tempRefs = heuristicEdges(r).filter( + (e) => e.metadata?.synthesizedBy === 'vba-tempvar', + ); + expect(tempRefs.length).toBeGreaterThan(0); + }); + + it('a mixed line — real call OUTSIDE the literal + quoted call INSIDE the literal — emits exactly one edge for the real one', () => { + const src = [ + 'Public Sub Mix()', + ' MsgBox "Tip: DoCmd.OpenForm ""frmMensaje"""', + ' DoCmd.OpenForm "frmClientes"', + 'End Sub', + ].join('\n'); + const r = extract(src); + const opens = heuristicEdges(r).filter((e) => e.kind === 'opens-form'); + expect(opens).toHaveLength(1); + expect(opens[0]?.metadata?.targetFormName).toBe('frmClientes'); + }); +}); From b4835df027d8d5e446f2879160243fd4eec445af Mon Sep 17 00:00:00 2001 From: colbymchenry Date: Tue, 21 Jul 2026 21:18:20 +0200 Subject: [PATCH 2/3] fix(vba): ignore scanner keywords inside string literals --- src/extraction/vba/call-sweep.ts | 6 +++--- src/extraction/vba/controls.ts | 6 ++++++ src/extraction/vba/docmd.ts | 4 ++++ src/extraction/vba/tempvars.ts | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/extraction/vba/call-sweep.ts b/src/extraction/vba/call-sweep.ts index 845b6d4..a33ff29 100644 --- a/src/extraction/vba/call-sweep.ts +++ b/src/extraction/vba/call-sweep.ts @@ -414,12 +414,12 @@ export function createCallsAndSqlClassifier( const caller2 = stack[stack.length - 1]!; // Issue #48: shared OpenForm/OpenReport dispatch; OpenQuery emits an // `UnresolvedReference` and stays separate. - scanDoCmdOpenCalls(ctx, line, caller2, lineNum); - scanDoCmdOpenQuery(ctx, line, caller2, lineNum); + scanDoCmdOpenCalls(ctx, line, callScanLine, caller2, lineNum); + scanDoCmdOpenQuery(ctx, line, callScanLine, caller2, lineNum); // Issue #44: cross-form bang references (`Forms!X` / `Forms("X")!Y`) — // scan the unmasked line (form name lives in a string literal in the // paren form). - scanFormsBang(ctx, line, caller2, lineNum); + scanFormsBang(ctx, line, callScanLine, caller2, lineNum); // Issue #50: cross-form TempVars key accesses. Bang form scans the // masked line, paren + Add forms scan the original. diff --git a/src/extraction/vba/controls.ts b/src/extraction/vba/controls.ts index 6a9fc78..18bb99e 100644 --- a/src/extraction/vba/controls.ts +++ b/src/extraction/vba/controls.ts @@ -210,12 +210,18 @@ export function scanMeControlReferences( export function scanFormsBang( ctx: VbaExtractorContext, line: string, + maskedLine: string, from: ProcInfo, lineNum: number, ): void { FORMS_BANG_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = FORMS_BANG_RE.exec(line)) !== null) { + // The original line is required for Forms("name") payloads, but the + // keyword itself must still be executable code rather than prose inside + // a string literal. maskStringContent preserves columns, so this check is + // both cheap and exact for every regex match. + if (maskedLine.slice(m.index, m.index + 5).toLowerCase() !== 'forms') continue; // Group 1: bang form name (bare or `[bracketed]`); group 2: paren // form name in `"quotes"`; group 3: paren form name bare or // `[bracketed]`. Take whichever the regex alternative produced and diff --git a/src/extraction/vba/docmd.ts b/src/extraction/vba/docmd.ts index d019d23..18b3fa5 100644 --- a/src/extraction/vba/docmd.ts +++ b/src/extraction/vba/docmd.ts @@ -93,6 +93,7 @@ const DOCMD_OPEN_DISPATCH: ReadonlyArray = [ export function scanDoCmdOpenCalls( ctx: VbaExtractorContext, line: string, + maskedLine: string, caller: ProcInfo, lineNum: number, ): void { @@ -103,6 +104,7 @@ export function scanDoCmdOpenCalls( const localRe = new RegExp(dispatch.re.source, dispatch.re.flags); let m: RegExpExecArray | null; while ((m = localRe.exec(line)) !== null) { + if (maskedLine.slice(m.index, m.index + 5).toLowerCase() !== 'docmd') continue; const rawArg = (m[1] ?? '').trim(); // Issue #52: const lookup is now per-proc-bucket with module // fallback (see `resolveLocalConst`). Two procs declaring the @@ -191,12 +193,14 @@ function emitOpensStubEdge( export function scanDoCmdOpenQuery( ctx: VbaExtractorContext, line: string, + maskedLine: string, caller: ProcInfo, lineNum: number, ): void { const localRe = new RegExp(OPEN_QUERY_ARG_RE.source, OPEN_QUERY_ARG_RE.flags); let m: RegExpExecArray | null; while ((m = localRe.exec(line)) !== null) { + if (maskedLine.slice(m.index, m.index + 5).toLowerCase() !== 'docmd') continue; const rawArg = (m[1] ?? '').trim(); // Issue #52: same per-proc-with-module-fallback lookup as // `scanDoCmdOpenCalls`. diff --git a/src/extraction/vba/tempvars.ts b/src/extraction/vba/tempvars.ts index 9864736..7ed06d6 100644 --- a/src/extraction/vba/tempvars.ts +++ b/src/extraction/vba/tempvars.ts @@ -135,6 +135,7 @@ export function sweepTempVars( const parenRe = new RegExp(TEMP_VAR_PAREN_RE.source, TEMP_VAR_PAREN_RE.flags); let pm: RegExpExecArray | null; while ((pm = parenRe.exec(originalLine)) !== null) { + if (maskedLine.slice(pm.index, pm.index + 8).toLowerCase() !== 'tempvars') continue; const key = pm[1] ?? ''; if (!key) continue; const access = detectAssignmentSuffix(originalLine, pm.index + pm[0].length) @@ -147,6 +148,7 @@ export function sweepTempVars( const addRe = new RegExp(TEMP_VAR_ADD_RE.source, TEMP_VAR_ADD_RE.flags); let am: RegExpExecArray | null; while ((am = addRe.exec(originalLine)) !== null) { + if (maskedLine.slice(am.index, am.index + 8).toLowerCase() !== 'tempvars') continue; const key = am[1] ?? ''; if (!key) continue; emitTempVarReference(ctx, caller, key, lineNum, am.index, 'write'); From ab23734dcf488fbc62e282f97ba70ad4e7a250e4 Mon Sep 17 00:00:00 2001 From: colbymchenry Date: Tue, 21 Jul 2026 21:18:33 +0200 Subject: [PATCH 3/3] docs(changelog): note string-literal scanner fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4320f6..dc7d990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- VBA references mentioned only inside messages, logs, and other string literals no longer create false form, query, or temporary-variable relationships. (#209) - VBA SQL extraction now ignores reserved words exposed by dynamic table-name concatenation instead of emitting misleading table references. (#203) - VBA continued statements are now parsed as one logical line while retaining their original source locations, restoring class references, qualified calls, procedure headers, and API declarations split with line continuations. (#202) - VBA SQL variables now stay within their procedure while module-level SQL remains available as a fallback, preventing same-named variables in separate procedures from creating false table references. (#204)