Skip to content

feat(vba): recognise the module-variable error channel - #291

Merged
ardelperal merged 5 commits into
mainfrom
feat/issue-261
Sep 3, 2026
Merged

feat(vba): recognise the module-variable error channel#291
ardelperal merged 5 commits into
mainfrom
feat/issue-261

Conversation

@ardelperal

Copy link
Copy Markdown
Owner

Task E4 of docs/vba-error-handling-plan.md. Closes #261.

What this does

A read or write of an error-channel variable now carries metadata.errorChannel: true — on the UnresolvedReference and on the resolved references edge — alongside the property-get / property-set kind #251 already emitted. New config knob vba.errorChannel (bare identifiers, whole-name match, extends the four defaults m_Error / p_Error / g_Error / Error, no user regex), threaded end-to-end through VbaExtractionOptions.

No new node kind, no new edge kind, no new row. src/types.ts is untouched.

One design decision worth flagging: the channel names and the channel-write matcher moved out of errors.ts into a new leaf src/extraction/vba/error-channel.ts. errors.ts owned both, and its own comment deferred the knob to this task. Leaving the list there would have produced two matchers the moment the knob became config-aware, so both consumers — E3's errorPolicy.behavior classifier and this task's reference flag — now read one compiled object. Consequence: vba.errorChannel also drives behavior, so a project that configures lastFailure gets its handlers classified channel rather than unknown.

Corpus measurements

Probe run over all three roots (00_EXPEDIENTES/src, 00_GESTION_RIESGOS/src, HPS_SOLICITUDES/src), branch vs a clean origin/main export of the same tree:

main this branch
nodes 26,089 26,089
edges 29,521 29,521
unresolved refs 26,755 26,755

nodesByKind, edgesByKind, unresolvedByKind, edgesBySynthesizer, files and the whole errorHandling block are byte-identical between the two runs. Nothing was added, dropped or reclassified.

References that gained the flag, across the three roots: 5,386 of 9,378 module-variable references (3,373 reads / 2,013 writes), in 232 files. By name: m_Error 3,842, Error 1,544. Indexed, that is 2,619 resolved edges in 00_GESTION_RIESGOS and 1,653 in 00_EXPEDIENTES.

The end-to-end chain

Verified by indexing a read-only copy of 00_GESTION_RIESGOS/src and querying the graph. The chain connects, within one module. Real file, real procedures, real variable — src/forms/Form_FormGestionRiesgos.cls:

EstablecerDatos                     errorPolicy.behavior = "channel"
  --references[property-set, errorChannel:true, access:write] @324-->  variable m_Error
                                                                              ^
Form_Load  --calls @276--> EstablecerDatos                                    |
Form_Load  --references[property-get, errorChannel:true, access:read] @276----+
Form_Load                           errorPolicy.behavior = "mixed"
                                    handler region 289-298, MsgBox(m_Error) @293

Source: EstablecerDatos line 324 is m_Error = "Es un usuario no autorizado"; Form_Load line 276 is EstablecerDatos m_Error, 277 is If m_Error <> "" Then, 293 is pregunta = MsgBox(m_Error, vbCritical, "Error"). Both hops target the same variable node, so the join is a real traversal, and the calls edge from Form_Load to EstablecerDatos gives the propagation its direction. The suite's integration test reproduces exactly this shape end-to-end through CodeGraph.indexAll().

The display hop is errorPolicy.behavior on Form_Load, not an edge: MsgBox is a VBA built-in, so it is not a node and there are zero MsgBox call edges in the index. E3's behavior field is the graph's display marker, and it reports mixed (channel + display) for this procedure.

What does NOT connect — read this before merging

Four honest findings from the corpus. None of them is caused by this change; all of them bound what it delivers.

1. The class-to-form hop does not exist, and cannot with this model. A form reads a class's message with p_Error = m_ObjRiesgoActivo.Error. That is a dotted access to another object's member, which #251's sweep skips by design (a dotted name is a member of something else). Confirmed in the index: zero edges from any form procedure to Riesgo.cls's Error variable. So the traversable chain is inner writes -> outer reads -> outer displays within one module, not across the class/form boundary. Bridging that needs member resolution against a declared type, which is a different task.

2. The corpus has a second channel this change cannot reach: the ByRef out-parameter. Optional ByRef p_Error As String is used heavily — 174 occurrences in ExpedienteOperaciones.cls alone. It is a parameter, so #205 scoping keeps it out of the module-variable sweep entirely and there is no reference to flag. Consequence for the acceptance criterion, measured per class:

