Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ body:
attributes:
label: Relay version
description: Run `relay --version`.
placeholder: relay 0.1.2
placeholder: relay 0.2.0
validations:
required: true
- type: dropdown
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Changelog

## 0.2.0 — 2026-07-29

- Reframed Relay as a conservative local source-review tool rather than compiler-grade static
analysis or schedule simulation.
- Added source discovery and blocking-wait profiles for Python, C/C++/Arduino, JavaScript,
TypeScript, Java, C#, Go, Rust, Kotlin, Swift, Ruby, and PHP.
- Added qualified file, class, method, and nested-function identities. Ambiguous call targets are
no longer merged by basename.
- Fixed Python async semantics: cooperative `await asyncio.sleep()` is not reported as a blocking
sleep.
- Added exact Python timeout-keyword handling, including correct treatment of `timeout=None`, and
removed substring-based timeout acceptance.
- Added safe duration evaluation for arithmetic, signed values, common C suffixes, `min`/`max`,
Go duration units, and common C++/Rust duration constructors.
- Made safety-critical reachability explicit with `safety_critical = true`; a latency budget no
longer implies emergency logic.
- Scoped timing impacts to reachable task contracts and added execution-context provenance.
- Renamed the primary `timeline` command to `summary`; the deprecated alias remains for 0.2.0.
- Added analysis-model metadata to every report, JSON schema 2.0, and stable SARIF fingerprints.
- Refused symlink analysis roots, added aggregate input limits, adopted Git-compatible root ignore
patterns, and made report writes atomic.
- Replaced the one-wheel inspection script with two independent deterministic builds and digest
comparison.
- Expanded the regression suite with positive, negative, ambiguous, security, configuration, and
polyglot cases.

## 0.1.2 — 2026-07-29

- Added the personal story behind Relay and a narrative walkthrough of how analysis moves from
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ assumptions and avoid claiming proof.
When proposing a new rule, tell me what you noticed in real code, what Relay should report, and
where the heuristic could be wrong. That last part matters: I would rather document uncertainty
than make a confident claim the analyser cannot support.

Every detector change needs positive, negative, and ambiguous corpus cases. A language profile
must state whether it is AST-backed, compiler-backed, or structural; structural results may not
claim type resolution or high semantic confidence.
95 changes: 56 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Relay

![Relay — static timing-impact analysis for control programs](docs/assets/relay-cover.png)
![Relay — local timing-risk review for control programs](docs/assets/relay-cover.png)

