Skip to content

feat(vba): mark edges emitted from inside an error handler - #289

Merged
ardelperal merged 1 commit into
mainfrom
feat/issue-260
Sep 3, 2026
Merged

ardelperal merged 1 commit into
mainfrom
feat/issue-260

Conversation

@ardelperal

Copy link
Copy Markdown
Owner

Task E3 of docs/vba-error-handling-plan.md. Closes #260.

What this does

Every call a procedure made inside its error handler was attributed to the enclosing procedure exactly like a call on the happy path, so Riesgo.Guardar -> MsgBox and Riesgo.Guardar -> Escribir were indistinguishable in the graph when only one of them runs on failure (section 3.4). E2 (#259) already resolved the handler region; this PR puts that information onto the rows.

Two fields, both on rows that already existed. No new node kind, no new edge kind, no new row:

  1. metadata.inErrorHandler: true on every edge and every UnresolvedReference emitted from inside the handler region.
  2. errorPolicy.behavior — channel / display / reraise / mixed / unknown, filling in the null E2 deliberately shipped.

Decisions worth reviewing

The flag is stamped at one place. closeErrorPolicy in src/extraction/vba/errors.ts walks the procedure's own slice of ctx.edges / ctx.unresolvedReferences (bounded by marks taken when the body opened, so a module with N procedures stays linear) and flags the rows whose line falls in [handlerStartLine, handlerEndLine], the exact region published on the node. The issue asked for this explicitly, and it is why the six emitters that can fire inside a handler needed no edit: a new emitter inherits the behaviour without knowing the feature exists. ctx.inErrorHandler(lineNum) exposes the same predicate for anything that needs it mid-walk.

The off-by-one guard is structural, not arithmetic. The region's upper bound is the open procedure itself — vbaErrorPolicy is cleared at End Sub / End Function / End Property, so the next procedure starts with a fresh accumulator and can never inherit the previous one's region. Pinned by a test either way.

The flag is only ever true; absence is the "not in a handler" encoding. That adds a key to ~2,880 rows instead of a false to 56,276.

behavior mirrors the probe by hand. scripts/vba-coverage-probe.mjs is the instrument the section 2.3 census was measured with, and it cannot import the extractor (it must also run against a shipped dist/). So the channel-write matcher, the display-call list, the Err.Raise test and the statement splitter with its If ... Then guard strip are copied verbatim, the same way #259 copied LINE_LABEL_RE / NOT_A_LABEL — change one, change the other. The guard strip is what makes the corpus's fifth most common handler shape classify correctly:

errores:
    DoCmd.Hourglass False
    If Err.Number <> 1000 Then m_Error = "..." & Err.Description

Signals are collected over the whole region, per statement, so the leading DoCmd cannot shadow the later write — channel, not unknown. There is a fixture for exactly this.

The three signals are collected independently and mixed is what more than one means. The pre-probe classifier tested them in sequence and let evaluation order hide the 921 handlers that both record the error and display it.

behavior is null only when there is no handler to describe (resume-next / none), and unknown when a handler exists but nothing in it is recognised. Two different facts, and the probe's rule.

The error-channel names are still the hard-coded four (m_Error, p_Error, g_Error, Error), identical to the probe's defaults. E4 (#261) is the task that turns them into the vba.errorChannel config knob; making them configurable here would have forked the knob from its owner.

Corpus measurements

00_EXPEDIENTES, 00_GESTION_RIESGOS, HPS_SOLICITUDES — 4,817 procedures, read-only.

behavior — extractor vs probe, procedure by procedure: 0 disagreements

behavior Extractor Probe
channel 2,681 2,681
mixed 921 921
unknown 106 106
display 52 52
reraise 14 14
null (227 resume-next + 816 unprotected) 1,043 1,043

This matches the probe's exclusive table in section 2.3 exactly. It deliberately does not match the "~970 display / ~2,788 channel" numbers the issue's acceptance box quotes: those are the raw non-exclusive signal totals (3,602 channel / 971 display / 16 re-raise), which the same probe run reproduces unchanged. The plan's section 2.3 already records that the exclusive table supersedes them, and mixed is where the difference lives — 921 handlers both record and display.

Cross-checked in CI too: a test runs both classifiers over one fixture and asserts they agree procedure by procedure, so a future edit that forks them fails there rather than in a re-measurement nobody runs.

Totals unchanged (the merge-blocking invariant)

Measured by running the extractor over the same corpus twice — once on this branch, once with src/extraction/vba/errors.ts and src/extraction/vba/context.ts restored to origin/main:

This branch origin/main
Nodes 26,089 26,089
Edges 29,521 29,521
Unresolved references 26,755 26,755

nodesByKind, edgesByKind and unresolvedByKind are identical between the two runs. On origin/main behavior is null for all 4,817 procedures and no row carries inErrorHandler.

What gained the flag

57 edges — 51 calls, 2 references / vba-tempvar, 2 opens-form, 1 references / vba-sql-table, 1 references / vba-set-new.

2,823 unresolved references — 2,047 calls, 690 property-get, 40 unqualified-ident, 26 references, 20 property-set.

The calls share depends on #265, which landed first: before it, statement-form calls (MsgBox "x", DoCmd.Hourglass False) were classified unqualified-ident and the handler's dominant construct would have been mis-shaped.

Tests

New __tests__/extraction-vba-error-handler-region.test.ts — 22 tests: the happy-path/handler split, no-handler and untargeted-label cases, an SQL table reference inside a handler still emitted and now flagged, a DoCmd.OpenForm edge from a different emitter inheriting the flag, the off-by-one guard, ctx.inErrorHandler directly, the full behaviour matrix including cleanup-then-record, channel read-vs-write, ErrorCount not matching Error, a string-literal MsgBox, and the probe cross-check. Plus an "adds no node, no edge and no unresolved reference" assertion.

__tests__/extraction-vba-error-policy.test.ts — the one test that pinned behavior: null for a handler is now the E3 assertion; a new test keeps the null-when-no-handler case covered.

Suite runs (in batches — a full npx vitest run OOMs on this machine, which is an environment limit, not this change):

  • All 59 VBA test files: 1,073 passed, 1 skipped, 0 failed.
  • All 147 non-VBA test files: 2,498 passed, 58 skipped, 21 failed — all 21 pre-existing and environmental (afterEach EPERM/EBUSY temp-dir removals in worktree-detection (15), multi-repo-workspace (2), extraction (2), npm-sdk (2)). No other failure.
  • npx tsc --noEmit clean.

What I could not verify

  • CI on a clean machine. The 21 failures above reproduce independently of this change, but I concluded that from their content (temp-dir removal in afterEach on Windows), not from a run on origin/main.
  • npm test as a single invocation. It dies with Fatal process out of memory here; everything was run in batches instead.
  • One corner the corpus cannot exercise: a handler label carrying a trailing statement on its own line (errores: MsgBox "x") occurs 0 times in the corpus's 3,774 handlers. The behavior signals read that trailing statement (the probe does, so this must too); the inErrorHandler flag uses [handlerStartLine, handlerEndLine], which starts on the line after the label — so an edge emitted on the label's own line would not be flagged. On this corpus the two can never disagree, and I chose consistency with the region published on the node over covering a shape that does not occur. It is documented in the plan and in the code.
  • No end-to-end index/query check. This is extractor-level metadata; whether the DB layer round-trips metadata.inErrorHandler unchanged is inherited from how every other edge-metadata field is stored, not re-proved here.

🤖 Generated with Claude Code

https://claude.ai/code/session_019gmKKUq1ng5ESk6Qhxu77d

Every call a procedure made inside its error handler was attributed to the
enclosing procedure exactly like a call on the happy path, so
`Riesgo.Guardar -> MsgBox` and `Riesgo.Guardar -> Escribir` were
indistinguishable in the graph when only one of them runs on failure. This is
§3.4 of `docs/vba-error-handling-plan.md`, and E2 already resolved the handler
region — the information was there, it just was not on the rows.

Two things land, both as fields on rows that already existed:

`metadata.inErrorHandler: true` on every edge and every unresolved reference
emitted from inside the region. It is stamped at ONE place — `closeErrorPolicy`,
over the procedure's own slice of the two accumulators, bounded by the
`[handlerStartLine, handlerEndLine]` the node publishes — rather than in each of
the six emitters that can fire inside a handler. Six emitters each remembering a
flag is six places to forget it; here a new emitter inherits the behaviour with
no knowledge of the feature. `ctx.inErrorHandler(lineNum)` exposes the same
predicate for anything that needs it mid-walk. The upper bound is the open
procedure itself: the accumulator is cleared at `End Sub`, so the next procedure
cannot inherit the previous one's region. That is the off-by-one guard, by
construction rather than by arithmetic.

`errorPolicy.behavior`, which E2 shipped as a deliberate `null`. It is derived
from three independently collected signals over the handler body — a write to an
error-channel variable, a `MsgBox` / `Debug.Print`, an `Err.Raise` — and `mixed`
is what more than one of them means. Collecting them independently is the point:
the pre-probe classifier tested one after another and let evaluation order hide
the 921 handlers that both record the error and show it.

The classifier mirrors `scripts/vba-coverage-probe.mjs` by hand, because the
probe is the instrument the corpus census was measured with and the probe cannot
import the extractor (it must also run against a shipped `dist/`). Same channel
matcher, same display list, same statement splitter with its `If … Then` guard
strip — which is what makes the corpus's fifth most common handler shape,
`DoCmd.Hourglass False` cleanup followed by a guarded channel write, classify as
`channel` instead of being thrown by the leading call. A test runs both
classifiers over one fixture and asserts they agree procedure by procedure, so a
future fork fails there rather than in a re-measurement nobody runs.

Measured over the three corpus projects the two agree on all 4,817 procedures,
with zero disagreements: 2,681 `channel`, 921 `mixed`, 106 `unknown`, 52
`display`, 14 `reraise`, 1,043 `null`. Node, edge and unresolved-reference
totals are byte-identical to `origin/main` (26,089 / 29,521 / 26,755) with
identical per-kind breakdowns; 57 edges and 2,823 references gained the flag.

Closes #260

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

Copy link
Copy Markdown
Owner Author

Rebased onto 0bf938a (main gained #288, the Access ERD half of #257, while this was in flight). The only conflict was one CHANGELOG line; both entries kept. Re-verified after the rebase: tsc --noEmit clean, and the five interacting suites together (error-handler region, error policy, Access ERD, parameter nodes, VBA extraction) — 326 passed.

Two notes on your honest caveats, both now settled from my side:

  • You did not run the 21 failing suites against origin/main to prove they pre-exist. I have, repeatedly, across this session's PRs — they reproduce unmodified on main, all afterEach fs.rmSync EPERM/EBUSY temp-dir removals. There is in fact a 22nd: __tests__/watcher.test.ts:77, same shape, also confirmed on unmodified main. My "exactly 21" brief was incomplete, not your branch.
  • Stamping in closeErrorPolicy over the procedure's own slice, rather than routing every push through a helper, is a better answer than the one the issue asked for. The issue wanted one stamping point so no emitter can forget the flag; you got that and zero emitter edits, so a future emitter inherits it for free. The opens-form test — an edge from docmd.ts, flagged without that file knowing the feature exists — is the right proof to have written.

@ardelperal
ardelperal merged commit 5f2f285 into main Sep 3, 2026
5 checks passed
@ardelperal
ardelperal deleted the feat/issue-260 branch September 3, 2026 05:46
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): mark edges emitted from inside an error handler

1 participant