@aroman22/codegraph-vba-darwin-arm64 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/lib/dist/extraction/index.d.ts.map +1 -1
  2. package/lib/dist/extraction/index.js +26 -7
  3. package/lib/dist/extraction/index.js.map +1 -1
  4. package/lib/dist/extraction/sql-query-extractor.d.ts +11 -3
  5. package/lib/dist/extraction/sql-query-extractor.d.ts.map +1 -1
  6. package/lib/dist/extraction/sql-query-extractor.js +19 -5
  7. package/lib/dist/extraction/sql-query-extractor.js.map +1 -1
  8. package/lib/dist/extraction/vba-extractor.d.ts +588 -59
  9. package/lib/dist/extraction/vba-extractor.d.ts.map +1 -1
  10. package/lib/dist/extraction/vba-extractor.js +1259 -147
  11. package/lib/dist/extraction/vba-extractor.js.map +1 -1
  12. package/lib/dist/extraction/vba-form-extractor.d.ts +127 -0
  13. package/lib/dist/extraction/vba-form-extractor.d.ts.map +1 -1
  14. package/lib/dist/extraction/vba-form-extractor.js +290 -3
  15. package/lib/dist/extraction/vba-form-extractor.js.map +1 -1
  16. package/lib/dist/extraction/vba-preprocess.d.ts +22 -3
  17. package/lib/dist/extraction/vba-preprocess.d.ts.map +1 -1
  18. package/lib/dist/extraction/vba-preprocess.js +137 -15
  19. package/lib/dist/extraction/vba-preprocess.js.map +1 -1
  20. package/lib/dist/extraction/vba-source.d.ts +58 -0
  21. package/lib/dist/extraction/vba-source.d.ts.map +1 -0
  22. package/lib/dist/extraction/vba-source.js +137 -0
  23. package/lib/dist/extraction/vba-source.js.map +1 -0
  24. package/lib/dist/types.d.ts +2 -2
  25. package/lib/dist/types.d.ts.map +1 -1
  26. package/lib/dist/types.js +6 -0
  27. package/lib/dist/types.js.map +1 -1
  28. package/lib/node_modules/.modules.yaml +1 -1
  29. package/lib/node_modules/.pnpm-workspace-state-v1.json +1 -1
  30. package/lib/package.json +2 -2
  31. package/package.json +1 -1
