Skip to content

feat(vba): emit label nodes and handles-error edges - #290

Merged
ardelperal merged 2 commits into
mainfrom
feat/issue-263
Sep 3, 2026
Merged

feat(vba): emit label nodes and handles-error edges#290
ardelperal merged 2 commits into
mainfrom
feat/issue-263

Conversation

@ardelperal

Copy link
Copy Markdown
Owner

Closes #263 — task E6 of docs/vba-error-handling-plan.md.

What this does

One label node per VBA line label, a contains edge from the owning procedure, and a new handles-error edge from the procedure to the handler each On Error GoTo routes to. Plain GoTo jumps reuse the generic references kind, tagged vba-goto.

#259 records whether a procedure has a handler; #260 marks which edges come from inside one. Neither gives the handler an identity you can point at, search for, or traverse to.

This adds no parsing. Everything 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, the dangling-target resolution. handlerBehavior is #260's derived errorPolicy.behavior, copied verbatim rather than re-classified. The one genuinely new signal is the plain-GoTo jump, which the policy classifier had no reason to look at while it emitted nothing; it arrives as a fifth rule on the same declarative table (goto-jump), not as a second scanner.

Emission lives in a new src/extraction/vba/labels.ts, called from one place in closeErrorPolicy — deliberately before #260's markErrorHandlerRegion, so a GoTo or a second On Error GoTo written inside a handler region is stamped inErrorHandler by that single stamping point like every other edge.

Corpus measurements

npx tsx scripts/vba-coverage-probe.mjs --json over 00_EXPEDIENTES, 00_GESTION_RIESGOS, HPS_SOLICITUDES, before and after this branch:

before after delta
label nodes 0 3,911 +3,911
contains edges 16,159 20,070 +3,911
handles-error edges 0 3,832 +3,832
references edges 6,281 6,473 +192
unresolved references 26,755 26,755 0
nodes, all kinds 26,089 30,000 +15.0%
edges, all kinds 29,521 37,456 +26.9%

No other node or edge kind moved — every one of the 16 other node kinds and 8 other edge kinds is byte-identical before and after, as are declaredProcedures (4,817) and stubProcedures (2,052).

The extractor matches the committed probe exactly on all three counts:

  • 3,911 label nodes = probe errorHandling.labels.defined 3,911
  • 3,832 handles-error = probe statements.onErrorGoToLabel 3,832
  • 192 vba-goto references = probe gotoStatements 4,062 − onErrorGoToLabel 3,832 − onErrorGoToZero 38

Where the probe and the issue's table disagree, the probe wins (the plan's E1 reconciliation records this). The issue's hand census said 3,912 labels, 3,776 handler targets and ~450 plain GoTo; the probe says 3,911 / 3,774 / 192, and this branch reproduces the probe. The issue's ≈+30% node forecast was based on the hand census — the measured figure is +15.0%, lower mainly because the plain GoTo count is 192, not ~450.

Dangling targets: zero, corpus-wide. No new unresolved reference appeared, which means every On Error GoTo and every plain GoTo target in all three projects is defined in its own procedure. The issue's table predicted 1; the probe's danglingGotoTargets is empty and this branch agrees with the probe. The dangling path is therefore covered by fixtures, not by the corpus.

The retrieval-filter decision

label is excluded from both HIGH_VALUE_NODE_KINDS (src/context/index.ts) and CONTAINER_NODE_KINDS (src/mcp/tools.ts), and a test asserts each.

The issue recommends this; the measurement makes it non-optional. At 3,911 nodes, label is now the most numerous declared VBA symbol in the corpus — more than the 4,817 real procedures once the 2,052 call stubs are discounted from the 6,869 function nodes, and 6× the number of constants. HIGH_VALUE_NODE_KINDS is the default node filter for context results, so including label would push thousands of near-identical errores nodes into every default response. That is precisely the failure mode #257 avoided when it kept parameter out of both arrays, and the argument is stronger here: a parameter at least varies by name, whereas 96.5% of these labels are the same word.

CONTAINER_NODE_KINDS expands a node's body into a structural outline in explore output. A handler label spans from its definition to the procedure's End Sub, so treating it as a container would print the tail of every procedure in the project.

Neither array is exported, so the tests pin the exclusion by regex over the source, following the identical assertions extraction-vba-parameters.test.ts already uses.

