Error Reporting & Diagnostics
Decision recap: Error Reporting established the intervention point strategy (first-party-deepest vs async-boundary) to guide users to the most actionable fix location. Determinism Contract mandates deterministic output ordering for test stability and reproducible CI runs.
[async] [tooling]
Error Codes
Strato emits four error codes, each corresponding to a distinct pattern of blocking call reachability from async contexts:
| Code | Meaning | Severity | Trigger Condition |
|---|---|---|---|
STRATO001 | Direct blocking call in async function | Error | Async function directly calls a blocking function with no intermediary call edges |
STRATO002 | Transitive blocking call reachable from async context | Error | Async function reaches a blocking function through one or more non-special call edges |
STRATO003 | Blocking @property accessed in async context | Error | Property getter (decorated with @property) is accessed and transitively blocks |
STRATO004 | Blocking dunder method invoked in async context | Error | Implicit dunder method call (e.g., str(obj), x + y) transitively blocks |
Message Templates
The templates below are illustrative examples of the rendered text shape for each diagnostic code. The stable product contract is the structured diagnostic data (code, severity, primary_location, related_locations, chain, help, and intervention_strategy) plus the ordering and coordinate rules documented below. Exact prose in message, related-location messages, and help text may include context-specific names and should only be asserted by fixtures that explicitly test wording.
STRATO001:
STRATO001: Direct blocking call in async function
--> {file}:{line}:{column}
|
{line} | {source_line}
| {underline} blocks the event loop
|
= call chain: {chain}
= help: {help_text}
STRATO002:
STRATO002: Transitive blocking call reachable from async context
--> {file}:{line}:{column}
|
{line} | {source_line}
| {underline} calls sync chain that blocks the event loop
|
= call chain: {chain}
= help: {help_text}
STRATO003:
STRATO003: Blocking property access in async context
--> {file}:{line}:{column}
|
{line} | {source_line}
| {underline} property getter blocks the event loop
|
= call chain: {chain}
= help: {help_text}
STRATO004:
STRATO004: Blocking dunder method in async context
--> {file}:{line}:{column}
|
{line} | {source_line}
| {underline} implicit dunder call blocks the event loop
|
= call chain: {chain}
= help: {help_text}
Wrapper Attribution
When a diagnostic fires because an @unblocker wrapper could not be resolved (type inference failed to track the alias), the diagnostic includes wrapper attribution:
= note: This call may be wrapped by an @unblocker decorator, but type inference
could not confirm the wrapper. If this is a false positive, ensure the
wrapper alias is directly assigned (e.g., `safe = sync_to_async(func)`)
without intermediate reassignments.
This note is appended to the diagnostic message when:
- The call site is to a name that was assigned from an
@unblocker-decorated function - Type inference (
ty) could not resolve the alias chain - The call was not marked
in_executordue to resolution failure
Error Code Classification Algorithm
The error code is determined by inspecting the semantic edge kinds along BlockingReason.chain_links, from the async context toward the blocking root. Property and dunder classifications are based on the first special semantic edge on the selected path, not on the final edge into the blocking root.
#![allow(unused)]
fn main() {
fn classify_error_code(chain: &BlockingReason, graph: &CallGraph) -> ErrorCode {
// The first link is always from the async function.
let first_link = &chain.chain_links[0];
// STRATO003/004 are semantic-edge classifications. The first property or
// implicit-dunder edge explains how blocking behavior entered the async path,
// even if the eventual blocking root is reached by ordinary direct calls.
for link in &chain.chain_links {
match link.edge_kind {
EdgeKind::PropertyAccess => return ErrorCode::STRATO003,
EdgeKind::ImplicitDunder => return ErrorCode::STRATO004,
_ => {}
}
}
// STRATO001 vs STRATO002: Is the blocking call directly in an async function?
// "Direct" means: chain has exactly 1 link AND the caller is async.
// That means: async_func directly calls blocking_func with no intermediaries.
if chain.chain_links.len() == 1 && first_link.is_async {
return ErrorCode::STRATO001; // Direct blocking call in async function
}
// Otherwise: blocking is reached transitively through ordinary call edges
ErrorCode::STRATO002
}
}
Classification Examples:
| Scenario | Chain | First Special Semantic Edge | Result |
|---|---|---|---|
async handler() -> time.sleep() | 1 link, caller is async | None | STRATO001 |
async handler() -> helper() -> time.sleep() | 2 links | None | STRATO002 |
async handler() -> loader.data [PropertyAccess] -> requests.get() | 2+ links | PropertyAccess | STRATO003 |
async handler() -> helper() -> loader.data [PropertyAccess] -> requests.get() | 3+ links | PropertyAccess | STRATO003 |
async handler() -> str(obj) [ImplicitDunder] -> __str__() -> requests.get() | 2+ links | ImplicitDunder | STRATO004 |
async handler() -> helper() -> str(obj) [ImplicitDunder] -> __str__() -> requests.get() | 3+ links | ImplicitDunder | STRATO004 |
Key invariants:
- The first link’s
is_asyncfield is alwaystrue(the chain starts from an async function) - The path ends at a
KnownBlockingnode (the blocking root cause) - Property and dunder edge kinds are checked across the whole path; the first special semantic edge wins
STRATO001andSTRATO002are used only when no property or dunder edge appears on the path
Intervention Point Strategy
The “intervention point” is the primary location shown in the diagnostic – the place in the user’s code where they should make a change. Strato supports two strategies for selecting this location:
Strategy: first-party-deepest (Default)
Select the deepest first-party call site on the call chain between the async context and the blocking call. This points users to the lowest-level first-party expression that could be refactored to be async or offloaded.
#![allow(unused)]
fn main() {
fn select_intervention_point(
chain: &[ChainLink],
strategy: InterventionStrategy
) -> &ChainLink {
match strategy {
InterventionStrategy::FirstPartyDeepest => {
// Walk the chain from the blocking end toward the async end
// Find the deepest first-party call site
for link in chain.iter().rev() {
if link.is_first_party {
return link;
}
}
// Fallback: if no first-party code on path, use async boundary
select_async_boundary(chain)
}
InterventionStrategy::AsyncBoundary => {
select_async_boundary(chain)
}
}
}
fn select_async_boundary(chain: &[ChainLink]) -> &ChainLink {
// Find the transition: last async function before sync code that leads to blocking
for i in 0..chain.len() - 1 {
if chain[i].is_async && !chain[i + 1].is_async {
return &chain[i];
}
}
// Fallback: first element
&chain[0]
}
}
Strategy: async-boundary
Select the async-to-sync transition point – the last async function before the sync call chain that leads to blocking. This points users to the boundary where they should consider offloading the sync work.
Example Comparison
# src/myapp/handler.py
async def handle_request(): # [0] async, first-party
await process() # [1] async, first-party
# src/myapp/processor.py
async def process(): # [1] async, first-party
validate(data) # [2] sync, first-party <-- async-boundary
# src/myapp/validator.py
def validate(data): # [2] sync, first-party
check_db(data) # [3] sync, first-party <-- first-party-deepest
# src/myapp/db.py
def check_db(data): # [3] sync, first-party
psycopg2.connect(...) # [4] sync, third-party, BLOCKING
first-party-deepest reports at the psycopg2.connect(...) call site inside first-party check_db() in db.py:
STRATO002: Transitive blocking call reachable from async context
--> src/myapp/db.py:15:5
|
15 | psycopg2.connect(dsn)
| ^^^^^^^^^^^^^^^^^^^^ calls sync chain that blocks the event loop
|
= call chain: process() -> validate() -> check_db() -> psycopg2.connect()
= help: Use `asyncpg` or wrap in `await loop.run_in_executor(None, psycopg2.connect, dsn)`
async-boundary reports at process() calling validate():
STRATO002: Transitive blocking call reachable from async context
--> src/myapp/processor.py:8:5
|
8 | validate(data)
| ^^^^^^^^^^^^^^ calls sync chain that blocks the event loop
|
= call chain: process() -> validate() -> check_db() -> psycopg2.connect()
= help: Use `asyncpg` or wrap in `await loop.run_in_executor(None, psycopg2.connect, dsn)`
Tie-Breaking Rules
first-party-deepest is the default strategy. Fixtures that expect async-boundary behavior must set it explicitly through their fixture manifest/config; expected JSON must not silently assume a non-default strategy.
For STRATO003 and STRATO004, the primary location is the first property or implicit-dunder edge that introduces the special semantic behavior. This keeps property and dunder diagnostics pointed at the expression the async function actually evaluates, while the chain still records deeper first-party calls underneath that edge.
When the first-party-deepest strategy finds multiple first-party functions at the same depth, select the one with the lexicographically smallest qualified_name. If still tied (same function called from multiple sites), select the call site with the smallest (line, column) pair.
Diagnostic Structure
The Diagnostic struct is the core data structure for error reporting. It contains all information needed to render a diagnostic in any output format (text, JSON, SARIF).
#![allow(unused)]
fn main() {
/// A single diagnostic emitted by Strato.
struct Diagnostic {
/// Unique error code (e.g., "STRATO001")
code: ErrorCode,
/// Severity level
severity: Severity, // Error, Warning
/// The "intervention point" – where the user should look
primary_location: Location,
/// Human-readable message
message: String,
/// The call chain from async context to blocking call
blocking_chain: Vec<ChainLink>,
/// Which intervention strategy was used
strategy: InterventionStrategy,
/// Static suggestion for fixing the issue (from BlockingDatabase).
help: Option<String>,
/// Related locations (additional context for the diagnostic)
related_locations: Vec<RelatedLocation>,
/// Wrapper attribution note (if applicable)
wrapper_attribution: Option<String>,
}
/// Source location with range information.
struct Location {
/// File path (relative to project root, `/`-normalized)
file: String,
/// Start line (1-based)
line: usize,
/// Start column (1-based, human-facing column number)
column: usize,
/// End line (1-based), present when a precise span is available
end_line: Option<usize>,
/// End column (1-based, human-facing column number), present when a precise span is available
end_column: Option<usize>,
}
/// A related location providing additional context.
struct RelatedLocation {
location: Location,
message: String,
}
/// Error code enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum ErrorCode {
STRATO001,
STRATO002,
STRATO003,
STRATO004,
}
}
Location Derivation from Ruff AST
Ruff AST nodes provide TextRange (byte-offset range from the start of the source file). Conversion to (line, column) uses ruff_source_file::SourceCode and ruff_source_file::LineIndex for O(log n) lookup.
Which AST span to use:
- Function definitions: Use the
nameidentifier range (not the entiredef) - Call sites: Use the full
ExprCallrange (includes parentheses) - Property access: Use the
Attribute.attridentifier range - Dunder operations: Use the operator/builtin call range
Column Convention (End-to-End)
| Context | Convention |
|---|---|
| Internal Ruff spans | 0-based byte offsets (from TextRange) |
Internal Location struct | 1-based line / 1-based column after conversion; end line/column are optional |
| Text output display | 1-based column |
| JSON output | 1-based column; optional end line/column when available |
| SARIF output | 1-based column (SARIF spec requires 1-based); optional end line/column when available |
Related Locations
Related locations provide additional context for diagnostics. They are attached based on the error code and call chain structure.
Related Location Rules by Error Code
| Error Code | Related Locations Attached | Purpose |
|---|---|---|
STRATO001 | 1. Async function definition 2. Blocking root definition (if available) | Show where the async context starts and the blocking root |
STRATO002 | 1. Async function definition 2. All intermediary first-party function definitions 3. Blocking root definition (if available) | Show full call chain |
STRATO003 | 1. Async function definition 2. Property definition 3. Blocking root definition (if available) | Show property and blocking root |
STRATO004 | 1. Async function definition 2. Dunder method definition 3. Blocking root definition (if available) | Show dunder method and blocking root |
Related locations are deterministic. Locations are emitted in the role order shown above; multiple locations with the same role are sorted by normalized file path, line, then column.
Example: STRATO002 with Related Locations
# src/myapp/handler.py
async def handle_request(): # Related location 1
process()
# src/myapp/processor.py
def process(): # Related location 2
validate()
# src/myapp/validator.py
def validate(): # Related location 3 (intervention point)
time.sleep(1) # Primary location
Text output:
STRATO002: Transitive blocking call reachable from async context
--> src/myapp/validator.py:8:5
|
8 | time.sleep(1)
| ^^^^^^^^^^^^^ calls sync chain that blocks the event loop
|
= call chain: handle_request() -> process() -> validate() -> time.sleep()
= help: Use `asyncio.sleep()` instead
|
note: async function `handle_request` defined here
--> src/myapp/handler.py:3:1
|
3 | async def handle_request():
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: sync function `process` defined here
--> src/myapp/processor.py:5:1
|
5 | def process():
| ^^^^^^^^^^^^^^
note: blocking function `time.sleep` is a known blocking stdlib function
Deterministic Output Rules
For test stability and reproducible CI runs, all outputs must be deterministic.
Diagnostic Ordering
When multiple diagnostics are emitted, they are sorted by this key (lexicographic, ascending):
- File path (string comparison, using
/-normalized relative paths) - Line number (numeric, ascending)
- Column number (numeric, ascending)
- Error code (string comparison: STRATO001 < STRATO002 < STRATO003 < STRATO004)
#![allow(unused)]
fn main() {
impl Ord for Diagnostic {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.primary_location.file.cmp(&other.primary_location.file)
.then(self.primary_location.line.cmp(&other.primary_location.line))
.then(self.primary_location.column.cmp(&other.primary_location.column))
.then(self.code.cmp(&other.code))
}
}
// Sort diagnostics before output
diagnostics.sort();
}
Blocking Reason Path Selection
When a function has multiple paths to different blocking roots, store the shortest path. If multiple paths have the same length, select the path whose root cause has the lexicographically smallest qualified_name.
BTreeMap Usage
All internal maps that affect output order use BTreeMap instead of HashMap:
#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
type SymbolTable = BTreeMap<String, SymbolDef>;
type ModuleMap = BTreeMap<String, PathBuf>;
type BlockingDatabase = BTreeMap<String, BlockingEntry>;
}
Determinism Contract
Guarantee: Given the same input files, configuration, and Strato version, the tool produces byte-for-byte identical output across runs, regardless of parallel processing order, hash map iteration order, file system traversal order, or operating system.
Enforcement: All diagnostic lists sorted before output; all maps use BTreeMap; all tie-breaking rules explicitly specified; integration tests include golden output comparison.
Output Formats
Strato supports three output formats:
| Format | Use Case | Audience |
|---|---|---|
| Text | Terminal output, CI logs | Developers reading diagnostics directly |
| JSON | Programmatic consumption, IDE integration | Tools parsing Strato output |
| SARIF | GitHub Code Scanning, IDE integration | Security/quality platforms |
Format is controlled by the --output CLI flag:
strato check . --output text # Default
strato check . --output json
strato check . --output sarif
Full specifications for each output format are provided in Appendix C: Output Format Specifications.