class handlers with behavior channel/mixed with a flagged module-variable reference
Riesgo.cls 146 90
Edicion.cls 110 70
ExpedienteOperaciones.cls 25 0

ExpedienteOperaciones.cls declares no m_Error and no public Error; it propagates purely through p_Error parameters. So the acceptance criterion "every channel-writing handler produces a flagged reference" is met for the module-variable half of the corpus and not for the ByRef half. The uncovered handlers in Riesgo.cls / Edicion.cls are the same shape (getHTML* helpers that write only p_Error). I did not stretch the flag to cover parameters: it needs a different data-flow model, and inventing one here would have been the half-bridged flow CLAUDE.md forbids.

3. A pre-existing #251 false positive that this flag now amplifies. These projects declare Public Error As String as the object's public message field. The identifier Error also appears in every On Error GoTo errores statement, and the module-variable sweep reads that as a bare-name access. Result: 909 references across the corpus to a module variable literally named Error, all reads, one per handler procedure — and they are now labelled errorChannel: true. The reference itself is already wrong on main; this change makes it more visible. Fixing it means suppressing identifiers inside an On Error … statement, which would change edge counts and is therefore out of scope for a task whose invariant is "unchanged totals". Worth its own issue.

4. inErrorHandler rarely co-occurs with a channel write on real code. #251 de-dupes references per (procedure, variable, direction) and keeps the FIRST occurrence. The corpus's dominant handler shape initialises m_Error = "" at the top of the body and writes the message again in the handler, so the surviving write reference is the initialisation one, outside the handler region. Corpus-wide: 5,386 flagged references, of which only 618 carry inErrorHandler. The unit test pins the flag on a fixture where the handler write is the only write. Changing which occurrence survives would alter existing rows' line values, so I left #251's de-dup alone.

Tests

New __tests__/extraction-vba-error-channel.test.ts, 20 tests, all passing. Covers every acceptance checkbox that is a unit test: the in-handler write (property-set + errorChannel + inErrorHandler), the caller's If m_Error <> "" Then read, the ErrorCount negative (plus four neighbouring names), case-insensitivity, vba.errorChannel: ["lastFailure"] matching that name and keeping the defaults, config validation dropping dotted/regex-ish entries, the knob driving behavior from the same list, the no-new-rows invariant, the two boundaries above (Me.Error = and a ByRef p_Error), the traversal integration test, and reading the knob out of a real codegraph.json.

Verification run: npx tsc --noEmit clean; VBA suites 377 + 716 passed, resolution/config suites 227 passed, non-VBA suites 1,166 + 537 + 828 passed. The 21 failures observed are the pre-existing environmental ones on this machine (worktree-detection x15, multi-repo-workspace x2, extraction x2, npm-sdk x2 — all afterEach temp-dir removal EPERM/EBUSY). Zero new failures. A full single npx vitest run OOMs on this machine, so suites were run in batches.

Not verified

  • Only Windows was exercised; no macOS or Linux run.
  • HPS_SOLICITUDES was measured by the probe (counts) but not indexed for a graph traversal.
  • The g_Error default matched nothing in this corpus — it is carried over from the probe's list, not corroborated here.

🤖 Generated with Claude Code

https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

ardelperal and others added 5 commits September 3, 2026 08:06
Error propagation in an Access codebase of this shape does not use VBA's
error mechanism. 16 handlers out of 3,774 re-raise; `Err.Raise 1000`
unwinds exactly one frame and the house guard `If Err.Number <> 1000`
means "an inner procedure already wrote a human-readable message". The
message itself travels through a field the failing procedure writes and
the caller reads.

That is module-variable data flow, which #251 already models as
`property-set` / `property-get` references onto a `variable` node. This
change only labels it: a read or write of a channel variable now carries
`metadata.errorChannel: true`, on the reference and on the resolved edge.
No new node kind, no new edge kind, and no new row — the corpus indexes
to byte-identical `nodesByKind` / `edgesByKind` / `unresolvedByKind`
totals (26,089 / 29,521 / 26,755, unchanged against origin/main).

Decisions taken:

- The channel names and the write matcher move into a new leaf module,
  `src/extraction/vba/error-channel.ts`. `errors.ts` owned both before,
  and its own comment deferred the config knob to this task; leaving the
  list there and importing it from `module-vars.ts` would have forked
  two matchers the moment the knob became config-aware. Both consumers
  now read one compiled object, so `vba.errorChannel` drives the
  reference flag AND `errorPolicy.behavior` rather than only the former.

