feat(vba): emit label nodes and handles-error edges - #290
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #263 — task E6 of
docs/vba-error-handling-plan.md.What this does
One
labelnode per VBA line label, acontainsedge from the owning procedure, and a newhandles-erroredge from the procedure to the handler eachOn Error GoToroutes to. PlainGoTojumps reuse the genericreferenceskind, taggedvba-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 GoTotargets, the handler region, the dangling-target resolution.handlerBehavioris #260's derivederrorPolicy.behavior, copied verbatim rather than re-classified. The one genuinely new signal is the plain-GoTojump, 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 incloseErrorPolicy— deliberately before #260'smarkErrorHandlerRegion, so aGoToor a secondOn Error GoTowritten inside a handler region is stampedinErrorHandlerby that single stamping point like every other edge.Corpus measurements
npx tsx scripts/vba-coverage-probe.mjs --jsonover00_EXPEDIENTES,00_GESTION_RIESGOS,HPS_SOLICITUDES, before and after this branch:labelnodescontainsedgeshandles-erroredgesreferencesedgesNo 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) andstubProcedures(2,052).The extractor matches the committed probe exactly on all three counts:
errorHandling.labels.defined3,911handles-error= probestatements.onErrorGoToLabel3,832vba-gotoreferences = probegotoStatements4,062 −onErrorGoToLabel3,832 −onErrorGoToZero38Where 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 plainGoTocount is 192, not ~450.Dangling targets: zero, corpus-wide. No new unresolved reference appeared, which means every
On Error GoToand every plainGoTotarget in all three projects is defined in its own procedure. The issue's table predicted 1; the probe'sdanglingGotoTargetsis empty and this branch agrees with the probe. The dangling path is therefore covered by fixtures, not by the corpus.The retrieval-filter decision
labelis excluded from bothHIGH_VALUE_NODE_KINDS(src/context/index.ts) andCONTAINER_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,
labelis 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,869functionnodes, and 6× the number of constants.HIGH_VALUE_NODE_KINDSis the default node filter for context results, so includinglabelwould push thousands of near-identicalerroresnodes into every default response. That is precisely the failure mode #257 avoided when it keptparameterout 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_KINDSexpands a node's body into a structural outline in explore output. A handler label spans from its definition to the procedure'sEnd 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.tsalready uses.Design decisions worth reviewing
qualifiedNameis always<ModuleOrClass>.<Procedure>.<label>. VBA scopes labels to the procedure and this corpus defineserrores3,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 defineerroresand asserts distinct ids and distinct qualified names.handles-erroris not deduplicated. 47 procedures (the probe'sproceduresWithMultipleHandlers; the issue said 337 from the hand census) issue more than oneOn 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.errorPolicyresolved carrieshandlerBehavior/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 getsisHandler: trueand no behaviour. Flagging this explicitly — the issue's node table does not say what to do in this case, and I made the call.GoTotarget is skipped.GoTo 100names 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.UnresolvedReferencewithreferenceKind: 'references', neverhandles-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-errorwould materialise a cross-procedure error edge VBA's scoping forbids. Usingreferencesdowngrades 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 unresolvednoExistecan 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.Tests
New
__tests__/extraction-vba-labels.test.ts— 23 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 getsisHandler: false, no region keys at all and nohandles-erroredge; and a procedure with twoOn Error GoTostatements gets two edges. Also covered: theerrores-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:labelparsing as a search filter, and the two filter exclusions.Updated existing pins, all of them deliberate:
extraction-vba-error-policy.test.ts— the "zero new node kinds, zero new edge kinds" invariant now sets feat(vba): label nodes and handles-error edges #263's rows aside and re-asserts. That guard is feat(vba): record each procedure's error policy #259's own and still holds: the error-policy classifier remains a pure annotator. The rule-table pin gainsgoto-jump.extraction-vba-error-handler-region.test.ts— same treatment for feat(vba): mark edges emitted from inside an error handler #260's "adds no rows" invariant.stats-vba-rules.test.ts—errorsruleCount 4 → 5, total 23 → 24.status-json.test.ts/status-human.test.ts—EXTRACTION_VERSION25 → 26.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:dumpwas re-run: no diff, becausenodes.kindandedges.kindare plainTEXTwith noCHECKconstraint, exactly as the issue predicted. Nothing to commit there.Verification
npx tsc --noEmit— clean.npx vitest run __tests__/extraction-vba*.test.ts— 49 files, 986 passed, 1 skipped, 0 failed.vitest runOOMs on this machine — environmental, not this change). Every failure that remains is pre-existing and reproduces on unmodifiedmain:worktree-detection×15,multi-repo-workspace×2,extraction×2,npm-sdk×2 — allafterEachfs.rmSyncEPERM/EBUSY temp-dir removal on Windows. No new failure.What I could NOT verify
callers/calleesbyte-identity criterion was not run as such. The issue asks for byte-identicalcallers/calleesoutput 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,callsedges are unchanged at 4,642, and a unit test asserts no row is ever sourced from a label node. Sincecallers/calleesread exactly those rows they cannot have moved — but I did not diff the rendered command output.VbaExtractordirectly; 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