@aroman22/codegraph-vba-darwin-arm64 1.3.5 → 1.5.0

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 (39) 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 +575 -55
  9. package/lib/dist/extraction/vba-extractor.d.ts.map +1 -1
  10. package/lib/dist/extraction/vba-extractor.js +1233 -135
  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/dist/utils/backtrace-helpers.d.ts +35 -0
  29. package/lib/dist/utils/backtrace-helpers.d.ts.map +1 -0
  30. package/lib/dist/utils/backtrace-helpers.js +129 -0
  31. package/lib/dist/utils/backtrace-helpers.js.map +1 -0
  32. package/lib/dist/utils/sql-impact-helpers.d.ts +38 -0
  33. package/lib/dist/utils/sql-impact-helpers.d.ts.map +1 -0
  34. package/lib/dist/utils/sql-impact-helpers.js +329 -0
  35. package/lib/dist/utils/sql-impact-helpers.js.map +1 -0
  36. package/lib/node_modules/.modules.yaml +2 -2
  37. package/lib/node_modules/.pnpm-workspace-state-v1.json +1 -1
  38. package/lib/package.json +2 -2
  39. 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,
@@ -554,6 +570,7 @@ class VbaExtractor {
554
570
  id: eventId,
555
571
  kind: 'event',
556
572
  name,
573
+ signature: line.trim(),
557
574
  qualifiedName: this.classNamePrefix ? `${this.classNamePrefix}.${name}` : name,
558
575
  filePath: this.filePath,
559
576
  language: 'vba',
@@ -620,6 +637,7 @@ class VbaExtractor {
620
637
  endColumn: line.length,
621
638
  visibility,
622
639
  metadata: {
640
+ isDeclare: true,
623
641
  dll,
624
642
  declareKind,
625
643
  ptrSafe,
@@ -693,17 +711,56 @@ class VbaExtractor {
693
711
  * DIM_UNQUAL_RE pair with a prefix-check + global scan that handles
694
712
  * `As New <Type>`, multi-variable `Dim a As Foo, b As Bar`, and all
695
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`.
696
719
  */
697
- 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;
698
721
  /**
699
722
  * Globally scan all `identifier As [New] TypePart1[.TypePart2]` on a
700
723
  * variable declaration line. Run with /g after confirming DIM_DECL_PREFIX_RE.
701
724
  *
702
- * 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.
703
732
  * `(?:New\s+)?` consumes the VBA auto-instantiation keyword so it is
704
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`).
705
762
  */
706
- 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;
707
764
  /**
708
765
  * VBA primitive type names — skipped when emitted as Dim targets so
709
766
  * we don't pollute the graph with `As Long` / `As String` references.
@@ -717,8 +774,8 @@ class VbaExtractor {
717
774
  'string', 'boolean', 'date', 'variant', 'object', 'error',
718
775
  'empty', 'null', 'longptr', 'longlong', 'new',
719
776
  ]);
720
- /** `WithEvents m_X As Form_Foo` — Dim/Private/Public prefix is optional. */
721
- 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;
722
779
  sweepDimsAndWithEvents(src) {
723
780
  const lines = src.split('\n');
724
781
  let count = 0;
@@ -734,8 +791,14 @@ class VbaExtractor {
734
791
  let m;
735
792
  while ((m = VbaExtractor.DIM_ALL_VARS_RE.exec(line)) !== null) {
736
793
  const varName = m[1] ?? '';
737
- const outerType = m[2] ?? '';
738
- 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] ?? '';
739
802
  // Fix 2 (Issue #2): populate the local var type map so that
740
803
  // `sweepCallsAndSql` can gate qualified statement-form calls.
741
804
  if (varName && outerType) {
@@ -761,6 +824,37 @@ class VbaExtractor {
761
824
  }
762
825
  }
763
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
+ }
764
858
  }
765
859
  // WithEvents declarations — handled by their own regex; also populate
766
860
  // the local var type map for completeness.
@@ -769,7 +863,7 @@ class VbaExtractor {
769
863
  const formType = weMatch[1] ?? '';
770
864
  if (formType) {
771
865
  // Extract the variable name from the WithEvents line for the map.
772
- 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);
773
867
  const weVarName = weVarM?.[1] ?? '';
774
868
  if (weVarName) {
775
869
  this.localVarTypeMap.set(weVarName.toLowerCase(), {
@@ -816,6 +910,15 @@ class VbaExtractor {
816
910
  static ENUM_MEMBER_RE = /^\s*(\p{L}[\p{L}\p{N}_]*)\s*(?:=|$)/u;
817
911
  /** `[visibility] Const <decls>` — captures visibility (1) and the rest (2). */
818
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;
819
922
  /**
820
923
  * Fold a VBA visibility keyword to the canonical lowercase enum, matching
821
924
  * the procedure convention: `Private` → 'private'; `Public`, `Global`,
@@ -842,9 +945,37 @@ class VbaExtractor {
842
945
  const lines = src.split('\n');
843
946
  let count = 0;
844
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';
845
956
  for (let i = 0; i < lines.length; i++) {
846
957
  const line = lines[i] ?? '';
847
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
+ }
848
979
  if (currentEnum) {
849
980
  if (VbaExtractor.ENUM_END_RE.test(line)) {
850
981
  currentEnum = null;
@@ -913,9 +1044,20 @@ class VbaExtractor {
913
1044
  const constName = declaration.name;
914
1045
  if (!constName)
915
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.
916
1056
  if (declaration.value !== null) {
917
- this.localConstants.set(constName.toLowerCase(), declaration.value);
1057
+ this.setLocalConstInScope(this.currentProcKey, constName, declaration.value);
918
1058
  }
1059
+ if (this.procStack.length > 0)
1060
+ continue;
919
1061
  const constId = (0, tree_sitter_helpers_1.generateNodeId)(this.filePath, 'constant', constName, lineNum);
920
1062
  this.nodes.push({
921
1063
  id: constId,
@@ -965,11 +1107,20 @@ class VbaExtractor {
965
1107
  * - While inside a procedure, scan the line for call-site patterns and
966
1108
  * SQL-wrapper patterns.
967
1109
  *
968
- * Call-site regex: `(?<!\w)([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\s*\(`
969
- * captures either `Name(...)` (same-file candidate) or `Receiver.Member(...)`
970
- * (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.
971
1122
  */
972
- 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;
973
1124
  /** SQL wrapper helpers — order matters because `db.Execute` is a suffix of others. */
974
1125
  static SQL_WRAPPERS = [
975
1126
  { name: 'DoCmd.RunSQL', re: /\bDoCmd\.RunSQL\s+"((?:[^"]|"")*)"/g },
@@ -1002,37 +1153,279 @@ class VbaExtractor {
1002
1153
  * the `opens-form` edge instead — sharing no logic with CALL_RE.
1003
1154
  */
1004
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;
1005
1224
  /** SQL assigned to a local variable, e.g. `m_SQL = "SELECT ..." & ...`. */
1006
1225
  static SQL_VAR_ASSIGN_RE = /^\s*(\p{L}[\p{L}\p{N}_]*)\s*=\s*(.*)$/iu;
1007
1226
  /** SQL wrapper called with a variable, e.g. `getdb().Execute m_SQL`. */
1008
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;
1009
- /** SQL table-name regex scoped to FROM / INTO / UPDATE. */
1010
- static SQL_TABLE_RE = /\b(?:FROM|INTO|UPDATE)\s+(\[?\p{L}[\p{L}\p{N}_]*\]?)/giu;
1011
1228
  /**
1012
- * `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.
1258
+ *
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).
1013
1322
  *
1014
- * Real VBA idiom:
1015
- * `Me.lblTitulo.Caption = "Hello"` ← property assignment
1016
- * `Me.txtDescripcion.Value = "World"` ← property assignment
1017
- * `Me.ComandoGrabar.Enabled = True` ← property assignment
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
1018
1329
  * `If Nz(Me.MotivoBorrado, "") = "" Then` ← read in expression
1019
1330
  *
1020
1331
  * The existing call-site scanner (CALL_RE) only fires on `Name(`
1021
1332
  * (paren form) and `Me` is in its keyword blacklist anyway, so
1022
1333
  * `Me.<Control>` references are silently invisible. This regex matches
1023
- * the FIRST identifier after `Me.` regardless of what follows (a
1024
- * 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
1025
1336
  * form → control binding is surfaced as an UnresolvedReference for the
1026
1337
  * resolver to pick up later. Subsequent segments (`.Caption`, `.Value`,
1027
1338
  * `.Enabled`) are intentionally NOT captured — they are properties of
1028
- * 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.
1029
1344
  *
1030
1345
  * Provenance: `metadata.synthesizedBy = 'vba-me-control'`. Mirrors the
1031
1346
  * `vba-form-binding` (form→sibling-`.cls`) and `vba-name-resolution`
1032
1347
  * (qualified Dim) patterns already documented on `UnresolvedReference.metadata`
1033
1348
  * in `src/types.ts`.
1034
1349
  */
1035
- 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;
1036
1429
  /** Keywords we never want to match as call receivers. */
1037
1430
  static CALL_KEYWORD_BLACKLIST = new Set([
1038
1431
  'If',
@@ -1156,9 +1549,20 @@ class VbaExtractor {
1156
1549
  * qualified, non-primitive) identifier — a candidate project-defined class.
1157
1550
  * Qualified types (e.g. `DAO.Recordset`) and primitives (`String`, `Long`)
1158
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.
1159
1561
  */
1160
1562
  isLocalProjectClassVar(receiverName) {
1161
- 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);
1162
1566
  if (!entry)
1163
1567
  return false; // not declared in this file → silent
1164
1568
  if (entry.qualified)
@@ -1181,28 +1585,88 @@ class VbaExtractor {
1181
1585
  */
1182
1586
  resolveReceiverType(receiverName) {
1183
1587
  if (this.isLocalProjectClassVar(receiverName)) {
1184
- const entry = this.localVarTypeMap.get(receiverName.toLowerCase());
1588
+ const key = receiverName.replace(/^\[|\]$/g, '').toLowerCase();
1589
+ const entry = this.localVarTypeMap.get(key);
1185
1590
  if (entry)
1186
1591
  return entry.outer;
1187
1592
  }
1188
1593
  return receiverName;
1189
1594
  }
1595
+ normalizeWithReceiver(expr) {
1596
+ let receiver = expr.trim();
1597
+ if (!receiver)
1598
+ return null;
1599
+ if (/^Call\s/i.test(receiver))
1600
+ receiver = receiver.replace(/^Call\s+/i, '').trimStart();
1601
+ if (receiver.startsWith('[')) {
1602
+ const m = /^\[([^\]]+)\]/u.exec(receiver);
1603
+ if (!m)
1604
+ return null;
1605
+ receiver = m[1] ?? '';
1606
+ }
1607
+ else {
1608
+ const m = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(receiver);
1609
+ if (!m)
1610
+ return null;
1611
+ receiver = m[1] ?? '';
1612
+ }
1613
+ if (!receiver)
1614
+ return null;
1615
+ if (VbaExtractor.CALL_KEYWORD_BLACKLIST.has(receiver))
1616
+ return null;
1617
+ if (VbaExtractor.RUNTIME_RECEIVER_BLACKLIST.has(receiver))
1618
+ return null;
1619
+ return receiver;
1620
+ }
1621
+ detectWithMemberCall(line) {
1622
+ let trimmed = line.trimStart();
1623
+ if (!trimmed)
1624
+ return null;
1625
+ if (/^Call\s/i.test(trimmed))
1626
+ trimmed = trimmed.replace(/^Call\s+/i, '').trimStart();
1627
+ if (trimmed.startsWith("'") || /^Rem(\s|$)/i.test(trimmed))
1628
+ return null;
1629
+ if (!trimmed.startsWith('.'))
1630
+ return null;
1631
+ const memberRest = trimmed.slice(1);
1632
+ const memberM = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(memberRest);
1633
+ if (!memberM)
1634
+ return null;
1635
+ const member = memberM[1] ?? '';
1636
+ const afterMember = memberRest.slice(member.length);
1637
+ if (afterMember.length > 0) {
1638
+ const ch = afterMember.charAt(0);
1639
+ if (ch !== '(' && ch !== ' ' && ch !== '\t')
1640
+ return null;
1641
+ const argsText = afterMember.trimStart();
1642
+ if (argsText.startsWith('='))
1643
+ return null;
1644
+ }
1645
+ if (VbaExtractor.CALL_KEYWORD_BLACKLIST.has(member))
1646
+ return null;
1647
+ if (VbaExtractor.RUNTIME_RECEIVER_BLACKLIST.has(member))
1648
+ return null;
1649
+ return { member };
1650
+ }
1190
1651
  sweepCallsAndSql(src) {
1191
1652
  const lines = src.split('\n');
1192
1653
  const procedureStartLines = new Set();
1193
- // S5 fix: also match `End Sub`/`End Function`/`End Property` after a
1194
- // colon (`:`), so single-line `Public Sub X(): End Sub` is recognized
1195
- // as ending the procedure. The previous `/^\s*End...` only matched at
1196
- // line start, so the proc stack never popped for colon-separated
1197
- // single-line declarations.
1198
- const procedureEndRe = /(?:^|:\s*)End\s+(?:Sub|Function|Property)\b/i;
1199
1654
  const sqlTargetsThisFile = new Set();
1655
+ // Issue #52: reset the shared scope state before the per-line walk
1656
+ // begins. `sweepEnumsAndConsts` already populated `procStack` /
1657
+ // `currentProcKey` during its own walk; clearing here guarantees the
1658
+ // `scanDoCmdOpenCalls` / `scanDoCmdOpenQuery` reads (which consult
1659
+ // `currentProcKey` per call-site) start in module scope and follow
1660
+ // the same push/pop discipline as the existing `stack` array below.
1661
+ this.procStack.length = 0;
1662
+ this.currentProcKey = 'module';
1200
1663
  // Walk the source once, emitting call edges and SQL edges per line and
1201
1664
  // tracking the current procedure stack. The previous implementation
1202
1665
  // did this in two passes; audit S1 (June 2026) flagged the first pass
1203
1666
  // as dead code (its `procStack` was never read after the loop). One
1204
1667
  // pass suffices.
1205
1668
  const stack = [];
1669
+ const withReceiverStack = [];
1206
1670
  const sqlVariables = new Map();
1207
1671
  // C2 fix: track each procedure's `endLine` (the line containing the
1208
1672
  // matching `End Sub`/`End Function`/`End Property`) keyed by its
@@ -1215,6 +1679,14 @@ class VbaExtractor {
1215
1679
  const lineNum = i + 1;
1216
1680
  const procStart = VbaExtractor.PROC_RE.exec(line);
1217
1681
  if (procStart) {
1682
+ // Issue #52: mirror the proc push into the shared
1683
+ // `procStack` + `currentProcKey` so Const reads in this same
1684
+ // sweep see the same scope as the const writes did (during
1685
+ // `sweepEnumsAndConsts`). Uses the same `startLine` key used
1686
+ // for the `localConstants` bucket.
1687
+ const procStartLine = lineNum;
1688
+ this.procStack.push(procStartLine);
1689
+ this.currentProcKey = String(procStartLine);
1218
1690
  const name = procStart[3] ?? '';
1219
1691
  const bucket = this.localProcs.get(name);
1220
1692
  if (bucket) {
@@ -1229,8 +1701,14 @@ class VbaExtractor {
1229
1701
  }
1230
1702
  procedureStartLines.add(lineNum);
1231
1703
  }
1232
- else if (procedureEndRe.test(line) && stack.length > 0) {
1704
+ else if (VbaExtractor.PROCEDURE_END_RE.test(line) && stack.length > 0) {
1233
1705
  const ending = stack.pop();
1706
+ // Issue #52: mirror the pop into the shared scope state.
1707
+ this.procStack.pop();
1708
+ this.currentProcKey =
1709
+ this.procStack.length > 0
1710
+ ? String(this.procStack[this.procStack.length - 1])
1711
+ : 'module';
1234
1712
  procEndLines.set(ending.startLine, lineNum);
1235
1713
  continue;
1236
1714
  }
@@ -1239,6 +1717,19 @@ class VbaExtractor {
1239
1717
  // mistakenly treated as call sites. SQL scanning still uses the original
1240
1718
  // line because SQL lives INSIDE string literals.
1241
1719
  const callScanLine = VbaExtractor.maskStringContent(line);
1720
+ if (stack.length > 0 && VbaExtractor.WITH_END_RE.test(callScanLine)) {
1721
+ withReceiverStack.pop();
1722
+ continue;
1723
+ }
1724
+ if (stack.length > 0 && !procedureStartLines.has(lineNum)) {
1725
+ const withStart = VbaExtractor.WITH_START_RE.exec(callScanLine);
1726
+ if (withStart) {
1727
+ const receiver = this.normalizeWithReceiver(withStart[1] ?? '');
1728
+ if (receiver)
1729
+ withReceiverStack.push(receiver);
1730
+ continue;
1731
+ }
1732
+ }
1242
1733
  // Don't scan call sites on the line that declares the procedure — it
1243
1734
  // would match the proc name itself in `Sub Outer()`.
1244
1735
  if (!procedureStartLines.has(lineNum) && stack.length > 0) {
@@ -1267,23 +1758,76 @@ class VbaExtractor {
1267
1758
  // Form_FormNCAuditoriaMotivoEliminado.cls fixture contributed
1268
1759
  // nothing to the call graph. Walked here so we share the proc stack
1269
1760
  // already maintained by this loop.
1761
+ //
1762
+ // Issue #45: the statement-call detector used to inspect only the
1763
+ // FIRST identifier of the line, so for `If x Then Foo` it returned
1764
+ // the keyword `If` (which is then dropped by
1765
+ // `emitStatementCallEdge`'s BLACKLIST check) — the actual `Foo` call
1766
+ // after `Then` was silently invisible. Same gap existed on the
1767
+ // `Else`/multi-statement (`:`) and qualified paths. The fix is to
1768
+ // split the line into one or more statement clauses via
1769
+ // `splitSingleLineIfClauses` (which handles `If … Then <body>`,
1770
+ // `Else <body>`, and colon-separated multi-statements) and run the
1771
+ // detectors per clause. Lines that don't match the single-line If
1772
+ // shape pass through unchanged as `[<line>]` so block-form `If`
1773
+ // (where the body lives on subsequent lines) keeps working through
1774
+ // the existing per-line scan that picks up the body line.
1270
1775
  if (stack.length > 0 && !procedureStartLines.has(lineNum)) {
1271
- const stmtCall = this.detectStatementCall(callScanLine);
1272
- if (stmtCall) {
1273
- const caller = stack[stack.length - 1];
1274
- this.emitStatementCallEdge(caller, stmtCall, lineNum);
1776
+ // Issue #46: `Set x = New <Type>[.<Inner>]` late-instantiation.
1777
+ // Run BEFORE the call-site scan so a later `<x>.Member ...` line
1778
+ // finds `x` already registered in `localVarTypeMap` and the PR #61
1779
+ // refined gate lets the qualified call resolve to `<Type>.Member`.
1780
+ const setNew = VbaExtractor.SET_NEW_RE.exec(callScanLine);
1781
+ if (setNew) {
1782
+ const varName = setNew[1] ?? '';
1783
+ const outerType = setNew[2] ?? '';
1784
+ const innerType = setNew[3] ?? '';
1785
+ if (varName && outerType) {
1786
+ // Skip primitives defensively — `Set x = New Long` is nonsense
1787
+ // in practice but the gate is cheap and consistent with the
1788
+ // Dim sweep's PRIMITIVE_TYPES guard.
1789
+ if (!VbaExtractor.PRIMITIVE_TYPES.has(outerType.toLowerCase())) {
1790
+ this.localVarTypeMap.set(varName.toLowerCase(), {
1791
+ outer: outerType,
1792
+ // Mirror `Dim x As Foo.Bar`: qualified `Set rs = New
1793
+ // DAO.Recordset` registers `qualified: true` so the PR #61
1794
+ // gate keeps downstream `rs.Method` calls silent (DAO is
1795
+ // a runtime / external library, not a project class).
1796
+ qualified: !!innerType,
1797
+ assignedWithSet: true,
1798
+ variableName: varName,
1799
+ });
1800
+ this.emitReference(outerType, lineNum, 0, 'vba-set-new');
1801
+ }
1802
+ }
1275
1803
  }
1276
- // Fix 7 + Fix 2: qualified statement-form calls (`Receiver.Member args`)
1277
- // the dominant cross-object call shape in real Dysflow fixtures.
1278
- // `CALL_RE` only matches the paren form; this path covers the no-paren
1279
- // statement form and emits a heuristic `calls` edge ONLY when the
1280
- // receiver is a file-local variable typed as a candidate project class
1281
- // (Fix 2: REQ-CODE-4 "unresolvable call is silent").
1282
- const qualStmt = this.detectQualifiedStatementCall(callScanLine);
1283
- if (qualStmt) {
1284
- const caller = stack[stack.length - 1];
1285
- if (this.isLocalProjectClassVar(qualStmt.receiver)) {
1286
- this.emitQualifiedStatementCallEdge(caller, qualStmt.receiver, qualStmt.member, lineNum);
1804
+ const clauseLines = this.splitSingleLineIfClauses(callScanLine);
1805
+ for (const clauseLine of clauseLines) {
1806
+ const stmtCall = this.detectStatementCall(clauseLine);
1807
+ if (stmtCall) {
1808
+ const caller = stack[stack.length - 1];
1809
+ this.emitStatementCallEdge(caller, stmtCall, lineNum);
1810
+ }
1811
+ // Fix 7 + Fix 2: qualified statement-form calls (`Receiver.Member args`)
1812
+ // the dominant cross-object call shape in real Dysflow fixtures.
1813
+ // `CALL_RE` only matches the paren form; this path covers the no-paren
1814
+ // statement form and emits a heuristic `calls` edge ONLY when the
1815
+ // receiver is a file-local variable typed as a candidate project class
1816
+ // (Fix 2: REQ-CODE-4 "unresolvable call is silent").
1817
+ const qualStmt = this.detectQualifiedStatementCall(clauseLine);
1818
+ if (qualStmt) {
1819
+ const caller = stack[stack.length - 1];
1820
+ if (this.isLocalProjectClassVar(qualStmt.receiver)) {
1821
+ this.emitQualifiedStatementCallEdge(caller, qualStmt.receiver, qualStmt.member, lineNum);
1822
+ }
1823
+ }
1824
+ const withReceiver = withReceiverStack[withReceiverStack.length - 1];
1825
+ if (withReceiver) {
1826
+ const withCall = this.detectWithMemberCall(clauseLine);
1827
+ if (withCall && this.isLocalProjectClassVar(withReceiver)) {
1828
+ const caller = stack[stack.length - 1];
1829
+ this.emitQualifiedStatementCallEdge(caller, withReceiver, withCall.member, lineNum);
1830
+ }
1287
1831
  }
1288
1832
  }
1289
1833
  // B4 (hueco 6): `DoCmd.OpenForm "FormName"` modelling.
@@ -1307,7 +1851,30 @@ class VbaExtractor {
1307
1851
  // node id (the same pattern the cross-file incoming-edges
1308
1852
  // snapshot already uses at `index.ts:getCrossFileIncomingEdges`).
1309
1853
  const caller2 = stack[stack.length - 1];
1310
- this.scanOpenFormCalls(line, caller2, lineNum);
1854
+ // Issue #48: shared OpenForm/OpenReport dispatch via
1855
+ // `scanDoCmdOpenCalls` (formerly `scanOpenFormCalls`, refactored to
1856
+ // iterate the `DOCMD_OPEN_DISPATCH` table — OpenForm behavior is
1857
+ // byte-identical to pre-#48). The OpenQuery scanner emits an
1858
+ // `UnresolvedReference` and stays separate from the dispatch since
1859
+ // its emission shape (no stub + edge) differs.
1860
+ this.scanDoCmdOpenCalls(line, caller2, lineNum);
1861
+ this.scanDoCmdOpenQuery(line, caller2, lineNum);
1862
+ // Issue #44: cross-form bang references (`Forms!X` / `Forms("X")!Y`).
1863
+ // Same line context as `scanDoCmdOpenCalls` — the form name lives in
1864
+ // a string literal in the paren form, so we MUST scan the unmasked
1865
+ // line. The scanner is independent of `scanCallSites` because
1866
+ // `Forms` is in `RUNTIME_RECEIVER_BLACKLIST` and would otherwise be
1867
+ // dropped by the generic path.
1868
+ this.scanFormsBang(line, caller2, lineNum);
1869
+ // Issue #50: cross-form TempVars key accesses.
1870
+ // Sibling of `scanFormsBang` — TempVars is Access's second global
1871
+ // cross-form state surface (alongside Forms/DoCmd.OpenForm). Each
1872
+ // static-literal key reference emits one `references` edge to a
1873
+ // synthetic `class` placeholder, tagged `synthesizedBy: 'vba-tempvar'`
1874
+ // and `access: 'read' | 'write'`. We need BOTH line sources: bang
1875
+ // form scans the masked line, paren + Add forms scan the original
1876
+ // (literal lives inside `"…"`).
1877
+ this.sweepTempVars(callScanLine, line, lineNum, caller2);
1311
1878
  }
1312
1879
  }
1313
1880
  // Apply endLine to every emitted function node keyed by its startLine.
@@ -1346,8 +1913,15 @@ class VbaExtractor {
1346
1913
  VbaExtractor.CALL_RE.lastIndex = 0;
1347
1914
  let m;
1348
1915
  while ((m = VbaExtractor.CALL_RE.exec(line)) !== null) {
1349
- const receiver = m[1] ?? '';
1350
- const member = m[2] ?? '';
1916
+ // Issue #54: CALL_RE groups (1)/(2) are alternative captures for the
1917
+ // receiver position, (3)/(4) for the member position. The bracketed
1918
+ // alternative wins when present; the captured value is already
1919
+ // unwrapped by the regex (it captures only the inner content, not
1920
+ // the surrounding `[...]`). Result: a `[FUNCIONES UTILES]` receiver
1921
+ // surfaces here as `FUNCIONES UTILES` so the downstream blacklist
1922
+ // checks and `resolveReceiverType` lookup see the bare identifier.
1923
+ const receiver = m[1] ?? m[2] ?? '';
1924
+ const member = m[3] ?? m[4] ?? '';
1351
1925
  if (!receiver)
1352
1926
  continue;
1353
1927
  // Skip VBA control-flow keywords.
@@ -1386,6 +1960,26 @@ class VbaExtractor {
1386
1960
  // `.cls` method's `${className}.${proc}` qualifiedName shape so the
1387
1961
  // #12b resolver can find it by exact match); otherwise it's the raw
1388
1962
  // `receiver` text unchanged (e.g. `.bas`-qualified module calls).
1963
+ //
1964
+ // Antigravity audit Task 3 (refined gate): if `receiver` is a
1965
+ // file-local variable declared as a PRIMITIVE (Variant, Object,
1966
+ // Empty, Null, LongPtr, LongLong, New, Long, String, ...), skip
1967
+ // emission. The previous behaviour emitted a heuristic `calls`
1968
+ // edge to a stub named `<receiver>.<member>` that no resolver
1969
+ // could ever repoint (Variant can hold anything, including
1970
+ // runtime singletons; the stub is dead-end graph pollution).
1971
+ //
1972
+ // This refined gate does NOT regress cross-module qualified
1973
+ // calls like `modUtils.Foo(1)` because `modUtils` is never
1974
+ // declared as a file-local variable and therefore is NOT in
1975
+ // `localVarTypeMap` — `localVarTypeMap.has(receiver.toLowerCase())`
1976
+ // returns false and the gate is skipped. The "stub emitted for
1977
+ // undeclared receivers → resolver repoints if a real module
1978
+ // exists" behaviour is preserved.
1979
+ const recvEntry = this.localVarTypeMap.get(receiver.toLowerCase());
1980
+ if (recvEntry && VbaExtractor.PRIMITIVE_TYPES.has(recvEntry.outer.toLowerCase())) {
1981
+ continue;
1982
+ }
1389
1983
  const receiverType = this.resolveReceiverType(receiver);
1390
1984
  const qualified = `${receiverType}.${member}`;
1391
1985
  // Avoid emitting duplicate edges for the same call (within a line).
@@ -1438,16 +2032,21 @@ class VbaExtractor {
1438
2032
  callDedupe = new Set();
1439
2033
  synthFunctionNodeIds = new Set();
1440
2034
  /**
1441
- * B4 (hueco 6): cache of stub `form-layout` node ids we've already emitted
1442
- * for a given target form name in this file. Avoids emitting duplicate
1443
- * stubs when `DoCmd.OpenForm "FormTest"` shows up N times across N calls.
1444
- * Keyed by the lowercased form name so `FormTest` / `formtest` collapse.
2035
+ * B4 (hueco 6) extended by Issue #48: cache of stub node ids we've already
2036
+ * emitted for a given (method, target name) pair in this file. Avoids
2037
+ * emitting duplicate stubs when `DoCmd.OpenForm "FormTest"` or
2038
+ * `DoCmd.OpenReport "InformeMensual"` shows up N times across N calls.
2039
+ * Keyed by `${cacheKey}:${lowerName}` so the OpenForm and OpenReport
2040
+ * de-dup buckets stay disjoint — `OpenForm:Form1` ≠ `OpenReport:Form1`.
2041
+ * The name part is lowercased so `FormTest` / `formtest` collapse.
1445
2042
  */
1446
- opensFormStubIdsByName = new Map();
2043
+ opensStubIdsByKey = new Map();
1447
2044
  /**
1448
- * Hueco 1: scan a line for `Me.<ControlName>` patterns and emit one
1449
- * UnresolvedReference per occurrence, tagged
1450
- * `metadata.synthesizedBy: 'vba-me-control'`.
2045
+ * Hueco 1: scan a line for `Me.<ControlName>` / `Me!<ControlName>`
2046
+ * patterns and emit one UnresolvedReference per occurrence, tagged
2047
+ * `metadata.synthesizedBy: 'vba-me-control'`. Issue #44 extended
2048
+ * `ME_CONTROL_RE` from `Me\.` to `Me[.!]` so the bang form (default-
2049
+ * collection shortcut) is captured byte-identically to the dot form.
1451
2050
  *
1452
2051
  * Operates on the masked `callScanLine` (string-literal content already
1453
2052
  * replaced with spaces) so `Me.X` inside a string literal is not falsely
@@ -1458,6 +2057,8 @@ class VbaExtractor {
1458
2057
  *
1459
2058
  * `fromNodeId` is the current procedure's function node — that's the
1460
2059
  * "owner" of the reference (the Sub body that wrote `Me.lblTitulo = …`).
2060
+ * The +3 column offset remains correct under Issue #44's regex change
2061
+ * because both `Me.` and `Me!` are 3-character prefixes.
1461
2062
  */
1462
2063
  scanMeControlReferences(line, from, lineNum) {
1463
2064
  VbaExtractor.ME_CONTROL_RE.lastIndex = 0;
@@ -1471,13 +2072,70 @@ class VbaExtractor {
1471
2072
  referenceName: controlName,
1472
2073
  referenceKind: 'references',
1473
2074
  line: lineNum,
1474
- column: m.index + 3, // +3 to skip the `Me.` prefix
2075
+ column: m.index + 3, // +3 to skip the `Me.` / `Me!` prefix
1475
2076
  filePath: this.filePath,
1476
2077
  language: 'vba',
1477
2078
  metadata: { synthesizedBy: 'vba-me-control' },
1478
2079
  });
1479
2080
  }
1480
2081
  }
2082
+ /**
2083
+ * Issue #44: scan a line for cross-form bang references
2084
+ * (`Forms!<FormName>[!<Ctl>]` and `Forms("<FormName>")!<Ctl>`) and
2085
+ * emit ONE UnresolvedReference per match with `metadata.synthesizedBy
2086
+ * = 'vba-forms-bang'`. Companion to `scanMeControlReferences` above;
2087
+ * shares the emission shape (a single `UnresolvedReference` per match,
2088
+ * `referenceKind: 'references'`), keeping the W4 invariant that we
2089
+ * synthesize NO `function` node for forms.
2090
+ *
2091
+ * Operates on the ORIGINAL (unmasked) line — the paren form
2092
+ * `Forms("FormX")!txtY` carries the form name INSIDE a string literal
2093
+ * and would be destroyed by `maskStringContent`. Mirrors
2094
+ * `scanOpenFormCalls`'s unmasked-line constraint.
2095
+ *
2096
+ * Regex alternatives (see `FORMS_BANG_RE`):
2097
+ * 1. `Forms!<FormName>` (bare or `[bracketed]`, NOT followed by `.X`)
2098
+ * — bang form, may have a trailing `!<Ctl>[.<Prop>]` that is
2099
+ * consumed but NOT emitted.
2100
+ * 2. `Forms("<FormName>")!<Ctl>` (quoted or bare/bracketed form arg)
2101
+ * — paren form, ALWAYS with a control segment.
2102
+ *
2103
+ * Stripping: the form's identifier is unwrapped of `"` quotes (string
2104
+ * literals) and `[…]` brackets (Issue #54 reserved-identifier shape) so
2105
+ * the public `referenceName` is the bare form name. Bracketed forms
2106
+ * (`Forms![Mi Formulario]`) follow the same strip rules as the paren
2107
+ * form so the resolver sees `Mi Formulario` either way.
2108
+ */
2109
+ scanFormsBang(line, from, lineNum) {
2110
+ VbaExtractor.FORMS_BANG_RE.lastIndex = 0;
2111
+ let m;
2112
+ while ((m = VbaExtractor.FORMS_BANG_RE.exec(line)) !== null) {
2113
+ // Group 1: bang form name (bare or `[bracketed]`); group 2: paren
2114
+ // form name in `"quotes"`; group 3: paren form name bare or
2115
+ // `[bracketed]`. Take whichever the regex alternative produced and
2116
+ // strip the surrounding decoration so the public referenceName is
2117
+ // the bare form identifier the resolver will compare against the
2118
+ // form's `form-layout` node name.
2119
+ const raw = m[1] ?? m[2] ?? m[3] ?? '';
2120
+ if (!raw)
2121
+ continue;
2122
+ const formName = raw
2123
+ .replace(/^"|"$/g, '') // strip surrounding string-literal quotes
2124
+ .replace(/^\[|\]$/g, ''); // strip surrounding bracket decoration (#54)
2125
+ if (!formName)
2126
+ continue;
2127
+ this.unresolvedReferences.push({
2128
+ fromNodeId: this.findOrCreateFunctionNodeId(from),
2129
+ referenceName: formName,
2130
+ referenceKind: 'references',
2131
+ line: lineNum,
2132
+ column: m.index, // start of the `Forms` keyword; the form name's column can be reconstructed by the UI if it cares
2133
+ filePath: this.filePath,
2134
+ language: 'vba',
2135
+ metadata: { synthesizedBy: 'vba-forms-bang' },
2136
+ });
2137
+ }
2138
+ }
1481
2139
  findOrCreateFunctionNodeId(proc) {
1482
2140
  // Fix 1: key the cache by `name:startLine` so Property Get/Let/Set
1483
2141
  // accessors with the same name each resolve to their own node.
@@ -1506,6 +2164,85 @@ class VbaExtractor {
1506
2164
  // meaningful on real .cls files with hundreds of procedures.
1507
2165
  return this.functionNodeByName.get(name);
1508
2166
  }
2167
+ /**
2168
+ * Issue #45: split a single-line VBA `If <cond> Then <body>` into one or
2169
+ * more statement-clause fragments that the existing `detectStatementCall`
2170
+ * and `detectQualifiedStatementCall` detectors can process. Handles:
2171
+ *
2172
+ * - `If x Then Foo` → `['Foo']`
2173
+ * - `If x Then Foo Else Bar` → `['Foo', 'Bar']`
2174
+ * - `If x Then DoA: DoB` → `['DoA', 'DoB']` (colon-separated multi-statement)
2175
+ * - `If x Then Foo Else A: B` → `['Foo', 'A', 'B']`
2176
+ * - `If x Then GoTo fin` → `[]` (GoTo clause filtered out)
2177
+ * - `If x Then Exit Sub` → `[]` (Exit clause filtered out)
2178
+ *
2179
+ * When the line does NOT match a single-line `If … Then` shape — for
2180
+ * instance a block-form `If x Then` whose body lives on subsequent
2181
+ * lines — the splitter returns `[<line>]` (the original input) so
2182
+ * callers can use this method unconditionally and let the existing
2183
+ * per-line scan pick up the body on a separate line.
2184
+ *
2185
+ * `GoTo`, `Exit`, and `Resume` clauses are filtered at the fragment
2186
+ * level (defense in depth): even though `emitStatementCallEdge` already
2187
+ * drops these via the `CALL_KEYWORD_BLACKLIST`, filtering here prevents
2188
+ * any chance of `detectStatementCall`'s generic identifier extractor
2189
+ * matching a substring (e.g. an identifier like `GoToFinishingTouches`)
2190
+ * as a side effect of a richer clause where the keyword happens to be
2191
+ * the leading token.
2192
+ *
2193
+ * `line` is the string-literal-masked scan line. The mask makes global
2194
+ * `:` splitting safe: real VBA colons never appear inside string
2195
+ * literals (already masked to spaces) and never inside expressions
2196
+ * inside parens at the source level (a colon ends a statement in VBA,
2197
+ * so it cannot appear inside a parenthesised argument list either).
2198
+ * The `Else` keyword is a VBA statement-level separator and is
2199
+ * forbidden inside parens or expressions, so splitting on
2200
+ * `\s+Else\s+` does not need paren tracking either.
2201
+ */
2202
+ splitSingleLineIfClauses(line) {
2203
+ const trimmed = line.trimStart();
2204
+ if (!trimmed)
2205
+ return [];
2206
+ // Match `If <cond> Then <body>` with a non-greedy condition. Requiring
2207
+ // at least one whitespace character after `Then` ensures the block
2208
+ // form `If x Then` (with the body on subsequent lines) is left alone
2209
+ // for the existing per-line call-site scan to handle on the body line.
2210
+ const ifThenRe = /^If\s[\s\S]+?\bThen\b\s+/i;
2211
+ const m = ifThenRe.exec(trimmed);
2212
+ if (!m) {
2213
+ // Not a single-line `If … Then` — preserve the original line so the
2214
+ // existing detection path picks it up unchanged.
2215
+ return [line];
2216
+ }
2217
+ const body = trimmed.slice(m[0].length);
2218
+ // Split on top-level `Else` (case-insensitive; word-bounded). The
2219
+ // statement-level-only nature of `Else` in VBA means the regex split
2220
+ // is safe without paren tracking on the masked-line invariant.
2221
+ const elseClauses = body.split(/\s+Else\s+/i);
2222
+ const clauses = [];
2223
+ for (const elseClause of elseClauses) {
2224
+ // Split each Else-clause on `:` for multi-statement single-line
2225
+ // `If` bodies. VBA expressions never contain `:`, so a global
2226
+ // split is correct on a masked line.
2227
+ const subStatements = elseClause.split(':');
2228
+ for (const sub of subStatements) {
2229
+ const t = sub.trim();
2230
+ if (!t)
2231
+ continue;
2232
+ // Defense in depth: GoTo / Exit / Resume are VBA control-flow
2233
+ // statements, not Sub calls — drop them before they reach the
2234
+ // statement-call detectors. (The existing
2235
+ // `CALL_KEYWORD_BLACKLIST` check in `emitStatementCallEdge`
2236
+ // also covers this; the fragment-level filter keeps the two
2237
+ // intent statements aligned and protects against any future
2238
+ // detector refactor that might relax the BLACKLIST check.)
2239
+ if (/^(?:GoTo|Exit|Resume)\b/i.test(t))
2240
+ continue;
2241
+ clauses.push(t);
2242
+ }
2243
+ }
2244
+ return clauses;
2245
+ }
1509
2246
  /**
1510
2247
  * H1: detect a statement-form Sub call.
1511
2248
  *
@@ -1616,22 +2353,26 @@ class VbaExtractor {
1616
2353
  // Skip declarations.
1617
2354
  if (/^(Dim|Private|Public|Static|Global|Const|ReDim)\s/i.test(trimmed))
1618
2355
  return null;
1619
- // Extract receiver identifier.
1620
- const receiverM = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(trimmed);
2356
+ // Issue #54: the receiver alternative accepts BOTH the bare form
2357
+ // (`Foo`) and the VBA bracketed form (`[Foo Bar]`). The bracketed
2358
+ // alternative wins when present; the captured value is already
2359
+ // unwrapped by the regex (it captures only the inner content, not
2360
+ // the surrounding `[...]`). The same shape applies to the member.
2361
+ const receiverM = /^(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*))/u.exec(trimmed);
1621
2362
  if (!receiverM)
1622
2363
  return null;
1623
- const receiver = receiverM[1] ?? '';
1624
- const rest = trimmed.slice(receiver.length);
2364
+ const receiver = receiverM[1] ?? receiverM[2] ?? '';
2365
+ const rest = trimmed.slice(receiverM[0].length);
1625
2366
  // Must have a dot separator.
1626
2367
  if (!rest.startsWith('.'))
1627
2368
  return null;
1628
2369
  // Extract member identifier.
1629
2370
  const memberRest = rest.slice(1); // skip the dot
1630
- const memberM = /^(\p{L}[\p{L}\p{N}_]*)/u.exec(memberRest);
2371
+ const memberM = /^(?:\[([^\]]+)\]|(\p{L}[\p{L}\p{N}_]*))/u.exec(memberRest);
1631
2372
  if (!memberM)
1632
2373
  return null;
1633
- const member = memberM[1] ?? '';
1634
- const afterMember = memberRest.slice(member.length);
2374
+ const member = memberM[1] ?? memberM[2] ?? '';
2375
+ const afterMember = memberRest.slice(memberM[0].length);
1635
2376
  // Must NOT be followed by `(` — the paren form is handled by CALL_RE.
1636
2377
  if (afterMember.startsWith('('))
1637
2378
  return null;
@@ -1711,83 +2452,108 @@ class VbaExtractor {
1711
2452
  });
1712
2453
  }
1713
2454
  /**
1714
- * B4 (hueco 6): scan one line of VBA source for `DoCmd.OpenForm "X"`
1715
- * calls. For each match, emit:
1716
- * - a stub `form-layout` node for the target form (cached by name so
1717
- * the same form referenced from N sites emits exactly ONE stub),
1718
- * - an `opens-form` heuristic edge from the calling Sub to that stub.
2455
+ * B4 (hueco 6) extended by Issue #48: scan one line of VBA source for
2456
+ * `DoCmd.OpenX "Target"` calls where X ∈ {Form, Report} (see the
2457
+ * `DOCMD_OPEN_DISPATCH` table). For each match, emit:
2458
+ * - a stub node (form-layout / report-layout) for the target, cached
2459
+ * per-(method, name) so the same target referenced from N sites
2460
+ * emits exactly ONE stub,
2461
+ * - an `opens-form` / `opens-report` heuristic edge from the calling
2462
+ * Sub to that stub.
1719
2463
  *
1720
2464
  * Both endpoints are pushed into `this.nodes` / `this.edges`, so the
1721
2465
  * per-file edge filter at `index.ts:insertedIds.has(source) &&
1722
2466
  * insertedIds.has(target)` passes the edge naturally without any
1723
2467
  * exemption to the filter.
1724
2468
  *
1725
- * Why a stub and not a direct lookup: the target form lives in a
1726
- * DIFFERENT file (its own `.form.txt`), and the extractor doesn't have
1727
- * DB access at parse time. The stub's synthetic file path
1728
- * (`synthetic:opensFormStub/<FormName>.form.txt`) guarantees a
1729
- * deterministic node id so re-indexes collapse to the same stub.
1730
- * When the consumer's `.form.txt` is later indexed, the real
1731
- * `form-layout` node carries a different id (it uses the real file
1732
- * path); the stub and the real coexist harmlessly. The orchestrator
1733
- * flagged this as acceptable for B4 only `OpenForm` is in scope.
1734
- * `OpenReport`, `OpenQuery`, `OpenTable`, … are follow-up work.
2469
+ * Why a stub and not a direct lookup: the target form/report lives in a
2470
+ * DIFFERENT file (its own `.form.txt` / `.report.txt`), and the extractor
2471
+ * doesn't have DB access at parse time. The stub's synthetic file path
2472
+ * (`synthetic:opensFormStub/<Name>.form.txt` /
2473
+ * `synthetic:opensReportStub/<Name>.form.txt`) guarantees a deterministic
2474
+ * node id so re-indexes collapse to the same stub. When the consumer's
2475
+ * `.form.txt` / `.report.txt` is later indexed, the real
2476
+ * `form-layout` / `report-layout` node carries a different id (it uses
2477
+ * the real file path); the stub and the real coexist harmlessly.
1735
2478
  *
1736
- * Scope note: this regex matches literal-string and bare-identifier forms.
1737
- * Bare identifiers are resolved only through local `Const` declarations;
1738
- * arbitrary variable data-flow remains intentionally out of scope.
2479
+ * Why a separate dispatch from CALL_RE: `DoCmd` is in
2480
+ * `RUNTIME_RECEIVER_BLACKLIST` (R4 invariant), so `DoCmd.OpenForm` /
2481
+ * `DoCmd.OpenReport` are intentionally SKIPPED by the generic CALL_RE
2482
+ * path that would otherwise emit a junk `calls` edge to a synthetic
2483
+ * `function` node for `DoCmd.OpenX`. The dispatch below matches BEFORE
2484
+ * the call-site scan and uses its own emission path.
2485
+ *
2486
+ * Scope note: literal-string and bare-identifier argument forms are
2487
+ * supported. Bare identifiers resolve only through local `Const`
2488
+ * declarations; arbitrary variable data-flow remains intentionally
2489
+ * out of scope.
1739
2490
  */
1740
- scanOpenFormCalls(line, caller, lineNum) {
1741
- // Each regex has /g so we MUST reset `lastIndex` before use; cloning
1742
- // the regex is the simplest way to avoid leaking state across lines.
1743
- const localRe = new RegExp(VbaExtractor.OPEN_FORM_ARG_RE.source, VbaExtractor.OPEN_FORM_ARG_RE.flags);
1744
- let m;
1745
- while ((m = localRe.exec(line)) !== null) {
1746
- const rawArg = (m[1] ?? '').trim();
1747
- const targetFormName = rawArg.startsWith('"')
1748
- ? unwrapVbaStringLiteral(rawArg)
1749
- : (this.localConstants.get(rawArg.toLowerCase()) ?? rawArg);
1750
- if (!targetFormName)
1751
- continue;
1752
- this.emitOpensFormEdge(caller, targetFormName, lineNum, m.index);
2491
+ scanDoCmdOpenCalls(line, caller, lineNum) {
2492
+ for (const dispatch of VbaExtractor.DOCMD_OPEN_DISPATCH) {
2493
+ // Each regex has /g so we MUST reset `lastIndex` before use; cloning
2494
+ // the regex is the simplest way to avoid leaking state across lines
2495
+ // AND across dispatch iterations.
2496
+ const localRe = new RegExp(dispatch.re.source, dispatch.re.flags);
2497
+ let m;
2498
+ while ((m = localRe.exec(line)) !== null) {
2499
+ const rawArg = (m[1] ?? '').trim();
2500
+ // Issue #52: const lookup is now per-proc-bucket with module
2501
+ // fallback (see `resolveLocalConst`). Two procs declaring the
2502
+ // same Const name with different values no longer collide —
2503
+ // each call site uses the value visible at its own scope.
2504
+ const targetName = rawArg.startsWith('"')
2505
+ ? unwrapVbaStringLiteral(rawArg)
2506
+ : (this.resolveLocalConst(rawArg) ?? rawArg);
2507
+ if (!targetName)
2508
+ continue;
2509
+ this.emitOpensStubEdge(dispatch, caller, targetName, lineNum, m.index);
2510
+ }
1753
2511
  }
1754
2512
  }
1755
2513
  /**
1756
- * B4 (hueco 6): emit a stub `form-layout` node for `targetFormName`
1757
- * (cached so duplicates collapse) and an `opens-form` heuristic edge
2514
+ * B4 (hueco 6) extended by Issue #48: emit a stub `form-layout` /
2515
+ * `report-layout` node for `targetName` (cached per dispatch entry so
2516
+ * duplicates collapse and OpenForm/OpenReport de-dup buckets stay
2517
+ * disjoint) and a single `opens-form` / `opens-report` heuristic edge
1758
2518
  * from `caller` to that stub.
1759
2519
  *
1760
2520
  * The edge carries:
1761
- * - `kind: 'opens-form'` new cross-file edge kind
1762
- * - `provenance: 'heuristic'` — synthesized, not parsed
1763
- * - `metadata.targetFormName` — the captured literal
1764
- * - `metadata.synthesizedBy: 'vba-opens-form'` — distinguishes this
1765
- * synthesis from the dim/sql/event-handler families
2521
+ * - `kind` — dispatch-specific (`opens-form` / `opens-report`)
2522
+ * - `provenance: 'heuristic'` — synthesized, not parsed
2523
+ * - `metadata.<dispatchTargetKey>` (e.g. `targetFormName`) — the resolved name
2524
+ * - `metadata.synthesizedBy` — dispatch-specific (`vba-opens-form` /
2525
+ * `vba-opens-report`); distinguishes this synthesis from the
2526
+ * dim/sql/event-handler families
1766
2527
  *
1767
2528
  * The stub's `metadata.stub: true` flag lets downstream UI render
1768
- * unresolved references distinctly (e.g. with a dashed border) and
1769
- * gives later re-resolution pass a hook for collapse. The stub is
2529
+ * stubs distinctly (e.g. with a dashed border) and gives later
2530
+ * re-resolution pass a hook for collapse. The stub is
1770
2531
  * line-independent (`line = 0`) so re-indexes produce identical ids.
1771
2532
  */
1772
- emitOpensFormEdge(caller, targetFormName, lineNum, column) {
1773
- const key = targetFormName.toLowerCase();
1774
- let stubId = this.opensFormStubIdsByName.get(key);
2533
+ emitOpensStubEdge(dispatch, caller, targetName, lineNum, column) {
2534
+ const key = `${dispatch.cacheKey}:${targetName.toLowerCase()}`;
2535
+ let stubId = this.opensStubIdsByKey.get(key);
1775
2536
  if (!stubId) {
1776
2537
  // Synthetic file path keeps the stub's id deterministic AND
1777
- // disambiguates it from any real `.form.txt` indexed later.
1778
- // The directory prefix (`synthetic:opensFormStub/`) is intentionally
1779
- // not a real filesystem path it just namespaces the id space.
1780
- const syntheticFilePath = `synthetic:opensFormStub/${targetFormName}.form.txt`;
1781
- stubId = (0, tree_sitter_helpers_1.generateNodeId)(syntheticFilePath, 'form-layout', targetFormName, 0);
1782
- this.opensFormStubIdsByName.set(key, stubId);
2538
+ // disambiguates it from any real `.form.txt` / `.report.txt`
2539
+ // indexed later. The directory prefix (`synthetic:opensFormStub/`
2540
+ // or `synthetic:opensReportStub/`) is intentionally not a real
2541
+ // filesystem path — it just namespaces the id space. The file
2542
+ // extension (`syntheticExtension`) DOES mirror the real form/report
2543
+ // file extension so a reader of the synthetic path can tell the
2544
+ // stub's intent at a glance.
2545
+ const syntheticFilePath = `${dispatch.syntheticPrefix}/${targetName}${dispatch.syntheticExtension}`;
2546
+ stubId = (0, tree_sitter_helpers_1.generateNodeId)(syntheticFilePath, dispatch.stubKind, targetName, 0);
2547
+ this.opensStubIdsByKey.set(key, stubId);
1783
2548
  this.nodes.push({
1784
2549
  id: stubId,
1785
- kind: 'form-layout',
1786
- name: targetFormName,
1787
- // Convention: form module names in Access are `Form_<Name>`.
1788
- // We follow the same convention in the synthetic stub's
1789
- // qualifiedName so cross-file lookups can find it consistently.
1790
- qualifiedName: `Form_${targetFormName}`,
2550
+ kind: dispatch.stubKind,
2551
+ name: targetName,
2552
+ // Convention: form module names in Access are `Form_<Name>` and
2553
+ // report module names are `Report_<Name>`. We follow the same
2554
+ // convention in the synthetic stub's qualifiedName so cross-file
2555
+ // lookups can find it consistently.
2556
+ qualifiedName: `${dispatch.moduleNamePrefix}${targetName}`,
1791
2557
  filePath: syntheticFilePath,
1792
2558
  language: 'vba',
1793
2559
  startLine: lineNum,
@@ -1801,23 +2567,115 @@ class VbaExtractor {
1801
2567
  this.edges.push({
1802
2568
  source: this.findOrCreateFunctionNodeId(caller),
1803
2569
  target: stubId,
1804
- kind: 'opens-form',
2570
+ kind: dispatch.edgeKind,
1805
2571
  provenance: 'heuristic',
1806
2572
  metadata: {
1807
- synthesizedBy: 'vba-opens-form',
1808
- targetFormName,
2573
+ synthesizedBy: dispatch.synthesizedBy,
2574
+ [dispatch.metadataTargetKey]: targetName,
1809
2575
  },
1810
2576
  line: lineNum,
1811
2577
  column,
1812
2578
  });
1813
2579
  }
2580
+ /**
2581
+ * Issue #48: scan one line of VBA source for `DoCmd.OpenQuery "X"` calls.
2582
+ * Each match emits ONE `UnresolvedReference` (NOT a stub + edge) so the
2583
+ * resolver binds to the REAL `query` node that `SqlQueryExtractor`
2584
+ * produces for `queries/<Name>.sql` (dysflow exports every saved QueryDef
2585
+ * + `queries.json` manifest). Falls back to silent when the .sql is not
2586
+ * yet in the index — the resolver does the binding when it's later
2587
+ * indexed, exactly like `vba-me-control` and `vba-forms-bang`.
2588
+ *
2589
+ * Companion to `scanDoCmdOpenCalls` but intentionally NOT in the
2590
+ * dispatch table — OpenQuery's emission shape (`UnresolvedReference`)
2591
+ * is structurally different from OpenForm/OpenReport's (synthetic node
2592
+ * + heuristic edge). The two pipelines share the literal-vs-Const
2593
+ * argument resolution pattern via `localConstants.get(...)` but emit
2594
+ * via two different branches of `unresolvedReferences` vs
2595
+ * `nodes`/`edges`.
2596
+ *
2597
+ * UnresolvedReference shape (per Issue #48 spec — must match
2598
+ * SqlQueryExtractor's query node name exactly):
2599
+ * - `referenceName` = resolved query name
2600
+ * - `referenceKind: 'references'` = same kind the resolver binds
2601
+ * - `metadata.synthesizedBy: 'vba-opens-query'`
2602
+ * - NO synthetic `function` node (W4 graph-pollution invariant — the
2603
+ * real `query` node already exists in the index once `.sql` is
2604
+ * processed, and creating stubs would compete with the binding).
2605
+ */
2606
+ scanDoCmdOpenQuery(line, caller, lineNum) {
2607
+ const localRe = new RegExp(VbaExtractor.OPEN_QUERY_ARG_RE.source, VbaExtractor.OPEN_QUERY_ARG_RE.flags);
2608
+ let m;
2609
+ while ((m = localRe.exec(line)) !== null) {
2610
+ const rawArg = (m[1] ?? '').trim();
2611
+ // Issue #52: same per-proc-with-module-fallback lookup as
2612
+ // `scanDoCmdOpenCalls` — proc-local consts resolve to their own
2613
+ // values, so a `DoCmd.OpenQuery LOCAL_QUERY` inside `Sub X()`
2614
+ // points at the correct query even when another proc declares a
2615
+ // different `LOCAL_QUERY` (pre-fix this was whichever wrote
2616
+ // the file-wide map last).
2617
+ const targetName = rawArg.startsWith('"')
2618
+ ? unwrapVbaStringLiteral(rawArg)
2619
+ : (this.resolveLocalConst(rawArg) ?? rawArg);
2620
+ if (!targetName)
2621
+ continue;
2622
+ this.unresolvedReferences.push({
2623
+ fromNodeId: this.findOrCreateFunctionNodeId(caller),
2624
+ referenceName: targetName,
2625
+ referenceKind: 'references',
2626
+ line: lineNum,
2627
+ column: m.index,
2628
+ filePath: this.filePath,
2629
+ language: 'vba',
2630
+ metadata: { synthesizedBy: 'vba-opens-query' },
2631
+ });
2632
+ }
2633
+ }
2634
+ /**
2635
+ * Regex matching the chained `& "..."` literals that may follow a
2636
+ * wrapper's first literal on the same physical line. Captures the
2637
+ * literal CONTENT (group 1); the surrounding `&` and quotes are
2638
+ * structural, not data. VBA allows whitespace around `&` and around
2639
+ * the inner quotes — handled with `\s*`. The `((?:[^"]|"")*)` body
2640
+ * mirrors the wrapper regex so a `""` inside a chained literal still
2641
+ * decodes to a single `"`.
2642
+ *
2643
+ * Cross-physical-line concat via `_` continuation is OUT OF SCOPE for
2644
+ * v1 (deferred; see commit message).
2645
+ */
2646
+ static SQL_WRAPPER_CHAIN_RE = /&\s*"((?:[^"]|"")*)"/g;
2647
+ /**
2648
+ * Given the text that follows a SQL wrapper's first literal on the same
2649
+ * physical line, return the contents of every `& "..."` chained literal
2650
+ * in source order. Operates per-physical-line only — VBA `_` line
2651
+ * continuation across physical lines is handled separately by
2652
+ * `collectStringLiteralText` for the variable-assignment path.
2653
+ */
2654
+ collectSqlWrapperChain(rest) {
2655
+ const out = [];
2656
+ const re = new RegExp(VbaExtractor.SQL_WRAPPER_CHAIN_RE.source, VbaExtractor.SQL_WRAPPER_CHAIN_RE.flags);
2657
+ let m;
2658
+ while ((m = re.exec(rest)) !== null) {
2659
+ out.push(m[1] ?? '');
2660
+ }
2661
+ return out;
2662
+ }
1814
2663
  scanSqlInLine(line, lineNum, dedupe, sqlVariables) {
1815
2664
  for (const { re } of VbaExtractor.SQL_WRAPPERS) {
1816
2665
  // Each wrapper regex is stateful (has /g); reset before use.
1817
2666
  const localRe = new RegExp(re.source, re.flags);
1818
2667
  let m;
1819
2668
  while ((m = localRe.exec(line)) !== null) {
1820
- this.emitSqlTableReferences(m[1] ?? '', lineNum, dedupe);
2669
+ const firstLiteral = m[1] ?? '';
2670
+ // After the wrapper regex consumes up to and including the closing
2671
+ // `"` of the first literal, walk the rest of the line for any
2672
+ // `& "..."` chains and concatenate every literal's content. Joining
2673
+ // with a space (mirrors `collectStringLiteralText`) keeps adjacent
2674
+ // `FROM tblA` & `FROM tblB` separated so `SQL_TABLE_RE` finds both.
2675
+ const rest = line.slice(m.index + m[0].length);
2676
+ const chain = this.collectSqlWrapperChain(rest);
2677
+ const joined = [firstLiteral, ...chain].join(' ');
2678
+ this.emitSqlTableReferences(joined, lineNum, dedupe);
1821
2679
  }
1822
2680
  }
1823
2681
  const localRe = new RegExp(VbaExtractor.SQL_VAR_EXEC_RE.source, VbaExtractor.SQL_VAR_EXEC_RE.flags);
@@ -1829,6 +2687,23 @@ class VbaExtractor {
1829
2687
  continue;
1830
2688
  this.emitSqlTableReferences(sqlString, lineNum, dedupe);
1831
2689
  }
2690
+ // Issue #42: `DoCmd.RunSQL <identifier>` (variable form). Mirrors the
2691
+ // SQL_VAR_EXEC_RE path above but for the Access-style `DoCmd.RunSQL`
2692
+ // idiom — the dominant pattern in real-world VBA modules. Resolve the
2693
+ // captured identifier against `sqlVariables` (populated by
2694
+ // `trackSqlVariableAssignment` with `&`-accumulate semantics, Issue
2695
+ // #13) and feed the resolved SQL string into `emitSqlTableReferences`.
2696
+ // Unresolved identifiers (no row in the map) are silently skipped —
2697
+ // same graceful-no-op contract as SQL_VAR_EXEC_RE.
2698
+ const docmdLocalRe = new RegExp(VbaExtractor.SQL_VAR_DOCMD_RUNSQL_RE.source, VbaExtractor.SQL_VAR_DOCMD_RUNSQL_RE.flags);
2699
+ let dm;
2700
+ while ((dm = docmdLocalRe.exec(line)) !== null) {
2701
+ const varName = (dm[1] ?? '').toLowerCase();
2702
+ const sqlString = sqlVariables.get(varName);
2703
+ if (!sqlString)
2704
+ continue;
2705
+ this.emitSqlTableReferences(sqlString, lineNum, dedupe);
2706
+ }
1832
2707
  }
1833
2708
  /**
1834
2709
  * #13 fix: `sql = sql & "..."` (self-referential concatenation) must
@@ -1936,6 +2811,135 @@ class VbaExtractor {
1936
2811
  this.pendingModuleOrClassSource.push(edge);
1937
2812
  }
1938
2813
  synthClassNodeIds = new Set();
2814
+ /**
2815
+ * Issue #50: TempVars placeholder-node de-dup cache. Keys are the
2816
+ * deterministic node ids produced by `emitTempVarReference` — those
2817
+ * ids intentionally ignore `this.filePath` (use a synthetic
2818
+ * `synthetic:tempvar/<key>` path instead) so the SAME key referenced
2819
+ * from Form_A.cls AND Form_B.cls collapses to ONE placeholder node.
2820
+ * Cross-file id stability is the cross-form state premise that makes
2821
+ * `codegraph_explore` connect producer ⇄ consumer in one hop.
2822
+ */
2823
+ synthTempVarNodeIds = new Set();
2824
+ /**
2825
+ * Issue #50: emit one TempVar reading/writing site. Per call:
2826
+ * - placeholder `class` node keyed on the synthetic
2827
+ * `synthetic:tempvar/<key>` file path so cross-file extraction
2828
+ * calls collapse to one node per key,
2829
+ * - `references` edge from the calling `function` node
2830
+ * (via `findOrCreateFunctionNodeId` — same access pattern as
2831
+ * `scanDoCmdOpenCalls` / `scanDoCmdOpenQuery`) carrying
2832
+ * `metadata.synthesizedBy: 'vba-tempvar'` AND
2833
+ * `metadata.access: 'read' | 'write'`.
2834
+ *
2835
+ * Reuses the synthetic-`class`-placeholder shape established by
2836
+ * `emitReference` for SQL tables, events, Dim types, etc. — so every
2837
+ * synthesized `references` edge in the file already carries the same
2838
+ * `kind: 'class'` target, and downstream consumers (UI, resolvers,
2839
+ * search queries) filter on `metadata.synthesizedBy` rather than
2840
+ * NodeKind anyway. The `metadata.synthesizedBy` tag cleanly distinguishes
2841
+ * TempVars refs from SQL-table refs inside the same NodeKind bucket.
2842
+ *
2843
+ * Skips when stack is empty (the writer/reader is module-level code
2844
+ * — REQ-CODE-4 "unresolvable/runtime reference is silent"). The
2845
+ * spec wires this per-proc only; the module-level shape would need
2846
+ * a different emission (no procedure source) and is out of scope.
2847
+ */
2848
+ emitTempVarReference(caller, key, lineNum, column, access) {
2849
+ if (!caller)
2850
+ return;
2851
+ if (!key)
2852
+ return;
2853
+ const syntheticFilePath = `synthetic:tempvar/${key}`;
2854
+ const targetId = (0, tree_sitter_helpers_1.generateNodeId)(syntheticFilePath, 'class', // placeholder kind — see SQL_TABLE_RE emitReference for precedent
2855
+ key, 0);
2856
+ if (!this.synthTempVarNodeIds.has(targetId)) {
2857
+ this.synthTempVarNodeIds.add(targetId);
2858
+ this.nodes.push({
2859
+ id: targetId,
2860
+ kind: 'class',
2861
+ name: key,
2862
+ qualifiedName: key,
2863
+ filePath: syntheticFilePath,
2864
+ language: 'vba',
2865
+ startLine: lineNum,
2866
+ endLine: lineNum,
2867
+ startColumn: column,
2868
+ endColumn: column + key.length,
2869
+ updatedAt: Date.now(),
2870
+ });
2871
+ }
2872
+ this.edges.push({
2873
+ source: this.findOrCreateFunctionNodeId(caller),
2874
+ target: targetId,
2875
+ kind: 'references',
2876
+ provenance: 'heuristic',
2877
+ metadata: {
2878
+ synthesizedBy: 'vba-tempvar',
2879
+ // User-facing enum: lowercase single-token string values.
2880
+ access,
2881
+ },
2882
+ line: lineNum,
2883
+ column,
2884
+ });
2885
+ }
2886
+ /**
2887
+ * Issue #50: scan one line of VBA source for TempVars access sites and
2888
+ * emit one `references` edge per site. Three regex runs:
2889
+ * - bang form `TempVars!x` over the MASKED line (`!` itself never
2890
+ * lives inside a string literal, so masked == unmasked here — using
2891
+ * the masked line is conservative against false positives in
2892
+ * concatenated string content),
2893
+ * - paren form `TempVars("x")` over the ORIGINAL line (the literal is
2894
+ * inside a string — masked would strip the key),
2895
+ * - Add form `TempVars.Add "x", v` over the ORIGINAL line (same reason),
2896
+ * always classified as a write.
2897
+ *
2898
+ * Each match classifies access by looking at the LINE SUFFIX (after the
2899
+ * closing paren / bang-key) on the SAME line: if `=` (and not the
2900
+ * nonexistent `==`) is the next non-whitespace character, it's a write.
2901
+ * VBA has no `==` so a bare `=` suffix check is safe.
2902
+ *
2903
+ * The caller parameter comes from `stack[stack.length - 1]` in
2904
+ * `sweepCallsAndSql`. Pass `undefined` for module-level access — we
2905
+ * drop the edge (per REQ-CODE-4 spirit, runtime references have no
2906
+ * static source to anchor against).
2907
+ */
2908
+ sweepTempVars(maskedLine, originalLine, lineNum, caller) {
2909
+ // 1) Bang form — masked line. No string-literal interaction.
2910
+ const bangRe = new RegExp(VbaExtractor.TEMP_VAR_BANG_RE.source, VbaExtractor.TEMP_VAR_BANG_RE.flags);
2911
+ let bm;
2912
+ while ((bm = bangRe.exec(maskedLine)) !== null) {
2913
+ const key = bm[1] ?? '';
2914
+ if (!key)
2915
+ continue;
2916
+ const access = detectAssignmentSuffix(maskedLine, bm.index + bm[0].length)
2917
+ ? 'write'
2918
+ : 'read';
2919
+ this.emitTempVarReference(caller, key, lineNum, bm.index, access);
2920
+ }
2921
+ // 2) Paren form — original line. The literal survives only here.
2922
+ const parenRe = new RegExp(VbaExtractor.TEMP_VAR_PAREN_RE.source, VbaExtractor.TEMP_VAR_PAREN_RE.flags);
2923
+ let pm;
2924
+ while ((pm = parenRe.exec(originalLine)) !== null) {
2925
+ const key = pm[1] ?? '';
2926
+ if (!key)
2927
+ continue;
2928
+ const access = detectAssignmentSuffix(originalLine, pm.index + pm[0].length)
2929
+ ? 'write'
2930
+ : 'read';
2931
+ this.emitTempVarReference(caller, key, lineNum, pm.index, access);
2932
+ }
2933
+ // 3) Add form — original line. Always a write.
2934
+ const addRe = new RegExp(VbaExtractor.TEMP_VAR_ADD_RE.source, VbaExtractor.TEMP_VAR_ADD_RE.flags);
2935
+ let am;
2936
+ while ((am = addRe.exec(originalLine)) !== null) {
2937
+ const key = am[1] ?? '';
2938
+ if (!key)
2939
+ continue;
2940
+ this.emitTempVarReference(caller, key, lineNum, am.index, 'write');
2941
+ }
2942
+ }
1939
2943
  /**
1940
2944
  * Fix 2 (Issue #2): maps `variableName.toLowerCase()` → declared type info.
1941
2945
  * Built by `sweepDimsAndWithEvents`; consulted by `sweepCallsAndSql` to gate
@@ -1943,8 +2947,71 @@ class VbaExtractor {
1943
2947
  * typed as a SIMPLE (non-qualified, non-primitive) identifier emit edges.
1944
2948
  */
1945
2949
  localVarTypeMap = new Map();
1946
- /** Local constant name (lowercase) → simple literal value for OpenForm resolution. */
2950
+ /**
2951
+ * Issue #52: Const resolution buckets, scoped per procedure. Key is
2952
+ * `'module'` for module-level Consts, or the procedure's `startLine`
2953
+ * (stringified) for proc-local Consts. Each bucket maps the lowercase
2954
+ * constant name to its simple-literal value (used by `DoCmd.OpenForm` /
2955
+ * `OpenReport` / `OpenQuery` argument resolution via `resolveLocalConst`).
2956
+ *
2957
+ * Two procs declaring the same Const name with different values stay
2958
+ * isolated (each in its own bucket); reads look up the current proc's
2959
+ * bucket first and fall back to the module bucket. The bucket-per-proc
2960
+ * model was chosen over a single file-wide Map (the pre-fix shape) so
2961
+ * `DoCmd.OpenForm FORM_DESTINO` resolves to the proc-local value, not
2962
+ * whichever was written last.
2963
+ */
1947
2964
  localConstants = new Map();
2965
+ /**
2966
+ * Issue #52: shared lookup helper for `scanDoCmdOpenCalls` and
2967
+ * `scanDoCmdOpenQuery`. The current scope is the procedure whose
2968
+ * `startLine` is on top of `procStack` (or `'module'` when the stack is
2969
+ * empty). Per-proc bucket first; module bucket is the fallback.
2970
+ */
2971
+ resolveLocalConst(name) {
2972
+ const lower = name.toLowerCase();
2973
+ const procBucket = this.localConstants.get(this.currentProcKey);
2974
+ if (procBucket) {
2975
+ const v = procBucket.get(lower);
2976
+ if (v !== undefined)
2977
+ return v;
2978
+ }
2979
+ const moduleBucket = this.localConstants.get('module');
2980
+ return moduleBucket?.get(lower);
2981
+ }
2982
+ /**
2983
+ * Issue #52: shared writer. `scopeKey` is `'module'` or the procedure's
2984
+ * startLine-as-string. Creates the bucket lazily so callers do not have
2985
+ * to pre-allocate per proc. Returns the bucket the value was written to
2986
+ * (mostly useful for tests; production code ignores it).
2987
+ */
2988
+ setLocalConstInScope(scopeKey, name, value) {
2989
+ let bucket = this.localConstants.get(scopeKey);
2990
+ if (!bucket) {
2991
+ bucket = new Map();
2992
+ this.localConstants.set(scopeKey, bucket);
2993
+ }
2994
+ bucket.set(name.toLowerCase(), value);
2995
+ return bucket;
2996
+ }
2997
+ /**
2998
+ * Issue #52: the current Const-lookup scope. `'module'` when no procedure
2999
+ * is open, otherwise the top-of-stack proc's `startLine` as a string.
3000
+ * Both `sweepEnumsAndConsts` (to decide whether to emit a `constant`
3001
+ * node) and `sweepCallsAndSql` (to drive OpenForm/OpenQuery resolution)
3002
+ * keep this in sync with their per-line stack walk by pushing/popping
3003
+ * `procStack` and writing the new top's key here.
3004
+ */
3005
+ currentProcKey = 'module';
3006
+ /**
3007
+ * Issue #52: per-extraction proc-stack shared between `sweepEnumsAndConsts`
3008
+ * and `sweepCallsAndSql`. Each sweep clears it at the start so the file's
3009
+ * mid-proc structural state never leaks across sweeps. Holds the
3010
+ * `startLine` (1-based, matches `ProcInfo.startLine`) of every procedure
3011
+ * whose body the sweep has not yet emitted `End Sub`/`End Function`/
3012
+ * `End Property` for.
3013
+ */
3014
+ procStack = [];
1948
3015
  /** Local event name (lowercase) → event node for `RaiseEvent` edge emission. */
1949
3016
  localEvents = new Map();
1950
3017
  }
@@ -2001,6 +3068,37 @@ function splitOutsideVbaStrings(value, separator) {
2001
3068
  parts.push(current);
2002
3069
  return parts;
2003
3070
  }
3071
+ /**
3072
+ * Issue #50 helper: return true iff `line.charAt(fromIndex..)` (after the
3073
+ * matched TempVars site, including any trailing whitespace) holds an `=`
3074
+ * (write-assignment) and not a `==` (VBA has no `==` operator, so a bare
3075
+ * check for `=` is safe). Used by `sweepTempVars` to classify a `TempVars`
3076
+ * access as read vs write from the local line context.
3077
+ *
3078
+ * We skip over trailing whitespace only — `.`, `(`, `<`, `>`, `*`, `+`
3079
+ * etc. all mean "this isn't an assignment target", and bare `=`
3080
+ * is the only access-suffix shape VBA syntax allows here. A line like
3081
+ * `TempVars!x = 1` (write) or `Debug.Print TempVars!x` (read) land in
3082
+ * the right bucket without further analysis.
3083
+ *
3084
+ * Strings should already be masked out of `line` (`maskStringContent`)
3085
+ * when this is called for the bang form (no string in scope anyway);
3086
+ * the paren form passes the ORIGINAL line — but for THAT form the
3087
+ * captured match ends at the closing `"` of the literal, so any `=`
3088
+ * that follows is unambiguously outside the string.
3089
+ */
3090
+ function detectAssignmentSuffix(line, fromIndex) {
3091
+ let i = fromIndex;
3092
+ while (i < line.length) {
3093
+ const ch = line[i];
3094
+ if (ch === ' ' || ch === '\t') {
3095
+ i++;
3096
+ continue;
3097
+ }
3098
+ return ch === '=';
3099
+ }
3100
+ return false;
3101
+ }
2004
3102
  function unwrapVbaStringLiteral(raw) {
2005
3103
  const trimmed = raw.trim();
2006
3104
  if (!trimmed.startsWith('"'))