Skip to content

Validate Internals

albertoodev edited this page Jul 25, 2026 · 5 revisions

validate: AST internals

A code-level companion to Commands: validate. That page is the feature spec (surface, exit codes, JSON schema, workflow role). This one explains how each check actually inspects the code: which layer of the analyzer it reads, and where the comparison is structural vs. string-on-normalized-source. Everything here reflects the checks currently implemented in lib/src/features/validation/.

TL;DR

spm validate is static and AST-based end to end, with no regex, no text scraping, and no dart analyze subprocess. Both files are fully resolved by the analyzer package, and every check reads either the resolved AST nodes or the semantic element model derived from them.

Two layers of "AST" are in play, and the distinction is the whole point:

Layer What it is Which checks use it
Resolved element model Semantic model from resolution: canonical library URIs, resolved types, compiler diagnostics Check 1 (imports), Check 5 (compile)
Syntax tree (AST nodes) The parsed tree: ClassDeclaration, InstanceCreationExpression, AwaitExpression, … Checks 2, 3, 6, 7, and State-class location

Resolution: one context, two units

ValidationDataSourceImpl.validatePair builds one AnalysisContextCollection over [basePath, mutationPath, depsPath?] and resolves both files with getResolvedUnit:

final collection = AnalysisContextCollection(
  includedPaths: [basePath, mutationPath, ?depsPath],
  resourceProvider: PhysicalResourceProvider.INSTANCE,
);

final baseResult     = await collection.contextFor(basePath)
    .currentSession.getResolvedUnit(basePath);
final mutationResult = await collection.contextFor(mutationPath)
    .currentSession.getResolvedUnit(mutationPath);

getResolvedUnit returns a ResolvedUnitResult carrying three things the checks rely on: .unit (the syntax tree), the resolved element model (reachable from the tree and .libraryFragment), and .diagnostics (errors/warnings the resolver computed).

Sharing one collection is deliberate:

  • the mutation resolves against the real, unmodified dependencies.dart, so dependency conditions can't silently drift between the two files;
  • the compile check (check 5) is free, because the resolver already computed the diagnostics, so no subprocess is needed.

Resolution outcomes

Outcome Handling Why
Base doesn't resolve throws ValidationExceptionValidationFailure, exit 1 A broken base is an orchestration bug, not a property of the mutation.
Mutation doesn't resolve short-circuit report with one hard doesNotCompile violation Non-compilation is a property of the mutation under test.

This split between bad input to the tool and bad mutation under test recurs throughout: the former becomes a Failure, the latter a Violation inside a normal report.

Locating the State subclass

Both resolved units are walked with the analysis feature's existing StateClassVisitor (reused, not duplicated). This yields, per file, the ClassDeclaration node handed to check 2 and an instanceId = hash(path:className) (the same identity function analyze uses), reported as baseInstanceId / mutationInstanceId. No State class in the base → ValidationException; none in the mutation → a hard seedsChanged violation, but processing continues.

The checks, layer by layer

Check 1: importsChanged (element model)

Compares the two sets of canonical library URIs, dropping synthetic imports (the implicit dart:core):

r.libraryFragment.libraryImports
    .where((i) => !i.isSynthetic)
    .map((i) => i.importedLibrary?.uri.toString())

Because it reads resolved libraries (not import-line text), reordering imports and adding an as alias are not violations, while a genuinely new library (e.g. dart:math) is caught even if never referenced.

Check 2: seedsChanged (AST nodes, compared via toSource())

On the located ClassDeclaration, the initState / dispose / didUpdateWidget bodies are compared individually and all FieldDeclarations are concatenated in declaration order as the "field region". AstNode.toSource() regenerates source from tokens, so comments and formatting never cause a false violation; only a real token-level change can (List.generate(10, …)List.generate(500, …)).

Check 3: contentDrift (AST visitor)

RenderedLeafVisitor (a RecursiveAstVisitor) collects the rendered-leaf vocabulary. Unlike a regex, a string in a comment can never trip it:

Collected Source node Compared as
Display strings first StringLiteral arg of Text/SelectableText, or message: of Tooltip multiset
Icon references PrefixedIdentifier with prefix Icons set
Asset paths first string arg of AssetImage(…) / Image.asset(…) set

lit.stringValue ?? lit.toSource() uses the analyzer's constant value when the literal is constant, and falls back to source text for interpolated strings ('Row $item' has no constant value). Texts are a multiset, so duplicating or dropping one instance of an existing label is caught even though the distinct string set is unchanged.

Check 5: doesNotCompile (element-model diagnostics)

Filters the mutation's already-computed diagnostics to analyzer errors and reports the first 8 in one hard violation. Warnings and lints (e.g. an unused import) do not fail this check; an unused new import is check 1's responsibility.

Check 6: forbiddenConstruct (AST visitor, mutation only)

Anything that makes a buildSpan measurement non-deterministic or timing-dependent is banned, matched on real AST nodes: AwaitExpression, any async function body, banned NamedTypes (Future, Stream, Timer, Random, Animation*, Ticker*, HttpClient, …) in every syntactic position, DateTime.now(), and imports starting with dart:io/dart:async/ dart:isolate/package:http//package:dio/. DateTime.now() is caught two ways (visitInstanceCreationExpression and a visitMethodInvocation fallback for partially-resolved code). Findings are de-duplicated, then emitted one hard violation each.

Check 7: noOp (whole-unit toSource())

base.unit.toSource() != mutation.unit.toSource() is required. Because toSource() drops comments and normalizes whitespace, a comment-only or reformat-only "mutation" is detected as a no-op.

Structural input vs. string comparison

Every check reads the AST / element model at the input level; none looks at raw bytes or runs a regex. But the equality test differs:

Check Input Comparison mechanism
1 imports element model set equality on canonical URIs
2 seeds AST nodes string equality on toSource()
3 content AST visitor multiset/set equality on literal values
5 compile diagnostics severity filter
6 forbidden AST visitor node-type + name match
7 no-op AST unit string equality on toSource()

Checks 2 and 7 are "AST-based" in that their inputs are tree nodes and the strings are regenerated from tokens (so formatting/comments are invisible), but the final test is string equality on normalized source, not a structural tree-diff. It is called out only so the mechanism is not misread as text scraping.

Control flow summary

ValidateCommand.run()                       # presentation: args, exit code
  └─ ValidatePairUseCase                     # domain: forwards to repo
       └─ ValidationRepositoryImpl           # data: maps exception → Left(Failure)
            └─ ValidationDataSourceImpl.validatePair
                 1. AnalysisContextCollection([base, mutation, deps?])
                 2. getResolvedUnit(base), getResolvedUnit(mutation)
                 3. StateClassVisitor on both → ClassDeclaration + instanceId
                 4. run checks 1,2,3,5,6,7 → List<Violation>
                 5. return ValidationReport

Pointers

  • Spec / surface / JSON schema / workflow role: Commands: validate
  • Code root: lib/src/features/validation/
  • Tests: test/features/validation/ · fixtures: test/fixtures/validation/

Clone this wiki locally