[![CI](https://github.com/devkyato/Relay/actions/workflows/ci.yml/badge.svg)](https://github.com/devkyato/Relay/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/devkyato/Relay?display_name=tag)](https://github.com/devkyato/Relay/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

**Relay shows what blocking code prevents from running.**

Relay is a local-first terminal static analyser and conservative timing-impact simulator for
Arduino, ESP32, embedded C++, MicroPython, and Python control programs. Version 0.1.x is an
alpha: useful for review and teaching, but intentionally cautious and not a safety certification.
Analysis never executes, imports, compiles, or uploads the source being inspected.
Relay is a local-first source-review tool for timing and blocking risks in polyglot control
programs. Version 0.2.0 is an alpha: useful as a review assistant, but intentionally not presented
as compiler-grade semantic analysis or safety certification. Analysis never executes, imports,
compiles, or uploads the source being inspected.

## Why I built Relay

Expand Down Expand Up @@ -43,7 +43,7 @@ offline after installation.
relay check examples
relay check examples/arduino/blocking_robot.ino --format markdown --output relay-report.md
relay explain examples/arduino/blocking_robot.ino --level beginner
relay timeline examples/arduino/blocking_robot.ino --config relay.toml.example --duration 5s
relay summary examples/arduino/blocking_robot.ino --config relay.toml.example --duration 5s
relay rules
relay doctor .
```
Expand All @@ -52,31 +52,35 @@ I usually start with `relay check examples`, then narrow the command to one file
looks interesting. Typical output looks like this:

```text
HIGH RLY101 blocking_robot.ino:16

delay() can block progress for approximately 2.000 seconds.
Confidence: high
ANALYSIS MODELS: cpp=structural C-family model
MODEL LIMIT: Structural models do not provide compiler-grade type, build, or dispatch semantics.

Estimated consequences (conservative):
update_motors: approximately 100 missed executions
emergency_stop: latency budget exceeded by approximately 1.990s
HIGH RLY101 blocking_robot.ino:16
delay() blocks its current execution context for approximately 2.000 seconds.
Confidence: medium
```

Exit status is non-zero when a finding reaches `--fail-on` (default: `warning`). Formats are
`text`, `json`, `sarif`, and `markdown`. Use `--suggest` or
`--suggestions-out relay-suggestions.md` for reviewable patterns; Relay never edits source in
0.1.x and never claims a suggestion is automatically safe for machinery.
0.2.0 and never claims a suggestion is automatically safe for machinery.

## How it works

Relay discovers supported source files, parses their structure without running them, and looks
for blocking or timing-sensitive patterns. It then connects direct calls into an estimated call
graph and compares reachable blocking time with any timing contracts you configured.
Relay discovers supported source files within per-file, file-count, and aggregate byte limits.
Python is parsed with the standard AST and qualified class/nested-function identities. Other
languages use explicitly labelled structural models with qualified, conservative call resolution:
ambiguous method names are left unresolved rather than collapsed.

Oh—on this part, I deliberately kept facts and estimates separate. A source location is a fact;
an inferred call path or missed-execution count is an estimate. Every finding includes severity,
confidence, evidence, and a suggested direction so you can decide what holds up on the real
target.
The rule engine distinguishes cooperative async waits from thread-blocking calls, evaluates simple
constant arithmetic, and only attaches timing impact to configured tasks that can reach a finding.
`relay summary` lists declared periods and detected risks; it does not claim to simulate a
schedule.

Oh—on this part, I deliberately made the model impossible to miss in text, Markdown, JSON, and
SARIF output. A source location is a fact; a structural call path or overlapping timing period is
an estimate. Every finding includes severity, confidence, evidence, and a suggested direction so
you can decide what holds up on the real target.

## Timing contracts

Expand All @@ -90,9 +94,12 @@ entrypoints = ["setup", "loop"]

[tasks.update_motors]
every = "20ms"
execution_context = "control-loop"

[tasks.emergency_stop]
maximum_latency = "10ms"
execution_context = "control-loop"
safety_critical = true

[functions]
update_motors = "updateMotors"
Expand All @@ -103,14 +110,24 @@ See [configuration](docs/configuration.md) and the [timing model](docs/timing-mo

## Supported sources and rules

Relay discovers `.ino`, `.cpp`, `.cc`, `.cxx`, `.h`, `.hpp`, `.py`, and `.pyw`. Stable rule
IDs RLY101–RLY115 cover delays and sleeps, busy/polling loops, serial and connection waits,
network timeouts, `pulseIn`, rollover-unsafe timers, state exits, emergency reachability,
timing-contract overruns, indirect blocking, and excessive read rates. Run `relay rules` or
read the [rule reference](docs/rules.md).

The core uses Python's AST plus a position-preserving C-family lexer and balanced-delimiter
structural parser. Findings carry severity and confidence so uncertain heuristics remain honest.
| Language | Extensions | Analysis model |
| --- | --- | --- |
| Python / MicroPython | `.py`, `.pyw` | Standard AST, file-local qualified symbols |
| C, C++, Arduino | `.c`, `.h`, `.cc`, `.cpp`, `.cxx`, `.ino` and header variants | Structural C-family model |
| JavaScript / TypeScript | `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts` | Structural language model |
| Java / Kotlin | `.java`, `.kt`, `.kts` | Structural language model |
| C# | `.cs` | Structural language model |
| Go | `.go` | Structural language model |
| Rust | `.rs` | Structural language model |
| Swift | `.swift` | Structural language model |
| Ruby | `.rb` | Structural language model |
| PHP | `.php` | Structural language model |

Versioned rule IDs RLY101–RLY115 cover known blocking waits, polling and connection loops,
selected network calls, Arduino timing hazards, configured safety-critical reachability,
reachability-scoped timing contracts, and repeated sensor reads. A rule's applicability and
confidence depend on the language model. Run `relay rules` or read the
[rule reference](docs/rules.md).

## Python API

Expand All @@ -127,18 +144,18 @@ The public `AnalysisResult`, `Finding`, `Severity`, and `Confidence` models are
## What Relay is not

I thought carefully about this boundary: Relay should explain suspicious timing paths, not pretend
to certify them. It is not a compiler, hardware simulator, formal-verification system,
replacement for tests on real devices, or a guarantee of real-time behaviour. It cannot see
interrupt timing, library internals, dynamic dispatch, electrical conditions, scheduler details,
or worst-case execution time. Its timeline is a conservative scheduling estimate, never a
cycle-accurate simulation.
to certify them. It is not a compiler, whole-program type resolver, scheduler simulator, hardware
simulator, formal-verification system, replacement for tests on real devices, or a guarantee of
real-time behaviour. Structural models cannot reliably see macros, conditional builds, overloads,
virtual dispatch, library internals, interrupts, electrical conditions, scheduler details, or
worst-case execution time.

## Security, contributing, and licence

Source files are treated as untrusted data. Relay bounds file count and size, does not follow
directory symlinks, does not run external commands during analysis, and will not overwrite an
analysed source or a symlink with a report. See [security](docs/security.md), [limitations](docs/limitations.md),
[roadmap](docs/roadmap.md), [support](SUPPORT.md), [contributing](CONTRIBUTING.md), and
[SECURITY.md](SECURITY.md).
Source files are treated as untrusted data. Relay bounds individual and aggregate input, refuses a
symlink analysis root, does not follow directory symlinks, does not run external commands during
analysis, and writes reports atomically without overwriting analysed source or symlinks. See
[security](docs/security.md), [limitations](docs/limitations.md), [roadmap](docs/roadmap.md),
[support](SUPPORT.md), [contributing](CONTRIBUTING.md), and [SECURITY.md](SECURITY.md).

This is a personal open-source project by devkyato, shared under the [MIT License](LICENSE).
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Security policy

Relay is a personal project, but I still want security reports handled carefully. Relay 0.1.x
Relay is a personal project, but I still want security reports handled carefully. Relay 0.2.x
receives best-effort security fixes. Report vulnerabilities privately through the GitHub
repository's security advisory feature; do not include sensitive source files in a public issue.

Expand Down
12 changes: 12 additions & 0 deletions corpus/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Relay regression corpus

This corpus labels small, reviewable examples as positive, negative, or ambiguous. It is not a
claim of real-world precision or recall; it is the public baseline used to prevent known semantic
regressions.

- `positive`: a specific rule must be emitted.
- `negative`: the named rule must not be emitted.
- `ambiguous`: Relay must preserve distinct symbols or withhold an uncertain edge.

`manifest.json` records the expected rule or invariant. New detectors need all three kinds of
case before they can be described as stable.
11 changes: 11 additions & 0 deletions corpus/ambiguous/duplicate_methods.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class Motor {
public:
void update() {}
void tick() { update(); }
};

class Display {
public:
void update() {}
void tick() { update(); }
};
17 changes: 17 additions & 0 deletions corpus/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
{"path": "positive/python_sleep.py", "classification": "positive", "rule": "RLY102"},
{"path": "positive/arduino_delay.ino", "classification": "positive", "rule": "RLY101"},
{"path": "positive/javascript_wait.js", "classification": "positive", "rule": "RLY102"},
{"path": "positive/typescript_wait.ts", "classification": "positive", "rule": "RLY102"},
{"path": "positive/java_sleep.java", "classification": "positive", "rule": "RLY102"},
{"path": "positive/csharp_sleep.cs", "classification": "positive", "rule": "RLY102"},
{"path": "positive/go_sleep.go", "classification": "positive", "rule": "RLY102"},
{"path": "positive/rust_sleep.rs", "classification": "positive", "rule": "RLY102"},
{"path": "positive/kotlin_sleep.kt", "classification": "positive", "rule": "RLY102"},
{"path": "positive/swift_sleep.swift", "classification": "positive", "rule": "RLY102"},
{"path": "positive/ruby_sleep.rb", "classification": "positive", "rule": "RLY102"},
{"path": "positive/php_sleep.php", "classification": "positive", "rule": "RLY102"},
{"path": "negative/python_asyncio.py", "classification": "negative", "rule": "RLY102"},
{"path": "negative/javascript_timer.js", "classification": "negative", "rule": "RLY102"},
{"path": "ambiguous/duplicate_methods.cpp", "classification": "ambiguous", "invariant": "qualified-methods"}
]
3 changes: 3 additions & 0 deletions corpus/negative/javascript_timer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
function worker() {
setTimeout(worker, 100);
}
5 changes: 5 additions & 0 deletions corpus/negative/python_asyncio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import asyncio


async def worker():
await asyncio.sleep(1)
3 changes: 3 additions & 0 deletions corpus/positive/arduino_delay.ino
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
void loop() {
delay(100);
}
5 changes: 5 additions & 0 deletions corpus/positive/csharp_sleep.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Worker {
void Run() {
Thread.Sleep(100);
}
}
5 changes: 5 additions & 0 deletions corpus/positive/go_sleep.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package corpus

func worker() {
time.Sleep(time.Second)
}
5 changes: 5 additions & 0 deletions corpus/positive/java_sleep.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Worker {
void run() {
Thread.sleep(100);
}
}
3 changes: 3 additions & 0 deletions corpus/positive/javascript_wait.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
function worker(values) {
Atomics.wait(values, 0, 100);
}
3 changes: 3 additions & 0 deletions corpus/positive/kotlin_sleep.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fun worker() {
Thread.sleep(100)
}
4 changes: 4 additions & 0 deletions corpus/positive/php_sleep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function worker() {
usleep(100000);
}
5 changes: 5 additions & 0 deletions corpus/positive/python_sleep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import time


def worker():
time.sleep(1)
5 changes: 5 additions & 0 deletions corpus/positive/ruby_sleep.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def worker
sleep(1)
end

worker()
3 changes: 3 additions & 0 deletions corpus/positive/rust_sleep.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn worker() {
std::thread::sleep(Duration::from_millis(100));
}
3 changes: 3 additions & 0 deletions corpus/positive/swift_sleep.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
func worker() {
Thread.sleep(forTimeInterval: 0.1)
}
3 changes: 3 additions & 0 deletions corpus/positive/typescript_wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
function worker(values: Int32Array): void {
Atomics.wait(values, 0, 100);
}
24 changes: 16 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
I split Relay into small stages so I can reason about what each stage knows—and, just as
importantly, what it does not know.

1. `discovery` walks the requested path with explicit file-count and size bounds.
2. `lexer` masks C-family comments and strings while preserving source offsets.
3. `parser` identifies balanced blocks, calls, loops, functions, and simple constants. Python
uses `ast.parse` without importing the target.
4. `callgraph` connects simple direct calls, and `rules` emits immutable findings.
5. `timing` compares reachable blocking estimates with configured contracts.
6. `reports` and `timeline` present the result in deterministic order.
1. `discovery` refuses symlink roots, applies Git-compatible root ignore patterns, and enforces
file-count, per-file, and aggregate-byte limits.
2. `languages` maps extensions to explicit analysis models.
3. `parser` uses `ast.parse` for Python without importing the target. C/C++, JavaScript,
TypeScript, Java, C#, Go, Rust, Kotlin, Swift, Ruby, and PHP use visibly labelled structural
models.
4. `callgraph` keeps file, class, and nested-function qualification. It links only exact or unique
references; ambiguous method basenames stay unresolved.
5. `rules` distinguishes cooperative async operations from blocking waits, emits immutable
findings, and lowers confidence for structural models.
6. `timing_summary` compares reachable waits with explicit task contracts. It does not simulate a
scheduler.
7. `reports` writes deterministic text, Markdown, JSON schema 2.0, and SARIF with stable
fingerprints and explicit model metadata.

`analyser.analyse_path` is the public orchestration boundary. Oh—one design choice I do not want
to blur: no stage invokes a compiler, interpreter, hardware tool, network service, or target
source.
source. Compiler- or Tree-sitter-backed front ends can be added later through the same model
boundary without pretending the present structural models are semantic.
11 changes: 8 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ The Python API uses the `RelayConfig` passed by its caller.
- `[project].target` is descriptive and `[project].entrypoints` seeds reachability.
- `[tasks.NAME].every` declares a desired period.
- `[tasks.NAME].maximum_latency` declares a response budget.
- `[tasks.NAME].execution_context` records the scheduler, thread, loop, core, or process assumption
supplied by the user. Relay reports it but does not infer or verify it.
- `[tasks.NAME].safety_critical = true` explicitly opts that task into critical RLY111 findings.
A latency budget alone does not imply emergency or safety-critical logic.
- `[functions].NAME` maps a task to a source function.
- `[analysis].enable` optionally creates an allowlist; `disable` always excludes IDs.
- `[analysis].python_sleep_threshold`, `maximum_file_bytes`, and `maximum_files` bound analysis.
- `[analysis].python_sleep_threshold` controls synchronous Python sleep reporting.
- `[analysis].maximum_file_bytes`, `maximum_files`, and `maximum_total_bytes` bound analysis.

Durations accept `us`, `ms`, `s`, and `m`. Unknown keys are retained as forward-compatible
input but currently have no effect. Invalid value types and durations fail with exit code 2.
Durations accept `us`, `ms`, `s`, and `m`. Unknown configuration keys and unknown rule IDs are
rejected so a typo cannot silently change an analysis. Invalid values fail with exit code 2.
Loading