-
-
Notifications
You must be signed in to change notification settings - Fork 0
Validate 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.
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 |
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.
| Outcome | Handling | Why |
|---|---|---|
| Base doesn't resolve | throws ValidationException → ValidationFailure, 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.
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.
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.
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, …)).
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.
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.
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.
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.
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.
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
- Spec / surface / JSON schema / workflow role: Commands: validate
- Code root:
lib/src/features/validation/ - Tests:
test/features/validation/· fixtures:test/fixtures/validation/
Commands
Reference
Internals
Contributing