- `vba.errorChannel` takes bare VBA identifiers, matched as whole names,
  and EXTENDS the built-in list — the same contract `vba.sqlWrappers`
  established in #244. No user-supplied regex: this runs per identifier
  per line, which is exactly where one is a backtracking hazard. Matching
  a name rather than a substring is what keeps `ErrorCount` out.

- The compiled form (a `Set` plus RegExps) lives on the extractor
  context, not in `VbaExtractionOptions`, because the options object
  crosses the `structuredClone` worker boundary.

- The flag is only ever `true`; its absence encodes "not the channel", so
  it is added to a minority of rows instead of a `false` to every one —
  the shape #260 chose for `inErrorHandler`.

Closes #261

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d
* feat(vba): emit label nodes and handles-error edges

Task E6 of `docs/vba-error-handling-plan.md`. #259 records *whether* a
procedure has an error handler and #260 marks *which* edges come from
inside one, but neither gives the handler an identity you can point at,
search for or traverse to. This does: one `label` node per VBA line label,
and a `handles-error` edge from the procedure to the handler it routes to.

The plan's §4 rejected this design for #259 and §4.3 named the condition
that reopens it — a query `inErrorHandler`'s per-procedure boolean cannot
serve. Addressing a handler as a thing is that query: a stable id per
handler, `kind:label` search, and dangling/duplicate/control-flow-label
detection as a graph query rather than a scan.

This adds no parsing. Every fact published here was already computed by the
error-policy classifier while the procedure body was open — the label
definitions, the `On Error GoTo` targets, the handler region and the
dangling-target resolution. `handlerBehavior` is #260's derived
`errorPolicy.behavior`, copied verbatim. The one genuinely new signal is
the plain-`GoTo` jump, which the policy classifier had no reason to look at
while it emitted nothing, and which arrives as a fifth rule on the same
declarative table rather than as a second scanner.

Decisions taken:

- `qualifiedName` is always `<ModuleOrClass>.<Procedure>.<label>`. VBA
  scopes labels to the procedure and this corpus writes `errores` 3,735
  times; without the procedure segment every handler in a project collapses
  into one symbol. Same shape #257's parameters and #251's module variables
  chose.
- `handles-error` is not deduplicated per procedure. 47 procedures issue
  more than one `On Error GoTo`, and each is a distinct routing decision
  with its own line, so each emits its own edge.
- A plain `GoTo` reuses the generic `references` kind tagged `vba-goto`.
  A jump is not an error-handling fact and 192 sites do not justify a
  second kind; the synthesizer tag keeps them filterable.
- A `GoTo` whose target the procedure never defines emits an
  `UnresolvedReference` and **no node**. A graph that invents its own
  targets cannot be used to find that defect, which is the only reason to
  look for it.
- Calls inside a handler stay attributed to the enclosing procedure. The
  label is addressable, not a container; re-parenting would change
  `callers`/`callees` for the 3,774 procedures that have a handler.
- Only the label whose region `errorPolicy` actually resolved carries
  `handlerBehavior` and the region lines. A procedure that swaps to a
  second handler label has a second region nobody classified, and deriving
  one here would be exactly the drift this split avoids.
- A numeric `GoTo` target is a VBA line number, not a line label. The label
  detector cannot define one, so referencing it would fabricate a permanent
  dangling reference for legal code.
- `label` stays out of `HIGH_VALUE_NODE_KINDS` and `CONTAINER_NODE_KINDS`,
  for the reason #257 kept `parameter` out of both: it is now the most
  numerous VBA symbol in the graph.

Measured on the three-project Access corpus with the committed probe: 3,911
label nodes, 3,832 `handles-error` edges, 192 `vba-goto` references, zero
new unresolved references, and no other node or edge kind moved. That is
+15.0% nodes and +26.9% edges — the extractor matches the probe's census
exactly on all three counts.

`EXTRACTION_VERSION` is bumped to 26: a new node kind and a new edge kind
change what a re-index would produce.

Closes #263

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

* docs(vba): correct the E6 landing-order claim in the plan

