Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions __tests__/extraction-vba-string-literal-scan.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof extract>) {
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');
});
});
6 changes: 3 additions & 3 deletions src/extraction/vba/call-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/extraction/vba/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/extraction/vba/docmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const DOCMD_OPEN_DISPATCH: ReadonlyArray<DoCmdOpenDispatch> = [
export function scanDoCmdOpenCalls(
ctx: VbaExtractorContext,
line: string,
maskedLine: string,
caller: ProcInfo,
lineNum: number,
): void {
Expand All @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/vba/tempvars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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');
Expand Down