diff --git a/CHANGELOG.md b/CHANGELOG.md index 161dae7..4344bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- VBA classifiers now share one rule dispatcher with consistent scan modes, structural gates, counting, and declarative terminal behavior, preventing future rule tables from silently bypassing their documented preconditions. (#216) - VBA extraction now exposes only the classifier factories used by the shared source walker, removing obsolete compatibility entry points and other unused public helpers. (#217) ### Fixes diff --git a/__tests__/extraction-vba-rule-dispatcher.test.ts b/__tests__/extraction-vba-rule-dispatcher.test.ts new file mode 100644 index 0000000..75fcd78 --- /dev/null +++ b/__tests__/extraction-vba-rule-dispatcher.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { VbaExtractorContext } from '../src/extraction/vba/context'; +import { + defineRule, + runRules, + type VbaExtractionRule, +} from '../src/extraction/vba/rules'; + +const ctx = {} as VbaExtractorContext; + +describe('VBA rule dispatcher', () => { + it('honours scan mode for every rule and accumulates emitted counts', () => { + const maskedEmit = vi.fn(() => ({ count: 2 })); + const unmaskedEmit = vi.fn(() => ({ count: 3 })); + const rules: VbaExtractionRule[] = [ + defineRule({ id: 'masked', description: 'masked', pattern: /Call/, emit: maskedEmit, count: (r) => (r as { count: number }).count }), + defineRule({ id: 'unmasked', description: 'unmasked', pattern: /secret/, scan: 'unmasked', emit: unmaskedEmit, count: (r) => (r as { count: number }).count }), + ]; + + expect(runRules(rules, ctx, 'Call "secret"', 'Call ""', 1, {})).toBe(5); + expect(maskedEmit).toHaveBeenCalledWith(expect.anything(), ctx, 'Call ""', 1); + expect(unmaskedEmit).toHaveBeenCalledWith(expect.anything(), ctx, 'Call "secret"', 1); + }); + + it('honours structural gates and declarative terminal rules without inspecting ids', () => { + const skipped = vi.fn(() => ({})); + const terminal = vi.fn(() => ({})); + const afterTerminal = vi.fn(() => ({})); + const rules: VbaExtractionRule[] = [ + defineRule({ id: 'any-id', description: 'gated', pattern: /x/, requires: 'inside-procedure', emit: skipped }), + defineRule({ id: 'not-a-special-id', description: 'terminal', pattern: /x/, terminal: true, emit: terminal }), + defineRule({ id: 'later', description: 'later', pattern: /x/, emit: afterTerminal }), + ]; + + expect(runRules(rules, ctx, 'x', 'x', 1, { 'inside-procedure': false })).toBe(1); + expect(skipped).not.toHaveBeenCalled(); + expect(terminal).toHaveBeenCalledOnce(); + expect(afterTerminal).not.toHaveBeenCalled(); + }); +}); diff --git a/src/extraction/vba/call-sweep.ts b/src/extraction/vba/call-sweep.ts index f2b70d2..21f7177 100644 --- a/src/extraction/vba/call-sweep.ts +++ b/src/extraction/vba/call-sweep.ts @@ -8,7 +8,7 @@ import { PROC_RE, PROCEDURE_END_RE, PRIMITIVE_TYPES, isVbaKeyword } from './constants'; import { maskStringContent } from './text-utils'; import { ProcInfo, VbaClassifier } from './context'; -import { defineRule, matchRuleForScan, VbaExtractionRule } from './rules'; +import { defineRule, runRules, VbaExtractionRule } from './rules'; import { scanRaiseEvents, scanCallSites, @@ -175,6 +175,7 @@ export const RULES: readonly VbaExtractionRule[] = [ pattern: WITH_END_RE, scan: 'masked', requires: 'inside-procedure', + terminal: true, emit: (_m, ctx) => { ctx.vbaWithStack.pop(); return { kind: 'with-end' as const }; @@ -274,18 +275,9 @@ export function createCallsAndSqlClassifier( // rules only fire when the call-sweep's per-instance proc stack // is non-empty (matching the legacy cascade's `if (stack.length // > 0)` gate). - for (const rule of RULES) { - if (rule.requires === 'inside-procedure' && stack.length === 0) continue; - const matched = matchRuleForScan(rule, line, callScanLine); - if (!matched) continue; - const result = rule.emit(matched.match, ctx, matched.line, lineNum); - if (result !== null && result !== undefined) { - this.count += rule.count ? rule.count(result as never) : 1; - } - // `with-end` is short-circuited: a line that's `End With` is - // not also a `Set x = New ...`, so break the loop. - if (rule.id === 'with-end') break; - } + this.count += runRules(RULES, ctx, line, callScanLine, lineNum, { + 'inside-procedure': stack.length > 0, + }); // Don't scan call sites on the line that declares the procedure — it // would match the proc name itself in `Sub Outer()`. diff --git a/src/extraction/vba/declarations.ts b/src/extraction/vba/declarations.ts index d9b5ae8..ad1adad 100644 --- a/src/extraction/vba/declarations.ts +++ b/src/extraction/vba/declarations.ts @@ -9,7 +9,7 @@ import { Node } from '../../types'; import { generateNodeId } from '../tree-sitter-helpers'; import { foldVisibility } from './text-utils'; import { VbaClassifier } from './context'; -import { defineRule, matchRule, VbaExtractionRule } from './rules'; +import { defineRule, runRules, VbaExtractionRule } from './rules'; /** `[visibility] Event (...)` custom event declaration. */ const EVENT_DECL_RE = @@ -126,6 +126,7 @@ export const RULES: readonly VbaExtractionRule[] = [ 'Match `End Type` while inside a type block; emit nothing and clear `ctx.vbaDeclTypeBlock` so subsequent lines route back to the outside-type-block rules.', pattern: TYPE_END_RE, requires: 'inside-type-block', + terminal: true, emit: (_m, ctx) => { ctx.vbaDeclTypeBlock = null; return { kind: 'end-type-block' as const }; @@ -231,28 +232,10 @@ export function createEventsTypesDeclaresClassifier(): VbaClassifier { count: 0, classifyLine(line, i, ctx) { const lineNum = i + 1; - for (const rule of RULES) { - // Gate by the rule's `requires` precondition. The - // `outside-type-block` / `inside-type-block` values are the - // concrete preconditions the dispatcher knows about today; - // unknown strings are treated as "always" so adding new - // preconditions does not require touching this loop. - if (rule.requires === 'inside-type-block' && !ctx.vbaDeclTypeBlock) continue; - if (rule.requires === 'outside-type-block' && ctx.vbaDeclTypeBlock) continue; - const m = matchRule(rule.pattern, line); - if (!m) continue; - const result = rule.emit(m, ctx, line, lineNum); - if (result !== null && result !== undefined) { - this.count += rule.count ? rule.count(result as never) : 1; - } - // `type-end` cleared the type block — nothing else on this - // line can apply (a VBA `End Type` line cannot also be an - // `Event ` declaration). The legacy cascade did the - // same short-circuit via `return`. Break out of the loop - // explicitly so a later refactor cannot accidentally let an - // outside-type-block rule fire on the same line. - if (rule.id === 'type-end') break; - } + this.count += runRules(RULES, ctx, line, line, lineNum, { + 'inside-type-block': ctx.vbaDeclTypeBlock !== null, + 'outside-type-block': ctx.vbaDeclTypeBlock === null, + }); }, }; return cls; diff --git a/src/extraction/vba/dims.ts b/src/extraction/vba/dims.ts index 9dc753d..61addf6 100644 --- a/src/extraction/vba/dims.ts +++ b/src/extraction/vba/dims.ts @@ -8,7 +8,7 @@ import { Edge } from '../../types'; import { generateNodeId } from '../tree-sitter-helpers'; import { PRIMITIVE_TYPES, PROC_RE, PROCEDURE_END_RE } from './constants'; import { VbaClassifier } from './context'; -import { defineRule, matchRule, VbaExtractionRule } from './rules'; +import { defineRule, runRules, VbaExtractionRule } from './rules'; /** * Check that a line is a variable declaration and NOT a Sub/Function/ @@ -335,14 +335,7 @@ export function createDimsClassifier(): VbaClassifier { ctx.currentVarTypeProcKey = stack.length > 0 ? String(stack[stack.length - 1]) : 'module'; } - for (const rule of RULES) { - const m = matchRule(rule.pattern, line); - if (!m) continue; - const result = rule.emit(m, ctx, line, lineNum); - if (result !== null && result !== undefined) { - this.count += rule.count ? rule.count(result as never) : 1; - } - } + this.count += runRules(RULES, ctx, line, line, lineNum, {}); }, }; } diff --git a/src/extraction/vba/enums-consts.ts b/src/extraction/vba/enums-consts.ts index a5a60d2..6adbe3e 100644 --- a/src/extraction/vba/enums-consts.ts +++ b/src/extraction/vba/enums-consts.ts @@ -8,7 +8,7 @@ import { generateNodeId } from '../tree-sitter-helpers'; import { PROC_RE, PROCEDURE_END_RE } from './constants'; import { foldVisibility, parseConstDeclarations } from './text-utils'; import { VbaClassifier } from './context'; -import { defineRule, matchRule, VbaExtractionRule } from './rules'; +import { defineRule, runRules, VbaExtractionRule } from './rules'; /** `[visibility] Enum ` — opens an enum block. */ const ENUM_START_RE = @@ -126,6 +126,7 @@ export const RULES: readonly VbaExtractionRule[] = [ 'Match `End Enum` while inside an enum block; emit nothing and clear `ctx.vbaEnumBlock` so subsequent lines route back to the outside-enum-block rules.', pattern: ENUM_END_RE, requires: 'inside-enum-block', + terminal: true, emit: (_m, ctx) => { ctx.vbaEnumBlock = null; return { kind: 'end-enum-block' as const }; @@ -246,20 +247,10 @@ export function createEnumsConstsClassifier(): VbaClassifier { count: 0, classifyLine(line, i, ctx) { const lineNum = i + 1; - for (const rule of RULES) { - if (rule.requires === 'inside-enum-block' && !ctx.vbaEnumBlock) continue; - if (rule.requires === 'outside-enum-block' && ctx.vbaEnumBlock) continue; - const m = matchRule(rule.pattern, line); - if (!m) continue; - const result = rule.emit(m, ctx, line, lineNum); - if (result !== null && result !== undefined) { - this.count += rule.count ? rule.count(result as never) : 1; - } - // `enum-end` cleared the enum block — nothing else on this - // line can apply. The legacy cascade did the same short-circuit - // via `return`. - if (rule.id === 'enum-end') break; - } + this.count += runRules(RULES, ctx, line, line, lineNum, { + 'inside-enum-block': ctx.vbaEnumBlock !== null, + 'outside-enum-block': ctx.vbaEnumBlock === null, + }); }, }; return cls; diff --git a/src/extraction/vba/implements.ts b/src/extraction/vba/implements.ts index 071c89a..49ac262 100644 --- a/src/extraction/vba/implements.ts +++ b/src/extraction/vba/implements.ts @@ -6,7 +6,7 @@ import { Edge } from '../../types'; import { generateNodeId } from '../tree-sitter-helpers'; import { VbaClassifier } from './context'; -import { defineRule, matchRule, VbaExtractionRule } from './rules'; +import { defineRule, runRules, VbaExtractionRule } from './rules'; /** Implements regex. */ const IMPLEMENTS_RE = /^\s*Implements\s+(\p{L}[\p{L}\p{N}_]*)/iu; @@ -77,14 +77,7 @@ export function createImplementsClassifier(): VbaClassifier { count: 0, classifyLine(line, i, ctx) { const lineNum = i + 1; - for (const rule of RULES) { - const m = matchRule(rule.pattern, line); - if (!m) continue; - const result = rule.emit(m, ctx, line, lineNum); - if (result !== null && result !== undefined) { - this.count += rule.count ? rule.count(result as never) : 1; - } - } + this.count += runRules(RULES, ctx, line, line, lineNum, {}); }, }; } diff --git a/src/extraction/vba/procedures.ts b/src/extraction/vba/procedures.ts index 6a56cb6..2570b71 100644 --- a/src/extraction/vba/procedures.ts +++ b/src/extraction/vba/procedures.ts @@ -10,7 +10,7 @@ import { generateNodeId } from '../tree-sitter-helpers'; import { PROC_RE, PRIMITIVE_TYPES } from './constants'; import { parseEventHandlerName } from './text-utils'; import { ProcInfo, VbaClassifier } from './context'; -import { defineRule, matchRule, VbaExtractionRule } from './rules'; +import { defineRule, runRules, VbaExtractionRule } from './rules'; /** * Parse a `Function`/`Property Get` declaration's return type — the `As @@ -298,14 +298,7 @@ export function createProceduresClassifier(): VbaClassifier { count: 0, classifyLine(line, i, ctx) { const lineNum = i + 1; - for (const rule of RULES) { - const m = matchRule(rule.pattern, line); - if (!m) continue; - const result = rule.emit(m, ctx, line, lineNum); - if (result !== null && result !== undefined) { - this.count += rule.count ? rule.count(result as never) : 1; - } - } + this.count += runRules(RULES, ctx, line, line, lineNum, {}); }, }; } diff --git a/src/extraction/vba/rules.ts b/src/extraction/vba/rules.ts index 36ecf39..fe89175 100644 --- a/src/extraction/vba/rules.ts +++ b/src/extraction/vba/rules.ts @@ -51,10 +51,8 @@ import type { VbaExtractorContext } from './context'; * or a non-empty `RegExp[]` (a rule is matched when ANY * of the alternatives matches). The dispatcher runs * `.exec()` for single regexes and iterates for arrays. - * - `requires?` Optional structural gate. `'class'` means the rule - * only fires inside a `.cls` file; `'module'` only in - * `.bas`/`.frm`/`.dsr`; `'inside-procedure'` only when - * `ctx.procStack.length > 0`. The orchestrator is + * - `requires?` Optional structural gate from the closed + * `VbaRuleRequirement` union. The orchestrator is * expected to honour this — keeping the gate declarative * lets the rule own its own preconditions instead of * scattering `if` checks into the rule body. @@ -76,6 +74,8 @@ import type { VbaExtractorContext } from './context'; * rules whose emit can fan out (e.g. a multi-variable * `Dim a As Foo, b As Bar` line that produces two * `references` edges from one match). + * - `terminal?` Stop dispatch after this rule matches, without coupling + * dispatcher control flow to a rule id. * * The `count` parameter is typed as `unknown` to keep the * `VbaExtractionRule` shape covariant in `T` — narrowing `T` @@ -88,8 +88,9 @@ export interface VbaExtractionRule { readonly id: string; readonly description: string; readonly pattern: RegExp | RegExp[]; - readonly requires?: 'class' | 'module' | 'inside-procedure' | string; + readonly requires?: VbaRuleRequirement; readonly scan?: 'masked' | 'unmasked' | 'both'; + readonly terminal?: boolean; readonly emit: ( match: RegExpMatchArray, ctx: VbaExtractorContext, @@ -99,6 +100,15 @@ export interface VbaExtractionRule { readonly count?: (result: unknown) => number; } +export type VbaRuleRequirement = + | 'inside-procedure' + | 'inside-type-block' + | 'outside-type-block' + | 'inside-enum-block' + | 'outside-enum-block'; + +export type VbaRuleGates = Partial>; + /** * Helper to build a `VbaExtractionRule` with a single RegExp. * Most rules are 1-line declarations; this collapses the boilerplate @@ -170,3 +180,26 @@ export function matchRuleForScan( } return null; } + +/** Dispatch a rule table with consistent scan, gate, count, and terminal semantics. */ +export function runRules( + rules: readonly VbaExtractionRule[], + ctx: VbaExtractorContext, + line: string, + maskedLine: string, + lineNum: number, + gates: VbaRuleGates, +): number { + let count = 0; + for (const rule of rules) { + if (rule.requires && gates[rule.requires] !== true) continue; + const matched = matchRuleForScan(rule, line, maskedLine); + if (!matched) continue; + const result = rule.emit(matched.match, ctx, matched.line, lineNum); + if (result !== null && result !== undefined) { + count += rule.count ? rule.count(result) : 1; + } + if (rule.terminal) break; + } + return count; +}