Skip to content

feat(vba): emit variable nodes for module-level state - #277

Merged
ardelperal merged 1 commit into
mainfrom
feat/issue-251
Sep 2, 2026
Merged

ardelperal merged 1 commit into
mainfrom
feat/issue-251

Conversation

@ardelperal

Copy link
Copy Markdown
Owner

What changed

Module-level VBA state is now in the graph, both as symbols and as relationships.

Nodes. Every module-level Public / Private / Global / Dim / Static declaration — including WithEvents fields — emits one variable node with qualifiedName: '<Module>.<name>', a visibility folded from the declaration keyword, metadata: { declaredType, isArray, isWithEvents, isConst: false }, and a contains edge from the module/class node via the existing pending-source mechanism. Procedure locals emit nothing.

References. Inside a procedure body, an identifier that matches one of that same file's module-level variable names emits an UnresolvedReference tagged synthesizedBy: 'vba-module-var', property-set for a write and property-get for a read. The direction comes from the control sweep's existing isDirectAssignment predicate, which is exported rather than duplicated; a leading Set is stripped from the prefix before the call so Set gblConn = New Foo reads as the write it is.

Resolution. These references bind only to a variable node in their own file and never fall through to global name matching — m_count and strSQL are declared privately in dozens of modules, and a global match would invent couplings between modules that never reference each other.

Decisions worth reviewing

  • Visibility does not reuse foldVisibility. That helper folds unrecognised keywords to public, which would claim every Dim gblFoo is exported. In a declarations section Dim and Static mean module-private; only Public and Global export.
  • References de-duplicate per (procedure, variable, direction). A procedure that reads a global on twenty lines couples to it once. Reads and writes stay separate rows, so the direction question is still answerable. First occurrence keeps the line number — the same trade-off the Me.<Control> sweep already makes.
  • Shadowing uses a dedicated per-procedure name registry, not localVarTypeMap. The type map's bare-Dim path yields to an outer declaration of the same name, and it never sees parameters at all, so both cases would have read as unshadowed. Parameters are parsed off the signature line, which also closes the shadow hole for Sub Guardar(ByVal codigo As String).
  • A declarations-only .bas now gets a module node. Module-level variables count toward hasAnySymbols, exactly as module-level Const already did.

Tested

__tests__/extraction-vba-module-variables.test.ts — 26 tests, one per acceptance criterion that is expressible as a unit test, plus the two regression pins:

  • Public gblConn As DAO.Database → one variable node + contains, and the DAO references edge still emitted exactly once
  • Dim x As Long inside a Sub → no node, no reference
  • module variable + procedure local of the same name → the local produces no node, and a read inside that procedure produces no module-var reference, while a procedure without the local still reports its read (fix(vba): localVarTypeMap has no procedure scope — qualified calls resolve to the wrong class #205 holds); covered for a typed local, a bare untyped local, and a parameter
  • Public WithEvents m_Form As Form_X → node with isWithEvents: true, subscribes-event edge unchanged
  • x = gblConn → property-get; gblConn = Nothing and Set gblConn = New Foo → property-set
  • visibility folding for all five declaration keywords, isArray, multi-variable lines, re-declaration, string-literal names, other.gblConn member access, and file-scoped resolution

npx tsc --noEmit is clean. npx vitest run: 3261 passed, 21 failed, 59 skipped.

Not verified

  • The 21 failures are pre-existing and environmental, not from this change. 19 are the documented Windows EPERM temp-directory teardown quirk (worktree-detection, multi-repo-workspace, and two extraction.test.ts submodule cases — all fail in afterEach rmSync, with their assertions passing). The other 2 are npm-sdk cases that resolve a real installed platform bundle on this machine instead of their fixture. No VBA extraction or resolution suite fails. I did not run a main baseline to prove this, because the shared stash and sibling worktrees made a clean checkout unsafe here — the evidence is the failure sites, which are all in files this change does not touch.
  • The corpus-wide acceptance numbers are not reproducible in this repo. nodesByKind['variable'] ≈ 2,900, the drop in proceduresWithNoOutgoing, and the 15-reference precision spot-check all need the Access corpus, which is not checked in here. Someone with the corpus should run the T0 probe before this is called done. The mechanism the proceduresWithNoOutgoing criterion depends on is in place: the probe counts a procedure as a leaf only when it has zero edges and zero unresolved references, and a procedure that only touches globals now has references.

Closes #251

🤖 Generated with Claude Code

https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

Module-level Public/Private/Global/Dim declarations populated the type map
and emitted type-reference edges, but never a node, so shared state was the
largest coupling channel the graph could not see: "who reads gblConn?" had
no answer at all.

Both halves of T9 land together. A variable node on its own is a symbol
nobody can trace, which is the partial-coverage trap the plan warns about,
so nodes and read/write references ship in the same change.

Design decisions:

- Nodes are emitted ONLY when the declaration is module-level
  (currentVarTypeProcKey === 'module'). Procedure locals stay out: every
  local of every procedure is the node-explosion failure mode, and the
  def-use frontier is deliberately uncovered.
- Dim and Static in a declarations section mean module-private in VBA, so
  visibility folds Public/Global to public and everything else to private.
  foldVisibility() is not reused: it folds unknown keywords to public and
  would claim every `Dim gblFoo` is exported.
- Read/write references are gated strictly to names already registered as
  module-level variables of the SAME file. The sweep never looks for
  identifiers that might be variables; any looser rule produces thousands
  of references to names that merely collide.
- Direction reuses isDirectAssignment() from the control sweep, which is
  exported rather than duplicated. A leading `Set ` is stripped from the
  prefix first so `Set gblConn = New Foo` reads as the write it is; that
  is input normalisation, not a second predicate.
- References are de-duplicated per (procedure, variable, direction), so a
  procedure that reads a global twenty times reports one read and, if it
  also assigns it, one write.
- Shadowing keeps the #205 rule holding for cases the type map alone
  cannot answer: its bare-Dim path yields to an outer declaration of the
  same name, and it never sees parameters. A dedicated per-procedure name
  registry records both, so a procedure that declares its own `codigo`
  reports no access to the module-level `codigo`.
- Resolution is file-scoped. Names like m_count and strSQL are declared
  privately in dozens of modules; a global name match would invent a
  coupling between modules that never reference each other, so a miss
  stays a miss.

The two pre-existing behaviours on the same lines are unchanged and pinned
by tests: a qualified `As DAO.Database` declaration still emits its type
reference exactly once, and a WithEvents field still emits its
subscribes-event edge.

Closes #251

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d
@ardelperal
ardelperal merged commit b851d5c into main Sep 2, 2026
5 checks passed
@ardelperal
ardelperal deleted the feat/issue-251 branch September 2, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(vba): emit variable nodes for module-level state

1 participant