Status: COMPLETE. The port shipped at v1.0.0. Milestones M0 through M10 are done (the full lexer, parser, symbol and project index, conditional compilation, type inference, host object model, member-completion surface, project-type registry, and all 85 registry rules), and a file/workbook ingestion layer was added on top (analyze_project, analyze_workbook, the loose-file reader, and the CLI). This document is retained as the historical build plan and the XLIDE data-sync reference; the milestone sections below are written in the future tense they were planned in, and the "first action: start M0" guidance is historical. For current contributor guidance see CONTRIBUTING.md, and treat
pyvbaanalysis/data/manifest.jsonas the authoritative list of vendored data files (the filenames sketched below predate the shipped layout).
This file is the operating plan for building pyVBAanalysis: an independent,
pure-Python static analyzer for VBA, ported from the proven XLIDE TypeScript
analyzer. It is both the porting gameplan and the repo-level agent instruction
file (per RG-17 in docs/agentic_ai_programming_best_practices.md). Read that
best-practices doc first; this plan assumes it. Write everything in plain ASCII,
no em dashes, no AI tells (UM-07).
Grounded against the XLIDE repo at the sibling checkout ../xlide_vscode (read on
2026-06-17). Every count and path below was read from that repo, not recalled. If
a fact here ever conflicts with the XLIDE source, the source wins; re-verify
before acting (RG-18).
Build a library that takes VBA source text and returns diagnostics, plus the analysis model behind them (tokens, AST, symbols, types). Full static-analysis parity with XLIDE is the goal: every diagnostic XLIDE ships, Python ships, with the same no-false-positive guarantee.
In scope:
- Lexer, conditional-compilation activity, parser/AST, symbols and scope, name resolution, type inference, dataflow, the diagnostics engine, and all 117 diagnostic codes (85 rule functions across 17 family files).
- The Excel host metadata as a data layer (it is data, not COM), because some rules (member-not-found, host type narrowing) need it for full parity. It is optional to load: pure-VBA analysis runs without it.
Out of scope:
- The Excel/VBE oracle and any COM. XLIDE owns the oracle and the evidence pipeline. Python never generates ground truth; it consumes the emitted data.
- Editor features: completion UI, hover, signature help, code actions, live-edit span heuristics. Port only the resolver cores that diagnostics depend on (see Risk 7), not the editor wrapping.
Dependencies: the only runtime dependency is pyOpenVBA (https://pypi.org/project/pyOpenVBA/, version 3.0.1, requires Python >=3.10), used to read VBA modules directly out of Excel workbooks (the analyzer targets Excel VBA; the shipped reader is Excel-only). pyOpenVBA is itself pure Python with no transitive dependencies, so the whole runtime tree stays pure Python; everything else is the standard library. The analysis core operates on source text and never imports pyOpenVBA. Only the reader layer (Section 3) imports it, so the core is testable without any Office file. Dev-only tooling (pytest, ruff, mypy) is not a runtime dependency.
The hard part is already done. XLIDE is a proven reference implementation and it carries a language-agnostic evidence corpus (397 oracle-verified cases plus a provenance audit and an MS-VBAL verification map). That turns this from research into a porting project with a correctness oracle at every step.
XLIDE owns: the oracle (Excel/VBE ground-truth generation), the evidence pipeline, and the data package below. Python owns: pure analysis only.
XLIDE exports a versioned data package that is the single contract surface. Python pins a version of it and consumes it verbatim. Do not reimplement any of this data in Python; load it.
| Asset | XLIDE source | Python consumes as |
|---|---|---|
| Oracle case corpus | syntax_corpus/oracle/vbe_oracle_cases.json (v1, 397 cases) |
pytest parametrize fixtures; ground truth for present/absent diagnostics |
| Diagnostic provenance audit | syntax_corpus/diagnostic_influence_audit.json (117 diagnostics: 71 vbe-oracle-verified, 46 spec-derived) |
single source of truth for which rules ship and their no-FP gating; the notes field becomes the rule docstring and gate |
| Rule metadata catalog | src/analyzer/diagnostics/ruleMetadata.ts (117 code: entries) |
rule_metadata.json -> dataclasses (code, title, severity, category, diagnosticKind, specReference, confidence, suppressionScopes) |
| MS-VBAL verification map | docs/spec/MS-VBAL.verification-map.md |
spec-reference annotations and a coverage report |
| Corpus provenance | syntax_corpus/corpus_provenance.json |
CI integrity check |
| Managed backlog | syntax_corpus/managed_backlog.md |
decision record: why a candidate is NOT ported |
| VBA runtime metadata | src/analyzer/runtime/vbaRuntime.ts |
vba_runtime.json (host-agnostic; the only metadata pure-VBA needs) |
| Excel host tables | src/analyzer/host/excelReferenceMembers.ts + reference/excel/json/*.json (1029) + reference/office/json/*.json (511) |
excel_host.json + office_constants.json (optional host layer) |
Oracle case shape: {id, description, provenance, expected, mode, source, evidencePhase, diagnosticMeaning}. Audit entry shape: {code, status, sourceOfTruth, corpusInfluence, assertedOracleCases[], observeOnlyOracleCases[], pendingLegacyCorpusFiles[], diagnosticKind, notes}.
Proposed export artifact (XLIDE side, one-time setup): a pyvba-data-package/
directory bundling vbe_oracle_cases.json, diagnostic_influence_audit.json,
rule_metadata.json, vba_runtime.json, excel_host.json,
office_constants.json, and a top-level manifest.json carrying a semver, a
sha256 per file, the 117 code ids, the 85 rule ids, and an explicit
code-to-function map (see Risk 8). Python vendors this under
pyvbaanalysis/data/ and pins the version. Until XLIDE builds the export, the
porting agent reads the raw files above directly and treats XLIDE's TypeScript as
the executable spec.
The no-false-positive rule is inherited and non-negotiable: a Python rule ships
only when it has a diagnostic_influence_audit.json entry with status in
{spec-derived, vbe-oracle-verified}. Never invent a rule. Always port a rule
together with its assertedOracleCases as fixtures. Copy the audit notes
verbatim as the rule's gate and docstring.
Idiomatic Python that mirrors XLIDE's separation of concerns (UM-01, UM-02).
Use Enum for every Kind/Severity discriminant, @dataclass(frozen=True)
for nodes with isinstance dispatch (no TS unions), frozenset/tuple for
read-only data, functools.lru_cache or a dict where XLIDE uses a WeakMap,
typing.Protocol for visitor/tracker interfaces, and case-insensitive lookups
via lowercased keys (VBA is case-insensitive).
pyvbaanalysis/
__init__.py # public API: analyze_module, parse_module, tokenize, ProjectIndex, analyze_workbook
reader/
workbook.py # read VBA modules from Excel .xlsm/.xlsb/.xlam/.xls via pyOpenVBA -> source; the ONLY pyOpenVBA import
lexer/
token_kinds.py # TokenKind, TriviaKind (Enum); VbaToken, Trivia (dataclass)
tokenize.py # tokenize(), tokenize_cached(), date-literal validator
token_helpers.py # match_paren_from, split_top_level_token_groups, statement_tokens, ...
trivia.py # leading trivia, line-continuation
keyword_table.py # canonical_keyword, RESERVED_*; loads keyword data
stripped_lines.py
constants/
integer_constant_expression.py # evaluate, resolve_raw, cycle detection
conditional/
conditional_compilation.py # ConditionalActivity (Enum), stack-replay tracker, evaluator
parser/
nodes.py # all Node/Expr dataclasses; NodeKind/ExprKind Enums; Span
parse_module.py # parse_module() entry + LRU
parse_expression.py # ExpressionParser (precedence climbing)
parser_state.py # LogicalStatement, StatementCursor, split_logical_statements
type_declaration_suffix.py
fixed_length_string.py
symbols/
symbol_model.py # VbaSymbol, VbaSymbolKind, signatures, formatters
build_module_symbols.py
name_resolution.py # resolve_bare_identifier_binding (MS-VBAL 5.3)
project_index.py # ProjectIndex class
types/
type_inference.py # signature tables, infer_argument_type, normalize_type
diagnostics/
model.py # VbaDiagnostic, DiagnosticSeverity/Category/EvidenceKind Enums
analyze_module.py # analyze_module(source, options) orchestrator
context.py # RulePassContext (shared, memoized)
registry.py # ordered rule list (85 entries); output order is a contract
rule_metadata.py # loads rule_metadata.json (117 codes)
call_extraction.py
walker.py / exprwalk.py / dataflow.py # shared walks + branch-merged lattice
rules/ # one module per family, mirrors src/analyzer/diagnostics/rules/**
lexical.py duplicates.py declarations.py call_arity.py
argument_types.py argument_shape.py runtime_values.py assignments.py
arrays.py type_of_is.py binary_operand_scalar.py object_state.py
undeclared.py module_kind.py expressions.py control_flow.py
numeric_literals.py structural.py shared.py
runtime/
vba_runtime.py # loads vba_runtime.json (host-agnostic)
host/ # OPTIONAL Excel layer; pure-VBA analysis runs without it
host_model.py object_model.py
data/excel_host.json office_constants.json
data/ # the vendored, version-pinned XLIDE export
manifest.json vbe_oracle_cases.json diagnostic_influence_audit.json rule_metadata.json
tests/
... mirrors tests/diagnostics/** (18 family suites)
conftest.py # loads the data package
differential/ # TS-vs-Python golden harness (see Section 5)
Do not over-split (UM-04): one module per rule family, not one per rule. Do not
let any file become a monolith (UM-03): the 8900-line excelReferenceMembers.ts
must land as JSON data plus a thin resolver, never as a giant .py.
The reader layer (reader/workbook.py) is the only place pyOpenVBA is imported.
It turns an Excel workbook (.xlsm/.xlsb/.xlam/.xls) into module source text and
feeds the pure analyzer; analyze_module(source) and everything below it stays
stdlib-only (UM-01), so the core is testable from plain strings without any file. The
import name is most likely pyopenvba (per the wheel pyopenvba-3.0.1); confirm
at first use.
Tags: CORE = analysis-core, must port. HOST = Excel-specific, port as data. EDITOR = out of scope. Size to port: S/M/L.
- Lexer (
src/analyzer/lexer/**,constants/integerConstantExpression.ts):tokenizewith full round-trip fidelity (CORE, S); 14 token kinds plus trivia (CORE, S); line-continuation trivia (CORE, S); statement-start context for Rem and directives (CORE, S); canonical keyword casing and the keyword tables RESERVED_*, CONTEXTUAL_KEYWORDS, OPERATOR_IDENTIFIERS, etc. (CORE data, S); integer/hex/octal/float literals with type suffixes%&^!#@$(CORE, S); date-literal ambiguous-grammar validator (CORE, M, subtle, see Risk 6); integer-constant-expression evaluator with cycle detection (CORE, M). - Conditional compilation (
conditional/conditionalCompilation.ts): directive index,#Constextraction, the default constants (VBA7=true, Win64=true, Win32=false, Mac=false), the Or/And/Not/comparison evaluator that returns a value or unknown, and the stack-replay activity tracker with O(log n) span queries (CORE, M). Unknown stays unknown; never guess a branch. - Parser/AST (
parser/**):parse_moduleentry (CORE, M); the full node set (24 NodeKind, 10 ExprKind) with every field (CORE, M); the precedence-climbing expression parser with bang/dot member access, New/AddressOf/TypeOf..Is, and named/omitted args (CORE, M); logical-statement splitting per MS-VBAL 3.3.1 (CORE, S); IfBranch flow modeling (CORE, S); type-suffix mapping (CORE data, S); error tolerance (never throw; emit ParseDiagnostic and best-effort nodes; dangling-directive auto-close; stray-closer recovery) (CORE, M); the structured-vs-raw fallback (Assignment/Call/blocks structured; goto/label/ return/exit raw) (CORE, M, see Risk 4). - Symbols and scope (
symbols/**): the symbol model (16 kinds, visibility, attributes) (CORE, S);build_module_symbolshonoring conditional activity (CORE, M); signature model and formatters (CORE, S);ProjectIndexcross- module class (visibility, type/member surfaces, definition resolution, duplicate detection) (CORE, L); bare-identifier resolution per MS-VBAL 5.3 with local/module/project plus ambiguous/unresolved tri-state and shadowing (CORE, M); member-surface resolution with Property Get/Let/Set merge,VB_UserMemId=0default member, andImplementsinheritance (CORE, M). - Type inference (
diagnostics/typeInference.ts): signature tables with memoization (CORE, L);is_known_scalar_type,normalize_type(CORE, S);infer_argument_typeover literals/identifiers/call-returns/member-access/New with const and enum folding (CORE, L);resolve_known_object_assignment_type(CORE, M). - Dataflow (
diagnostics/dataflow.ts,walker.ts,exprWalk.ts): one shared statement pass and one shared expression pass per body (CORE, M); the straight-line lattice walk (CORE, M);walk_branch_merged_body, the meet- toward-unknown join over balanced If arms (CORE, L, hardest algorithm, see Risk 3); the object-var-not-set and unallocated-dynamic-array state trackers (CORE, M). - Diagnostics engine (
diagnostics/analyzeModule.ts,analysisContext.ts,registry.ts,ruleMetadata.ts,callExtraction.ts):analyze_module(source, options)orchestrator (CORE, M); the options input contract (project visibility, host model, conditional compilation, parsed module, severity overrides) (CORE, S); the shared/memoizedRulePassContext(CORE, M); the 85-entry ordered registry with run/procedureStatements/procedureExpressions forms and per-rule buffered flush, where output order is a contract (CORE, M); the 117-code metadata (CORE data, S); the severity model with off/downgrade overrides and the suppression-scope vocabulary (CORE, S); call extraction with arity and named-arg detection (CORE, M). - Rules (
diagnostics/rules/**, 17 files, 85 functions, 117 codes): lexical, duplicates, declarations, call arity, argument types, argument shape, runtime values, assignments, arrays, type-of-is, binary-operand-scalar, object state, undeclared (member-not-found), module kind, expressions, control flow, numeric literals, plus the structural block-balance pass. One function can emit several codes (for example arrays: 8 functions -> 12 codes; control flow: 9 -> 14), so track parity per code, not per function. - Host and runtime metadata:
vbaRuntime.tsis host-agnostic and required (CORE data, M).excelReferenceMembers.ts(229 types, 5900+ members, 36 of them with hard diagnostics) plus the 1029 Excel and 511 Office reference JSON files are the Excel host layer (HOST, L). Port as JSON plus thin resolvers; replicatescripts/generate-excel-reference-metadata.mjsin Python so the host JSON regenerates fromreference/**.
The validation backbone is a TS-vs-Python differential harness. Build it first; it makes every later milestone objectively checkable.
- M0: Data ingestion plus the differential harness. Vendor the data package into
pyvbaanalysis/data/. Add a small Node script in XLIDE that dumps, for each oraclesourceand eachtests/diagnostics/**fixture, the TStokenize,parse_module,build_module_symbols, andanalyze_moduleoutput to JSON. Python compares against these goldens. Validate: harness runs, goldens captured. - M1: Lexer plus integer-const-expr. Validate: round-trip (trivia plus rawText
reconstructs the source exactly) on all 397 oracle sources; token parity vs the
TS dump and
vbaLexer.test.ts. Round-trippability is the acceptance gate. - M2: Parser/AST plus parser_state. Validate: AST shape and span parity vs the TS
parse_moduledump over the corpus, plusparseExpression.test.tsandvbaParser.test.ts; a never-throws property test on malformed input. - M3: Conditional compilation. Validate:
vbaConditionalCompilation.test.ts(VBA7/Win64/Win32/Mac plus project#Const; unknown stays unknown). - M4: Symbols, ProjectIndex, name resolution. Validate:
vbaSymbolGraph.test.ts,vbaProjectIndexService.test.ts(visibility, ambiguity preserved, shadowing); inactive-branch filtering vs M3. - M5: Type inference, call extraction, engine skeleton (registry, context,
severity, buffered flush, zero rules). Validate: signature-table and
infer_argument_typeparity vs the TS dump;analyze_modulereturns empty in registry order. - M6: First rules: structural, lexical, duplicates, declarations, control flow,
expressions (token/structure-heavy, low type dependence; about 46 functions).
Validate: per-family suites plus every
assertedOracleCasefor these codes fires or stays silent per the auditstatus. - M7: Dataflow plus object_state plus arrays (needs
walk_branch_merged_body). Validate: object-var-not-set and unallocated-array oracle cases including the balanced-If merge controls. - M8: Type and call rules: argument types, argument shape, call arity, assignments, runtime values, type-of-is, binary-operand-scalar, numeric literals. Validate: the runtime-error 13/94/6 and arity (PCEC_008) oracle cases plus their compile-valid controls.
- M9: Project and host rules: undeclared (member-not-found), module kind, plus the VBA runtime metadata; Excel host as an optional extra. Validate: cross- module fixtures; member-not-found fires only on the 36 exhaustive host types.
- M10: Full parity sweep. Run all 397 oracle cases and all family suites through the differential harness. Acceptance: registry output order matches, every shipped code is reachable, zero diffs vs TS on control cases.
At each milestone, follow the agentic loop in the best-practices doc: understand, plan the smallest coherent step, change with tests, validate with the harness and pytest, report honestly (UM-08). "Done" means the harness and the relevant oracle cases are green, not "looks right."
Shared contract. XLIDE is the sole owner of the oracle, the evidence pipeline,
and the data package. It exports a versioned pyvba-data-package/ (manifest with
semver, per-file sha256, the 117 code ids, the 85 rule ids, and the
code-to-function map). Python pins a package version. Python never regenerates
oracle data and never invents a rule that lacks an audit entry.
Drift detection (Python CI). A test_manifest_parity.py that fails when:
- A code in the audit with status in {spec-derived, vbe-oracle-verified} has no Python rule emitting it (unported code).
- A Python rule emits a code absent from the manifest (orphan or divergent code).
- A manifest
assertedOracleCaseid has no corresponding Python fixture assertion (untested case). - The vendored package sha256 does not match the manifest (stale vendor).
This turns the audit into an executable parity gate.
Porting a new XLIDE rule. (a) XLIDE adds the rule, its oracle cases, the audit
entry, and the metadata, then re-exports the data package with a version bump.
(b) Python bumps the pin; drift CI flags the new unported code red. (c) Port the
rule function into the matching rules/*.py, copying the audit notes verbatim
as the no-FP gate and docstring. (d) The rule's assertedOracleCases become its
fixtures automatically. (e) Drift CI goes green. Rules are always ported with
their oracle cases as fixtures, never code without evidence.
Avoiding divergence. Single source of truth for evidence: the data package. Python holds no independent oracle or spec. Registry order is part of the contract and is snapshot-tested. Keep the differential harness in CI on a schedule so a TS behavior change surfaces as a Python diff even before a manifest bump.
- Inherit
docs/agentic_ai_programming_best_practices.mdin full. Highlights that bite here: small focused changes (RG-01); tests as contracts (RG-04); simplicity over generality (RG-05); ground every claim against the XLIDE source (RG-18); honest status (UM-08); stop and ask when blocked (UM-09). - No-false-positive discipline is the prime directive. If a construct is not provably wrong via the audit plus its oracle/spec evidence, the analyzer stays quiet. A false positive is a worse failure than a missed diagnostic.
- Dependencies (RG-10): pyOpenVBA is the only runtime dependency, and only the reader layer imports it. Everything else is the Python standard library. Do not add a runtime dependency; if you think you need one, stop and ask (UM-09).
- Plain ASCII only (UM-07). Use
->not an arrow glyph, straight quotes, no em dashes, no emoji, spelled-out spec references. - Mark generated and vendored content.
pyvbaanalysis/data/andhost/data/are vendored from XLIDE; do not hand-edit them. The host JSON is generated fromreference/**; regenerate, do not edit. - The XLIDE TypeScript is the executable spec during the port. When the harness shows a diff, XLIDE is right unless you can prove a TS bug, in which case fix it in XLIDE first and re-export, never fork the behavior in Python.
- Determinism. Same input plus same data-package version yields the same diagnostics in the same order, every run.
- Excel host scope for v1. The host layer (excelReferenceMembers plus 1029+511 reference JSON) is large and Excel-specific. Recommendation: ship the host- agnostic core first (lexer through M8 plus vbaRuntime), then add the Excel host layer (M9) to light up member-not-found and host type narrowing. Decide whether full Excel parity is required for the first usable release or can follow.
- Node typing model. Use a frozen
@dataclasshierarchy withisinstancedispatch (recommended) rather than a single tagged dataclass. This affects every walker and rule; decide once, up front. walk_branch_merged_bodyis the hardest single algorithm and underpins the no-FP soundness of object-var-not-set and unallocated-array. Gate it behind its full oracle-control set before enabling M7 and M8.- Parser structured-vs-raw fallback. Rules branch on whether a statement is a structured Assignment/Call or a raw Statement. A Python parser that structures more or less than TS silently changes which rules fire. The differential AST harness must assert raw/structured parity exactly.
- Performance. Target under 1 second for a 10k-line module. WeakMap memo becomes lru_cache or dict; Python is slower per token. If the lexer hot path is too slow on large modules, consider a native extension, but only after correctness parity.
- Date-literal ambiguous grammar. The "try all candidate readings, accept if any
matches exactly" routine is the trickiest lexer code. Port it verbatim and
validate against the
vbaLexer.test.tsdate cases first. - Resolver seams that look like editor code but are CORE.
resolve_exact_member_completionandresolve_exhaustive_member_surfacelive undercompletion/andtypeInference.ts, but member-not-found, scalar-member-access, and argument-object-type rules depend on them. Port the resolver core; drop only the completion-array/UI wrapping. Misclassifying these as editor code would silently disable real rules. - Code-to-function map. 117 codes map onto 85 functions (one function can emit
several codes). The manifest must carry an explicit code-to-function map so
drift detection is per code. It is implicit in
registry.tsplusruleMetadata.tstoday; make it explicit in the data package.
- Registry and output order:
src/analyzer/diagnostics/registry.ts - Rule metadata (117 codes):
src/analyzer/diagnostics/ruleMetadata.ts - Rule families (17 files):
src/analyzer/diagnostics/rules/*.ts - Public entry and result type:
src/analyzer/diagnostics/analyzeModule.ts - Dataflow and walkers:
src/analyzer/diagnostics/{dataflow,walker,exprWalk}.ts - Evidence:
syntax_corpus/diagnostic_influence_audit.json,syntax_corpus/oracle/vbe_oracle_cases.json - Spec map:
docs/spec/MS-VBAL.verification-map.md - Host generation:
scripts/generate-excel-reference-metadata.mjs,scripts/generate-office-reference-metadata.mjs - XLIDE tests to mirror:
tests/diagnostics/**,tests/vba*.test.ts
First action for the agent: read this file, then read the XLIDE registry, ruleMetadata, and the audit JSON, then start M0 (vendor the data package and stand up the differential harness). Do not write a rule before its oracle cases are available as fixtures.