Skip to content

Validate Internals

albertoodev edited this page Aug 21, 2026 · 5 revisions

validate: AST internals

A code-level companion to the validate command. The command page owns the CLI surface, exit codes, and JSON schema. This page follows each implemented check into lib/src/features/validation/ and identifies whether it reads the syntax tree or the resolved element model.

What the validator reads

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

Two analyzer layers are involved:

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, such as ClassDeclaration, InstanceCreationExpression, and 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) needs no subprocess because the resolver already computed 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 RebuildScopeAnalysisVisitor (reused, not duplicated), keeping only the State scopes it reports. 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*, and 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. RebuildScopeAnalysisVisitor on both, State scopes only
                    → 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