@@ -431,21 +431,37 @@ class VbaExtractor {
431
431
  // See vba-form-extractor.ts:findControlName for the matching real
432
432
  // form-instance-control node emission.
433
433
  const handler = parseEventHandlerName(name);
434
- const isFormCodeBehind = /Form_[^/\\]*\.cls$/i.test(this.filePath);
434
+ // Prefix-driven sibling binding (issue #41). Both `Form_*.cls` and
435
+ // `Report_*.cls` Dysflow code-behind files share the same code path;
436
+ // only the sibling extension differs (`.form.txt` vs `.report.txt`).
437
+ // The check is on the BASENAME prefix so a class called
438
+ // `FormularioVentas.cls` or `ReportingHelper.cls` (no trailing
439
+ // underscore) does not match — the trailing `_` is the discriminator.
440
+ // Any other `.cls` (e.g. `InformeRiesgoPDFServicio.cls` with methods
441
+ // like `GenerarHTML_Principal`) gets `codeBehindExt === null` and is
442
+ // skipped, preserving the original Form_-only guard's behaviour for
443
+ // non-form classes.
444
+ const basename = path.basename(this.filePath).toLowerCase();
445
+ const codeBehindExt = basename.startsWith('report_')
446
+ ? '.report.txt'
447
+ : basename.startsWith('form_')
448
+ ? '.form.txt'
449
+ : null;
450
+ const isFormCodeBehind = codeBehindExt !== null;
435
451
  if (handler && isFormCodeBehind) {
436
- const formFilePath = this.filePath.replace(/\.cls$/i, '.form.txt');
437
- const controlNodeId = (0, tree_sitter_helpers_1.generateNodeId)(formFilePath, 'form-instance-control', handler.controlName, 0);
452
+ const siblingPath = this.filePath.replace(/\.cls$/i, codeBehindExt);
453
+ const controlNodeId = (0, tree_sitter_helpers_1.generateNodeId)(siblingPath, 'form-instance-control', handler.controlName, 0);
438
454
  // Stub form-instance-control: local so the per-file edge filter
439
455
  // passes the event-handler edge. Overwritten by the real node
440
- // emitted from the sibling .form.txt at index time (same id, same
441
- // schema, INSERT OR REPLACE). No metadata.controlType here — the
442
- // .form.txt side carries the real control type.
456
+ // emitted from the sibling .form.txt (or .report.txt) at index time
457
+ // (same id, same schema, INSERT OR REPLACE). No metadata.controlType
458
+ // here — the sibling side carries the real control type.
443
459
  this.nodes.push({
444
460
  id: controlNodeId,
445
461
  kind: 'form-instance-control',
446
462
  name: handler.controlName,
447
- qualifiedName: `${formFilePath}::${handler.controlName}`,
448
- filePath: formFilePath,
463
+ qualifiedName: `${siblingPath}::${handler.controlName}`,
464
+ filePath: siblingPath,
449
465
  language: 'vba',
450
466
  startLine: 0,
451
467
  endLine: 0,
@@ -621,6 +637,7 @@ class VbaExtractor {
621
637
  endColumn: line.length,
622
638
  visibility,
623
639
  metadata: {
640
+ isDeclare: true,
624
641
  dll,
625
642
  declareKind,
626
643
  ptrSafe,
@@ -694,17 +711,56 @@ class VbaExtractor {
694
711
  * DIM_UNQUAL_RE pair with a prefix-check + global scan that handles
695
712
  * `As New <Type>`, multi-variable `Dim a As Foo, b As Bar`, and all
696
713
  * visibility keywords in one pass.
714
+ * Issue #47: now also accepts `Global` (module-level typed instance) and
715
+ * `Static` (procedure-local retention modifier) so they emit the same
716
+ * `references` edge and `localVarTypeMap` registration as their `Dim`
717
+ * siblings today. The negative lookahead is unchanged: `Const` is still
718
+ * routed to `sweepEnumsAndConsts`.
697
719
  */
698
- static DIM_DECL_PREFIX_RE = /^\s*(?:Dim|Private|Public)\s+(?!(?:Function|Sub|Property|Const|WithEvents)\b)/i;
720
+ static DIM_DECL_PREFIX_RE = /^\s*(?:Dim|Private|Public|Global|Static)\s+(?!(?:Function|Sub|Property|Const|WithEvents)\b)/i;
699
721
  /**
700
722
  * Globally scan all `identifier As [New] TypePart1[.TypePart2]` on a
701
723
  * variable declaration line. Run with /g after confirming DIM_DECL_PREFIX_RE.
702
724
  *
703
- * Groups: (1) variable name, (2) type outer part, (3) type inner part (if qualified).
725
+ * Groups: (1) variable name, (2) bracketed outer type, (3) unbracketed
726
+ * outer type, (4) bracketed inner type (if qualified), (5) unbracketed
727
+ * inner type. The variable name is always bare (`Dim` cannot declare a
728
+ * bracketed variable). The TYPE position accepts BOTH bracketed names
729
+ * with spaces (e.g. `[Clase Con Espacios]`) and bare identifiers — the
730
+ * bracketed capture wins when present. Only one of (2)/(3) and one of
731
+ * (4)/(5) is ever populated per match.
704
732
  * `(?:New\s+)?` consumes the VBA auto-instantiation keyword so it is
705
733
  * never captured as the type name (Fix 1).
734
+ *
735
+ * Issue #54: extends the type alternative to accept `[Name With Spaces]`
736
+ * so `Dim x As [Clase Con Espacios]` emits a `references` edge to
737
+ * `Clase Con Espacios` (brackets unwrapped). The unwrap is applied in
738
+ * the sweep loop by picking the bracketed capture group when present.
739
+ */
740
+ static DIM_ALL_VARS_RE = /\b(\p{L}[\p{L}\p{N}_]*)\s+As\s+(?:New\s+)?(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*))(?:\.(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*)))?/giu;
741
+ /**
742
+ * Bare-declared variable capture for the `Dim|Private|Public|Global|Static`
743
+ * prefix. Captures (1) the variable name. Used to register bare `Dim x`
744
+ * (no `As` clause) and explicit-primitive `Dim x As Long|String|...`
745
+ * declarations into `localVarTypeMap` so the type tracking is consistent
746
+ * across all three Dim shapes:
747
+ *
748
+ * `Dim x` → outer = 'variant' (VBA default)
749
+ * `Dim x As Variant` → outer = 'variant' (PRIMITIVE_TYPES member)
750
+ * `Dim x As Long` → outer = 'long' (PRIMITIVE_TYPES member)
751
+ * `Dim x As Foo` → outer = 'foo' (project class — non-primitive)
752
+ *
753
+ * Antigravity audit Task 3: the previous `DIM_ALL_VARS_RE` only matched
754
+ * the `... As <Type>` form, so a bare `Dim x` was invisible to
755
+ * `isLocalProjectClassVar` / `scanCallSites` and `x.Method(1)` produced
756
+ * a dead-end `calls` edge to a stub named `x.Method` that no resolver
757
+ * could repoint. Registering bare Dim with `outer = 'variant'` closes
758
+ * the gate, so `scanCallSites` skips ONLY when the receiver is mapped
759
+ * as a primitive — leaving the "undeclared receiver → stub → resolver
760
+ * repoints" path intact for cross-module qualified calls like
761
+ * `modUtils.Foo(1)` (`modUtils` is not in `localVarTypeMap`).
706
762
  */
707
- static DIM_ALL_VARS_RE = /\b(\p{L}[\p{L}\p{N}_]*)\s+As\s+(?:New\s+)?(\p{L}[\p{L}\p{N}_]*)(?:\.(\p{L}[\p{L}\p{N}_]*))?/giu;
763
+ static BARE_DIM_VAR_RE = /^\s*(?:Dim|Private|Public|Global|Static)\s+(\p{L}[\p{L}\p{N}_]*)\s*(?:,|$|\b)/iu;
708
764
  /**
709
765
  * VBA primitive type names — skipped when emitted as Dim targets so
710
766
  * we don't pollute the graph with `As Long` / `As String` references.
@@ -718,8 +774,8 @@ class VbaExtractor {
718
774
  'string', 'boolean', 'date', 'variant', 'object', 'error',
719
775
  'empty', 'null', 'longptr', 'longlong', 'new',
720
776
  ]);
721
- /** `WithEvents m_X As Form_Foo` — Dim/Private/Public prefix is optional. */
722
- static WITHEVENTS_RE = /^\s*(?:(?:Dim|Private|Public)\s+)?WithEvents\s+\p{L}[\p{L}\p{N}_]*\s+As\s+(\p{L}[\p{L}\p{N}_]*)/iu;
777
+ /** `WithEvents m_X As Form_Foo` — Dim/Private/Public/Global/Static prefix is optional. */
778
+ static WITHEVENTS_RE = /^\s*(?:(?:Dim|Private|Public|Global|Static)\s+)?WithEvents\s+\p{L}[\p{L}\p{N}_]*\s+As\s+(\p{L}[\p{L}\p{N}_]*)/iu;
723
779
  sweepDimsAndWithEvents(src) {
724
780
  const lines = src.split('\n');
725
781
  let count = 0;
@@ -735,8 +791,14 @@ class VbaExtractor {
735
791
  let m;
736
792
  while ((m = VbaExtractor.DIM_ALL_VARS_RE.exec(line)) !== null) {
737
793
  const varName = m[1] ?? '';
738
- const outerType = m[2] ?? '';
739
- const innerType = m[3] ?? '';
794
+ // Issue #54: DIM_ALL_VARS_RE groups (2) and (3) are alternative
795
+ // captures for the same outer-type position — the bracketed
796
+ // alternative wins when present, the bare one otherwise. The
797
+ // captured value is already unwrapped (the `[...]` is consumed
798
+ // by the regex, group (2) holds the inner content). Same shape
799
+ // for the inner type at groups (4)/(5).
800
+ const outerType = m[2] ?? m[3] ?? '';
801
+ const innerType = m[4] ?? m[5] ?? '';
740
802
  // Fix 2 (Issue #2): populate the local var type map so that
741
803
  // `sweepCallsAndSql` can gate qualified statement-form calls.
742
804
  if (varName && outerType) {
@@ -762,6 +824,37 @@ class VbaExtractor {
762
824
  }
763
825
  }
764
826
  }
827
+ // Antigravity audit Task 3: bare `Dim x` (no `As` clause) and
828
+ // explicit-primitive `Dim x As <primitive>` declarations must
829
+ // still register `x` in `localVarTypeMap` so the qualified-call
830
+ // site scan in `scanCallSites` can gate the dead-end stub that
831
+ // no resolver could ever repoint.
832
+ //
833
+ // Captures the FIRST variable name only. Multi-variable bare
834
+ // Dim (e.g. `Dim a, b, c` without an `As` clause) is rare in
835
+ // real Dysflow fixtures and is intentionally NOT tracked here —
836
+ // those bare variables fall back to the undeclared-receiver
837
+ // path, which is the conservative choice. Skip if the typed-form
838
+ // loop already populated the entry.
839
+ const bm = VbaExtractor.BARE_DIM_VAR_RE.exec(line);
840
+ if (bm) {
841
+ const varName = bm[1] ?? '';
842
+ if (varName) {
843
+ const key = varName.toLowerCase();
844
+ if (!this.localVarTypeMap.has(key)) {
845
+ // Look for an `As <Type>` continuation on the same line so the
846
+ // outer type matches the existing typed-form behaviour. If
847
+ // absent, the variable is implicit `Variant` per VBA semantics.
848
+ const asRe = /\bAs\s+(\p{L}[\p{L}\p{N}_]*)/iu;
849
+ const asMatch = asRe.exec(line);
850
+ const outer = asMatch ? (asMatch[1] ?? '').toLowerCase() : 'variant';
851
+ this.localVarTypeMap.set(key, {
852
+ outer,
853
+ qualified: false,
854
+ });
855
+ }
856
+ }
857
+ }
765
858
  }
766
859
  // WithEvents declarations — handled by their own regex; also populate
767
860
  // the local var type map for completeness.
@@ -770,7 +863,7 @@ class VbaExtractor {
770
863
  const formType = weMatch[1] ?? '';
771
864
  if (formType) {
772
865
  // Extract the variable name from the WithEvents line for the map.
773
- const weVarM = /^\s*(?:(?:Dim|Private|Public)\s+)?WithEvents\s+(\p{L}[\p{L}\p{N}_]*)/iu.exec(line);
866
+ const weVarM = /^\s*(?:(?:Dim|Private|Public|Global|Static)\s+)?WithEvents\s+(\p{L}[\p{L}\p{N}_]*)/iu.exec(line);
774
867
  const weVarName = weVarM?.[1] ?? '';
775
868
  if (weVarName) {
776
869
  this.localVarTypeMap.set(weVarName.toLowerCase(), {
@@ -817,6 +910,15 @@ class VbaExtractor {
817
910
  static ENUM_MEMBER_RE = /^\s*(\p{L}[\p{L}\p{N}_]*)\s*(?:=|$)/u;
818
911
  /** `[visibility] Const <decls>` — captures visibility (1) and the rest (2). */
819
912
  static CONST_DECL_RE = /^\s*(?:(Public|Private|Friend|Global)\s+)?Const\s+(.+)$/i;
913
+ /**
914
+ * Issue #52: shared `End Sub` / `End Function` / `End Property` marker.
915
+ * Promoted from a local regex in `sweepCallsAndSql` so `sweepEnumsAndConsts`
916
+ * can walk the same proc boundaries and decide Const scope per line.
917
+ * The `(?:^|:\s*)` prefix tolerates colon-separated single-line procs
918
+ * (`Public Sub X(): ... : End Sub`) so the proc stack pops on the same
919
+ * physical line.
920
+ */
921
+ static PROCEDURE_END_RE = /(?:^|:\s*)End\s+(?:Sub|Function|Property)\b/i;
820
922
  /**
821
923
  * Fold a VBA visibility keyword to the canonical lowercase enum, matching
822
924
  * the procedure convention: `Private` → 'private'; `Public`, `Global`,
@@ -843,9 +945,37 @@ class VbaExtractor {
843
945
  const lines = src.split('\n');
844
946
  let count = 0;
845
947
  let currentEnum = null;
948
+ // Issue #52: reset the shared scope stack + lookup key so leftover
949
+ // state from a previous extract() (impossible in production but
950
+ // possible in unit tests that construct a fresh extractor and run
951
+ // twice) never leaks across sweeps. The walk below updates both
952
+ // every iteration; `sweepCallsAndSql` resets again at its own
953
+ // start, before any OpenForm/OpenQuery reader consults them.
954
+ this.procStack.length = 0;
955
+ this.currentProcKey = 'module';
846
956
  for (let i = 0; i < lines.length; i++) {
847
957
  const line = lines[i] ?? '';
848
958
  const lineNum = i + 1;
959
+ // Issue #52: track proc scope so Const declarations on this line
960
+ // can decide whether they belong to the module (currentProcKey
961
+ // === 'module') or to the top-most procedure (write the per-proc
962
+ // bucket, skip the module-level `constant` node emission).
963
+ //
964
+ // PROC_RE cannot overlap with CONST_DECL_RE on the same physical
965
+ // line (different leading keywords), so it is safe to advance the
966
+ // stack here and then fall through to the rest of the body.
967
+ const procStart = VbaExtractor.PROC_RE.exec(line);
968
+ if (procStart) {
969
+ this.procStack.push(lineNum);
970
+ this.currentProcKey = String(lineNum);
971
+ }
972
+ else if (VbaExtractor.PROCEDURE_END_RE.test(line) && this.procStack.length > 0) {
973
+ this.procStack.pop();
974
+ this.currentProcKey =
975
+ this.procStack.length > 0
976
+ ? String(this.procStack[this.procStack.length - 1])
977
+ : 'module';
978
+ }
849
979
  if (currentEnum) {
850
980
  if (VbaExtractor.ENUM_END_RE.test(line)) {
851
981
  currentEnum = null;
@@ -914,9 +1044,20 @@ class VbaExtractor {
914
1044
  const constName = declaration.name;
915
1045
  if (!constName)
916
1046
  continue;
1047
+ // Issue #52: every Const line (module-level or proc-local)
1048
+ // writes into a per-scope resolution bucket so
1049
+ // `DoCmd.OpenForm FORM_X` later resolves correctly; module-level
1050
+ // Consts additionally emit a `constant` graph node + the
1051
+ // module→constant `contains` edge. Proc-local Consts skip both
1052
+ // (the const is not a module symbol, so the wrong-containment
1053
+ // node + edge the pre-fix code emitted are gone), but the
1054
+ // per-proc bucket keeps OpenForm/OpenQuery argument
1055
+ // resolution working exactly as before.
917
1056
  if (declaration.value !== null) {
918
- this.localConstants.set(constName.toLowerCase(), declaration.value);
1057
+ this.setLocalConstInScope(this.currentProcKey, constName, declaration.value);
919
1058
  }
1059
+ if (this.procStack.length > 0)
1060
+ continue;
920
1061
  const constId = (0, tree_sitter_helpers_1.generateNodeId)(this.filePath, 'constant', constName, lineNum);
921
1062
  this.nodes.push({
922
1063
  id: constId,
@@ -966,11 +1107,20 @@ class VbaExtractor {
966
1107
  * - While inside a procedure, scan the line for call-site patterns and
967
1108
  * SQL-wrapper patterns.
968
1109
  *
969
- * Call-site regex: `(?<!\w)([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\s*\(`
970
- * captures either `Name(...)` (same-file candidate) or `Receiver.Member(...)`
971
- * (qualified emit a synthetic node + heuristic edge).
1110
+ * Call-site regex captures either `Name(...)` (same-file candidate) or
1111
+ * `Receiver.Member(...)` (qualified emit a synthetic node + heuristic
1112
+ * edge). The receiver AND member alternatives accept BOTH the bare form
1113
+ * (`Foo`) and the VBA bracketed form (`[Foo Bar]`) — bracketed captures
1114
+ * win when present. Only one of (1)/(2) and one of (3)/(4) is ever
1115
+ * populated per match. Brackets are stripped by the regex itself (the
1116
+ * capture groups hold the inner content), so callers receive unwrapped
1117
+ * identifiers and the `${name}.${proc}` stub shape stays canonical.
1118
+ *
1119
+ * Issue #54: the bracketed alternative was previously absent, so
1120
+ * `[FUNCIONES UTILES].FormatearFecha(fecha)` (a real Dysflow-exported
1121
+ * idiom for modules with spaces in their names) was silently dropped.
972
1122
  */
973
- static CALL_RE = /(?<![\w.])(\p{L}[\p{L}\p{N}_]*)(?:\.(\p{L}[\p{L}\p{N}_]*))?\s*\(/gu;
1123
+ static CALL_RE = /(?<![\w.])(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*))(?:\.(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*)))?\s*\(/gu;
974
1124
  /** SQL wrapper helpers — order matters because `db.Execute` is a suffix of others. */
975
1125
  static SQL_WRAPPERS = [
976
1126
  { name: 'DoCmd.RunSQL', re: /\bDoCmd\.RunSQL\s+"((?:[^"]|"")*)"/g },
@@ -1003,37 +1153,279 @@ class VbaExtractor {
1003
1153
  * the `opens-form` edge instead — sharing no logic with CALL_RE.
1004
1154
  */
1005
1155
  static OPEN_FORM_ARG_RE = /\bDoCmd\.OpenForm\s+("(?:(?:[^"]|"")*)"|\p{L}[\p{L}\p{N}_]*)/gu;
1156
+ /**
1157
+ * Issue #48: `DoCmd.OpenReport "<ReportName>"` modelling regex — sibling
1158
+ * of `OPEN_FORM_ARG_RE` (hueco 6 expanded). Same literal-or-bare-id argument
1159
+ * capture (group 1) and same trailing positional-args drop. The dispatch
1160
+ * table `DOCMD_OPEN_DISPATCH` (below) carries the per-method metadata so
1161
+ * OpenForm and OpenReport share the same scan/emit pipeline while their
1162
+ * edge kinds (`opens-form` vs `opens-report`), stub node kinds
1163
+ * (`form-layout` vs `report-layout`), synthetic file-path prefixes, and
1164
+ * qualifiedName prefixes (`Form_<Name>` vs `Report_<Name>`) stay
1165
+ * distinct.
1166
+ */
1167
+ static OPEN_REPORT_ARG_RE = /\bDoCmd\.OpenReport\s+("(?:(?:[^"]|"")*)"|\p{L}[\p{L}\p{N}_]*)/gu;
1168
+ /**
1169
+ * Issue #48 dispatch table — shared literal-or-Const argument resolution
1170
+ * for `DoCmd.OpenForm` and `DoCmd.OpenReport`. Each entry is everything
1171
+ * `scanDoCmdOpenCalls` + `emitOpensStubEdge` need to share the pipeline
1172
+ * between methods while keeping the per-method names distinct.
1173
+ *
1174
+ * OpenQuery is intentionally NOT in this dispatch — it emits an
1175
+ * `UnresolvedReference` (not a stub + edge), resolution to the REAL
1176
+ * `query` node emitted by `SqlQueryExtractor`. See
1177
+ * `OPEN_QUERY_ARG_RE` + `scanDoCmdOpenQuery`.
1178
+ *
1179
+ * Why a separate dispatch: `DoCmd` is in `RUNTIME_RECEIVER_BLACKLIST`
1180
+ * (R4 invariant), so ALL of these methods are intentionally SKIPPED by
1181
+ * the generic `CALL_RE` path that would otherwise emit a junk `calls`
1182
+ * edge to a synthetic `function` node for `DoCmd.OpenX`. This dispatch
1183
+ * matches BEFORE the call-site scan and uses its own emission path.
1184
+ */
1185
+ static DOCMD_OPEN_DISPATCH = [
1186
+ {
1187
+ method: 'OpenForm',
1188
+ re: VbaExtractor.OPEN_FORM_ARG_RE,
1189
+ edgeKind: 'opens-form',
1190
+ stubKind: 'form-layout',
1191
+ syntheticPrefix: 'synthetic:opensFormStub',
1192
+ syntheticExtension: '.form.txt',
1193
+ moduleNamePrefix: 'Form_',
1194
+ cacheKey: 'OpenForm',
1195
+ metadataTargetKey: 'targetFormName',
1196
+ synthesizedBy: 'vba-opens-form',
1197
+ },
1198
+ {
1199
+ method: 'OpenReport',
1200
+ re: VbaExtractor.OPEN_REPORT_ARG_RE,
1201
+ edgeKind: 'opens-report',
1202
+ stubKind: 'report-layout',
1203
+ syntheticPrefix: 'synthetic:opensReportStub',
1204
+ syntheticExtension: '.report.txt',
1205
+ moduleNamePrefix: 'Report_',
1206
+ cacheKey: 'OpenReport',
1207
+ metadataTargetKey: 'targetReportName',
1208
+ synthesizedBy: 'vba-opens-report',
1209
+ },
1210
+ ];
1211
+ /**
1212
+ * Issue #48: `DoCmd.OpenQuery "<QueryName>"` modelling regex. Emits an
1213
+ * `UnresolvedReference` (NOT a stub + edge) so the resolver binds to the
1214
+ * REAL `query` node `SqlQueryExtractor` emits for `queries/<Name>.sql`
1215
+ * — the same shape as `vba-me-control` and `vba-forms-bang`. The query
1216
+ * may not yet exist in the index when the .bas is parsed; the resolver
1217
+ * does the binding when the .sql is later indexed.
1218
+ *
1219
+ * Argument shape: identical to OpenForm/OpenReport — literal `"..."` or
1220
+ * bare identifier resolved against local `Const` declarations, falling
1221
+ * back to the bare identifier when unknown.
1222
+ */
1223
+ static OPEN_QUERY_ARG_RE = /\bDoCmd\.OpenQuery\s+("(?:(?:[^"]|"")*)"|\p{L}[\p{L}\p{N}_]*)/gu;
1006
1224
  /** SQL assigned to a local variable, e.g. `m_SQL = "SELECT ..." & ...`. */
1007
1225
  static SQL_VAR_ASSIGN_RE = /^\s*(\p{L}[\p{L}\p{N}_]*)\s*=\s*(.*)$/iu;
1008
1226
  /** SQL wrapper called with a variable, e.g. `getdb().Execute m_SQL`. */
1009
1227
  static SQL_VAR_EXEC_RE = /\b(?:\p{L}[\p{L}\p{N}_]*)?db\b(?:\(\))?\.(?:OpenRecordset|Execute)\s*\(?\s*(\p{L}[\p{L}\p{N}_]*)\s*\)?/giu;
1010
- /** SQL table-name regex scoped to FROM / INTO / UPDATE. */
1011
- static SQL_TABLE_RE = /\b(?:FROM|INTO|UPDATE)\s+(\[?\p{L}[\p{L}\p{N}_]*\]?)/giu;
1012
1228
  /**
1013
- * `Me.<ControlName>` reference capturehole 1 of VBA control-modeling.
1229
+ * Issue #42: `DoCmd.RunSQL <identifier>` (variable form)the dominant
1230
+ * Access idiom for executing a dynamically-built SQL string. Today only
1231
+ * the literal form `DoCmd.RunSQL "DELETE FROM X"` is tracked via the
1232
+ * `SQL_WRAPPERS` regex at line 1108; the variable form silently dropped
1233
+ * table impact for every procedure that builds SQL in a string and runs
1234
+ * it through `DoCmd.RunSQL`.
1235
+ *
1236
+ * This regex is the DoCmd.RunSQL analogue of `SQL_VAR_EXEC_RE` above and
1237
+ * is iterated by `scanSqlInLine` (lines 2051+). When a match is found,
1238
+ * the captured identifier is resolved against `sqlVariables` (populated
1239
+ * by `trackSqlVariableAssignment` with `&`-accumulate semantics — Issue
1240
+ * #13) and the resulting SQL string drives `emitSqlTableReferences`.
1241
+ *
1242
+ * The optional `(?:\(\))?` + `\s*\(?` shape lets the regex match both
1243
+ * the parenthesised form `DoCmd.RunSQL(strSQL)` and the no-paren form
1244
+ * `DoCmd.RunSQL strSQL` that the existing SQL_WRAPPERS literal regex
1245
+ * does not cover. The captured identifier is the only thing we need —
1246
+ * we DO NOT try to parse what the variable points at; that's the
1247
+ * existing `sqlVariables` map's job.
1248
+ */
1249
+ static SQL_VAR_DOCMD_RUNSQL_RE = /\bDoCmd\.RunSQL\s*\(?\s*(\p{L}[\p{L}\p{N}_]*)\s*\)?/giu;
1250
+ /**
1251
+ * SQL table-name regex scoped to the clauses that introduce a table
1252
+ * reference: `FROM <t>`, `JOIN <t>`, `INTO <t>`, `UPDATE <t>`. Adding
1253
+ * `JOIN` lets the scanner pick up tables from joined fragments that
1254
+ * arrive via `&`-concatenated wrapper literals (e.g.
1255
+ * `db.Execute "FROM A" & " JOIN B"`); without it the second literal's
1256
+ * table was silently dropped even though the wrapper regex now matches
1257
+ * the chain.
1014
1258
  *
1015
- * Real VBA idiom:
1016
- * `Me.lblTitulo.Caption = "Hello"` ← property assignment
1017
- * `Me.txtDescripcion.Value = "World"` property assignment
1018
- * `Me.ComandoGrabar.Enabled = True` ← property assignment
1259
+ * The captured table name is an optional bracketed/unbracketed schema
1260
+ * prefix followed by a `.`, then a bracketed-or-bare identifier — so
1261
+ * `FROM dbo.tblCustomers` and `FROM [My Schema].[My Table]` come
1262
+ * through as one composite reference. Without the prefix the regex
1263
+ * still matches a single identifier byte-identical to the old shape.
1264
+ * Brackets in the captured composite are stripped by
1265
+ * `emitSqlTableReferences` (`replace(/[\[\]]/g, '')`), so the public
1266
+ * node name is the unwrapped form `dbo.tblCustomers` /
1267
+ * `My Schema.My Table` — matching how plain `[Order Details]` is also
1268
+ * unwrapped to `Order Details`. The identifier class
1269
+ * `\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*` (same as the saved-queries
1270
+ * `TABLE_RE` in `sql-query-extractor.ts`) ensures bracketed names
1271
+ * with spaces — `[Order Details]`, `[My Schema]`, `[My Table]` —
1272
+ * are captured whole.
1273
+ */
1274
+ static SQL_TABLE_RE = /\b(?:FROM|JOIN|INTO|UPDATE)\s+((?:(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*)\.)?(?:\[[^\]]+\]|\p{L}[\p{L}\p{N}_]*))/giu;
1275
+ /**
1276
+ * Issue #50: TempVars — Access's global key-value store for cross-form
1277
+ * state. We model each STATIC-LITERAL key as a synthetic `class` placeholder
1278
+ * (same NodeKind as SQL table refs, see `emitReference`) and emit one
1279
+ * `references` edge per reading/writing procedure with
1280
+ * `metadata.synthesizedBy: 'vba-tempvar'` and `metadata.access` ∈
1281
+ * `{'read','write'}` (the user-facing enum; lowercase single-tokens).
1282
+ *
1283
+ * Three scanner surfaces cover the four real idioms:
1284
+ * - `TEMP_VAR_BANG_RE` — `TempVars!clave` (no parens; write or read)
1285
+ * - `TEMP_VAR_PAREN_RE` — `TempVars("clave")` (parens; write or read)
1286
+ * - `TEMP_VAR_ADD_RE` — `TempVars.Add "clave", v` (always a write)
1287
+ *
1288
+ * Bang vs paren split by line-source: the bang form has no string literals
1289
+ * in scope, so we scan the MASKED line (`maskStringContent` replaces
1290
+ * `"…"` content with spaces — same line source the call-site / With
1291
+ * event scanners consume). The paren and Add forms have their key INSIDE
1292
+ * a `"…"` literal that gets blanked by the masker, so those regexes scan
1293
+ * the ORIGINAL (unmasked) line — same split the SQL_TABLE_RE/OpenForm
1294
+ * literal scanners already use.
1295
+ *
1296
+ * Dynamic-key forms — `TempVars(strNombre)`,
1297
+ * `TempVars("clave" & suffix)` — are silently unmatched by all three
1298
+ * regexes (none of them tolerate a function-call or `&` arg). REQ-CODE-4
1299
+ * "unresolvable is silent" applies: these stay silent by design to
1300
+ * prevent placeholder-node explosion.
1301
+ */
1302
+ static TEMP_VAR_BANG_RE = /\bTempVars!\s*(\p{L}[\p{L}\p{N}_]*)/gu;
1303
+ /**
1304
+ * Issue #50 (cont.):
1305
+ * `TempVars("clave")` / `TempVars( "clave" )` capture. Scanned
1306
+ * over the original (unmasked) line — the literal lives INSIDE a
1307
+ * string and would be stripped by `maskStringContent`.
1308
+ */
1309
+ static TEMP_VAR_PAREN_RE = /\bTempVars\s*\(\s*"([^"]+)"\s*\)/gu;
1310
+ /**
1311
+ * Issue #50 (cont.):
1312
+ * `TempVars.Add "clave", value` capture. Always a write (the
1313
+ * `.Add` method inserts/updates the entry). Scanned over the
1314
+ * original (unmasked) line for the same string-literal reason as
1315
+ * `TEMP_VAR_PAREN_RE`.
1316
+ */
1317
+ static TEMP_VAR_ADD_RE = /\bTempVars\.Add\s+"([^"]+)"\s*,/gi;
1318
+ /**
1319
+ * `Me.<ControlName>` / `Me!<ControlName>` reference capture — hole 1
1320
+ * of VBA control-modeling. Extended by Issue #44 to accept the bang
1321
+ * form (the default-collection shortcut Access VBA inherits from VB).
1322
+ *
1323
+ * Real VBA idioms:
1324
+ * `Me.lblTitulo.Caption = "Hello"` ← dot form, property assignment
1325
+ * `Me.txtDescripcion.Value = "World"` ← dot form, property assignment
1326
+ * `Me.ComandoGrabar.Enabled = True` ← dot form, property assignment
1327
+ * `Me!txtNombre = "Hello"` ← BANG form (default collection)
1328
+ * `Me!txtEstado.Value = 1` ← BANG form, then property
1019
1329
  * `If Nz(Me.MotivoBorrado, "") = "" Then` ← read in expression
1020
1330
  *
1021
1331
  * The existing call-site scanner (CALL_RE) only fires on `Name(`
1022
1332
  * (paren form) and `Me` is in its keyword blacklist anyway, so
1023
1333
  * `Me.<Control>` references are silently invisible. This regex matches
1024
- * the FIRST identifier after `Me.` regardless of what follows (a
1025
- * property, an index, an assignment, a call argument, etc.) so the
1334
+ * the FIRST identifier after `Me.` or `Me!` regardless of what follows
1335
+ * (a property, an index, an assignment, a call argument, etc.) so the
1026
1336
  * form → control binding is surfaced as an UnresolvedReference for the
1027
1337
  * resolver to pick up later. Subsequent segments (`.Caption`, `.Value`,
1028
1338
  * `.Enabled`) are intentionally NOT captured — they are properties of
1029
- * the control, not new symbols.
1339
+ * the control, not new symbols. The bang (`!`) is the default-collection
1340
+ * shortcut: `Me!txtFoo` is semantically identical to `Me.txtFoo` and
1341
+ * produces a byte-identical UnresolvedReference (same `referenceName`,
1342
+ * `referenceKind`, and `metadata.synthesizedBy`) — the regression test
1343
+ * in `__tests__/extraction-vba.test.ts` pins this parity.
1030
1344
  *
1031
1345
  * Provenance: `metadata.synthesizedBy = 'vba-me-control'`. Mirrors the
1032
1346
  * `vba-form-binding` (form→sibling-`.cls`) and `vba-name-resolution`
1033
1347
  * (qualified Dim) patterns already documented on `UnresolvedReference.metadata`
1034
1348
  * in `src/types.ts`.
1035
1349
  */
1036
- static ME_CONTROL_RE = /\bMe\.(\p{L}[\p{L}\p{N}_]*)/gu;
1350
+ static ME_CONTROL_RE = /\bMe[.!](\p{L}[\p{L}\p{N}_]*)/gu;
1351
+ /**
1352
+ * Issue #44: `Forms!<FormName>` / `Forms("<FormName>")!<Ctl>` cross-form
1353
+ * reference capture — companion to `ME_CONTROL_RE` above. Access VBA's
1354
+ * default-collection shortcut for cross-form control access, captured
1355
+ * BEFORE the generic call-site scan and emitted as its own
1356
+ * `UnresolvedReference` family.
1357
+ *
1358
+ * Real VBA idioms:
1359
+ * `Forms!FormX!txtY.Value = 1` — bang form, with control segment
1360
+ * `Forms!FormX.Recordsource = "..."` — bang form, trailing property access
1361
+ * `Set f = Forms!FormX` — bang form, no control segment
1362
+ * `Forms("FormX")!txtY.Value = 1` — paren form, with control
1363
+ * `Forms![Mi Formulario]!txtY` — bracketed form name (#54 mirror)
1364
+ *
1365
+ * Why a dedicated scanner (mirroring `OPEN_FORM_ARG_RE` / B4 / DoCmd.OpenForm):
1366
+ * `Forms` is in `RUNTIME_RECEIVER_BLACKLIST`, so the generic CALL_RE
1367
+ * path silently skips both `Forms!X` and `Forms("X")!Y`. Without this
1368
+ * scanner, cross-form UI traffic from `Forms!FormX!txtY.Value` would
1369
+ * never surface the form → control binding that the resolver needs to
1370
+ * glue form `form-layout` nodes to control `form-instance-control`
1371
+ * nodes. The dedicated dispatch fires BEFORE the call-site scan and
1372
+ * uses its own emission path; the runtime-blacklist constraint is
1373
+ * preserved (we intentionally do not rewrite CALL_RE).
1374
+ *
1375
+ * What this scanner emits per match:
1376
+ * - ONE `UnresolvedReference` whose `referenceName` is the form's
1377
+ * identifier (stripped of any surrounding quote or bracket
1378
+ * decoration), tagged `metadata.synthesizedBy = 'vba-forms-bang'`
1379
+ * and `referenceKind = 'references'`. The control segment (if
1380
+ * present) is consumed by the regex so `Forms!FormX!txtY.Value` is
1381
+ * captured as a single match, but the control name is NOT emitted
1382
+ * as its own reference — control emission is the form's
1383
+ * responsibility downstream.
1384
+ * - NO synthetic `function` node (W4 graph-pollution invariant — the
1385
+ * form is a real `.cls` / `.form.txt` pair the resolver already
1386
+ * picks up via the `vba-form-binding` path). Without this guard the
1387
+ * bang form would synthesize one stub per cross-form reference
1388
+ * site, identical to what `bumpShapes` from #43 audited out.
1389
+ *
1390
+ * What this scanner does NOT match (intentional, pinned by tests):
1391
+ * - `Forms!FormX.Foo` — the trailing `.Foo` IS a property
1392
+ * access on the form (e.g. `Recordsource`), NOT a control access.
1393
+ * The bang alternative carries a `(?![.\w])` negative-lookahead
1394
+ * after the form identifier to drop this shape. The "W4
1395
+ * no-synthetic-fn" test pins both halves of this contract.
1396
+ * - `rs!Campo` — recordset field access (DAO/ADO
1397
+ * default-member field read) is explicitly out of scope for the
1398
+ * current change. The runtime-receiver blacklist plus the absence
1399
+ * of any `Forms`/`Me` prefix means the existing scanners already
1400
+ * skip it cleanly; the "STRETCH SCOPE" test pins that behaviour
1401
+ * so a future change to bring recordset bangs in is reviewed
1402
+ * explicitly against the bang-form scope decision.
1403
+ *
1404
+ * Operates on the ORIGINAL (unmasked) line — the paren form
1405
+ * `Forms("FormX")!txtY` has the form name INSIDE a string literal, so
1406
+ * masking string content would destroy the form identifier. Same
1407
+ * unmasked-line constraint as `scanOpenFormCalls`.
1408
+ */
1409
+ static FORMS_BANG_RE = /\b(?:Forms!(\p{L}[\p{L}\p{N}_]*|\[[^\]]+\])(?![.\w])|Forms\(\s*(?:"((?:[^"]|"")*)"|(\p{L}[\p{L}\p{N}_]*|\[[^\]]+\]))\s*\)\s*!\s*(?:\p{L}[\p{L}\p{N}_]*|\[[^\]]+\]))/gu;
1410
+ /**
1411
+ * Issue #46: scan `Set <var> = New <Type>[.<Inner>]` lines — the dominant
1412
+ * VBA late-instantiation idiom. Run inside `sweepCallsAndSql`'s proc-stack
1413
+ * loop so the surrounding procedure is known. For each match:
1414
+ * - register `<var>` in `localVarTypeMap` with `outer=<Type>`,
1415
+ * `qualified=<hasInner>`, `assignedWithSet=true` so the PR #61 refined
1416
+ * gate lets subsequent `<var>.Member ...` qualified calls resolve via
1417
+ * the resolved class name;
1418
+ * - emit a `references` edge from the module/class node to a synthetic
1419
+ * node named `<Type>`, tagged `synthesizedBy: 'vba-set-new'`.
1420
+ *
1421
+ * Groups: (1) variable name, (2) outer type, (3) optional inner type.
1422
+ * Operates on the MASKED line (string-literal content already replaced
1423
+ * with spaces) so `Set x = New Foo` inside a string literal never matches.
1424
+ */
1425
+ static SET_NEW_RE = /\bSet\s+(\p{L}[\p{L}\p{N}_]*)\s*=\s*New\s+(\p{L}[\p{L}\p{N}_]*)(?:\.(\p{L}[\p{L}\p{N}_]*))?/iu;
1426
+ /** Issue #43: track the receiver for `With <expr>` / `End With` blocks. */
1427
+ static WITH_START_RE = /^\s*With\b\s+(.+?)\s*$/iu;
1428
+ static WITH_END_RE = /^\s*End\s+With\b/iu;
1037
1429
  /** Keywords we never want to match as call receivers. */
1038
1430
  static CALL_KEYWORD_BLACKLIST = new Set([
1039
1431
  'If',
@@ -1157,9 +1549,20 @@ class VbaExtractor {
1157
1549
  * qualified, non-primitive) identifier — a candidate project-defined class.
1158
1550
  * Qualified types (e.g. `DAO.Recordset`) and primitives (`String`, `Long`)
1159
1551
  * return false so runtime/DAO calls are suppressed.
1552
+ *
1553
+ * Issue #54 (defensive): brackets are stripped from the lookup key, so a
1554
+ * caller that forgets to unwrap a bracketed name still finds the
1555
+ * corresponding entry. This is a no-op when the name is already bare —
1556
+ * the unwrap pattern only matches an opening `[` at the start and a
1557
+ * closing `]` at the end. Today's call sites (`scanCallSites`,
1558
+ * `detectQualifiedStatementCall`) already unwrap in the regex captures,
1559
+ * so this defensive strip is a belt-and-braces guard for any future
1560
+ * caller that forgets.
1160
1561
  */
1161
1562
  isLocalProjectClassVar(receiverName) {
1162
- const entry = this.localVarTypeMap.get(receiverName.toLowerCase());
1563
+ // Issue #54: strip a single leading `[` and/or trailing `]` if present.
1564
+ const key = receiverName.replace(/^\[|\]$/g, '').toLowerCase();
1565
+ const entry = this.localVarTypeMap.get(key);
1163
1566
  if (!entry)
1164
1567
  return false; // not declared in this file → silent
1165
1568
  if (entry.qualified)
@@ -1168,6 +1571,18 @@ class VbaExtractor {
1168
1571
  return false;
1169
1572
  return true;
1170
1573
  }
1574
+ /**
1575
+ * Qualified-call eligibility is intentionally shared by paren-form
1576
+ * (`Receiver.Member(...)`) and statement-form (`Receiver.Member args`) scans:
1577
+ * project-class locals are processed after type resolution, declared
1578
+ * primitive/external locals are silent, and undeclared receivers remain
1579
+ * candidate module names.
1580
+ */
1581
+ shouldProcessQualifiedCall(receiverName) {
1582
+ if (this.isLocalProjectClassVar(receiverName))
1583
+ return true;
1584
+ return !this.localVarTypeMap.has(receiverName.toLowerCase());
1585
+ }
1171
1586
  /**
1172
1587
  * #12a: resolve the "receiver type" used to build a qualified call-stub's
1173
1588
  * name/qualifiedName. When `receiverName` is a file-local variable typed
@@ -1182,28 +1597,88 @@ class VbaExtractor {
1182
1597
  */
1183
1598
  resolveReceiverType(receiverName) {
1184
1599
  if (this.isLocalProjectClassVar(receiverName)) {
1185
- const entry = this.localVarTypeMap.get(receiverName.toLowerCase());
1600
+ const key = receiverName.replace(/^\[|\]$/g, '').toLowerCase();
1601
+ const entry = this.localVarTypeMap.get(key);
1186
1602
  if (entry)
1187
1603
  return entry.outer;
1188
1604
  }
1189
1605
  return receiverName;
1190
1606
  }
1607
+ normalizeWithReceiver(expr) {
1608
+ let receiver = expr.trim();
1609
+ if (!receiver)
1610
+ return null;
1611
+ if (/^Call\s/i.test(receiver))
1612
+ receiver = receiver.replace(/^Call\s+/i, '').trimStart();
1613
+ if (receiver.startsWith('[')) {
1614
+ const m = /^\[([^\]]+)\]/u.exec(receiver);
1615
+ if (!m)
1616
+ return null;
1617
+ receiver = m[1] ?? '';
1618
+ }
1619
+ else {
1620
+ const m = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(receiver);
1621
+ if (!m)
1622
+ return null;
1623
+ receiver = m[1] ?? '';
1624
+ }
1625
+ if (!receiver)
1626
+ return null;
1627
+ if (VbaExtractor.CALL_KEYWORD_BLACKLIST.has(receiver))
1628
+ return null;
1629
+ if (VbaExtractor.RUNTIME_RECEIVER_BLACKLIST.has(receiver))
1630
+ return null;
1631
+ return receiver;
1632
+ }
1633
+ detectWithMemberCall(line) {
1634
+ let trimmed = line.trimStart();
1635
+ if (!trimmed)
1636
+ return null;
1637
+ if (/^Call\s/i.test(trimmed))
1638
+ trimmed = trimmed.replace(/^Call\s+/i, '').trimStart();
1639
+ if (trimmed.startsWith("'") || /^Rem(\s|$)/i.test(trimmed))
1640
+ return null;
1641
+ if (!trimmed.startsWith('.'))
1642
+ return null;
1643
+ const memberRest = trimmed.slice(1);
1644
+ const memberM = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(memberRest);
1645
+ if (!memberM)
1646
+ return null;
1647
+ const member = memberM[1] ?? '';
1648
+ const afterMember = memberRest.slice(member.length);
1649
+ if (afterMember.length > 0) {
1650
+ const ch = afterMember.charAt(0);
1651
+ if (ch !== '(' && ch !== ' ' && ch !== '\t')
1652
+ return null;
1653
+ const argsText = afterMember.trimStart();
1654
+ if (argsText.startsWith('='))
1655
+ return null;
1656
+ }
1657
+ if (VbaExtractor.CALL_KEYWORD_BLACKLIST.has(member))
1658
+ return null;
1659
+ if (VbaExtractor.RUNTIME_RECEIVER_BLACKLIST.has(member))
1660
+ return null;
1661
+ return { member };
1662
+ }
1191
1663
  sweepCallsAndSql(src) {
1192
1664
  const lines = src.split('\n');
1193
1665
  const procedureStartLines = new Set();
1194
- // S5 fix: also match `End Sub`/`End Function`/`End Property` after a
1195
- // colon (`:`), so single-line `Public Sub X(): End Sub` is recognized
1196
- // as ending the procedure. The previous `/^\s*End...` only matched at
1197
- // line start, so the proc stack never popped for colon-separated
1198
- // single-line declarations.
1199
- const procedureEndRe = /(?:^|:\s*)End\s+(?:Sub|Function|Property)\b/i;
1200
1666
  const sqlTargetsThisFile = new Set();
1667
+ // Issue #52: reset the shared scope state before the per-line walk
1668
+ // begins. `sweepEnumsAndConsts` already populated `procStack` /
1669
+ // `currentProcKey` during its own walk; clearing here guarantees the
1670
+ // `scanDoCmdOpenCalls` / `scanDoCmdOpenQuery` reads (which consult
1671
+ // `currentProcKey` per call-site) start in module scope and follow
1672
+ // the same push/pop discipline as the existing `stack` array below.
1673
+ this.procStack.length = 0;
1674
+ this.currentProcKey = 'module';
1201
1675
  // Walk the source once, emitting call edges and SQL edges per line and
1202
1676
  // tracking the current procedure stack. The previous implementation
1203
1677
  // did this in two passes; audit S1 (June 2026) flagged the first pass
1204
1678
  // as dead code (its `procStack` was never read after the loop). One
1205
1679
  // pass suffices.
1206
1680
  const stack = [];
1681
+ const withReceiverStack = [];
1207
1682
  const sqlVariables = new Map();
1208
1683
  // C2 fix: track each procedure's `endLine` (the line containing the
1209
1684
  // matching `End Sub`/`End Function`/`End Property`) keyed by its
@@ -1216,6 +1691,14 @@ class VbaExtractor {
1216
1691
  const lineNum = i + 1;
1217
1692
  const procStart = VbaExtractor.PROC_RE.exec(line);
1218
1693
  if (procStart) {
1694
+ // Issue #52: mirror the proc push into the shared
1695
+ // `procStack` + `currentProcKey` so Const reads in this same
1696
+ // sweep see the same scope as the const writes did (during
1697
+ // `sweepEnumsAndConsts`). Uses the same `startLine` key used
1698
+ // for the `localConstants` bucket.
1699
+ const procStartLine = lineNum;
1700
+ this.procStack.push(procStartLine);
1701
+ this.currentProcKey = String(procStartLine);
1219
1702
  const name = procStart[3] ?? '';
1220
1703
  const bucket = this.localProcs.get(name);
1221
1704
  if (bucket) {
@@ -1230,8 +1713,14 @@ class VbaExtractor {
1230
1713
  }
1231
1714
  procedureStartLines.add(lineNum);
1232
1715
  }
1233
- else if (procedureEndRe.test(line) && stack.length > 0) {
1716
+ else if (VbaExtractor.PROCEDURE_END_RE.test(line) && stack.length > 0) {
1234
1717
  const ending = stack.pop();
1718
+ // Issue #52: mirror the pop into the shared scope state.
1719
+ this.procStack.pop();
1720
+ this.currentProcKey =
1721
+ this.procStack.length > 0
1722
+ ? String(this.procStack[this.procStack.length - 1])
1723
+ : 'module';
1235
1724
  procEndLines.set(ending.startLine, lineNum);
1236
1725
  continue;
1237
1726
  }
@@ -1240,6 +1729,19 @@ class VbaExtractor {
1240
1729
  // mistakenly treated as call sites. SQL scanning still uses the original
1241
1730
  // line because SQL lives INSIDE string literals.
1242
1731
  const callScanLine = VbaExtractor.maskStringContent(line);
1732
+ if (stack.length > 0 && VbaExtractor.WITH_END_RE.test(callScanLine)) {
1733
+ withReceiverStack.pop();
1734
+ continue;
1735
+ }
1736
+ if (stack.length > 0 && !procedureStartLines.has(lineNum)) {
1737
+ const withStart = VbaExtractor.WITH_START_RE.exec(callScanLine);
1738
+ if (withStart) {
1739
+ const receiver = this.normalizeWithReceiver(withStart[1] ?? '');
1740
+ if (receiver)
1741
+ withReceiverStack.push(receiver);
1742
+ continue;
1743
+ }
1744
+ }
1243
1745
  // Don't scan call sites on the line that declares the procedure — it
1244
1746
  // would match the proc name itself in `Sub Outer()`.
1245
1747
  if (!procedureStartLines.has(lineNum) && stack.length > 0) {
@@ -1268,23 +1770,79 @@ class VbaExtractor {
1268
1770
  // Form_FormNCAuditoriaMotivoEliminado.cls fixture contributed
1269
1771
  // nothing to the call graph. Walked here so we share the proc stack
1270
1772
  // already maintained by this loop.
1773
+ //
1774
+ // Issue #45: the statement-call detector used to inspect only the
1775
+ // FIRST identifier of the line, so for `If x Then Foo` it returned
1776
+ // the keyword `If` (which is then dropped by
1777
+ // `emitStatementCallEdge`'s BLACKLIST check) — the actual `Foo` call
1778
+ // after `Then` was silently invisible. Same gap existed on the
1779
+ // `Else`/multi-statement (`:`) and qualified paths. The fix is to
1780
+ // split the line into one or more statement clauses via
1781
+ // `splitSingleLineIfClauses` (which handles `If … Then <body>`,
1782
+ // `Else <body>`, and colon-separated multi-statements) and run the
1783
+ // detectors per clause. Lines that don't match the single-line If
1784
+ // shape pass through unchanged as `[<line>]` so block-form `If`
1785
+ // (where the body lives on subsequent lines) keeps working through
1786
+ // the existing per-line scan that picks up the body line.
1271
1787
  if (stack.length > 0 && !procedureStartLines.has(lineNum)) {
1272
- const stmtCall = this.detectStatementCall(callScanLine);
1273
- if (stmtCall) {
1274
- const caller = stack[stack.length - 1];
1275
- this.emitStatementCallEdge(caller, stmtCall, lineNum);
1788
+ // Issue #46: `Set x = New <Type>[.<Inner>]` late-instantiation.
1789
+ // Run BEFORE the call-site scan so a later `<x>.Member ...` line
1790
+ // finds `x` already registered in `localVarTypeMap` and the PR #61
1791
+ // refined gate lets the qualified call resolve to `<Type>.Member`.
1792
+ const setNew = VbaExtractor.SET_NEW_RE.exec(callScanLine);
1793
+ if (setNew) {
1794
+ const varName = setNew[1] ?? '';
1795
+ const outerType = setNew[2] ?? '';
1796
+ const innerType = setNew[3] ?? '';
1797
+ if (varName && outerType) {
1798
+ // Skip primitives defensively — `Set x = New Long` is nonsense
1799
+ // in practice but the gate is cheap and consistent with the
1800
+ // Dim sweep's PRIMITIVE_TYPES guard.
1801
+ if (!VbaExtractor.PRIMITIVE_TYPES.has(outerType.toLowerCase())) {
1802
+ this.localVarTypeMap.set(varName.toLowerCase(), {
1803
+ outer: outerType,
1804
+ // Mirror `Dim x As Foo.Bar`: qualified `Set rs = New
1805
+ // DAO.Recordset` registers `qualified: true` so the PR #61
1806
+ // gate keeps downstream `rs.Method` calls silent (DAO is
1807
+ // a runtime / external library, not a project class).
1808
+ qualified: !!innerType,
1809
+ assignedWithSet: true,
1810
+ variableName: varName,
1811
+ });
1812
+ this.emitReference(outerType, lineNum, 0, 'vba-set-new');
1813
+ }
1814
+ }
1276
1815
  }
1277
- // Fix 7 + Fix 2: qualified statement-form calls (`Receiver.Member args`)
1278
- // the dominant cross-object call shape in real Dysflow fixtures.
1279
- // `CALL_RE` only matches the paren form; this path covers the no-paren
1280
- // statement form and emits a heuristic `calls` edge ONLY when the
1281
- // receiver is a file-local variable typed as a candidate project class
1282
- // (Fix 2: REQ-CODE-4 "unresolvable call is silent").
1283
- const qualStmt = this.detectQualifiedStatementCall(callScanLine);
1284
- if (qualStmt) {
1285
- const caller = stack[stack.length - 1];
1286
- if (this.isLocalProjectClassVar(qualStmt.receiver)) {
1287
- this.emitQualifiedStatementCallEdge(caller, qualStmt.receiver, qualStmt.member, lineNum);
1816
+ const clauseLines = this.splitSingleLineIfClauses(callScanLine);
1817
+ for (const clauseLine of clauseLines) {
1818
+ const stmtCall = this.detectStatementCall(clauseLine);
1819
+ if (stmtCall) {
1820
+ const caller = stack[stack.length - 1];
1821
+ this.emitStatementCallEdge(caller, stmtCall, lineNum);
1822
+ }
1823
+ // Fix 7 + Fix 2 + Issue #40: qualified statement-form calls
1824
+ // (`Receiver.Member args`) the dominant cross-object call shape in
1825
+ // real Dysflow fixtures. `CALL_RE` only matches the paren form; this
1826
+ // path covers the no-paren statement form and emits a heuristic
1827
+ // `calls` edge through the unified `shouldProcessQualifiedCall`
1828
+ // gate (shared with the paren form): declared project-class locals
1829
+ // process, declared primitive/external locals stay silent, and
1830
+ // undeclared receivers remain module-name candidates for the
1831
+ // post-extraction resolver.
1832
+ const qualStmt = this.detectQualifiedStatementCall(clauseLine);
1833
+ if (qualStmt) {
1834
+ const caller = stack[stack.length - 1];
1835
+ if (this.shouldProcessQualifiedCall(qualStmt.receiver)) {
1836
+ this.emitQualifiedStatementCallEdge(caller, qualStmt.receiver, qualStmt.member, lineNum);
1837
+ }
1838
+ }
1839
+ const withReceiver = withReceiverStack[withReceiverStack.length - 1];
1840
+ if (withReceiver) {
1841
+ const withCall = this.detectWithMemberCall(clauseLine);
1842
+ if (withCall && this.isLocalProjectClassVar(withReceiver)) {
1843
+ const caller = stack[stack.length - 1];
1844
+ this.emitQualifiedStatementCallEdge(caller, withReceiver, withCall.member, lineNum);
1845
+ }
1288
1846
  }
1289
1847
  }
1290
1848
  // B4 (hueco 6): `DoCmd.OpenForm "FormName"` modelling.
@@ -1308,7 +1866,30 @@ class VbaExtractor {
1308
1866
  // node id (the same pattern the cross-file incoming-edges
1309
1867
  // snapshot already uses at `index.ts:getCrossFileIncomingEdges`).
1310
1868
  const caller2 = stack[stack.length - 1];
1311
- this.scanOpenFormCalls(line, caller2, lineNum);
1869
+ // Issue #48: shared OpenForm/OpenReport dispatch via
1870
+ // `scanDoCmdOpenCalls` (formerly `scanOpenFormCalls`, refactored to
1871
+ // iterate the `DOCMD_OPEN_DISPATCH` table — OpenForm behavior is
1872
+ // byte-identical to pre-#48). The OpenQuery scanner emits an
1873
+ // `UnresolvedReference` and stays separate from the dispatch since
1874
+ // its emission shape (no stub + edge) differs.
1875
+ this.scanDoCmdOpenCalls(line, caller2, lineNum);
1876
+ this.scanDoCmdOpenQuery(line, caller2, lineNum);
1877
+ // Issue #44: cross-form bang references (`Forms!X` / `Forms("X")!Y`).
1878
+ // Same line context as `scanDoCmdOpenCalls` — the form name lives in
1879
+ // a string literal in the paren form, so we MUST scan the unmasked
1880
+ // line. The scanner is independent of `scanCallSites` because
1881
+ // `Forms` is in `RUNTIME_RECEIVER_BLACKLIST` and would otherwise be
1882
+ // dropped by the generic path.
1883
+ this.scanFormsBang(line, caller2, lineNum);
1884
+ // Issue #50: cross-form TempVars key accesses.
1885
+ // Sibling of `scanFormsBang` — TempVars is Access's second global
1886
+ // cross-form state surface (alongside Forms/DoCmd.OpenForm). Each
1887
+ // static-literal key reference emits one `references` edge to a
1888
+ // synthetic `class` placeholder, tagged `synthesizedBy: 'vba-tempvar'`
1889
+ // and `access: 'read' | 'write'`. We need BOTH line sources: bang
1890
+ // form scans the masked line, paren + Add forms scan the original
1891
+ // (literal lives inside `"…"`).
1892
+ this.sweepTempVars(callScanLine, line, lineNum, caller2);
1312
1893
  }
1313
1894
  }
1314
1895
  // Apply endLine to every emitted function node keyed by its startLine.
@@ -1347,8 +1928,15 @@ class VbaExtractor {
1347
1928
  VbaExtractor.CALL_RE.lastIndex = 0;
1348
1929
  let m;
1349
1930
  while ((m = VbaExtractor.CALL_RE.exec(line)) !== null) {
1350
- const receiver = m[1] ?? '';
1351
- const member = m[2] ?? '';
1931
+ // Issue #54: CALL_RE groups (1)/(2) are alternative captures for the
1932
+ // receiver position, (3)/(4) for the member position. The bracketed
1933
+ // alternative wins when present; the captured value is already
1934
+ // unwrapped by the regex (it captures only the inner content, not
1935
+ // the surrounding `[...]`). Result: a `[FUNCIONES UTILES]` receiver
1936
+ // surfaces here as `FUNCIONES UTILES` so the downstream blacklist
1937
+ // checks and `resolveReceiverType` lookup see the bare identifier.
1938
+ const receiver = m[1] ?? m[2] ?? '';
1939
+ const member = m[3] ?? m[4] ?? '';
1352
1940
  if (!receiver)
1353
1941
  continue;
1354
1942
  // Skip VBA control-flow keywords.
@@ -1381,12 +1969,35 @@ class VbaExtractor {
1381
1969
  });
1382
1970
  }
1383
1971
  else {
1384
- // Qualified `Receiver.Member(...)` — synthesize the call target.
1972
+ // Qualified `Receiver.Member(...)` — synthesize the call target only
1973
+ // for project-class local variables or undeclared module candidates.
1974
+ if (!this.shouldProcessQualifiedCall(receiver))
1975
+ continue;
1385
1976
  // #12a: `receiverType` resolves to the real class name when
1386
1977
  // `receiver` is a declared project-class local var (matching a real
1387
1978
  // `.cls` method's `${className}.${proc}` qualifiedName shape so the
1388
1979
  // #12b resolver can find it by exact match); otherwise it's the raw
1389
1980
  // `receiver` text unchanged (e.g. `.bas`-qualified module calls).
1981
+ //
1982
+ // Antigravity audit Task 3 (refined gate): if `receiver` is a
1983
+ // file-local variable declared as a PRIMITIVE (Variant, Object,
1984
+ // Empty, Null, LongPtr, LongLong, New, Long, String, ...), skip
1985
+ // emission. The previous behaviour emitted a heuristic `calls`
1986
+ // edge to a stub named `<receiver>.<member>` that no resolver
1987
+ // could ever repoint (Variant can hold anything, including
1988
+ // runtime singletons; the stub is dead-end graph pollution).
1989
+ //
1990
+ // This refined gate does NOT regress cross-module qualified
1991
+ // calls like `modUtils.Foo(1)` because `modUtils` is never
1992
+ // declared as a file-local variable and therefore is NOT in
1993
+ // `localVarTypeMap` — `localVarTypeMap.has(receiver.toLowerCase())`
1994
+ // returns false and the gate is skipped. The "stub emitted for
1995
+ // undeclared receivers → resolver repoints if a real module
1996
+ // exists" behaviour is preserved.
1997
+ const recvEntry = this.localVarTypeMap.get(receiver.toLowerCase());
1998
+ if (recvEntry && VbaExtractor.PRIMITIVE_TYPES.has(recvEntry.outer.toLowerCase())) {
1999
+ continue;
2000
+ }
1390
2001
  const receiverType = this.resolveReceiverType(receiver);
1391
2002
  const qualified = `${receiverType}.${member}`;
1392
2003
  // Avoid emitting duplicate edges for the same call (within a line).
@@ -1439,16 +2050,21 @@ class VbaExtractor {
1439
2050
  callDedupe = new Set();
1440
2051
  synthFunctionNodeIds = new Set();
1441
2052
  /**
1442
- * B4 (hueco 6): cache of stub `form-layout` node ids we've already emitted
1443
- * for a given target form name in this file. Avoids emitting duplicate
1444
- * stubs when `DoCmd.OpenForm "FormTest"` shows up N times across N calls.
1445
- * Keyed by the lowercased form name so `FormTest` / `formtest` collapse.
2053
+ * B4 (hueco 6) extended by Issue #48: cache of stub node ids we've already
2054
+ * emitted for a given (method, target name) pair in this file. Avoids
2055
+ * emitting duplicate stubs when `DoCmd.OpenForm "FormTest"` or
2056
+ * `DoCmd.OpenReport "InformeMensual"` shows up N times across N calls.
2057
+ * Keyed by `${cacheKey}:${lowerName}` so the OpenForm and OpenReport
2058
+ * de-dup buckets stay disjoint — `OpenForm:Form1` ≠ `OpenReport:Form1`.
2059
+ * The name part is lowercased so `FormTest` / `formtest` collapse.
1446
2060
  */
1447
- opensFormStubIdsByName = new Map();
2061
+ opensStubIdsByKey = new Map();
1448
2062
  /**
1449
- * Hueco 1: scan a line for `Me.<ControlName>` patterns and emit one
1450
- * UnresolvedReference per occurrence, tagged
1451
- * `metadata.synthesizedBy: 'vba-me-control'`.
2063
+ * Hueco 1: scan a line for `Me.<ControlName>` / `Me!<ControlName>`
2064
+ * patterns and emit one UnresolvedReference per occurrence, tagged
2065
+ * `metadata.synthesizedBy: 'vba-me-control'`. Issue #44 extended
2066
+ * `ME_CONTROL_RE` from `Me\.` to `Me[.!]` so the bang form (default-
2067
+ * collection shortcut) is captured byte-identically to the dot form.
1452
2068
  *
1453
2069
  * Operates on the masked `callScanLine` (string-literal content already
1454
2070
  * replaced with spaces) so `Me.X` inside a string literal is not falsely
@@ -1459,6 +2075,8 @@ class VbaExtractor {
1459
2075
  *
1460
2076
  * `fromNodeId` is the current procedure's function node — that's the
1461
2077
  * "owner" of the reference (the Sub body that wrote `Me.lblTitulo = …`).
2078
+ * The +3 column offset remains correct under Issue #44's regex change
2079
+ * because both `Me.` and `Me!` are 3-character prefixes.
1462
2080
  */
1463
2081
  scanMeControlReferences(line, from, lineNum) {
1464
2082
  VbaExtractor.ME_CONTROL_RE.lastIndex = 0;
@@ -1472,13 +2090,70 @@ class VbaExtractor {
1472
2090
  referenceName: controlName,
1473
2091
  referenceKind: 'references',
1474
2092
  line: lineNum,
1475
- column: m.index + 3, // +3 to skip the `Me.` prefix
2093
+ column: m.index + 3, // +3 to skip the `Me.` / `Me!` prefix
1476
2094
  filePath: this.filePath,
1477
2095
  language: 'vba',
1478
2096
  metadata: { synthesizedBy: 'vba-me-control' },
1479
2097
  });
1480
2098
  }
1481
2099
  }
2100
+ /**
2101
+ * Issue #44: scan a line for cross-form bang references
2102
+ * (`Forms!<FormName>[!<Ctl>]` and `Forms("<FormName>")!<Ctl>`) and
2103
+ * emit ONE UnresolvedReference per match with `metadata.synthesizedBy
2104
+ * = 'vba-forms-bang'`. Companion to `scanMeControlReferences` above;
2105
+ * shares the emission shape (a single `UnresolvedReference` per match,
2106
+ * `referenceKind: 'references'`), keeping the W4 invariant that we
2107
+ * synthesize NO `function` node for forms.
2108
+ *
2109
+ * Operates on the ORIGINAL (unmasked) line — the paren form
2110
+ * `Forms("FormX")!txtY` carries the form name INSIDE a string literal
2111
+ * and would be destroyed by `maskStringContent`. Mirrors
2112
+ * `scanOpenFormCalls`'s unmasked-line constraint.
2113
+ *
2114
+ * Regex alternatives (see `FORMS_BANG_RE`):
2115
+ * 1. `Forms!<FormName>` (bare or `[bracketed]`, NOT followed by `.X`)
2116
+ * — bang form, may have a trailing `!<Ctl>[.<Prop>]` that is
2117
+ * consumed but NOT emitted.
2118
+ * 2. `Forms("<FormName>")!<Ctl>` (quoted or bare/bracketed form arg)
2119
+ * — paren form, ALWAYS with a control segment.
2120
+ *
2121
+ * Stripping: the form's identifier is unwrapped of `"` quotes (string
2122
+ * literals) and `[…]` brackets (Issue #54 reserved-identifier shape) so
2123
+ * the public `referenceName` is the bare form name. Bracketed forms
2124
+ * (`Forms![Mi Formulario]`) follow the same strip rules as the paren
2125
+ * form so the resolver sees `Mi Formulario` either way.
2126
+ */
2127
+ scanFormsBang(line, from, lineNum) {
2128
+ VbaExtractor.FORMS_BANG_RE.lastIndex = 0;
2129
+ let m;
2130
+ while ((m = VbaExtractor.FORMS_BANG_RE.exec(line)) !== null) {
2131
+ // Group 1: bang form name (bare or `[bracketed]`); group 2: paren
2132
+ // form name in `"quotes"`; group 3: paren form name bare or
2133
+ // `[bracketed]`. Take whichever the regex alternative produced and
2134
+ // strip the surrounding decoration so the public referenceName is
2135
+ // the bare form identifier the resolver will compare against the
2136
+ // form's `form-layout` node name.
2137
+ const raw = m[1] ?? m[2] ?? m[3] ?? '';
2138
+ if (!raw)
2139
+ continue;
2140
+ const formName = raw
2141
+ .replace(/^"|"$/g, '') // strip surrounding string-literal quotes
2142
+ .replace(/^\[|\]$/g, ''); // strip surrounding bracket decoration (#54)
2143
+ if (!formName)
2144
+ continue;
2145
+ this.unresolvedReferences.push({
2146
+ fromNodeId: this.findOrCreateFunctionNodeId(from),
2147
+ referenceName: formName,
2148
+ referenceKind: 'references',
2149
+ line: lineNum,
2150
+ column: m.index, // start of the `Forms` keyword; the form name's column can be reconstructed by the UI if it cares
2151
+ filePath: this.filePath,
2152
+ language: 'vba',
2153
+ metadata: { synthesizedBy: 'vba-forms-bang' },
2154
+ });
2155
+ }
2156
+ }
1482
2157
  findOrCreateFunctionNodeId(proc) {
1483
2158
  // Fix 1: key the cache by `name:startLine` so Property Get/Let/Set
1484
2159
  // accessors with the same name each resolve to their own node.
@@ -1507,6 +2182,85 @@ class VbaExtractor {
1507
2182
  // meaningful on real .cls files with hundreds of procedures.
1508
2183
  return this.functionNodeByName.get(name);
1509
2184
  }
2185
+ /**
2186
+ * Issue #45: split a single-line VBA `If <cond> Then <body>` into one or
2187
+ * more statement-clause fragments that the existing `detectStatementCall`
2188
+ * and `detectQualifiedStatementCall` detectors can process. Handles:
2189
+ *
2190
+ * - `If x Then Foo` → `['Foo']`
2191
+ * - `If x Then Foo Else Bar` → `['Foo', 'Bar']`
2192
+ * - `If x Then DoA: DoB` → `['DoA', 'DoB']` (colon-separated multi-statement)
2193
+ * - `If x Then Foo Else A: B` → `['Foo', 'A', 'B']`
2194
+ * - `If x Then GoTo fin` → `[]` (GoTo clause filtered out)
2195
+ * - `If x Then Exit Sub` → `[]` (Exit clause filtered out)
2196
+ *
2197
+ * When the line does NOT match a single-line `If … Then` shape — for
2198
+ * instance a block-form `If x Then` whose body lives on subsequent
2199
+ * lines — the splitter returns `[<line>]` (the original input) so
2200
+ * callers can use this method unconditionally and let the existing
2201
+ * per-line scan pick up the body on a separate line.
2202
+ *
2203
+ * `GoTo`, `Exit`, and `Resume` clauses are filtered at the fragment
2204
+ * level (defense in depth): even though `emitStatementCallEdge` already
2205
+ * drops these via the `CALL_KEYWORD_BLACKLIST`, filtering here prevents
2206
+ * any chance of `detectStatementCall`'s generic identifier extractor
2207
+ * matching a substring (e.g. an identifier like `GoToFinishingTouches`)
2208
+ * as a side effect of a richer clause where the keyword happens to be
2209
+ * the leading token.
2210
+ *
2211
+ * `line` is the string-literal-masked scan line. The mask makes global
2212
+ * `:` splitting safe: real VBA colons never appear inside string
2213
+ * literals (already masked to spaces) and never inside expressions
2214
+ * inside parens at the source level (a colon ends a statement in VBA,
2215
+ * so it cannot appear inside a parenthesised argument list either).
2216
+ * The `Else` keyword is a VBA statement-level separator and is
2217
+ * forbidden inside parens or expressions, so splitting on
2218
+ * `\s+Else\s+` does not need paren tracking either.
2219
+ */
2220
+ splitSingleLineIfClauses(line) {
2221
+ const trimmed = line.trimStart();
2222
+ if (!trimmed)
2223
+ return [];
2224
+ // Match `If <cond> Then <body>` with a non-greedy condition. Requiring
2225
+ // at least one whitespace character after `Then` ensures the block
2226
+ // form `If x Then` (with the body on subsequent lines) is left alone
2227
+ // for the existing per-line call-site scan to handle on the body line.
2228
+ const ifThenRe = /^If\s[\s\S]+?\bThen\b\s+/i;
2229
+ const m = ifThenRe.exec(trimmed);
2230
+ if (!m) {
2231
+ // Not a single-line `If … Then` — preserve the original line so the
2232
+ // existing detection path picks it up unchanged.
2233
+ return [line];
2234
+ }
2235
+ const body = trimmed.slice(m[0].length);
2236
+ // Split on top-level `Else` (case-insensitive; word-bounded). The
2237
+ // statement-level-only nature of `Else` in VBA means the regex split
2238
+ // is safe without paren tracking on the masked-line invariant.
2239
+ const elseClauses = body.split(/\s+Else\s+/i);
2240
+ const clauses = [];
2241
+ for (const elseClause of elseClauses) {
2242
+ // Split each Else-clause on `:` for multi-statement single-line
2243
+ // `If` bodies. VBA expressions never contain `:`, so a global
2244
+ // split is correct on a masked line.
2245
+ const subStatements = elseClause.split(':');
2246
+ for (const sub of subStatements) {
2247
+ const t = sub.trim();
2248
+ if (!t)
2249
+ continue;
2250
+ // Defense in depth: GoTo / Exit / Resume are VBA control-flow
2251
+ // statements, not Sub calls — drop them before they reach the
2252
+ // statement-call detectors. (The existing
2253
+ // `CALL_KEYWORD_BLACKLIST` check in `emitStatementCallEdge`
2254
+ // also covers this; the fragment-level filter keeps the two
2255
+ // intent statements aligned and protects against any future
2256
+ // detector refactor that might relax the BLACKLIST check.)
2257
+ if (/^(?:GoTo|Exit|Resume)\b/i.test(t))
2258
+ continue;
2259
+ clauses.push(t);
2260
+ }
2261
+ }
2262
+ return clauses;
2263
+ }
1510
2264
  /**
1511
2265
  * H1: detect a statement-form Sub call.
1512
2266
  *
@@ -1617,22 +2371,26 @@ class VbaExtractor {
1617
2371
  // Skip declarations.
1618
2372
  if (/^(Dim|Private|Public|Static|Global|Const|ReDim)\s/i.test(trimmed))
1619
2373
  return null;
1620
- // Extract receiver identifier.
1621
- const receiverM = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(trimmed);
2374
+ // Issue #54: the receiver alternative accepts BOTH the bare form
2375
+ // (`Foo`) and the VBA bracketed form (`[Foo Bar]`). The bracketed
2376
+ // alternative wins when present; the captured value is already
2377
+ // unwrapped by the regex (it captures only the inner content, not
2378
+ // the surrounding `[...]`). The same shape applies to the member.
2379
+ const receiverM = /^(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*))/u.exec(trimmed);
1622
2380
  if (!receiverM)
1623
2381
  return null;
1624
- const receiver = receiverM[1] ?? '';
1625
- const rest = trimmed.slice(receiver.length);
2382
+ const receiver = receiverM[1] ?? receiverM[2] ?? '';
2383
+ const rest = trimmed.slice(receiverM[0].length);
1626
2384
  // Must have a dot separator.
1627
2385
  if (!rest.startsWith('.'))
1628
2386
  return null;
1629
2387
  // Extract member identifier.
1630
2388
  const memberRest = rest.slice(1); // skip the dot
1631
- const memberM = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(memberRest);
2389
+ const memberM = /^(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*))/u.exec(memberRest);
1632
2390
  if (!memberM)
1633
2391
  return null;
1634
- const member = memberM[1] ?? '';
1635
- const afterMember = memberRest.slice(member.length);
2392
+ const member = memberM[1] ?? memberM[2] ?? '';
2393
+ const afterMember = memberRest.slice(memberM[0].length);
1636
2394
  // Must NOT be followed by `(` — the paren form is handled by CALL_RE.
1637
2395
  if (afterMember.startsWith('('))
1638
2396
  return null;
@@ -1664,13 +2422,9 @@ class VbaExtractor {
1664
2422
  * paren and non-paren form on the same line don't create duplicate edges.
1665
2423
  */
1666
2424
  emitQualifiedStatementCallEdge(caller, receiver, member, lineNum) {
1667
- // #12a: the caller already checked `isLocalProjectClassVar(receiver)`
1668
- // before calling this method, so `resolveReceiverType` always returns
1669
- // the RESOLVED CLASS NAME here the stub's name/qualifiedName matches
1670
- // the real `.cls` method's `${className}.${proc}` shape (e.g.
1671
- // `m_NCOp` typed `As NCOperaciones` → `NCOperaciones.Registrar`, not
1672
- // `m_NCOp.Registrar`) so the #12b resolver can find it by exact
1673
- // qualifiedName match.
2425
+ // Qualified-call eligibility is checked before this method. Project-class
2426
+ // local receivers resolve to their class name (for exact `.cls` method
2427
+ // matching), while undeclared receivers stay as raw module-name candidates.
1674
2428
  const receiverType = this.resolveReceiverType(receiver);
1675
2429
  const qualified = `${receiverType}.${member}`;
1676
2430
  const dedupeKey = `${caller.name}->${qualified}@${lineNum}`;
@@ -1712,83 +2466,108 @@ class VbaExtractor {
1712
2466
  });
1713
2467
  }
1714
2468
  /**
1715
- * B4 (hueco 6): scan one line of VBA source for `DoCmd.OpenForm "X"`
1716
- * calls. For each match, emit:
1717
- * - a stub `form-layout` node for the target form (cached by name so
1718
- * the same form referenced from N sites emits exactly ONE stub),
1719
- * - an `opens-form` heuristic edge from the calling Sub to that stub.
2469
+ * B4 (hueco 6) extended by Issue #48: scan one line of VBA source for
2470
+ * `DoCmd.OpenX "Target"` calls where X ∈ {Form, Report} (see the
2471
+ * `DOCMD_OPEN_DISPATCH` table). For each match, emit:
2472
+ * - a stub node (form-layout / report-layout) for the target, cached
2473
+ * per-(method, name) so the same target referenced from N sites
2474
+ * emits exactly ONE stub,
2475
+ * - an `opens-form` / `opens-report` heuristic edge from the calling
2476
+ * Sub to that stub.
1720
2477
  *
1721
2478
  * Both endpoints are pushed into `this.nodes` / `this.edges`, so the
1722
2479
  * per-file edge filter at `index.ts:insertedIds.has(source) &&
1723
2480
  * insertedIds.has(target)` passes the edge naturally without any
1724
2481
  * exemption to the filter.
1725
2482
  *
1726
- * Why a stub and not a direct lookup: the target form lives in a
1727
- * DIFFERENT file (its own `.form.txt`), and the extractor doesn't have
1728
- * DB access at parse time. The stub's synthetic file path
1729
- * (`synthetic:opensFormStub/<FormName>.form.txt`) guarantees a
1730
- * deterministic node id so re-indexes collapse to the same stub.
1731
- * When the consumer's `.form.txt` is later indexed, the real
1732
- * `form-layout` node carries a different id (it uses the real file
1733
- * path); the stub and the real coexist harmlessly. The orchestrator
1734
- * flagged this as acceptable for B4 only `OpenForm` is in scope.
1735
- * `OpenReport`, `OpenQuery`, `OpenTable`, … are follow-up work.
2483
+ * Why a stub and not a direct lookup: the target form/report lives in a
2484
+ * DIFFERENT file (its own `.form.txt` / `.report.txt`), and the extractor
2485
+ * doesn't have DB access at parse time. The stub's synthetic file path
2486
+ * (`synthetic:opensFormStub/<Name>.form.txt` /
2487
+ * `synthetic:opensReportStub/<Name>.form.txt`) guarantees a deterministic
2488
+ * node id so re-indexes collapse to the same stub. When the consumer's
2489
+ * `.form.txt` / `.report.txt` is later indexed, the real
2490
+ * `form-layout` / `report-layout` node carries a different id (it uses
2491
+ * the real file path); the stub and the real coexist harmlessly.
2492
+ *
2493
+ * Why a separate dispatch from CALL_RE: `DoCmd` is in
2494
+ * `RUNTIME_RECEIVER_BLACKLIST` (R4 invariant), so `DoCmd.OpenForm` /
2495
+ * `DoCmd.OpenReport` are intentionally SKIPPED by the generic CALL_RE
2496
+ * path that would otherwise emit a junk `calls` edge to a synthetic
2497
+ * `function` node for `DoCmd.OpenX`. The dispatch below matches BEFORE
2498
+ * the call-site scan and uses its own emission path.
1736
2499
  *
1737
- * Scope note: this regex matches literal-string and bare-identifier forms.
1738
- * Bare identifiers are resolved only through local `Const` declarations;
1739
- * arbitrary variable data-flow remains intentionally out of scope.
2500
+ * Scope note: literal-string and bare-identifier argument forms are
2501
+ * supported. Bare identifiers resolve only through local `Const`
2502
+ * declarations; arbitrary variable data-flow remains intentionally
2503
+ * out of scope.
1740
2504
  */
1741
- scanOpenFormCalls(line, caller, lineNum) {
1742
- // Each regex has /g so we MUST reset `lastIndex` before use; cloning
1743
- // the regex is the simplest way to avoid leaking state across lines.
1744
- const localRe = new RegExp(VbaExtractor.OPEN_FORM_ARG_RE.source, VbaExtractor.OPEN_FORM_ARG_RE.flags);
1745
- let m;
1746
- while ((m = localRe.exec(line)) !== null) {
1747
- const rawArg = (m[1] ?? '').trim();
1748
- const targetFormName = rawArg.startsWith('"')
1749
- ? unwrapVbaStringLiteral(rawArg)
1750
- : (this.localConstants.get(rawArg.toLowerCase()) ?? rawArg);
1751
- if (!targetFormName)
1752
- continue;
1753
- this.emitOpensFormEdge(caller, targetFormName, lineNum, m.index);
2505
+ scanDoCmdOpenCalls(line, caller, lineNum) {
2506
+ for (const dispatch of VbaExtractor.DOCMD_OPEN_DISPATCH) {
2507
+ // Each regex has /g so we MUST reset `lastIndex` before use; cloning
2508
+ // the regex is the simplest way to avoid leaking state across lines
2509
+ // AND across dispatch iterations.
2510
+ const localRe = new RegExp(dispatch.re.source, dispatch.re.flags);
2511
+ let m;
2512
+ while ((m = localRe.exec(line)) !== null) {
2513
+ const rawArg = (m[1] ?? '').trim();
2514
+ // Issue #52: const lookup is now per-proc-bucket with module
2515
+ // fallback (see `resolveLocalConst`). Two procs declaring the
2516
+ // same Const name with different values no longer collide —
2517
+ // each call site uses the value visible at its own scope.
2518
+ const targetName = rawArg.startsWith('"')
2519
+ ? unwrapVbaStringLiteral(rawArg)
2520
+ : (this.resolveLocalConst(rawArg) ?? rawArg);
2521
+ if (!targetName)
2522
+ continue;
2523
+ this.emitOpensStubEdge(dispatch, caller, targetName, lineNum, m.index);
2524
+ }
1754
2525
  }
1755
2526
  }
1756
2527
  /**
1757
- * B4 (hueco 6): emit a stub `form-layout` node for `targetFormName`
1758
- * (cached so duplicates collapse) and an `opens-form` heuristic edge
2528
+ * B4 (hueco 6) extended by Issue #48: emit a stub `form-layout` /
2529
+ * `report-layout` node for `targetName` (cached per dispatch entry so
2530
+ * duplicates collapse and OpenForm/OpenReport de-dup buckets stay
2531
+ * disjoint) and a single `opens-form` / `opens-report` heuristic edge
1759
2532
  * from `caller` to that stub.
1760
2533
  *
1761
2534
  * The edge carries:
1762
- * - `kind: 'opens-form'` new cross-file edge kind
1763
- * - `provenance: 'heuristic'` — synthesized, not parsed
1764
- * - `metadata.targetFormName` — the captured literal
1765
- * - `metadata.synthesizedBy: 'vba-opens-form'` — distinguishes this
1766
- * synthesis from the dim/sql/event-handler families
2535
+ * - `kind` — dispatch-specific (`opens-form` / `opens-report`)
2536
+ * - `provenance: 'heuristic'` — synthesized, not parsed
2537
+ * - `metadata.<dispatchTargetKey>` (e.g. `targetFormName`) — the resolved name
2538
+ * - `metadata.synthesizedBy` — dispatch-specific (`vba-opens-form` /
2539
+ * `vba-opens-report`); distinguishes this synthesis from the
2540
+ * dim/sql/event-handler families
1767
2541
  *
1768
2542
  * The stub's `metadata.stub: true` flag lets downstream UI render
1769
- * unresolved references distinctly (e.g. with a dashed border) and
1770
- * gives later re-resolution pass a hook for collapse. The stub is
2543
+ * stubs distinctly (e.g. with a dashed border) and gives later
2544
+ * re-resolution pass a hook for collapse. The stub is
1771
2545
  * line-independent (`line = 0`) so re-indexes produce identical ids.
1772
2546
  */
1773
- emitOpensFormEdge(caller, targetFormName, lineNum, column) {
1774
- const key = targetFormName.toLowerCase();
1775
- let stubId = this.opensFormStubIdsByName.get(key);
2547
+ emitOpensStubEdge(dispatch, caller, targetName, lineNum, column) {
2548
+ const key = `${dispatch.cacheKey}:${targetName.toLowerCase()}`;
2549
+ let stubId = this.opensStubIdsByKey.get(key);
1776
2550
  if (!stubId) {
1777
2551
  // Synthetic file path keeps the stub's id deterministic AND
1778
- // disambiguates it from any real `.form.txt` indexed later.
1779
- // The directory prefix (`synthetic:opensFormStub/`) is intentionally
1780
- // not a real filesystem path it just namespaces the id space.
1781
- const syntheticFilePath = `synthetic:opensFormStub/${targetFormName}.form.txt`;
1782
- stubId = (0, tree_sitter_helpers_1.generateNodeId)(syntheticFilePath, 'form-layout', targetFormName, 0);
1783
- this.opensFormStubIdsByName.set(key, stubId);
2552
+ // disambiguates it from any real `.form.txt` / `.report.txt`
2553
+ // indexed later. The directory prefix (`synthetic:opensFormStub/`
2554
+ // or `synthetic:opensReportStub/`) is intentionally not a real
2555
+ // filesystem path — it just namespaces the id space. The file
2556
+ // extension (`syntheticExtension`) DOES mirror the real form/report
2557
+ // file extension so a reader of the synthetic path can tell the
2558
+ // stub's intent at a glance.
2559
+ const syntheticFilePath = `${dispatch.syntheticPrefix}/${targetName}${dispatch.syntheticExtension}`;
2560
+ stubId = (0, tree_sitter_helpers_1.generateNodeId)(syntheticFilePath, dispatch.stubKind, targetName, 0);
2561
+ this.opensStubIdsByKey.set(key, stubId);
1784
2562
  this.nodes.push({
1785
2563
  id: stubId,
1786
- kind: 'form-layout',
1787
- name: targetFormName,
1788
- // Convention: form module names in Access are `Form_<Name>`.
1789
- // We follow the same convention in the synthetic stub's
1790
- // qualifiedName so cross-file lookups can find it consistently.
1791
- qualifiedName: `Form_${targetFormName}`,
2564
+ kind: dispatch.stubKind,
2565
+ name: targetName,
2566
+ // Convention: form module names in Access are `Form_<Name>` and
2567
+ // report module names are `Report_<Name>`. We follow the same
2568
+ // convention in the synthetic stub's qualifiedName so cross-file
2569
+ // lookups can find it consistently.
2570
+ qualifiedName: `${dispatch.moduleNamePrefix}${targetName}`,
1792
2571
  filePath: syntheticFilePath,
1793
2572
  language: 'vba',
1794
2573
  startLine: lineNum,
@@ -1802,23 +2581,115 @@ class VbaExtractor {
1802
2581
  this.edges.push({
1803
2582
  source: this.findOrCreateFunctionNodeId(caller),
1804
2583
  target: stubId,
1805
- kind: 'opens-form',
2584
+ kind: dispatch.edgeKind,
1806
2585
  provenance: 'heuristic',
1807
2586
  metadata: {
1808
- synthesizedBy: 'vba-opens-form',
1809
- targetFormName,
2587
+ synthesizedBy: dispatch.synthesizedBy,
2588
+ [dispatch.metadataTargetKey]: targetName,
1810
2589
  },
1811
2590
  line: lineNum,
1812
2591
  column,
1813
2592
  });
1814
2593
  }
2594
+ /**
2595
+ * Issue #48: scan one line of VBA source for `DoCmd.OpenQuery "X"` calls.
2596
+ * Each match emits ONE `UnresolvedReference` (NOT a stub + edge) so the
2597
+ * resolver binds to the REAL `query` node that `SqlQueryExtractor`
2598
+ * produces for `queries/<Name>.sql` (dysflow exports every saved QueryDef
2599
+ * + `queries.json` manifest). Falls back to silent when the .sql is not
2600
+ * yet in the index — the resolver does the binding when it's later
2601
+ * indexed, exactly like `vba-me-control` and `vba-forms-bang`.
2602
+ *
2603
+ * Companion to `scanDoCmdOpenCalls` but intentionally NOT in the
2604
+ * dispatch table — OpenQuery's emission shape (`UnresolvedReference`)
2605
+ * is structurally different from OpenForm/OpenReport's (synthetic node
2606
+ * + heuristic edge). The two pipelines share the literal-vs-Const
2607
+ * argument resolution pattern via `localConstants.get(...)` but emit
2608
+ * via two different branches of `unresolvedReferences` vs
2609
+ * `nodes`/`edges`.
2610
+ *
2611
+ * UnresolvedReference shape (per Issue #48 spec — must match
2612
+ * SqlQueryExtractor's query node name exactly):
2613
+ * - `referenceName` = resolved query name
2614
+ * - `referenceKind: 'references'` = same kind the resolver binds
2615
+ * - `metadata.synthesizedBy: 'vba-opens-query'`
2616
+ * - NO synthetic `function` node (W4 graph-pollution invariant — the
2617
+ * real `query` node already exists in the index once `.sql` is
2618
+ * processed, and creating stubs would compete with the binding).
2619
+ */
2620
+ scanDoCmdOpenQuery(line, caller, lineNum) {
2621
+ const localRe = new RegExp(VbaExtractor.OPEN_QUERY_ARG_RE.source, VbaExtractor.OPEN_QUERY_ARG_RE.flags);
2622
+ let m;
2623
+ while ((m = localRe.exec(line)) !== null) {
2624
+ const rawArg = (m[1] ?? '').trim();
2625
+ // Issue #52: same per-proc-with-module-fallback lookup as
2626
+ // `scanDoCmdOpenCalls` — proc-local consts resolve to their own
2627
+ // values, so a `DoCmd.OpenQuery LOCAL_QUERY` inside `Sub X()`
2628
+ // points at the correct query even when another proc declares a
2629
+ // different `LOCAL_QUERY` (pre-fix this was whichever wrote
2630
+ // the file-wide map last).
2631
+ const targetName = rawArg.startsWith('"')
2632
+ ? unwrapVbaStringLiteral(rawArg)
2633
+ : (this.resolveLocalConst(rawArg) ?? rawArg);
2634
+ if (!targetName)
2635
+ continue;
2636
+ this.unresolvedReferences.push({
2637
+ fromNodeId: this.findOrCreateFunctionNodeId(caller),
2638
+ referenceName: targetName,
2639
+ referenceKind: 'references',
2640
+ line: lineNum,
2641
+ column: m.index,
2642
+ filePath: this.filePath,
2643
+ language: 'vba',
2644
+ metadata: { synthesizedBy: 'vba-opens-query' },
2645
+ });
2646
+ }
2647
+ }
2648
+ /**
2649
+ * Regex matching the chained `& "..."` literals that may follow a
2650
+ * wrapper's first literal on the same physical line. Captures the
2651
+ * literal CONTENT (group 1); the surrounding `&` and quotes are
2652
+ * structural, not data. VBA allows whitespace around `&` and around
2653
+ * the inner quotes — handled with `\s*`. The `((?:[^"]|"")*)` body
2654
+ * mirrors the wrapper regex so a `""` inside a chained literal still
2655
+ * decodes to a single `"`.
2656
+ *
2657
+ * Cross-physical-line concat via `_` continuation is OUT OF SCOPE for
2658
+ * v1 (deferred; see commit message).
2659
+ */
2660
+ static SQL_WRAPPER_CHAIN_RE = /&\s*"((?:[^"]|"")*)"/g;
2661
+ /**
2662
+ * Given the text that follows a SQL wrapper's first literal on the same
2663
+ * physical line, return the contents of every `& "..."` chained literal
2664
+ * in source order. Operates per-physical-line only — VBA `_` line
2665
+ * continuation across physical lines is handled separately by
2666
+ * `collectStringLiteralText` for the variable-assignment path.
2667
+ */
2668
+ collectSqlWrapperChain(rest) {
2669
+ const out = [];
2670
+ const re = new RegExp(VbaExtractor.SQL_WRAPPER_CHAIN_RE.source, VbaExtractor.SQL_WRAPPER_CHAIN_RE.flags);
2671
+ let m;
2672
+ while ((m = re.exec(rest)) !== null) {
2673
+ out.push(m[1] ?? '');
2674
+ }
2675
+ return out;
2676
+ }
1815
2677
  scanSqlInLine(line, lineNum, dedupe, sqlVariables) {
1816
2678
  for (const { re } of VbaExtractor.SQL_WRAPPERS) {
1817
2679
  // Each wrapper regex is stateful (has /g); reset before use.
1818
2680
  const localRe = new RegExp(re.source, re.flags);
1819
2681
  let m;
1820
2682
  while ((m = localRe.exec(line)) !== null) {
1821
- this.emitSqlTableReferences(m[1] ?? '', lineNum, dedupe);
2683
+ const firstLiteral = m[1] ?? '';
2684
+ // After the wrapper regex consumes up to and including the closing
2685
+ // `"` of the first literal, walk the rest of the line for any
2686
+ // `& "..."` chains and concatenate every literal's content. Joining
2687
+ // with a space (mirrors `collectStringLiteralText`) keeps adjacent
2688
+ // `FROM tblA` & `FROM tblB` separated so `SQL_TABLE_RE` finds both.
2689
+ const rest = line.slice(m.index + m[0].length);
2690
+ const chain = this.collectSqlWrapperChain(rest);
2691
+ const joined = [firstLiteral, ...chain].join(' ');
2692
+ this.emitSqlTableReferences(joined, lineNum, dedupe);
1822
2693
  }
1823
2694
  }
1824
2695
  const localRe = new RegExp(VbaExtractor.SQL_VAR_EXEC_RE.source, VbaExtractor.SQL_VAR_EXEC_RE.flags);
@@ -1830,6 +2701,23 @@ class VbaExtractor {
1830
2701
  continue;
1831
2702
  this.emitSqlTableReferences(sqlString, lineNum, dedupe);
1832
2703
  }
2704
+ // Issue #42: `DoCmd.RunSQL <identifier>` (variable form). Mirrors the
2705
+ // SQL_VAR_EXEC_RE path above but for the Access-style `DoCmd.RunSQL`
2706
+ // idiom — the dominant pattern in real-world VBA modules. Resolve the
2707
+ // captured identifier against `sqlVariables` (populated by
2708
+ // `trackSqlVariableAssignment` with `&`-accumulate semantics, Issue
2709
+ // #13) and feed the resolved SQL string into `emitSqlTableReferences`.
2710
+ // Unresolved identifiers (no row in the map) are silently skipped —
2711
+ // same graceful-no-op contract as SQL_VAR_EXEC_RE.
2712
+ const docmdLocalRe = new RegExp(VbaExtractor.SQL_VAR_DOCMD_RUNSQL_RE.source, VbaExtractor.SQL_VAR_DOCMD_RUNSQL_RE.flags);
2713
+ let dm;
2714
+ while ((dm = docmdLocalRe.exec(line)) !== null) {
2715
+ const varName = (dm[1] ?? '').toLowerCase();
2716
+ const sqlString = sqlVariables.get(varName);
2717
+ if (!sqlString)
2718
+ continue;
2719
+ this.emitSqlTableReferences(sqlString, lineNum, dedupe);
2720
+ }
1833
2721
  }
1834
2722
  /**
1835
2723
  * #13 fix: `sql = sql & "..."` (self-referential concatenation) must
@@ -1938,14 +2826,207 @@ class VbaExtractor {
1938
2826
  }
1939
2827
  synthClassNodeIds = new Set();
1940
2828
  /**
1941
- * Fix 2 (Issue #2): maps `variableName.toLowerCase()` declared type info.
1942
- * Built by `sweepDimsAndWithEvents`; consulted by `sweepCallsAndSql` to gate
1943
- * qualified statement-form calls only receivers that are file-local variables
1944
- * typed as a SIMPLE (non-qualified, non-primitive) identifier emit edges.
2829
+ * Issue #50: TempVars placeholder-node de-dup cache. Keys are the
2830
+ * deterministic node ids produced by `emitTempVarReference` those
2831
+ * ids intentionally ignore `this.filePath` (use a synthetic
2832
+ * `synthetic:tempvar/<key>` path instead) so the SAME key referenced
2833
+ * from Form_A.cls AND Form_B.cls collapses to ONE placeholder node.
2834
+ * Cross-file id stability is the cross-form state premise that makes
2835
+ * `codegraph_explore` connect producer ⇄ consumer in one hop.
2836
+ */
2837
+ synthTempVarNodeIds = new Set();
2838
+ /**
2839
+ * Issue #50: emit one TempVar reading/writing site. Per call:
2840
+ * - placeholder `class` node keyed on the synthetic
2841
+ * `synthetic:tempvar/<key>` file path so cross-file extraction
2842
+ * calls collapse to one node per key,
2843
+ * - `references` edge from the calling `function` node
2844
+ * (via `findOrCreateFunctionNodeId` — same access pattern as
2845
+ * `scanDoCmdOpenCalls` / `scanDoCmdOpenQuery`) carrying
2846
+ * `metadata.synthesizedBy: 'vba-tempvar'` AND
2847
+ * `metadata.access: 'read' | 'write'`.
2848
+ *
2849
+ * Reuses the synthetic-`class`-placeholder shape established by
2850
+ * `emitReference` for SQL tables, events, Dim types, etc. — so every
2851
+ * synthesized `references` edge in the file already carries the same
2852
+ * `kind: 'class'` target, and downstream consumers (UI, resolvers,
2853
+ * search queries) filter on `metadata.synthesizedBy` rather than
2854
+ * NodeKind anyway. The `metadata.synthesizedBy` tag cleanly distinguishes
2855
+ * TempVars refs from SQL-table refs inside the same NodeKind bucket.
2856
+ *
2857
+ * Skips when stack is empty (the writer/reader is module-level code
2858
+ * — REQ-CODE-4 "unresolvable/runtime reference is silent"). The
2859
+ * spec wires this per-proc only; the module-level shape would need
2860
+ * a different emission (no procedure source) and is out of scope.
2861
+ */
2862
+ emitTempVarReference(caller, key, lineNum, column, access) {
2863
+ if (!caller)
2864
+ return;
2865
+ if (!key)
2866
+ return;
2867
+ const syntheticFilePath = `synthetic:tempvar/${key}`;
2868
+ const targetId = (0, tree_sitter_helpers_1.generateNodeId)(syntheticFilePath, 'class', // placeholder kind — see SQL_TABLE_RE emitReference for precedent
2869
+ key, 0);
2870
+ if (!this.synthTempVarNodeIds.has(targetId)) {
2871
+ this.synthTempVarNodeIds.add(targetId);
2872
+ this.nodes.push({
2873
+ id: targetId,
2874
+ kind: 'class',
2875
+ name: key,
2876
+ qualifiedName: key,
2877
+ filePath: syntheticFilePath,
2878
+ language: 'vba',
2879
+ startLine: lineNum,
2880
+ endLine: lineNum,
2881
+ startColumn: column,
2882
+ endColumn: column + key.length,
2883
+ updatedAt: Date.now(),
2884
+ });
2885
+ }
2886
+ this.edges.push({
2887
+ source: this.findOrCreateFunctionNodeId(caller),
2888
+ target: targetId,
2889
+ kind: 'references',
2890
+ provenance: 'heuristic',
2891
+ metadata: {
2892
+ synthesizedBy: 'vba-tempvar',
2893
+ // User-facing enum: lowercase single-token string values.
2894
+ access,
2895
+ },
2896
+ line: lineNum,
2897
+ column,
2898
+ });
2899
+ }
2900
+ /**
2901
+ * Issue #50: scan one line of VBA source for TempVars access sites and
2902
+ * emit one `references` edge per site. Three regex runs:
2903
+ * - bang form `TempVars!x` over the MASKED line (`!` itself never
2904
+ * lives inside a string literal, so masked == unmasked here — using
2905
+ * the masked line is conservative against false positives in
2906
+ * concatenated string content),
2907
+ * - paren form `TempVars("x")` over the ORIGINAL line (the literal is
2908
+ * inside a string — masked would strip the key),
2909
+ * - Add form `TempVars.Add "x", v` over the ORIGINAL line (same reason),
2910
+ * always classified as a write.
2911
+ *
2912
+ * Each match classifies access by looking at the LINE SUFFIX (after the
2913
+ * closing paren / bang-key) on the SAME line: if `=` (and not the
2914
+ * nonexistent `==`) is the next non-whitespace character, it's a write.
2915
+ * VBA has no `==` so a bare `=` suffix check is safe.
2916
+ *
2917
+ * The caller parameter comes from `stack[stack.length - 1]` in
2918
+ * `sweepCallsAndSql`. Pass `undefined` for module-level access — we
2919
+ * drop the edge (per REQ-CODE-4 spirit, runtime references have no
2920
+ * static source to anchor against).
2921
+ */
2922
+ sweepTempVars(maskedLine, originalLine, lineNum, caller) {
2923
+ // 1) Bang form — masked line. No string-literal interaction.
2924
+ const bangRe = new RegExp(VbaExtractor.TEMP_VAR_BANG_RE.source, VbaExtractor.TEMP_VAR_BANG_RE.flags);
2925
+ let bm;
2926
+ while ((bm = bangRe.exec(maskedLine)) !== null) {
2927
+ const key = bm[1] ?? '';
2928
+ if (!key)
2929
+ continue;
2930
+ const access = detectAssignmentSuffix(maskedLine, bm.index + bm[0].length)
2931
+ ? 'write'
2932
+ : 'read';
2933
+ this.emitTempVarReference(caller, key, lineNum, bm.index, access);
2934
+ }
2935
+ // 2) Paren form — original line. The literal survives only here.
2936
+ const parenRe = new RegExp(VbaExtractor.TEMP_VAR_PAREN_RE.source, VbaExtractor.TEMP_VAR_PAREN_RE.flags);
2937
+ let pm;
2938
+ while ((pm = parenRe.exec(originalLine)) !== null) {
2939
+ const key = pm[1] ?? '';
2940
+ if (!key)
2941
+ continue;
2942
+ const access = detectAssignmentSuffix(originalLine, pm.index + pm[0].length)
2943
+ ? 'write'
2944
+ : 'read';
2945
+ this.emitTempVarReference(caller, key, lineNum, pm.index, access);
2946
+ }
2947
+ // 3) Add form — original line. Always a write.
2948
+ const addRe = new RegExp(VbaExtractor.TEMP_VAR_ADD_RE.source, VbaExtractor.TEMP_VAR_ADD_RE.flags);
2949
+ let am;
2950
+ while ((am = addRe.exec(originalLine)) !== null) {
2951
+ const key = am[1] ?? '';
2952
+ if (!key)
2953
+ continue;
2954
+ this.emitTempVarReference(caller, key, lineNum, am.index, 'write');
2955
+ }
2956
+ }
2957
+ /**
2958
+ * Maps `variableName.toLowerCase()` → declared type info.
2959
+ * Built by `sweepDimsAndWithEvents`; consulted by the unified qualified-call
2960
+ * gate (`shouldProcessQualifiedCall`) so declared project-class locals emit
2961
+ * edges, declared primitive/external locals stay silent, and undeclared
2962
+ * receivers remain module-name candidates for the resolver (Fix 2 / Issue #2).
1945
2963
  */
1946
2964
  localVarTypeMap = new Map();
1947
- /** Local constant name (lowercase) → simple literal value for OpenForm resolution. */
2965
+ /**
2966
+ * Issue #52: Const resolution buckets, scoped per procedure. Key is
2967
+ * `'module'` for module-level Consts, or the procedure's `startLine`
2968
+ * (stringified) for proc-local Consts. Each bucket maps the lowercase
2969
+ * constant name to its simple-literal value (used by `DoCmd.OpenForm` /
2970
+ * `OpenReport` / `OpenQuery` argument resolution via `resolveLocalConst`).
2971
+ *
2972
+ * Two procs declaring the same Const name with different values stay
2973
+ * isolated (each in its own bucket); reads look up the current proc's
2974
+ * bucket first and fall back to the module bucket. The bucket-per-proc
2975
+ * model was chosen over a single file-wide Map (the pre-fix shape) so
2976
+ * `DoCmd.OpenForm FORM_DESTINO` resolves to the proc-local value, not
2977
+ * whichever was written last.
2978
+ */
1948
2979
  localConstants = new Map();
2980
+ /**
2981
+ * Issue #52: shared lookup helper for `scanDoCmdOpenCalls` and
2982
+ * `scanDoCmdOpenQuery`. The current scope is the procedure whose
2983
+ * `startLine` is on top of `procStack` (or `'module'` when the stack is
2984
+ * empty). Per-proc bucket first; module bucket is the fallback.
2985
+ */
2986
+ resolveLocalConst(name) {
2987
+ const lower = name.toLowerCase();
2988
+ const procBucket = this.localConstants.get(this.currentProcKey);
2989
+ if (procBucket) {
2990
+ const v = procBucket.get(lower);
2991
+ if (v !== undefined)
2992
+ return v;
2993
+ }
2994
+ const moduleBucket = this.localConstants.get('module');
2995
+ return moduleBucket?.get(lower);
2996
+ }
2997
+ /**
2998
+ * Issue #52: shared writer. `scopeKey` is `'module'` or the procedure's
2999
+ * startLine-as-string. Creates the bucket lazily so callers do not have
3000
+ * to pre-allocate per proc. Returns the bucket the value was written to
3001
+ * (mostly useful for tests; production code ignores it).
3002
+ */
3003
+ setLocalConstInScope(scopeKey, name, value) {
3004
+ let bucket = this.localConstants.get(scopeKey);
3005
+ if (!bucket) {
3006
+ bucket = new Map();
3007
+ this.localConstants.set(scopeKey, bucket);
3008
+ }
3009
+ bucket.set(name.toLowerCase(), value);
3010
+ return bucket;
3011
+ }
3012
+ /**
3013
+ * Issue #52: the current Const-lookup scope. `'module'` when no procedure
3014
+ * is open, otherwise the top-of-stack proc's `startLine` as a string.
3015
+ * Both `sweepEnumsAndConsts` (to decide whether to emit a `constant`
3016
+ * node) and `sweepCallsAndSql` (to drive OpenForm/OpenQuery resolution)
3017
+ * keep this in sync with their per-line stack walk by pushing/popping
3018
+ * `procStack` and writing the new top's key here.
3019
+ */
3020
+ currentProcKey = 'module';
3021
+ /**
3022
+ * Issue #52: per-extraction proc-stack shared between `sweepEnumsAndConsts`
3023
+ * and `sweepCallsAndSql`. Each sweep clears it at the start so the file's
3024
+ * mid-proc structural state never leaks across sweeps. Holds the
3025
+ * `startLine` (1-based, matches `ProcInfo.startLine`) of every procedure
3026
+ * whose body the sweep has not yet emitted `End Sub`/`End Function`/
3027
+ * `End Property` for.
3028
+ */
3029
+ procStack = [];
1949
3030
  /** Local event name (lowercase) → event node for `RaiseEvent` edge emission. */
1950
3031
  localEvents = new Map();
1951
3032
  }
@@ -2002,6 +3083,37 @@ function splitOutsideVbaStrings(value, separator) {
2002
3083
  parts.push(current);
2003
3084
  return parts;
2004
3085
  }
3086
+ /**
3087
+ * Issue #50 helper: return true iff `line.charAt(fromIndex..)` (after the
3088
+ * matched TempVars site, including any trailing whitespace) holds an `=`
3089
+ * (write-assignment) and not a `==` (VBA has no `==` operator, so a bare
3090
+ * check for `=` is safe). Used by `sweepTempVars` to classify a `TempVars`
3091
+ * access as read vs write from the local line context.
3092
+ *
3093
+ * We skip over trailing whitespace only — `.`, `(`, `<`, `>`, `*`, `+`
3094
+ * etc. all mean "this isn't an assignment target", and bare `=`
3095
+ * is the only access-suffix shape VBA syntax allows here. A line like
3096
+ * `TempVars!x = 1` (write) or `Debug.Print TempVars!x` (read) land in
3097
+ * the right bucket without further analysis.
3098
+ *
3099
+ * Strings should already be masked out of `line` (`maskStringContent`)
3100
+ * when this is called for the bang form (no string in scope anyway);
3101
+ * the paren form passes the ORIGINAL line — but for THAT form the
3102
+ * captured match ends at the closing `"` of the literal, so any `=`
3103
+ * that follows is unambiguously outside the string.
3104
+ */
3105
+ function detectAssignmentSuffix(line, fromIndex) {
3106
+ let i = fromIndex;
3107
+ while (i < line.length) {
3108
+ const ch = line[i];
3109
+ if (ch === ' ' || ch === '\t') {
3110
+ i++;
3111
+ continue;
3112
+ }
3113
+ return ch === '=';
3114
+ }
3115
+ return false;
3116
+ }
2005
3117
  function unwrapVbaStringLiteral(raw) {
2006
3118
  const trimmed = raw.trim();
2007
3119
  if (!trimmed.startsWith('"'))