The E6 section said it landed "after E1-E5 were built". E4 (#261) is still
in flight and E5 (#262) has not started, so that sentence asserted an order
that did not happen. Corrected to what is true: E6 landed after E1-E3,
alongside E4, and before E5.

The rest of the section — the three §4.3 conditions, the measured budget, and
the warning not to read it as licence to add a kind elsewhere — is unchanged
and accurate.

Refs #263

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ed Error (#293)

`scanModuleVariableReferences` walks every identifier on a line and emits a
reference for any that names a module-level variable. It guarded a `.` / `!`
prefix and procedure-local shadowing (#205), but not VBA keyword context — so
in a module declaring `Public Error As String`, the word `Error` in
`On Error GoTo errores` was read as an access to that variable.

`Public Error As String` is this codebase's error-channel convention and
appears in dozens of classes, so this fired constantly. On its own it is a
stray edge; #261 labels channel references with `errorChannel: true`, which
would have turned every one of them into a confident claim that an
`On Error` statement takes part in error propagation — the failure mode
`CLAUDE.md` and guardrail 1 of `docs/vba-error-handling-plan.md` both name as
the worst available here. #261 is held until this lands so it is measured on
clean data.

The `On Error` pair is blanked out before the identifier walk, replaced with
spaces of the SAME length. That is load-bearing rather than incidental: the
emitted reference carries `column: m.index`, so a substitution that shifted
offsets would corrupt every column on the line. A fixture pins the column of a
genuine reference sharing a line with `On Error GoTo`.

Scoped to the `On Error` pair only. VBA spells the `Error` statement
(`Error 5`) and the `Error$()` function with the same word; both have zero
occurrences in this corpus, and telling those from an identically-named
variable is a parser problem rather than a masking one. They are left for a
corpus that contains them.

Measured on the corpus (`00_EXPEDIENTES`, `00_GESTION_RIESGOS`,
`HPS_SOLICITUDES`): unresolved references fall 26,755 -> 25,211. The entire
delta is `property-get`, 5,636 -> 4,092 — 1,544 false reads removed, and no
other reference kind, node kind or edge kind moves. Nodes stay at 30,000 and
edges at 37,456.

The issue estimated ~909; the measured figure is 1,544. The estimate counted
handler bodies, while the sweep de-duplicates per (procedure, variable,
direction) — so every procedure whose ONLY apparent read of `Error` came from
its own `On Error` line contributed one, including procedures the estimate did
not look at.

Closes #292


Claude-Session: https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Error propagation in an Access codebase of this shape does not use VBA's
error mechanism. 16 handlers out of 3,774 re-raise; `Err.Raise 1000`
unwinds exactly one frame and the house guard `If Err.Number <> 1000`
means "an inner procedure already wrote a human-readable message". The
message itself travels through a field the failing procedure writes and
the caller reads.

That is module-variable data flow, which #251 already models as
`property-set` / `property-get` references onto a `variable` node. This
change only labels it: a read or write of a channel variable now carries
`metadata.errorChannel: true`, on the reference and on the resolved edge.
No new node kind, no new edge kind, and no new row — the corpus indexes
to byte-identical `nodesByKind` / `edgesByKind` / `unresolvedByKind`
totals (26,089 / 29,521 / 26,755, unchanged against origin/main).

Decisions taken:

- The channel names and the write matcher move into a new leaf module,
  `src/extraction/vba/error-channel.ts`. `errors.ts` owned both before,
  and its own comment deferred the config knob to this task; leaving the
  list there and importing it from `module-vars.ts` would have forked
  two matchers the moment the knob became config-aware. Both consumers
  now read one compiled object, so `vba.errorChannel` drives the
  reference flag AND `errorPolicy.behavior` rather than only the former.

- `vba.errorChannel` takes bare VBA identifiers, matched as whole names,
  and EXTENDS the built-in list — the same contract `vba.sqlWrappers`
  established in #244. No user-supplied regex: this runs per identifier
  per line, which is exactly where one is a backtracking hazard. Matching
  a name rather than a substring is what keeps `ErrorCount` out.

- The compiled form (a `Set` plus RegExps) lives on the extractor
  context, not in `VbaExtractionOptions`, because the options object
  crosses the `structuredClone` worker boundary.

- The flag is only ever `true`; its absence encodes "not the channel", so
  it is added to a minority of rows instead of a `false` to every one —
  the shape #260 chose for `inErrorHandler`.

Closes #261
@ardelperal
ardelperal merged commit 1407759 into main Sep 3, 2026
5 checks passed
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): recognise the module-variable error channel

1 participant