Design decisions worth reviewing

  • qualifiedName is always <ModuleOrClass>.<Procedure>.<label>. VBA scopes labels to the procedure and this corpus defines errores 3,735 times; without the procedure segment every handler in a project collapses into one symbol. A test extracts two procedures in one module that both define errores and asserts distinct ids and distinct qualified names.
  • handles-error is not deduplicated. 47 procedures (the probe's proceduresWithMultipleHandlers; the issue said 337 from the hand census) issue more than one On Error GoTo. Each is a distinct routing decision with its own line, so each gets its own edge — including two statements naming the same label.
  • Only the label whose region errorPolicy resolved carries handlerBehavior / regionStartLine / regionEndLine. A procedure that swaps to a second handler label has a second region nobody classified. Giving it the first region's behaviour would be wrong, and deriving a new one here would be exactly the re-classification the issue forbids. It gets isHandler: true and no behaviour. Flagging this explicitly — the issue's node table does not say what to do in this case, and I made the call.
  • A numeric GoTo target is skipped. GoTo 100 names a VBA line number, which the label detector cannot define, so a node for it can never exist and referencing it would fabricate a permanent dangling reference for legal code. Zero occurrences in this corpus; pinned by a fixture.
  • Dangling targets emit an UnresolvedReference with referenceKind: 'references', never handles-error. The row records a target that does not exist, so nothing routes errors to it — and if a resolver later matched a same-named symbol elsewhere in the project, handles-error would materialise a cross-procedure error edge VBA's scoping forbids. Using references downgrades that failure mode to a generic (wrong-but-inert) reference rather than a false error-handling claim. This residual risk is real and I could not eliminate it: an unresolved noExiste can still be name-matched against an unrelated project symbol by the generic resolver. It is 0 sites in this corpus, and the alternative — suppressing the row — would delete the only signal that finds the defect.
  • No re-parenting. Calls inside a handler stay attributed to the enclosing procedure; a test asserts nothing is ever sourced from a label node.

Tests

New __tests__/extraction-vba-labels.test.ts23 tests, all passing — covering every acceptance-criteria checkbox that is a unit test, plus the two the brief asked for specifically: a control-flow label gets isHandler: false, no region keys at all and no handles-error edge; and a procedure with two On Error GoTo statements gets two edges. Also covered: the errores-in-two-procedures collision, a label mentioned only inside a string literal (#209 discipline), a handler-swap procedure with two distinct labels, a label defined in a sibling procedure still counting as dangling, kind:label parsing as a search filter, and the two filter exclusions.

Updated existing pins, all of them deliberate:

docs/vba-error-handling-plan.md §E6 said "blocked, do not implement". It is rewritten to record that the block lifted, why (§4.3's three conditions), and the measured budget — with the guardrail that this is not licence to add a kind for anything else in E1–E5.

npm run schema:dump was re-run: no diff, because nodes.kind and edges.kind are plain TEXT with no CHECK constraint, exactly as the issue predicted. Nothing to commit there.

Verification

  • npx tsc --noEmit — clean.
  • npx vitest run __tests__/extraction-vba*.test.ts49 files, 986 passed, 1 skipped, 0 failed.
  • Remaining suites run in batches (a full single vitest run OOMs on this machine — environmental, not this change). Every failure that remains is pre-existing and reproduces on unmodified main: worktree-detection ×15, multi-repo-workspace ×2, extraction ×2, npm-sdk ×2 — all afterEach fs.rmSync EPERM/EBUSY temp-dir removal on Windows. No new failure.

What I could NOT verify

  • The callers/callees byte-identity criterion was not run as such. The issue asks for byte-identical callers/callees output across 10 sampled procedures before and after. What I verified instead is the property that criterion protects, at corpus scale and more strongly: unresolved-reference totals are identical (26,755 → 26,755) with an identical per-kind breakdown, calls edges are unchanged at 4,642, and a unit test asserts no row is ever sourced from a label node. Since callers/callees read exactly those rows they cannot have moved — but I did not diff the rendered command output.
  • No agent A/B run. This adds coverage rather than changing retrieval, and the exclusions above are what keep it out of the default surfaces, but the wall-clock and tool-call effect on a real flow question is unmeasured.
  • Corpus measurement is extractor-level, not index-level. The probe drives VbaExtractor directly; I did not build a full SQLite index over the corpus and re-run resolution, so post-resolution edge counts are unmeasured.

🤖 Generated with Claude Code

https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

ardelperal and others added 2 commits September 3, 2026 08:03
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
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
@ardelperal
ardelperal merged commit b27a5a9 into main Sep 3, 2026
5 checks passed
@ardelperal
ardelperal deleted the feat/issue-263 branch September 3, 2026 06:43
ardelperal added a commit that referenced this pull request Sep 3, 2026
* feat(vba): recognise the module-variable error channel

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 (#290)

* 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>

* fix(vba): stop reading On Error GoTo as a reference to a variable named 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>

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

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>
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): label nodes and handles-error edges

1 participant