From f9244bb1eeb161030765d320e4226aafddcb6eda Mon Sep 17 00:00:00 2001 From: devkyato <95612413+devkyato@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:49:32 +0800 Subject: [PATCH] Release Relay 0.2.0 semantic recovery --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 26 ++ CONTRIBUTING.md | 4 + README.md | 95 +++-- SECURITY.md | 2 +- corpus/README.md | 12 + corpus/ambiguous/duplicate_methods.cpp | 11 + corpus/manifest.json | 17 + corpus/negative/javascript_timer.js | 3 + corpus/negative/python_asyncio.py | 5 + corpus/positive/arduino_delay.ino | 3 + corpus/positive/csharp_sleep.cs | 5 + corpus/positive/go_sleep.go | 5 + corpus/positive/java_sleep.java | 5 + corpus/positive/javascript_wait.js | 3 + corpus/positive/kotlin_sleep.kt | 3 + corpus/positive/php_sleep.php | 4 + corpus/positive/python_sleep.py | 5 + corpus/positive/ruby_sleep.rb | 5 + corpus/positive/rust_sleep.rs | 3 + corpus/positive/swift_sleep.swift | 3 + corpus/positive/typescript_wait.ts | 3 + docs/architecture.md | 24 +- docs/configuration.md | 11 +- docs/limitations.md | 21 +- docs/releases/0.2.0.md | 70 ++++ docs/roadmap.md | 19 +- docs/rules.md | 23 +- docs/security.md | 11 +- docs/timing-model.md | 21 +- docs/tutorials.md | 19 +- pyproject.toml | 11 +- relay-report.md | 134 ++++--- relay.toml.example | 8 +- scripts/verify_reproducible.py | 71 +++- src/relay/__init__.py | 2 +- src/relay/analyser.py | 26 +- src/relay/callgraph.py | 88 ++++- src/relay/cli.py | 50 ++- src/relay/config.py | 64 +++- src/relay/diagnostics.py | 4 +- src/relay/discovery.py | 93 +++-- src/relay/languages.py | 54 +++ src/relay/models.py | 8 + src/relay/parser.py | 292 +++++++++++++-- src/relay/reports.py | 92 ++++- src/relay/rules.py | 366 +++++++++++++++---- src/relay/{timeline.py => timing_summary.py} | 30 +- tests/test_callgraph.py | 62 +++- tests/test_cli.py | 5 +- tests/test_config.py | 17 +- tests/test_corpus.py | 33 ++ tests/test_parser.py | 54 ++- tests/test_polyglot.py | 125 +++++++ tests/test_reports.py | 13 +- tests/test_rules.py | 11 +- tests/test_security.py | 40 ++ timeline.md | 17 - timing-summary.md | 16 + 59 files changed, 1846 insertions(+), 383 deletions(-) create mode 100644 corpus/README.md create mode 100644 corpus/ambiguous/duplicate_methods.cpp create mode 100644 corpus/manifest.json create mode 100644 corpus/negative/javascript_timer.js create mode 100644 corpus/negative/python_asyncio.py create mode 100644 corpus/positive/arduino_delay.ino create mode 100644 corpus/positive/csharp_sleep.cs create mode 100644 corpus/positive/go_sleep.go create mode 100644 corpus/positive/java_sleep.java create mode 100644 corpus/positive/javascript_wait.js create mode 100644 corpus/positive/kotlin_sleep.kt create mode 100644 corpus/positive/php_sleep.php create mode 100644 corpus/positive/python_sleep.py create mode 100644 corpus/positive/ruby_sleep.rb create mode 100644 corpus/positive/rust_sleep.rs create mode 100644 corpus/positive/swift_sleep.swift create mode 100644 corpus/positive/typescript_wait.ts create mode 100644 docs/releases/0.2.0.md create mode 100644 src/relay/languages.py rename src/relay/{timeline.py => timing_summary.py} (62%) create mode 100644 tests/test_corpus.py create mode 100644 tests/test_polyglot.py delete mode 100644 timeline.md create mode 100644 timing-summary.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 15e0415..bcca7c7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f821e..5cdc9ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebdfea4..f1bcbb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 4c35007..14190d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) @@ -8,10 +8,10 @@ **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 @@ -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 . ``` @@ -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 @@ -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" @@ -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 @@ -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). diff --git a/SECURITY.md b/SECURITY.md index cbafa8c..29e5789 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/corpus/README.md b/corpus/README.md new file mode 100644 index 0000000..e2be624 --- /dev/null +++ b/corpus/README.md @@ -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. diff --git a/corpus/ambiguous/duplicate_methods.cpp b/corpus/ambiguous/duplicate_methods.cpp new file mode 100644 index 0000000..30ee1bf --- /dev/null +++ b/corpus/ambiguous/duplicate_methods.cpp @@ -0,0 +1,11 @@ +class Motor { + public: + void update() {} + void tick() { update(); } +}; + +class Display { + public: + void update() {} + void tick() { update(); } +}; diff --git a/corpus/manifest.json b/corpus/manifest.json new file mode 100644 index 0000000..ad1bf05 --- /dev/null +++ b/corpus/manifest.json @@ -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"} +] diff --git a/corpus/negative/javascript_timer.js b/corpus/negative/javascript_timer.js new file mode 100644 index 0000000..d93aeff --- /dev/null +++ b/corpus/negative/javascript_timer.js @@ -0,0 +1,3 @@ +function worker() { + setTimeout(worker, 100); +} diff --git a/corpus/negative/python_asyncio.py b/corpus/negative/python_asyncio.py new file mode 100644 index 0000000..86e0f1a --- /dev/null +++ b/corpus/negative/python_asyncio.py @@ -0,0 +1,5 @@ +import asyncio + + +async def worker(): + await asyncio.sleep(1) diff --git a/corpus/positive/arduino_delay.ino b/corpus/positive/arduino_delay.ino new file mode 100644 index 0000000..e8edc11 --- /dev/null +++ b/corpus/positive/arduino_delay.ino @@ -0,0 +1,3 @@ +void loop() { + delay(100); +} diff --git a/corpus/positive/csharp_sleep.cs b/corpus/positive/csharp_sleep.cs new file mode 100644 index 0000000..f6fe204 --- /dev/null +++ b/corpus/positive/csharp_sleep.cs @@ -0,0 +1,5 @@ +class Worker { + void Run() { + Thread.Sleep(100); + } +} diff --git a/corpus/positive/go_sleep.go b/corpus/positive/go_sleep.go new file mode 100644 index 0000000..b4cc9b9 --- /dev/null +++ b/corpus/positive/go_sleep.go @@ -0,0 +1,5 @@ +package corpus + +func worker() { + time.Sleep(time.Second) +} diff --git a/corpus/positive/java_sleep.java b/corpus/positive/java_sleep.java new file mode 100644 index 0000000..8426304 --- /dev/null +++ b/corpus/positive/java_sleep.java @@ -0,0 +1,5 @@ +class Worker { + void run() { + Thread.sleep(100); + } +} diff --git a/corpus/positive/javascript_wait.js b/corpus/positive/javascript_wait.js new file mode 100644 index 0000000..c4f0da1 --- /dev/null +++ b/corpus/positive/javascript_wait.js @@ -0,0 +1,3 @@ +function worker(values) { + Atomics.wait(values, 0, 100); +} diff --git a/corpus/positive/kotlin_sleep.kt b/corpus/positive/kotlin_sleep.kt new file mode 100644 index 0000000..90b6c8b --- /dev/null +++ b/corpus/positive/kotlin_sleep.kt @@ -0,0 +1,3 @@ +fun worker() { + Thread.sleep(100) +} diff --git a/corpus/positive/php_sleep.php b/corpus/positive/php_sleep.php new file mode 100644 index 0000000..8c4f2ce --- /dev/null +++ b/corpus/positive/php_sleep.php @@ -0,0 +1,4 @@ +=3.9" license = { file = "LICENSE" } authors = [{ name = "devkyato" }] -keywords = ["arduino", "esp32", "static-analysis", "timing", "micropython"] +keywords = ["arduino", "esp32", "source-review", "timing", "polyglot"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", @@ -24,7 +24,10 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Quality Assurance", ] -dependencies = ["tomli>=2.0; python_version < '3.11'"] +dependencies = [ + "pathspec>=0.12,<1", + "tomli>=2.0; python_version < '3.11'", +] [project.optional-dependencies] dev = [ @@ -53,7 +56,7 @@ exclude = [ "/.release-smoke", "/dist", "/relay-report.md", - "/timeline.md", + "/timing-summary.md", ] [tool.pytest.ini_options] diff --git a/relay-report.md b/relay-report.md index cc58263..14fe049 100644 --- a/relay-report.md +++ b/relay-report.md @@ -1,40 +1,26 @@ # Relay analysis report -Analysed 5 supported source file(s); found 8 issue(s). +Analysed 5 supported source file(s); found 10 issue(s). -> Timing impacts are conservative estimates, not cycle-accurate measurements. +> Relay uses file-local AST analysis for Python and explicitly labelled structural models for other languages. Structural findings are review prompts, not compiler-verified facts. + +**Analysis models:** `cpp`: structural C-family model, `python`: Python AST (file-local) ## HIGH RLY101 **Location:** `arduino/blocking_robot.ino:8` -**Confidence:** high - -delay() can block progress for approximately 2.000 seconds. +**Confidence:** medium -Evidence: `delay(2000)` +delay() blocks its current execution context for approximately 2.000 seconds. -Estimated consequences: +Evidence: -- update_motors: approximately 100 missed executions -- read_distance: approximately 40 missed executions -- send_telemetry: approximately 4 missed executions -- emergency_stop: latency budget exceeded by approximately 1.990s +```text +delay(2000) +``` Suggested approach: Use a millis()-based state transition. -## HIGH RLY114 - -**Location:** `arduino/blocking_robot.ino:8` -**Confidence:** high - -A blocking delay is reachable indirectly from loop(). - -Evidence: `delay(2000);` - -Call path: `loop → autonomousMode → rotateRobot → delay()` - -Suggested approach: Propagate a non-blocking state machine through the call chain. - ## HIGH RLY103 **Location:** `esp32/wifi_wait.cpp:5` @@ -42,7 +28,11 @@ Suggested approach: Propagate a non-blocking state machine through the call chai This loop has no obvious cooperative yield or bounded exit. -Evidence: `while (WiFi.status() != WL_CONNECTED) {` +Evidence: + +```text +while (WiFi.status() != WL_CONNECTED) { +``` Suggested approach: Add a deadline, bounded exit, and cooperative yield. @@ -53,7 +43,11 @@ Suggested approach: Add a deadline, bounded exit, and cooperative yield. Connection status is polled without an obvious deadline or attempt limit. -Evidence: `while (WiFi.status() != WL_CONNECTED) {` +Evidence: + +```text +while (WiFi.status() != WL_CONNECTED) { +``` Suggested approach: Bound attempts or elapsed time. @@ -64,26 +58,73 @@ Suggested approach: Bound attempts or elapsed time. Repeated polling has no obvious delay, backoff, or attempt limit. -Evidence: `while (WiFi.status() != WL_CONNECTED) {` +Evidence: + +```text +while (WiFi.status() != WL_CONNECTED) { +``` + +Suggested approach: Add capped backoff and an attempt/deadline limit. + +## HIGH RLY103 + +**Location:** `micropython/controller.py:5` +**Confidence:** medium + +This loop has no obvious cooperative yield or bounded exit. + +Evidence: + +```text +while True: +``` + +Suggested approach: Add a deadline, bounded exit, and cooperative yield. + +## WARNING RLY113 + +**Location:** `micropython/controller.py:5` +**Confidence:** medium + +Repeated polling has no obvious delay, backoff, or attempt limit. + +Evidence: + +```text +while True: +``` Suggested approach: Add capped backoff and an attempt/deadline limit. +## WARNING RLY115 + +**Location:** `micropython/controller.py:5` +**Confidence:** low + +Input is repeatedly read without obvious rate control. + +Evidence: + +```text +while True: +``` + +Suggested approach: Rate-limit sensor or input reads. + ## WARNING RLY102 **Location:** `micropython/controller.py:8` **Confidence:** high -time.sleep() pauses this task for approximately 0.250 seconds. - -Evidence: `time.sleep(0.25)` +time.sleep() blocks its current execution context for approximately 0.250 seconds. -Estimated consequences: +Evidence: -- update_motors: approximately 12 missed executions -- read_distance: approximately 5 missed executions -- emergency_stop: latency budget exceeded by approximately 0.240s +```text +time.sleep(0.25) +``` -Suggested approach: Use shorter cooperative sleeps or event-driven scheduling. +Suggested approach: Use a cooperative timer, scheduler, or event-driven wait where appropriate. ## HIGH RLY106 @@ -92,7 +133,11 @@ Suggested approach: Use shorter cooperative sleeps or event-driven scheduling. requests.get() has no obvious explicit timeout. -Evidence: `response = requests.get("https://device.invalid/status")` +Evidence: + +```text +response = requests.get("https://device.invalid/status") +``` Suggested approach: Pass an explicit timeout and handle expiry. @@ -101,15 +146,16 @@ Suggested approach: Pass an explicit timeout and handle expiry. **Location:** `python/network_poll.py:7` **Confidence:** high -time.sleep() pauses this task for approximately 1.000 seconds. +time.sleep() blocks its current execution context for approximately 1.000 seconds. + +Evidence: -Evidence: `time.sleep(1)` +```text +time.sleep(1) +``` -Estimated consequences: +Suggested approach: Use a cooperative timer, scheduler, or event-driven wait where appropriate. -- update_motors: approximately 50 missed executions -- read_distance: approximately 20 missed executions -- send_telemetry: approximately 2 missed executions -- emergency_stop: latency budget exceeded by approximately 0.990s +## Analysis warnings -Suggested approach: Use shorter cooperative sleeps or event-driven scheduling. +- cpp: structural C-family model; type resolution, dynamic dispatch, macros, and build-configuration semantics are not modelled diff --git a/relay.toml.example b/relay.toml.example index 2c3607e..f4a2c9c 100644 --- a/relay.toml.example +++ b/relay.toml.example @@ -4,15 +4,20 @@ entrypoints = ["setup", "loop"] [tasks.update_motors] every = "20ms" +execution_context = "control-loop" [tasks.read_distance] every = "50ms" +execution_context = "control-loop" [tasks.send_telemetry] every = "500ms" +execution_context = "telemetry-worker" [tasks.emergency_stop] maximum_latency = "10ms" +execution_context = "control-loop" +safety_critical = true [functions] update_motors = "updateMotors" @@ -24,4 +29,5 @@ emergency_stop = "checkEmergencyStop" disable = [] python_sleep_threshold = "100ms" maximum_file_bytes = 2000000 -maximum_files = 10000 +maximum_files = 2000 +maximum_total_bytes = 50000000 diff --git a/scripts/verify_reproducible.py b/scripts/verify_reproducible.py index 05693dc..b85469a 100644 --- a/scripts/verify_reproducible.py +++ b/scripts/verify_reproducible.py @@ -1,28 +1,71 @@ -"""Inspect wheels for timestamps and member ordering that affect reproducibility.""" +"""Build distributions twice in isolation and compare their SHA-256 digests.""" from __future__ import annotations +import hashlib +import os +import subprocess import sys -import zipfile +import tempfile from pathlib import Path +from typing import Dict def main() -> int: - wheels = sorted((Path(__file__).resolve().parents[1] / "dist").glob("*.whl")) - if not wheels: - print("No wheel found.", file=sys.stderr) + root = Path(__file__).resolve().parents[1] + environment = os.environ.copy() + environment.update( + { + "PYTHONHASHSEED": "0", + # 1980-01-01 is valid for ZIP members and removes wall-clock build variance. + "SOURCE_DATE_EPOCH": "315532800", + } + ) + with tempfile.TemporaryDirectory(prefix="relay-repro-") as temporary: + base = Path(temporary) + first = base / "first" + second = base / "second" + first.mkdir() + second.mkdir() + _build(root, first, environment) + _build(root, second, environment) + first_hashes = _hashes(first) + second_hashes = _hashes(second) + if first_hashes != second_hashes: + print("Independent builds produced different artifacts.", file=sys.stderr) + print(f"first: {first_hashes}", file=sys.stderr) + print(f"second: {second_hashes}", file=sys.stderr) return 1 - with zipfile.ZipFile(wheels[-1]) as archive: - names = archive.namelist() - if names != sorted(names): - print("Wheel members are not lexically ordered (informational).") - invalid = [item.filename for item in archive.infolist() if item.date_time[0] < 1980] - if invalid: - print(f"Invalid wheel timestamps: {invalid}", file=sys.stderr) - return 1 - print(f"Wheel structure verified: {wheels[-1].name}") + for name, digest in sorted(first_hashes.items()): + print(f"reproducible {name} sha256:{digest}") return 0 +def _build(root: Path, output: Path, environment: Dict[str, str]) -> None: + subprocess.run( + [ + sys.executable, + "-m", + "build", + "--sdist", + "--wheel", + "--outdir", + str(output), + str(root), + ], + check=True, + cwd=root, + env=environment, + ) + + +def _hashes(directory: Path) -> Dict[str, str]: + return { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(directory.iterdir()) + if path.is_file() + } + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/relay/__init__.py b/src/relay/__init__.py index 1dbabab..90791b0 100644 --- a/src/relay/__init__.py +++ b/src/relay/__init__.py @@ -12,4 +12,4 @@ "Severity", "analyse_path", ] -__version__ = "0.1.2" +__version__ = "0.2.0" diff --git a/src/relay/analyser.py b/src/relay/analyser.py index d41b9a9..2b1be91 100644 --- a/src/relay/analyser.py +++ b/src/relay/analyser.py @@ -17,10 +17,18 @@ def analyse_path(path: Union[str, Path], config: Optional[RelayConfig] = None) - """Analyse a source file or directory without executing any analysed code.""" target = Path(path) active = config or RelayConfig.default() - files, warnings = discover_sources(target, active.maximum_files) - root = target.resolve() if target.is_dir() else target.resolve().parent + files, warnings = discover_sources( + target, + active.maximum_files, + active.maximum_file_bytes, + active.maximum_total_bytes, + ) + absolute_target = target.absolute() + root = absolute_target if absolute_target.is_dir() else absolute_target.parent result = AnalysisResult(files=files, warnings=warnings, root=root) parsed_sources: List[ParsedSource] = [] + total_read = 0 + warned_models: set[str] = set() for source_path in files: try: size = source_path.stat().st_size @@ -29,12 +37,26 @@ def analyse_path(path: Union[str, Path], config: Optional[RelayConfig] = None) - f"skipped {source_path}: {size} bytes exceeds {active.maximum_file_bytes}" ) continue + if total_read + size > active.maximum_total_bytes: + result.warnings.append( + "analysis stopped at configured aggregate input limit " + f"({active.maximum_total_bytes} bytes)" + ) + break text = source_path.read_text(encoding="utf-8", errors="replace") + total_read += size except OSError as exc: result.warnings.append(f"cannot read {source_path}: {exc}") continue parsed = parse_source(source_path, text) parsed_sources.append(parsed) + result.analysis_models[parsed.language] = parsed.analysis_model + if "structural" in parsed.analysis_model and parsed.analysis_model not in warned_models: + result.warnings.append( + f"{parsed.language}: {parsed.analysis_model}; type resolution, dynamic dispatch, " + "macros, and build-configuration semantics are not modelled" + ) + warned_models.add(parsed.analysis_model) if parsed.syntax_warning: result.warnings.append(f"{source_path}: {parsed.syntax_warning}") graph = build_call_graph(parsed_sources) diff --git a/src/relay/callgraph.py b/src/relay/callgraph.py index 5647315..519dfc5 100644 --- a/src/relay/callgraph.py +++ b/src/relay/callgraph.py @@ -1,4 +1,4 @@ -"""Simple per-project call graph and path lookup.""" +"""Conservative qualified-symbol call graph and path lookup.""" from __future__ import annotations @@ -12,31 +12,78 @@ def build_call_graph(sources: Iterable[ParsedSource]) -> Graph: graph: Graph = {} - declared: Set[str] = set() calls: List[Tuple[Optional[str], str]] = [] for source in sources: - declared.update(source.functions) - calls.extend( - (call.enclosing_function, _base_name(call.name)) - for call in source.calls - if "." not in call.name and "->" not in call.name and "::" not in call.name - ) - for name in declared: - graph[name] = set() - for owner, callee in calls: - if owner is not None and callee in declared: + graph.update({name: set() for name in source.functions}) + calls.extend((call.enclosing_function, call.name) for call in source.calls) + declared = set(graph) + for owner, reference in calls: + if owner is None: + continue + callee = resolve_reference(declared, reference, owner) + if callee is not None: graph.setdefault(owner, set()).add(callee) return graph +def resolve_reference( + declared: Iterable[str], reference: str, owner: Optional[str] = None +) -> Optional[str]: + """Resolve only exact or unique symbol references. + + Ambiguous basenames are intentionally left unresolved. This trades false-negative paths for + avoiding the false-positive method collapse that an unqualified graph would create. + """ + + symbols = set(declared) + if reference in symbols: + return reference + normalized = _normalize(reference) + if owner is not None and "::" in owner: + module, local = owner.split("::", 1) + local = local.rsplit("@", 1)[0] + local_parts = local.split(".") + scope = local_parts[:-1] + if normalized.startswith("self.") or normalized.startswith("this."): + normalized_self = normalized.split(".", 1)[1] + if scope: + candidate = f"{module}::{'.'.join([*scope, normalized_self])}" + if candidate in symbols: + return candidate + if "." not in normalized: + for depth in range(len(local_parts), -1, -1): + local_candidate = ".".join([*local_parts[:depth], normalized]) + candidate = f"{module}::{local_candidate}" + if candidate in symbols: + return candidate + + matches = [ + symbol + for symbol in symbols + if _symbol_tail(symbol) == normalized + or _symbol_tail(symbol).endswith(f".{normalized}") + or normalized.endswith(f".{_symbol_tail(symbol)}") + ] + return matches[0] if len(matches) == 1 else None + + def shortest_path( graph: Mapping[str, Set[str]], starts: Iterable[str], target: str ) -> Optional[List[str]]: - queue = deque((start, [start]) for start in starts if start in graph) + resolved_starts = { + resolved + for start in starts + for resolved in [resolve_reference(graph, start)] + if resolved is not None + } + resolved_target = target if target in graph else resolve_reference(graph, target) + if resolved_target is None: + return None + queue = deque((start, [start]) for start in sorted(resolved_starts)) visited: Set[str] = set() while queue: node, path = queue.popleft() - if node == target: + if node == resolved_target: return path if node in visited: continue @@ -66,5 +113,14 @@ def visit(node: str, stack: List[str]) -> None: return sorted(found) -def _base_name(name: str) -> str: - return name.replace("->", ".").replace("::", ".").split(".")[-1] +def display_symbol(symbol: str) -> str: + tail = symbol.split("::", 1)[-1] + return tail.rsplit("@", 1)[0] + + +def _symbol_tail(symbol: str) -> str: + return _normalize(display_symbol(symbol)) + + +def _normalize(name: str) -> str: + return name.replace("->", ".").replace("::", ".").strip(".") diff --git a/src/relay/cli.py b/src/relay/cli.py index e9a8a06..275dd5b 100644 --- a/src/relay/cli.py +++ b/src/relay/cli.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import importlib.util import os import platform import sys @@ -18,7 +17,7 @@ from .models import Severity from .reports import render, suggestions, write_report from .rules import RULES -from .timeline import render_timeline +from .timing_summary import render_timing_summary _INIT_CONTENT = """# Relay timing contract. Relay estimates; it does not measure hardware timing. [project] @@ -27,12 +26,16 @@ [tasks.update_motors] every = "20ms" +execution_context = "control-loop" [tasks.read_distance] every = "50ms" +execution_context = "control-loop" [tasks.emergency_stop] maximum_latency = "10ms" +execution_context = "control-loop" +safety_critical = true [functions] update_motors = "updateMotors" @@ -43,7 +46,8 @@ disable = [] python_sleep_threshold = "100ms" maximum_file_bytes = 2000000 -maximum_files = 10000 +maximum_files = 2000 +maximum_total_bytes = 50000000 """ @@ -72,7 +76,12 @@ def build_parser() -> argparse.ArgumentParser: explain.add_argument("--level", choices=("beginner", "technical"), default="beginner") explain.add_argument("--rule") - timeline = commands.add_parser("timeline", help="estimate timing consequences") + summary = commands.add_parser("summary", help="summarise declared timing consequences") + _path_and_config(summary) + summary.add_argument("--duration", default="5s") + summary.add_argument("--format", choices=("text", "markdown"), default="text") + summary.add_argument("--output", type=Path) + timeline = commands.add_parser("timeline", help="deprecated alias for summary") _path_and_config(timeline) timeline.add_argument("--duration", default="5s") timeline.add_argument("--format", choices=("text", "markdown"), default="text") @@ -95,8 +104,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return _check(arguments) if arguments.command == "explain": return _explain(arguments) - if arguments.command == "timeline": - return _timeline(arguments) + if arguments.command in {"summary", "timeline"}: + if arguments.command == "timeline": + print( + "relay: 'timeline' is deprecated; use 'summary' because Relay does not " + "simulate a schedule", + file=sys.stderr, + ) + return _summary(arguments) if arguments.command == "init": return _init(arguments.directory) if arguments.command == "rules": @@ -150,12 +165,12 @@ def _explain(arguments: argparse.Namespace) -> int: return 0 -def _timeline(arguments: argparse.Namespace) -> int: +def _summary(arguments: argparse.Namespace) -> int: _require_existing(arguments.path) config = _load_config(arguments.path, arguments.config) duration = parse_duration(arguments.duration) result = analyse_path(arguments.path, config) - content = render_timeline(result, config, duration, arguments.format == "markdown") + content = render_timing_summary(result, config, duration, arguments.format == "markdown") if arguments.output: write_report(arguments.output, content, result.files) else: @@ -178,6 +193,7 @@ def _init(directory: Path) -> int: def _rules() -> int: + print("Catalog confidence is capped to medium when a structural language model is used.") for rule in RULES: print( f"{rule.rule_id} {rule.severity.value:<8} {rule.confidence.value:<6} " @@ -204,16 +220,24 @@ def _doctor(path: Path, explicit_config: Optional[Path]) -> int: failures.append(str(exc)) else: print("Configuration: not found [using defaults]") - files, warnings = discover_sources(path) + active = RelayConfig.load(config_path) if config_path else RelayConfig.default() + files, warnings = discover_sources( + path, + active.maximum_files, + active.maximum_file_bytes, + active.maximum_total_bytes, + ) print(f"Source discovery: {len(files)} supported file(s) [{len(warnings)} warning(s)]") report_directory = path.resolve() if path.is_dir() else path.resolve().parent writable = os.access(report_directory, os.W_OK) - print(f"Report directory: {report_directory} [{'writable' if writable else 'not writable'}]") + print( + f"Report directory permission hint: {report_directory} " + f"[{'writable' if writable else 'not writable'}]" + ) if not writable: failures.append("report directory is not writable") - clang = importlib.util.find_spec("clang") is not None - print(f"Optional Clang Python bindings: {'available' if clang else 'not installed (optional)'}") - print("External commands: never run by analysis [ok]") + print("Analysis front ends: Python AST plus explicitly labelled structural language models") + print("External commands: analysis code does not invoke compilers or language servers") return int(bool(failures)) diff --git a/src/relay/config.py b/src/relay/config.py index ee4ec5d..90dae93 100644 --- a/src/relay/config.py +++ b/src/relay/config.py @@ -16,6 +16,18 @@ from .models import TaskContract _DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(us|ms|s|m)\s*$", re.IGNORECASE) +_KNOWN_RULE_IDS = {f"RLY{number}" for number in range(101, 116)} +_ROOT_KEYS = {"project", "functions", "tasks", "analysis"} +_PROJECT_KEYS = {"target", "entrypoints"} +_TASK_KEYS = {"every", "maximum_latency", "execution_context", "safety_critical"} +_ANALYSIS_KEYS = { + "disable", + "enable", + "maximum_file_bytes", + "maximum_files", + "maximum_total_bytes", + "python_sleep_threshold", +} class ConfigError(ValueError): @@ -40,7 +52,8 @@ class RelayConfig: disabled_rules: Set[str] = field(default_factory=set) python_sleep_threshold: float = 0.1 maximum_file_bytes: int = 2_000_000 - maximum_files: int = 10_000 + maximum_files: int = 2_000 + maximum_total_bytes: int = 50_000_000 @classmethod def default(cls) -> "RelayConfig": @@ -59,9 +72,12 @@ def load(cls, path: Path) -> "RelayConfig": @classmethod def from_mapping(cls, raw: Mapping[str, Any]) -> "RelayConfig": + _reject_unknown(raw, _ROOT_KEYS, "configuration") project = _table(raw, "project") functions = _table(raw, "functions") analysis = _table(raw, "analysis") + _reject_unknown(project, _PROJECT_KEYS, "project") + _reject_unknown(analysis, _ANALYSIS_KEYS, "analysis") target = _string(project.get("target", "generic"), "project.target") entrypoints_value = project.get("entrypoints", ["setup", "loop", "main"]) if not isinstance(entrypoints_value, list) or not all( @@ -73,26 +89,53 @@ def from_mapping(cls, raw: Mapping[str, Any]) -> "RelayConfig": for name, row in task_rows.items(): if not isinstance(row, dict): raise ConfigError(f"tasks.{name} must be a table") + _reject_unknown(row, _TASK_KEYS, f"tasks.{name}") every = _optional_duration(row.get("every"), f"tasks.{name}.every") latency = _optional_duration( row.get("maximum_latency"), f"tasks.{name}.maximum_latency" ) + context = _optional_string( + row.get("execution_context"), f"tasks.{name}.execution_context" + ) + safety_critical = row.get("safety_critical", False) + if not isinstance(safety_critical, bool): + raise ConfigError(f"tasks.{name}.safety_critical must be a boolean") function = functions.get(name, name) if not isinstance(function, str): raise ConfigError(f"functions.{name} must be a string") - tasks.append(TaskContract(str(name), function, every, latency)) + tasks.append( + TaskContract( + str(name), + function, + every, + latency, + context, + safety_critical, + ) + ) enabled = _string_set(analysis.get("enable"), "analysis.enable") disabled = _string_set(analysis.get("disable", []), "analysis.disable") or set() + unknown_rules = (enabled or set()) | disabled + unknown_rules -= _KNOWN_RULE_IDS + if unknown_rules: + raise ConfigError(f"unknown rule ID(s): {', '.join(sorted(unknown_rules))}") threshold = analysis.get("python_sleep_threshold", "100ms") if not isinstance(threshold, str): raise ConfigError("analysis.python_sleep_threshold must be a duration string") maximum_file_bytes = analysis.get("maximum_file_bytes", 2_000_000) - maximum_files = analysis.get("maximum_files", 10_000) + maximum_files = analysis.get("maximum_files", 2_000) + maximum_total_bytes = analysis.get("maximum_total_bytes", 50_000_000) if not isinstance(maximum_file_bytes, int) or maximum_file_bytes <= 0: raise ConfigError("analysis.maximum_file_bytes must be a positive integer") if not isinstance(maximum_files, int) or maximum_files <= 0: raise ConfigError("analysis.maximum_files must be a positive integer") + if not isinstance(maximum_total_bytes, int) or maximum_total_bytes <= 0: + raise ConfigError("analysis.maximum_total_bytes must be a positive integer") + if maximum_total_bytes < maximum_file_bytes: + raise ConfigError( + "analysis.maximum_total_bytes must be greater than or equal to maximum_file_bytes" + ) return cls( target=target, entrypoints=list(entrypoints_value), @@ -102,6 +145,7 @@ def from_mapping(cls, raw: Mapping[str, Any]) -> "RelayConfig": python_sleep_threshold=parse_duration(threshold), maximum_file_bytes=maximum_file_bytes, maximum_files=maximum_files, + maximum_total_bytes=maximum_total_bytes, ) def rule_enabled(self, rule_id: str) -> bool: @@ -142,9 +186,23 @@ def _optional_duration(value: Any, name: str) -> Optional[float]: return parse_duration(value) +def _optional_string(value: Any, name: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{name} must be a non-empty string") + return value + + def _string_set(value: Any, name: str) -> Optional[Set[str]]: if value is None: return None if not isinstance(value, list) or not all(isinstance(item, str) for item in value): raise ConfigError(f"{name} must be an array of strings") return set(value) + + +def _reject_unknown(raw: Mapping[str, Any], allowed: Set[str], name: str) -> None: + unknown = set(raw) - allowed + if unknown: + raise ConfigError(f"unknown {name} key(s): {', '.join(sorted(unknown))}") diff --git a/src/relay/diagnostics.py b/src/relay/diagnostics.py index 2f72906..f3ff86d 100644 --- a/src/relay/diagnostics.py +++ b/src/relay/diagnostics.py @@ -18,8 +18,8 @@ def explain_findings( lines.extend((f"{finding.rule_id} at {finding.file}:{finding.start_line}", "")) if level == "beginner": lines.append( - f"{finding.message} While it waits, other checks and updates " - "may run late or not at all." + f"{finding.message} If other checks share this execution context, " + "they may run late." ) else: lines.extend((finding.message, finding.explanation)) diff --git a/src/relay/discovery.py b/src/relay/discovery.py index e91662f..b89abd5 100644 --- a/src/relay/discovery.py +++ b/src/relay/discovery.py @@ -1,13 +1,15 @@ -"""Bounded, deterministic source discovery.""" +"""Bounded, deterministic, symlink-safe source discovery.""" from __future__ import annotations -import fnmatch import os from pathlib import Path -from typing import Iterable, List, Sequence, Set, Tuple +from typing import List, Optional, Set, Tuple + +from pathspec import GitIgnoreSpec + +from .languages import SUPPORTED_EXTENSIONS -SUPPORTED_EXTENSIONS = {".ino", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".py", ".pyw"} EXCLUDED_DIRECTORIES = { ".git", ".hg", @@ -24,15 +26,33 @@ } -def discover_sources(path: Path, maximum_files: int = 10_000) -> Tuple[List[Path], List[str]]: - root = path.resolve() +def discover_sources( + path: Path, + maximum_files: int = 2_000, + maximum_file_bytes: int = 2_000_000, + maximum_total_bytes: int = 50_000_000, +) -> Tuple[List[Path], List[str]]: warnings: List[str] = [] + if path.is_symlink(): + return [], [f"refusing symlink analysis root: {path}"] + root = path.resolve() if root.is_file(): - return ([root] if root.suffix.lower() in SUPPORTED_EXTENSIONS else []), warnings + if root.suffix.lower() not in SUPPORTED_EXTENSIONS: + return [], warnings + size = _size(root, warnings) + if size is None: + return [], warnings + if size > maximum_file_bytes: + return [], [f"skipped {root}: {size} bytes exceeds {maximum_file_bytes}"] + if size > maximum_total_bytes: + return [], [f"skipped {root}: aggregate input limit is {maximum_total_bytes} bytes"] + return [root], warnings if not root.is_dir(): return [], [f"path does not exist or is not readable: {path}"] - patterns = _gitignore_patterns(root) + + ignore = _gitignore_spec(root, warnings) files: List[Path] = [] + total_bytes = 0 visited: Set[Tuple[int, int]] = set() for directory, names, filenames in os.walk(root, followlinks=False): directory_path = Path(directory) @@ -52,40 +72,63 @@ def discover_sources(path: Path, maximum_files: int = 10_000) -> Tuple[List[Path for name in names if name not in EXCLUDED_DIRECTORIES and not (directory_path / name).is_symlink() - and not _ignored((directory_path / name).relative_to(root), patterns) + and not _ignored( + (directory_path / name).relative_to(root), + ignore, + directory=True, + ) ) for name in sorted(filenames): candidate = directory_path / name if candidate.is_symlink() or candidate.suffix.lower() not in SUPPORTED_EXTENSIONS: continue - if _ignored(candidate.relative_to(root), patterns): + if _ignored(candidate.relative_to(root), ignore): + continue + size = _size(candidate, warnings) + if size is None: continue + if size > maximum_file_bytes: + warnings.append(f"skipped {candidate}: {size} bytes exceeds {maximum_file_bytes}") + continue + if total_bytes + size > maximum_total_bytes: + warnings.append( + "source discovery stopped at configured aggregate input limit " + f"({maximum_total_bytes} bytes)" + ) + return files, warnings files.append(candidate.resolve()) + total_bytes += size if len(files) >= maximum_files: warnings.append(f"source discovery stopped at configured limit ({maximum_files})") return files, warnings return files, warnings -def _gitignore_patterns(root: Path) -> Sequence[str]: +def _gitignore_spec(root: Path, warnings: List[str]) -> Optional[GitIgnoreSpec]: path = root / ".gitignore" if not path.is_file(): - return () + return None try: - return tuple( - line.strip() - for line in path.read_text(encoding="utf-8", errors="replace").splitlines() - if line.strip() and not line.lstrip().startswith("#") and not line.startswith("!") + return GitIgnoreSpec.from_lines( + path.read_text(encoding="utf-8", errors="replace").splitlines() ) - except OSError: - return () + except (OSError, ValueError) as exc: + warnings.append(f"cannot apply {path}: {exc}") + return None -def _ignored(relative: Path, patterns: Iterable[str]) -> bool: +def _ignored(relative: Path, spec: Optional[GitIgnoreSpec], directory: bool = False) -> bool: + if spec is None: + return False value = relative.as_posix() - return any( - fnmatch.fnmatch(value, pattern.rstrip("/")) - or fnmatch.fnmatch(relative.name, pattern.rstrip("/")) - or value.startswith(pattern.rstrip("/") + "/") - for pattern in patterns - ) + if directory: + value += "/" + return spec.match_file(value) + + +def _size(path: Path, warnings: List[str]) -> Optional[int]: + try: + return path.stat().st_size + except OSError as exc: + warnings.append(f"cannot inspect {path}: {exc}") + return None diff --git a/src/relay/languages.py b/src/relay/languages.py new file mode 100644 index 0000000..a3df56c --- /dev/null +++ b/src/relay/languages.py @@ -0,0 +1,54 @@ +"""Language profiles used by discovery, parsing, and rule dispatch.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, FrozenSet, Optional + + +@dataclass(frozen=True) +class LanguageProfile: + language: str + family: str + extensions: FrozenSet[str] + model: str + + +LANGUAGES = ( + LanguageProfile("python", "python", frozenset({".py", ".pyw"}), "Python AST (file-local)"), + LanguageProfile( + "cpp", + "c-like", + frozenset({".ino", ".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx"}), + "structural C-family model", + ), + LanguageProfile( + "javascript", + "c-like", + frozenset({".js", ".jsx", ".mjs", ".cjs"}), + "structural JavaScript model", + ), + LanguageProfile( + "typescript", + "c-like", + frozenset({".ts", ".tsx", ".mts", ".cts"}), + "structural TypeScript model", + ), + LanguageProfile("java", "c-like", frozenset({".java"}), "structural Java model"), + LanguageProfile("csharp", "c-like", frozenset({".cs"}), "structural C# model"), + LanguageProfile("go", "c-like", frozenset({".go"}), "structural Go model"), + LanguageProfile("rust", "c-like", frozenset({".rs"}), "structural Rust model"), + LanguageProfile("kotlin", "c-like", frozenset({".kt", ".kts"}), "structural Kotlin model"), + LanguageProfile("swift", "c-like", frozenset({".swift"}), "structural Swift model"), + LanguageProfile("ruby", "ruby", frozenset({".rb"}), "structural Ruby model"), + LanguageProfile("php", "c-like", frozenset({".php"}), "structural PHP model"), +) + +BY_EXTENSION: Dict[str, LanguageProfile] = { + extension: profile for profile in LANGUAGES for extension in profile.extensions +} +SUPPORTED_EXTENSIONS = frozenset(BY_EXTENSION) + + +def profile_for_extension(extension: str) -> Optional[LanguageProfile]: + return BY_EXTENSION.get(extension.lower()) diff --git a/src/relay/models.py b/src/relay/models.py index 8a7ae33..35a0dae 100644 --- a/src/relay/models.py +++ b/src/relay/models.py @@ -32,6 +32,8 @@ class TaskContract: function: str every_seconds: Optional[float] = None maximum_latency_seconds: Optional[float] = None + execution_context: Optional[str] = None + safety_critical: bool = False @dataclass(frozen=True) @@ -70,6 +72,7 @@ class AnalysisResult: files: List[Path] = field(default_factory=list) warnings: List[str] = field(default_factory=list) root: Optional[Path] = None + analysis_models: Dict[str, str] = field(default_factory=dict) def sorted(self) -> "AnalysisResult": self.findings.sort( @@ -90,6 +93,8 @@ class Call: line: int arguments: Tuple[str, ...] enclosing_function: Optional[str] + keyword_arguments: Tuple[Tuple[str, str], ...] = () + awaited: bool = False @dataclass(frozen=True) @@ -100,6 +105,8 @@ class Loop: condition: str body: str enclosing_function: Optional[str] + has_exit: Optional[bool] = None + has_yield: Optional[bool] = None @dataclass @@ -113,3 +120,4 @@ class ParsedSource: functions: Dict[str, Tuple[int, int]] = field(default_factory=dict) constants: Dict[str, float] = field(default_factory=dict) syntax_warning: Optional[str] = None + analysis_model: str = "structural" diff --git a/src/relay/parser.py b/src/relay/parser.py index 856f954..a556581 100644 --- a/src/relay/parser.py +++ b/src/relay/parser.py @@ -1,76 +1,143 @@ -"""Conservative structural parsers for supported languages.""" +"""Language-aware, non-executing source parsers. + +Python uses its standard AST. Other languages use an explicitly labelled structural model; Relay +does not treat that model as compiler-grade semantic analysis. +""" from __future__ import annotations import ast import re from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, Iterable, List, Optional, Set, Tuple, Union +from .languages import profile_for_extension from .lexer import line_number, mask_comments_and_strings, matching_delimiter, split_arguments from .models import Call, Loop, ParsedSource -_CONTROL_WORDS = {"if", "for", "while", "switch", "catch"} -_CPP_FUNCTION = re.compile( - r"(?:^|[;}\n])\s*(?:[\w:<>,*&~]+\s+)+(?P[A-Za-z_]\w*)\s*" - r"\((?P[^;{}()]*(?:\([^)]*\)[^;{}()]*)*)\)\s*(?:const\s*)?\{", +_CONTROL_WORDS = { + "catch", + "for", + "if", + "match", + "sizeof", + "switch", + "synchronized", + "while", +} +_C_LIKE_FUNCTION = re.compile( + r"(?:^|[;{}\n])\s*" + r"(?:[\w:<>,*&~@\[\].?+-]+\s+)+" + r"(?P[A-Za-z_]\w*(?:(?:::)[A-Za-z_]\w*)*)\s*" + r"\((?P[^;{}()]*(?:\([^)]*\)[^;{}()]*)*)\)\s*" + r"(?:const\s*)?(?:noexcept(?:\s*\([^)]*\))?\s*)?" + r"(?:(?:->|:)\s*[\w:<>,*&~\[\].?+-]+\s*)?\{", + re.MULTILINE, +) +_C_LIKE_METHOD = re.compile( + r"(?:^|[;{}\n])\s*(?P[A-Za-z_]\w*)\s*" + r"\((?P[^;{}()]*(?:\([^)]*\)[^;{}()]*)*)\)\s*\{", re.MULTILINE, ) -_CPP_CALL = re.compile(r"\b(?P[A-Za-z_]\w*(?:(?:::|\.|->)[A-Za-z_]\w*)*)\s*\(") -_CPP_LOOP = re.compile(r"\b(?Pwhile|for)\s*\(") -_CPP_CONSTANT = re.compile( - r"\b(?:const(?:expr)?\s+)?(?:unsigned\s+)?(?:long|int|float|double|uint\d+_t)\s+" - r"(?P[A-Za-z_]\w*)\s*=\s*(?P\d+(?:\.\d+)?)\s*;" +_C_LIKE_CALL = re.compile(r"\b(?P[A-Za-z_]\w*(?:(?:::|\.|->)[A-Za-z_]\w*)*)\s*\(") +_ARROW_FUNCTION = re.compile( + r"\b(?:const|let|var)\s+(?P[A-Za-z_]\w*)\s*" + r"(?:\:\s*(?:\([^)]*\)\s*=>\s*)?[\w<>,*&~\[\].?+-]+)?\s*=\s*" + r"(?:async\s*)?\([^)]*\)\s*" + r"(?:\:\s*[\w<>,*&~\[\].?+-]+\s*)?=>\s*\{" ) +_GO_METHOD = re.compile(r"\bfunc\s*\([^)]*\)\s*(?P[A-Za-z_]\w*)\s*\([^)]*\)\s*\{") +_C_LIKE_LOOP = re.compile(r"\b(?Pwhile|for)\s*\(") +_NAMED_SCOPE = re.compile( + r"\b(?:namespace|class|struct|interface|object|enum)\s+(?P[A-Za-z_]\w*)" + r"[^;{]*\{" +) +_NUMBER_CONSTANT = re.compile( + r"\b(?P[A-Za-z_]\w*)\s*=\s*" + r"(?P[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)(?:[uUlLfF]+)?\s*;" +) +_RUBY_FUNCTION = re.compile(r"^\s*def\s+(?P(?:self\.)?[A-Za-z_]\w*[!?=]?)", re.MULTILINE) +_RUBY_CALL = re.compile(r"\b(?P[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*\(") def parse_source(path: Path, source: str) -> ParsedSource: - if path.suffix.lower() in {".py", ".pyw"}: + profile = profile_for_extension(path.suffix) + if profile is None: + return ParsedSource( + path, + "unknown", + source, + source, + syntax_warning=f"unsupported source extension: {path.suffix}", + analysis_model="unsupported", + ) + if profile.family == "python": return _parse_python(path, source) - return _parse_cpp(path, source) + if profile.family == "ruby": + return _parse_ruby(path, source, profile.language, profile.model) + return _parse_c_like(path, source, profile.language, profile.model) -def _parse_cpp(path: Path, source: str) -> ParsedSource: +def _parse_c_like(path: Path, source: str, language: str, model: str) -> ParsedSource: clean = mask_comments_and_strings(source) - parsed = ParsedSource(path, "cpp", source, clean) + parsed = ParsedSource(path, language, source, clean, analysis_model=model) + module = path.as_posix() + scope_spans = _named_scope_spans(clean) function_spans: List[Tuple[int, int, str]] = [] - definition_names: Dict[int, str] = {} - for match in _CPP_FUNCTION.finditer(clean): + definition_names: Set[int] = set() + matches = [*_C_LIKE_FUNCTION.finditer(clean), *_C_LIKE_METHOD.finditer(clean)] + if language in {"javascript", "typescript"}: + matches.extend(_ARROW_FUNCTION.finditer(clean)) + if language == "go": + matches.extend(_GO_METHOD.finditer(clean)) + matches.sort(key=lambda item: item.start("name")) + seen_definitions: Set[int] = set() + for match in matches: + name_offset = match.start("name") + if name_offset in seen_definitions: + continue + seen_definitions.add(name_offset) name = match.group("name") if name in _CONTROL_WORDS: continue brace = clean.find("{", match.start(), match.end()) end = matching_delimiter(clean, brace, "{", "}") if end is None: - end = len(clean) - 1 - parsed.syntax_warning = "unbalanced function braces; results may be incomplete" - start_line = line_number(clean, match.start("name")) + end = max(len(clean) - 1, 0) + parsed.syntax_warning = ( + "unbalanced function braces; structural results may be incomplete" + ) + start_line = line_number(clean, name_offset) end_line = line_number(clean, end) - parsed.functions[name] = (start_line, end_line) - function_spans.append((match.start(), end, name)) - definition_names[match.start("name")] = name + scope = _scope_path(match.start(), scope_spans) + local_name = ".".join([*scope, name.replace("::", ".")]) + qualified = _unique_symbol(parsed.functions, f"{module}::{local_name}", start_line) + parsed.functions[qualified] = (start_line, end_line) + function_spans.append((match.start(), end, qualified)) + definition_names.add(name_offset) - for match in _CPP_CALL.finditer(clean): + for match in _C_LIKE_CALL.finditer(clean): name = match.group("name") - if name in _CONTROL_WORDS: - continue - if match.start("name") in definition_names: + if name in _CONTROL_WORDS or match.start("name") in definition_names: continue opening = clean.find("(", match.start(), match.end()) closing = matching_delimiter(clean, opening, "(", ")") if closing is None: continue arguments = tuple(split_arguments(source[opening + 1 : closing])) + line_start = clean.rfind("\n", 0, match.start()) + 1 + awaited = bool(re.search(r"\bawait\s*$", clean[line_start : match.start()])) parsed.calls.append( Call( name, line_number(clean, match.start()), arguments, _owner(match.start(), function_spans), + awaited=awaited, ) ) - for match in _CPP_LOOP.finditer(clean): + for match in _C_LIKE_LOOP.finditer(clean): opening = clean.find("(", match.start(), match.end()) closing = matching_delimiter(clean, opening, "(", ")") if closing is None: @@ -81,7 +148,7 @@ def _parse_cpp(path: Path, source: str) -> ParsedSource: if clean[body_start] == "{": body_end = matching_delimiter(clean, body_start, "{", "}") if body_end is None: - body_end = len(clean) - 1 + body_end = max(len(clean) - 1, body_start) body = clean[body_start + 1 : body_end] else: semicolon = clean.find(";", body_start) @@ -97,15 +164,54 @@ def _parse_cpp(path: Path, source: str) -> ParsedSource: _owner(match.start(), function_spans), ) ) - for match in _CPP_CONSTANT.finditer(clean): + for match in _NUMBER_CONSTANT.finditer(clean): parsed.constants[match.group("name")] = float(match.group("value")) return parsed +def _parse_ruby(path: Path, source: str, language: str, model: str) -> ParsedSource: + parsed = ParsedSource(path, language, source, source, analysis_model=model) + module = path.as_posix() + function_spans: List[Tuple[int, int, str]] = [] + definition_names: Set[int] = set() + for match in _RUBY_FUNCTION.finditer(source): + start_line = line_number(source, match.start("name")) + qualified = _unique_symbol(parsed.functions, f"{module}::{match.group('name')}", start_line) + end_match = re.search(r"^\s*end\b", source[match.end() :], re.MULTILINE) + end_offset = match.end() + end_match.end() if end_match else len(source) + parsed.functions[qualified] = (start_line, line_number(source, end_offset)) + function_spans.append((match.start(), end_offset, qualified)) + definition_names.add(match.start("name")) + for match in _RUBY_CALL.finditer(source): + if match.start("name") in definition_names: + continue + opening = source.find("(", match.start(), match.end()) + closing = matching_delimiter(source, opening, "(", ")") + if closing is None: + continue + parsed.calls.append( + Call( + match.group("name"), + line_number(source, match.start()), + tuple(split_arguments(source[opening + 1 : closing])), + _owner(match.start(), function_spans), + ) + ) + return parsed + + class _PythonVisitor(ast.NodeVisitor): def __init__(self, parsed: ParsedSource) -> None: self.parsed = parsed + self.module = parsed.path.as_posix() + self.scope_stack: List[str] = [] self.function_stack: List[str] = [] + self.await_depth = 0 + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.scope_stack.append(node.name) + self.generic_visit(node) + self.scope_stack.pop() def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self._visit_function(node) @@ -114,20 +220,35 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self._visit_function(node) def _visit_function(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> None: - self.parsed.functions[node.name] = (node.lineno, node.end_lineno or node.lineno) - self.function_stack.append(node.name) + local_name = ".".join([*self.scope_stack, node.name]) + qualified = f"{self.module}::{local_name}" + self.parsed.functions[qualified] = (node.lineno, node.end_lineno or node.lineno) + self.scope_stack.append(node.name) + self.function_stack.append(qualified) self.generic_visit(node) self.function_stack.pop() + self.scope_stack.pop() + + def visit_Await(self, node: ast.Await) -> None: + self.await_depth += 1 + self.generic_visit(node) + self.await_depth -= 1 def visit_Call(self, node: ast.Call) -> None: name = _python_name(node.func) arguments = tuple( ast.get_source_segment(self.parsed.source, argument) or "" for argument in node.args - ) + tuple( - f"{keyword.arg}={ast.get_source_segment(self.parsed.source, keyword.value) or ''}" + ) + keywords = tuple( + ( + keyword.arg or "**", + ast.get_source_segment(self.parsed.source, keyword.value) or "", + ) for keyword in node.keywords ) - self.parsed.calls.append(Call(name, node.lineno, arguments, self.owner)) + self.parsed.calls.append( + Call(name, node.lineno, arguments, self.owner, keywords, self.await_depth > 0) + ) self.generic_visit(node) def visit_While(self, node: ast.While) -> None: @@ -140,10 +261,11 @@ def visit_AsyncFor(self, node: ast.AsyncFor) -> None: self._loop(node, "for", node.iter) def visit_Assign(self, node: ast.Assign) -> None: - if isinstance(node.value, ast.Constant) and isinstance(node.value.value, (int, float)): + value = _python_number(node.value, self.parsed.constants) + if value is not None: for target in node.targets: if isinstance(target, ast.Name): - self.parsed.constants[target.id] = float(node.value.value) + self.parsed.constants[target.id] = value self.generic_visit(node) @property @@ -164,13 +286,21 @@ def _loop(self, node: ast.AST, kind: str, condition: ast.AST) -> None: ast.get_source_segment(self.parsed.source, condition) or "", body, self.owner, + has_exit=_statements_guarantee_exit(body_nodes), + has_yield=_contains_cooperative_yield(body_nodes), ) ) self.generic_visit(node) def _parse_python(path: Path, source: str) -> ParsedSource: - parsed = ParsedSource(path, "python", source, source) + parsed = ParsedSource( + path, + "python", + source, + source, + analysis_model="Python AST (file-local)", + ) try: tree = ast.parse(source, filename=str(path)) except (SyntaxError, ValueError) as exc: @@ -180,6 +310,92 @@ def _parse_python(path: Path, source: str) -> ParsedSource: return parsed +def _statements_guarantee_exit(statements: Iterable[ast.stmt]) -> bool: + for statement in statements: + if isinstance(statement, (ast.Break, ast.Return, ast.Raise)): + return True + if isinstance(statement, ast.If): + if _constant_truth(statement.test) is True: + if _statements_guarantee_exit(statement.body): + return True + elif _constant_truth(statement.test) is False: + if _statements_guarantee_exit(statement.orelse): + return True + elif ( + statement.orelse + and _statements_guarantee_exit(statement.body) + and _statements_guarantee_exit(statement.orelse) + ): + return True + return False + + +def _contains_cooperative_yield(statements: Iterable[ast.stmt]) -> bool: + cooperative = {"asyncio.sleep", "trio.sleep", "anyio.sleep"} + for statement in statements: + for node in ast.walk(statement): + if isinstance(node, (ast.Await, ast.Yield, ast.YieldFrom)): + return True + if isinstance(node, ast.Call) and _python_name(node.func) in cooperative: + return True + return False + + +def _constant_truth(node: ast.AST) -> Optional[bool]: + if isinstance(node, ast.Constant) and isinstance(node.value, bool): + return node.value + return None + + +def _python_number(node: ast.AST, constants: Dict[str, float]) -> Optional[float]: + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return float(node.value) + if isinstance(node, ast.Name): + return constants.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = _python_number(node.operand, constants) + if value is not None: + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv) + ): + left = _python_number(node.left, constants) + right = _python_number(node.right, constants) + if left is None or right is None: + return None + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + if right == 0: + return None + if isinstance(node.op, ast.Div): + return left / right + return left // right + return None + + +def _unique_symbol(functions: Dict[str, Tuple[int, int]], preferred: str, line: int) -> str: + return preferred if preferred not in functions else f"{preferred}@{line}" + + +def _named_scope_spans(source: str) -> List[Tuple[int, int, str]]: + spans: List[Tuple[int, int, str]] = [] + for match in _NAMED_SCOPE.finditer(source): + brace = source.find("{", match.start(), match.end()) + end = matching_delimiter(source, brace, "{", "}") + if end is not None: + spans.append((match.start(), end, match.group("name"))) + return spans + + +def _scope_path(offset: int, spans: List[Tuple[int, int, str]]) -> List[str]: + containing = [(start, end, name) for start, end, name in spans if start <= offset <= end] + return [name for _, _, name in sorted(containing, key=lambda item: item[0])] + + def _owner(offset: int, spans: List[Tuple[int, int, str]]) -> Optional[str]: candidates = [(end - start, name) for start, end, name in spans if start <= offset <= end] return min(candidates)[1] if candidates else None diff --git a/src/relay/reports.py b/src/relay/reports.py index 9df2580..c99f145 100644 --- a/src/relay/reports.py +++ b/src/relay/reports.py @@ -2,9 +2,13 @@ from __future__ import annotations +import hashlib import json +import os +import re +import tempfile from pathlib import Path -from typing import Any, Dict, Iterable, List, Mapping, Sequence +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from . import __version__ from .models import AnalysisResult, Finding @@ -70,18 +74,48 @@ def suggestions(findings: Iterable[Finding]) -> str: def write_report(path: Path, content: str, source_files: Sequence[Path] = ()) -> None: - resolved = path.resolve() - if path.exists() and path.is_symlink(): + if path.is_symlink(): raise ValueError(f"refusing to overwrite symlink: {path}") - if resolved in {source.resolve() for source in source_files}: + parent = path.absolute().parent.resolve() + target = parent / path.name + if target in {source.resolve() for source in source_files}: raise ValueError(f"refusing to overwrite analysed source: {path}") - if not resolved.parent.is_dir(): - raise ValueError(f"output directory does not exist: {resolved.parent}") - resolved.write_text(content, encoding="utf-8") + if not parent.is_dir(): + raise ValueError(f"output directory does not exist: {parent}") + temporary: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(handle.name) + os.replace(temporary, target) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) def _text_report(result: AnalysisResult) -> str: - lines: List[str] = [] + lines: List[str] = [ + "ANALYSIS MODELS: " + + ( + ", ".join( + f"{language}={model}" for language, model in sorted(result.analysis_models.items()) + ) + or "none" + ), + "MODEL LIMIT: Structural models do not provide compiler-grade type, build, or dispatch " + "semantics.", + "", + ] for finding in result.findings: relative = _relative(finding.file, result) lines.extend( @@ -122,7 +156,18 @@ def _markdown_report(result: AnalysisResult) -> str: f"Analysed {len(result.files)} supported source file(s); " f"found {len(result.findings)} issue(s).", "", - "> Timing impacts are conservative estimates, not cycle-accurate measurements.", + "> Relay uses file-local AST analysis for Python and explicitly labelled structural " + "models for other languages. Structural findings are review prompts, not " + "compiler-verified facts.", + "", + "**Analysis models:** " + + ( + ", ".join( + f"`{language}`: {model}" + for language, model in sorted(result.analysis_models.items()) + ) + or "none" + ), "", ] for finding in result.findings: @@ -135,7 +180,9 @@ def _markdown_report(result: AnalysisResult) -> str: "", finding.message, "", - f"Evidence: `{finding.evidence}`", + "Evidence:", + "", + _markdown_fence(finding.evidence), "", ) ) @@ -155,9 +202,10 @@ def _markdown_report(result: AnalysisResult) -> str: def _json_report(result: AnalysisResult) -> Dict[str, Any]: return { - "schema_version": "1.0", + "schema_version": "2.0", "relay_version": __version__, "summary": {"files": len(result.files), "findings": len(result.findings)}, + "analysis_models": dict(sorted(result.analysis_models.items())), "findings": [finding.to_dict(result.root) for finding in result.findings], "warnings": result.warnings, } @@ -196,6 +244,10 @@ def _sarif_report(result: AnalysisResult) -> Dict[str, Any]: "properties": { "confidence": finding.confidence.value, "evidence": finding.evidence, + "analysisModels": dict(sorted(result.analysis_models.items())), + }, + "partialFingerprints": { + "relayFinding/v1": _fingerprint(finding, result), }, } ) @@ -225,3 +277,21 @@ def _relative(path: Path, result: AnalysisResult) -> str: except ValueError: pass return path.as_posix() + + +def _markdown_fence(value: str) -> str: + longest = max((len(match.group(0)) for match in re.finditer(r"`+", value)), default=0) + fence = "`" * max(3, longest + 1) + return f"{fence}text\n{value}\n{fence}" + + +def _fingerprint(finding: Finding, result: AnalysisResult) -> str: + material = "\0".join( + ( + finding.rule_id, + _relative(finding.file, result), + str(finding.start_line), + finding.evidence, + ) + ) + return hashlib.sha256(material.encode("utf-8")).hexdigest() diff --git a/src/relay/rules.py b/src/relay/rules.py index 03e8166..b464a95 100644 --- a/src/relay/rules.py +++ b/src/relay/rules.py @@ -1,14 +1,15 @@ -"""Stable Relay rule catalogue and conservative detectors.""" +"""Versioned Relay rule catalogue and conservative detectors.""" from __future__ import annotations +import ast import math import re from dataclasses import dataclass from pathlib import Path -from typing import Iterable, List, Mapping, Optional, Sequence, Tuple +from typing import List, Mapping, Optional, Sequence, Tuple -from .callgraph import Graph, cycles, shortest_path +from .callgraph import Graph, cycles, display_symbol, shortest_path from .config import RelayConfig from .models import Call, Confidence, Finding, Loop, ParsedSource, Severity, TaskContract @@ -26,7 +27,7 @@ class RuleInfo: RULES: Tuple[RuleInfo, ...] = ( RuleInfo( "RLY101", - "Blocking Arduino delay", + "Blocking Arduino delay in the current execution context", Severity.HIGH, "C/C++", Confidence.HIGH, @@ -34,17 +35,17 @@ class RuleInfo: ), RuleInfo( "RLY102", - "Long Python sleep", + "Blocking sleep or thread wait", Severity.WARNING, - "Python", + "Python, C/C++, Java, C#, Go, Rust, Ruby, PHP, Kotlin, Swift, JS/TS", Confidence.HIGH, - "Use shorter cooperative sleeps or event-driven scheduling.", + "Use a cooperative timer, scheduler, or event-driven wait where appropriate.", ), RuleInfo( "RLY103", "Busy-wait or infinite loop", Severity.HIGH, - "C/C++, Python", + "All supported", Confidence.MEDIUM, "Add a deadline, bounded exit, and cooperative yield.", ), @@ -52,7 +53,7 @@ class RuleInfo: "RLY104", "Unbounded serial wait", Severity.HIGH, - "C/C++, Python", + "All supported", Confidence.HIGH, "Add a deadline and handle missing input.", ), @@ -60,7 +61,7 @@ class RuleInfo: "RLY105", "Connection loop without deadline", Severity.HIGH, - "C/C++, Python", + "All supported", Confidence.MEDIUM, "Bound attempts or elapsed time.", ), @@ -68,7 +69,7 @@ class RuleInfo: "RLY106", "Network operation without timeout", Severity.HIGH, - "C/C++, Python", + "Python", Confidence.MEDIUM, "Pass an explicit timeout and handle expiry.", ), @@ -106,11 +107,11 @@ class RuleInfo: ), RuleInfo( "RLY111", - "Blocking call reachable from emergency path", + "Blocking call reachable from configured safety-critical path", Severity.CRITICAL, "All", Confidence.MEDIUM, - "Keep emergency paths bounded and non-blocking.", + "Keep configured safety-critical paths bounded and non-blocking.", ), RuleInfo( "RLY112", @@ -166,22 +167,26 @@ def run_rules( source, call, f"delay({', '.join(call.arguments)})", - f"delay() can block progress{description}.", + f"delay() blocks its current execution context{description}.", ) - findings.append(_with_impact(finding, _impacts(config.tasks, seconds or 0.0))) + findings.append(finding) blocking.append((call, seconds or 0.0, "delay")) - elif source.language == "python" and call.name in {"time.sleep", "sleep", "asyncio.sleep"}: - seconds = _duration_argument(call.arguments, source.constants, 1.0) - if seconds is None or seconds >= config.python_sleep_threshold: + elif (sleep_details := _blocking_sleep(source, call)) is not None: + seconds, kind = sleep_details + if ( + source.language != "python" + or seconds is None + or seconds >= config.python_sleep_threshold + ): finding = _finding( "RLY102", source, call, _line(source, call.line), - f"{call.name}() pauses this task{_duration_text(seconds)}.", + f"{call.name}() blocks its current execution context{_duration_text(seconds)}.", ) - findings.append(_with_impact(finding, _impacts(config.tasks, seconds or 0.0))) - blocking.append((call, seconds or 0.0, "sleep")) + findings.append(finding) + blocking.append((call, seconds or 0.0, kind)) elif base == "pulseIn" and len(call.arguments) < 3: finding = _finding( "RLY107", @@ -190,7 +195,7 @@ def run_rules( _line(source, call.line), "pulseIn() may wait for its long platform default timeout.", ) - findings.append(_with_impact(finding, _impacts(config.tasks, 1.0))) + findings.append(finding) blocking.append((call, 1.0, "pulseIn")) elif source.language == "cpp" and base in { "parseInt", @@ -210,7 +215,7 @@ def run_rules( ) ) blocking.append((call, 1.0, base)) - elif _network_without_timeout(call): + elif _network_without_timeout(source, call): findings.append( _finding( "RLY106", @@ -225,8 +230,16 @@ def run_rules( for loop in source.loops: compact = re.sub(r"\s+", "", loop.body) condition = re.sub(r"\s+", "", loop.condition).lower() - has_yield = bool(re.search(r"\b(delay|sleep|yield|await|vTaskDelay)\s*\(", loop.body)) - has_exit = bool(re.search(r"\b(break|return|raise|throw)\b", loop.body)) + has_yield = ( + loop.has_yield + if loop.has_yield is not None + else bool(re.search(r"\b(delay|sleep|yield|await|vTaskDelay)\s*\(", loop.body)) + ) + has_exit = ( + loop.has_exit + if loop.has_exit is not None + else bool(re.search(r"\b(break|return|raise|throw)\b", loop.body)) + ) bounded_for = loop.kind == "for" and bool( re.search(r"[;<]\s*\d+|range\s*\(", loop.condition) ) @@ -283,7 +296,7 @@ def run_rules( "Repeated polling has no obvious delay, backoff, or attempt limit.", ) ) - reads = re.findall(r"\b(?:analogRead|digitalRead|read|read_u16|read_adc)\s*\(", loop.body) + reads = re.findall(r"\b(?:analogRead|digitalRead|read_u16|read_adc)\s*\(", loop.body) if reads and not has_yield and not bounded_for: findings.append( _loop_finding( @@ -344,12 +357,7 @@ def _call_chain_findings( blocking: Sequence[Tuple[Call, float, str]], ) -> List[Finding]: findings: List[Finding] = [] - emergency_functions = { - task.function for task in config.tasks if task.maximum_latency_seconds is not None - } - emergency_functions.update( - name for name in graph if "emergency" in name.lower() or "estop" in name.lower() - ) + safety_functions = {task.function for task in config.tasks if task.safety_critical} for call, _, kind in blocking: owner = call.enclosing_function if owner is None: @@ -363,19 +371,29 @@ def _call_chain_findings( _line(source, call.line), f"A blocking {kind} is reachable indirectly from {path[0]}().", ) - findings.append(_with_path(finding, (*path, f"{_base(call.name)}()"))) - emergency_path = shortest_path(graph, emergency_functions, owner) - if owner in emergency_functions: - emergency_path = [owner] - if emergency_path: + findings.append( + _with_path( + finding, + (*(_display_path(path)), f"{_base(call.name)}()"), + ) + ) + safety_path = shortest_path(graph, safety_functions, owner) + if owner in safety_functions: + safety_path = [owner] + if safety_path: finding = _finding( "RLY111", source, call, _line(source, call.line), - f"A blocking {kind} is reachable from emergency-stop logic.", + f"A blocking {kind} is reachable from configured safety-critical logic.", + ) + findings.append( + _with_path( + finding, + (*(_display_path(safety_path)), f"{_base(call.name)}()"), + ) ) - findings.append(_with_path(finding, (*emergency_path, f"{_base(call.name)}()"))) return findings @@ -390,7 +408,6 @@ def _timing_findings( if seconds <= 0: continue owner = call.enclosing_function - impacts = _impacts(config.tasks, seconds) for task in config.tasks: path = shortest_path(graph, [task.function], owner) if owner else None if owner == task.function: @@ -406,7 +423,13 @@ def _timing_findings( f"{task.name}'s declared {budget:.3f}s budget.", ) findings.append( - _with_impact(_with_path(finding, (*path, f"{_base(call.name)}()")), impacts) + _with_impact( + _with_path( + finding, + (*(_display_path(path)), f"{_base(call.name)}()"), + ), + _task_impacts(task, seconds), + ) ) return findings @@ -423,7 +446,7 @@ def _cycle_findings(source: ParsedSource, graph: Graph) -> List[Finding]: "RLY103", source, line, - " -> ".join(cycle), + " -> ".join(_display_path(cycle)), "This recursive call cycle may prevent forward progress or exhaust the stack.", Confidence.MEDIUM, ) @@ -431,49 +454,213 @@ def _cycle_findings(source: ParsedSource, graph: Graph) -> List[Finding]: return findings -def _impacts(tasks: Iterable[TaskContract], seconds: float) -> Tuple[str, ...]: +def _task_impacts(task: TaskContract, seconds: float) -> Tuple[str, ...]: impacts: List[str] = [] - for task in tasks: - if task.every_seconds: - missed = math.floor(seconds / task.every_seconds) - if missed: - impacts.append(f"{task.name}: approximately {missed} missed executions") - if task.maximum_latency_seconds is not None and seconds > task.maximum_latency_seconds: - excess = seconds - task.maximum_latency_seconds - impacts.append(f"{task.name}: latency budget exceeded by approximately {excess:.3f}s") + context = f" in context {task.execution_context!r}" if task.execution_context else "" + if task.every_seconds: + missed = math.floor(seconds / task.every_seconds) + if missed: + impacts.append( + f"{task.name}{context}: up to {missed} declared periods overlap this wait" + ) + if task.maximum_latency_seconds is not None and seconds > task.maximum_latency_seconds: + excess = seconds - task.maximum_latency_seconds + impacts.append(f"{task.name}{context}: declared latency budget exceeded by {excess:.3f}s") return tuple(impacts) -def _network_without_timeout(call: Call) -> bool: +def _network_without_timeout(source: ParsedSource, call: Call) -> bool: + if source.language != "python": + return False name = call.name.lower() - candidates = ( + keyword_timeout = dict(call.keyword_arguments).get("timeout") + if keyword_timeout is not None: + return _is_missing_timeout(keyword_timeout) + if name in { "requests.get", "requests.post", "requests.put", "requests.delete", - "urllib.request.urlopen", - "socket.create_connection", - ".connect", - ".accept", - ".recv", - "httpclient.get", - "http.get", - ) - if not any(name.endswith(candidate) or name == candidate for candidate in candidates): + "requests.patch", + "requests.head", + }: + return True + if name == "urllib.request.urlopen": + return len(call.arguments) < 3 or _is_missing_timeout(call.arguments[2]) + if name == "socket.create_connection": + return len(call.arguments) < 2 or _is_missing_timeout(call.arguments[1]) + if name.endswith(".recv") or name.endswith(".accept") or name.endswith(".connect"): + # Timeout state may have been configured on the socket earlier. Without object-flow + # analysis Relay cannot make a defensible per-call claim. return False - return not any("timeout" in argument.lower() for argument in call.arguments) + return False + + +def _blocking_sleep(source: ParsedSource, call: Call) -> Optional[Tuple[Optional[float], str]]: + name = call.name.replace("->", ".").replace("::", ".") + lowered = name.lower() + constants = dict(source.constants) + constants.update( + { + "time.Nanosecond": 1.0, + "time.Microsecond": 1_000.0, + "time.Millisecond": 1_000_000.0, + "time.Second": 1_000_000_000.0, + "time.Minute": 60_000_000_000.0, + } + ) + if source.language == "python": + if name != "time.sleep": + # asyncio/trio/anyio sleep calls yield when awaited and must not be modelled as + # thread-blocking. Bare sleep is ambiguous without import resolution. + return None + return _duration_argument(call.arguments, constants, 1.0), "synchronous sleep" + if source.language == "cpp": + if lowered.endswith(".sleep_for"): + return _wrapped_duration(call.arguments), "thread sleep" + if lowered == "sleep": + return _duration_argument(call.arguments, constants, 1.0), "thread sleep" + if lowered == "usleep": + return _duration_argument(call.arguments, constants, 0.000001), "thread sleep" + if source.language in {"java", "kotlin"} and lowered.endswith("thread.sleep"): + return _duration_argument(call.arguments, constants, 0.001), "thread sleep" + if source.language == "csharp" and lowered.endswith("thread.sleep"): + return _duration_argument(call.arguments, constants, 0.001), "thread sleep" + if source.language == "go" and lowered == "time.sleep": + return _duration_argument(call.arguments, constants, 0.000000001), "goroutine sleep" + if source.language == "rust" and lowered.endswith("thread.sleep"): + return _wrapped_duration(call.arguments), "thread sleep" + if source.language == "ruby" and lowered == "sleep": + return _duration_argument(call.arguments, constants, 1.0), "thread sleep" + if source.language == "php": + if lowered == "sleep": + return _duration_argument(call.arguments, constants, 1.0), "process sleep" + if lowered == "usleep": + return _duration_argument(call.arguments, constants, 0.000001), "process sleep" + if source.language == "swift": + if lowered.endswith("thread.sleep"): + arguments = tuple(_strip_argument_label(value) for value in call.arguments) + return _duration_argument(arguments, constants, 1.0), "thread sleep" + if lowered == "usleep": + return _duration_argument(call.arguments, constants, 0.000001), "thread sleep" + if source.language in {"javascript", "typescript"} and lowered == "atomics.wait": + return _duration_argument(call.arguments, constants, 0.001, index=2), "thread wait" + return None + + +def _wrapped_duration(arguments: Sequence[str]) -> Optional[float]: + if not arguments: + return None + expression = arguments[0].strip() + units = { + "nanoseconds": 0.000000001, + "microseconds": 0.000001, + "milliseconds": 0.001, + "seconds": 1.0, + "minutes": 60.0, + "from_nanos": 0.000000001, + "from_micros": 0.000001, + "from_millis": 0.001, + "from_secs": 1.0, + } + match = re.search( + r"(?Pnanoseconds|microseconds|milliseconds|seconds|minutes|" + r"from_nanos|from_micros|from_millis|from_secs)\s*\((?P.*)\)\s*$", + expression, + ) + if not match: + return None + value = _safe_number(match.group("value"), {}) + return value * units[match.group("unit")] if value is not None else None + + +def _safe_number(expression: str, constants: Mapping[str, float]) -> Optional[float]: + cleaned = _strip_argument_label(expression.strip()) + cleaned = re.sub( + r"(?<=\d)(?:[uU](?:ll?|LL?)?|[lL]{1,2}|[fF])\b", + "", + cleaned, + ) + try: + tree = ast.parse(cleaned, mode="eval") + except (SyntaxError, ValueError): + return constants.get(cleaned) + + def evaluate(node: ast.AST) -> Optional[float]: + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return float(node.value) + if isinstance(node, ast.Name): + return constants.get(node.id) + if isinstance(node, ast.Attribute): + return constants.get(_attribute_name(node)) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = evaluate(node.operand) + if value is None: + return None + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv) + ): + left = evaluate(node.left) + right = evaluate(node.right) + if left is None or right is None: + return None + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + if right == 0: + return None + if isinstance(node.op, ast.Div): + return left / right + return left // right + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in {"min", "max"} + ): + values = [evaluate(argument) for argument in node.args] + if not values or any(value is None for value in values): + return None + numeric = [value for value in values if value is not None] + return min(numeric) if node.func.id == "min" else max(numeric) + return None + + return evaluate(tree.body) + + +def _attribute_name(node: ast.Attribute) -> str: + parts = [node.attr] + value = node.value + while isinstance(value, ast.Attribute): + parts.append(value.attr) + value = value.value + if isinstance(value, ast.Name): + parts.append(value.id) + return ".".join(reversed(parts)) + + +def _strip_argument_label(value: str) -> str: + match = re.match(r"^[A-Za-z_]\w*\s*:\s*(?P.*)$", value, re.DOTALL) + return match.group("value") if match else value + + +def _is_missing_timeout(value: str) -> bool: + return value.strip().lower() in {"none", "null", "nil", "false", "0", "0.0"} def _duration_argument( - arguments: Sequence[str], constants: Mapping[str, float], scale: float + arguments: Sequence[str], + constants: Mapping[str, float], + scale: float, + index: int = 0, ) -> Optional[float]: - if not arguments: + if len(arguments) <= index: return None - expression = arguments[0].strip() - try: - return float(expression) * scale - except ValueError: - return constants[expression] * scale if expression in constants else None + value = _safe_number(arguments[index], constants) + return value * scale if value is not None else None def _duration_text(seconds: Optional[float]) -> str: @@ -487,7 +674,15 @@ def _duration_text(seconds: Optional[float]) -> str: def _finding( rule_id: str, source: ParsedSource, call: Call, evidence: str, message: str ) -> Finding: - return _make(rule_id, source.path, call.line, call.line, evidence.strip(), message) + return _make( + rule_id, + source.path, + call.line, + call.line, + evidence.strip(), + message, + _model_confidence(source, rule_id), + ) def _loop_finding(rule_id: str, source: ParsedSource, loop: Loop, message: str) -> Finding: @@ -498,6 +693,7 @@ def _loop_finding(rule_id: str, source: ParsedSource, loop: Loop, message: str) int(loop.end_line), _line(source, int(loop.line)), message, + _model_confidence(source, rule_id), ) @@ -509,7 +705,15 @@ def _offset_finding( message: str, confidence: Optional[Confidence] = None, ) -> Finding: - return _make(rule_id, source.path, line, line, evidence, message, confidence) + return _make( + rule_id, + source.path, + line, + line, + evidence, + message, + _model_confidence(source, rule_id, confidence), + ) def _make( @@ -531,7 +735,8 @@ def _make( end, evidence, message, - "Relay conservatively models this construct as a possible obstacle to other work.", + "Relay models this construct as a possible obstacle to work sharing the same execution " + "context. Structural language models do not infer that context.", rule.remediation, f"https://github.com/devkyato/Relay/blob/main/docs/rules.md#{rule_id.lower()}", ) @@ -549,6 +754,21 @@ def _base(name: str) -> str: return name.replace("->", ".").replace("::", ".").split(".")[-1] +def _display_path(path: Sequence[str]) -> Tuple[str, ...]: + return tuple(display_symbol(symbol) for symbol in path) + + +def _model_confidence( + source: ParsedSource, + rule_id: str, + explicit: Optional[Confidence] = None, +) -> Confidence: + confidence = explicit or RULE_MAP[rule_id].confidence + if "structural" in source.analysis_model and confidence is Confidence.HIGH: + return Confidence.MEDIUM + return confidence + + def _line(source: ParsedSource, line: int) -> str: rows = source.source.splitlines() return rows[line - 1].strip() if 0 < line <= len(rows) else "" diff --git a/src/relay/timeline.py b/src/relay/timing_summary.py similarity index 62% rename from src/relay/timeline.py rename to src/relay/timing_summary.py index 850a226..2e58a26 100644 --- a/src/relay/timeline.py +++ b/src/relay/timing_summary.py @@ -1,27 +1,25 @@ -"""Conservative task timeline simulation.""" +"""Declared timing-contract summary; this module does not simulate a schedule.""" from __future__ import annotations +from contextlib import suppress from typing import List from .config import RelayConfig from .models import AnalysisResult -def render_timeline( +def render_timing_summary( result: AnalysisResult, config: RelayConfig, duration: float, markdown: bool = False ) -> str: - title = ( - "# Relay conservative scheduling estimate" - if markdown - else "Relay conservative scheduling estimate" - ) + title = "# Relay declared timing summary" if markdown else "Relay declared timing summary" lines: List[str] = [ title, "", - "This is a static, conservative estimate; it is not cycle-accurate " - "and does not model hardware.", - f"Window: 0.000s to {duration:.3f}s", + "This report lists declared release periods and detected blocking findings. " + "It does not simulate scheduling, execution time, priority, pre-emption, cores, " + "interrupts, or hardware.", + f"Reference window: 0.000s to {duration:.3f}s", "", ] if not config.tasks: @@ -33,7 +31,7 @@ def render_timeline( count = int(duration / task.every_seconds) prefix = "- " if markdown else "" lines.append( - f"{prefix}{task.name}: scheduled approximately {count} time(s), " + f"{prefix}{task.name}: {count} nominal release point(s), " f"every {task.every_seconds:.3f}s" ) if task.maximum_latency_seconds is not None: @@ -50,16 +48,18 @@ def render_timeline( lines.extend( ( "", - "Potential schedule disruptions:" - if not markdown - else "## Potential schedule disruptions", + "Detected blocking findings:" if not markdown else "## Detected blocking findings", "", ) ) for finding in blocking: prefix = "- " if markdown else " " + location = finding.file.name + if result.root is not None: + with suppress(ValueError): + location = finding.file.relative_to(result.root).as_posix() lines.append( - f"{prefix}at source line {finding.start_line}: {finding.rule_id} {finding.message}" + f"{prefix}{location}:{finding.start_line}: {finding.rule_id} {finding.message}" ) lines.extend(f" {impact}" for impact in finding.estimated_impact) else: diff --git a/tests/test_callgraph.py b/tests/test_callgraph.py index 75f2b48..ade77f0 100644 --- a/tests/test_callgraph.py +++ b/tests/test_callgraph.py @@ -1,6 +1,6 @@ from pathlib import Path -from relay.callgraph import build_call_graph, cycles, shortest_path +from relay.callgraph import build_call_graph, cycles, display_symbol, shortest_path from relay.parser import parse_source @@ -10,5 +10,61 @@ def test_path_and_cycle() -> None: "void a(){b();} void b(){c();} void c(){c();}", ) graph = build_call_graph([parsed]) - assert shortest_path(graph, ["a"], "c") == ["a", "b", "c"] - assert ("c", "c") in cycles(graph) + path = shortest_path(graph, ["a"], "c") + assert path is not None + assert [display_symbol(symbol) for symbol in path] == ["a", "b", "c"] + assert any( + tuple(display_symbol(symbol) for symbol in cycle) == ("c", "c") for cycle in cycles(graph) + ) + + +def test_ambiguous_methods_are_not_collapsed() -> None: + parsed = parse_source( + Path("x.cpp"), + """ + class Motor { void update(){} void tick(){ update(); } }; + class Display { void update(){} void tick(){ update(); } }; + """, + ) + graph = build_call_graph([parsed]) + motor_update = next(name for name in graph if name.endswith("Motor.update")) + display_update = next(name for name in graph if name.endswith("Display.update")) + motor_tick = next(name for name in graph if name.endswith("Motor.tick")) + display_tick = next(name for name in graph if name.endswith("Display.tick")) + assert graph[motor_tick] == {motor_update} + assert graph[display_tick] == {display_update} + + +def test_python_methods_resolve_with_class_qualification() -> None: + parsed = parse_source( + Path("control.py"), + """ +class Motor: + def run(self): + self.update() + def update(self): + pass +class Display: + def run(self): + self.update() + def update(self): + pass +""", + ) + graph = build_call_graph([parsed]) + assert graph["control.py::Motor.run"] == {"control.py::Motor.update"} + assert graph["control.py::Display.run"] == {"control.py::Display.update"} + + +def test_python_nested_function_resolves_in_lexical_scope() -> None: + parsed = parse_source( + Path("nested.py"), + """ +def outer(): + def inner(): + return None + inner() +""", + ) + graph = build_call_graph([parsed]) + assert graph["nested.py::outer"] == {"nested.py::outer.inner"} diff --git a/tests/test_cli.py b/tests/test_cli.py index d68495b..c78b781 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,7 +17,7 @@ def test_check_exit_codes() -> None: def test_check_json_output(tmp_path: Path) -> None: output = tmp_path / "result.json" assert main(["check", str(FIXTURE), "--format", "json", "--output", str(output)]) == 1 - assert '"schema_version": "1.0"' in output.read_text(encoding="utf-8") + assert '"schema_version": "2.0"' in output.read_text(encoding="utf-8") def test_init_is_non_destructive(tmp_path: Path) -> None: @@ -28,8 +28,9 @@ def test_init_is_non_destructive(tmp_path: Path) -> None: assert (tmp_path / "relay.toml").read_text(encoding="utf-8") == content -def test_explain_and_timeline(tmp_path: Path) -> None: +def test_explain_and_timing_summary(tmp_path: Path, capsys: object) -> None: assert main(["explain", str(FIXTURE), "--level", "beginner"]) == 0 + assert main(["summary", str(FIXTURE), "--duration", "1s"]) == 0 assert main(["timeline", str(FIXTURE), "--duration", "1s"]) == 0 diff --git a/tests/test_config.py b/tests/test_config.py index f6cf92b..4e3e584 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,13 +15,21 @@ def test_configuration_mapping() -> None: config = RelayConfig.from_mapping( { "project": {"target": "esp32", "entrypoints": ["loop"]}, - "tasks": {"motors": {"every": "20ms"}}, + "tasks": { + "motors": { + "every": "20ms", + "execution_context": "control-loop", + "safety_critical": True, + } + }, "functions": {"motors": "updateMotors"}, "analysis": {"disable": ["RLY115"]}, } ) assert config.target == "esp32" assert config.tasks[0].function == "updateMotors" + assert config.tasks[0].execution_context == "control-loop" + assert config.tasks[0].safety_critical assert not config.rule_enabled("RLY115") @@ -35,3 +43,10 @@ def test_load_invalid_toml(tmp_path: Path) -> None: path.write_text("[broken", encoding="utf-8") with pytest.raises(ConfigError): RelayConfig.load(path) + + +def test_unknown_rules_and_keys_are_rejected() -> None: + with pytest.raises(ConfigError, match="unknown rule"): + RelayConfig.from_mapping({"analysis": {"enable": ["RLY999"]}}) + with pytest.raises(ConfigError, match="unknown analysis key"): + RelayConfig.from_mapping({"analysis": {"mystery": True}}) diff --git a/tests/test_corpus.py b/tests/test_corpus.py new file mode 100644 index 0000000..1b9eadf --- /dev/null +++ b/tests/test_corpus.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path +from typing import Any, Dict, List + +from relay import analyse_path +from relay.callgraph import build_call_graph +from relay.parser import parse_source + +CORPUS = Path(__file__).parents[1] / "corpus" + + +def test_labeled_positive_and_negative_corpus() -> None: + cases: List[Dict[str, Any]] = json.loads((CORPUS / "manifest.json").read_text(encoding="utf-8")) + for case in cases: + if case["classification"] == "ambiguous": + continue + found = {finding.rule_id for finding in analyse_path(CORPUS / str(case["path"])).findings} + if case["classification"] == "positive": + assert case["rule"] in found, case + else: + assert case["rule"] not in found, case + + +def test_ambiguous_method_corpus_keeps_qualified_edges() -> None: + path = CORPUS / "ambiguous" / "duplicate_methods.cpp" + parsed = parse_source(path, path.read_text(encoding="utf-8")) + graph = build_call_graph([parsed]) + motor_update = next(name for name in graph if name.endswith("Motor.update")) + display_update = next(name for name in graph if name.endswith("Display.update")) + motor_tick = next(name for name in graph if name.endswith("Motor.tick")) + display_tick = next(name for name in graph if name.endswith("Display.tick")) + assert graph[motor_tick] == {motor_update} + assert graph[display_tick] == {display_update} diff --git a/tests/test_parser.py b/tests/test_parser.py index e97fdc3..9b38e80 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -8,20 +8,68 @@ def test_cpp_functions_calls_loops_and_lines() -> None: path = FIXTURES / "blocking.ino" parsed = parse_source(path, path.read_text(encoding="utf-8")) - assert parsed.functions["rotateRobot"] == (4, 6) + rotate = next(name for name in parsed.functions if name.endswith("::rotateRobot")) + assert parsed.functions[rotate] == (4, 6) delay = next(call for call in parsed.calls if call.name == "delay") assert delay.line == 5 - assert delay.enclosing_function == "rotateRobot" + assert delay.enclosing_function is not None + assert delay.enclosing_function.endswith("::rotateRobot") def test_python_ast_does_not_import_source() -> None: source = "def f():\n time.sleep(1)\n" parsed = parse_source(Path("control.py"), source) - assert parsed.functions["f"] == (1, 2) + assert parsed.functions["control.py::f"] == (1, 2) assert parsed.calls[0].name == "time.sleep" +def test_python_functions_have_qualified_identity_and_await_state() -> None: + source = """ +class Motor: + def run(self): + self.tick() + + def tick(self): + pass + +class Display: + async def run(self): + await asyncio.sleep(1) +""" + parsed = parse_source(Path("control.py"), source) + assert {"control.py::Motor.run", "control.py::Motor.tick", "control.py::Display.run"} <= set( + parsed.functions + ) + cooperative = next(call for call in parsed.calls if call.name == "asyncio.sleep") + assert cooperative.awaited + + def test_malformed_source_is_bounded() -> None: parsed = parse_source(Path("bad.py"), "def incomplete(") assert parsed.syntax_warning assert parsed.calls == [] + + +def test_typescript_arrow_and_typed_function_owners() -> None: + parsed = parse_source( + Path("worker.ts"), + """ +function run(values: Int32Array): void { Atomics.wait(values, 0, 10); } +const tick: () => void = () => { run(values); }; +""", + ) + assert {"worker.ts::run", "worker.ts::tick"} <= set(parsed.functions) + wait = next(call for call in parsed.calls if call.name == "Atomics.wait") + assert wait.enclosing_function == "worker.ts::run" + run = next(call for call in parsed.calls if call.name == "run") + assert run.enclosing_function == "worker.ts::tick" + + +def test_go_method_has_distinct_owner() -> None: + parsed = parse_source( + Path("worker.go"), + "type Worker struct{}\nfunc (w Worker) Run(){ time.Sleep(time.Second) }\n", + ) + assert "worker.go::Run" in parsed.functions + sleep = next(call for call in parsed.calls if call.name == "time.Sleep") + assert sleep.enclosing_function == "worker.go::Run" diff --git a/tests/test_polyglot.py b/tests/test_polyglot.py new file mode 100644 index 0000000..bb1fc8c --- /dev/null +++ b/tests/test_polyglot.py @@ -0,0 +1,125 @@ +from pathlib import Path + +import pytest + +from relay import RelayConfig, analyse_path + + +@pytest.mark.parametrize( + ("filename", "source"), + ( + ("Main.java", "class Main { void run(){ Thread.sleep(100); } }"), + ("Main.cs", "class Main { void Run(){ Thread.Sleep(100); } }"), + ("main.go", "package main\nfunc run(){ time.Sleep(time.Second * 2) }\n"), + ( + "main.rs", + "fn run(){ std::thread::sleep(Duration::from_millis(100)); }", + ), + ("main.js", "function run(){ Atomics.wait(values, 0, 100); }"), + ("Main.kt", "fun run(){ Thread.sleep(100) }"), + ("Main.swift", "func run(){ Thread.sleep(forTimeInterval: 0.5) }"), + ("main.rb", "def run\n sleep(1)\nend\nrun()"), + ("main.php", " None: + path = tmp_path / filename + path.write_text(source, encoding="utf-8") + result = analyse_path(path) + sleep = next(finding for finding in result.findings if finding.rule_id == "RLY102") + assert "blocks its current execution context" in sleep.message + assert sleep.estimated_impact == () + assert result.analysis_models + + +def test_asyncio_sleep_is_cooperative_not_blocking(tmp_path: Path) -> None: + path = tmp_path / "worker.py" + path.write_text( + "import asyncio\nasync def worker():\n await asyncio.sleep(10)\n", + encoding="utf-8", + ) + assert "RLY102" not in {finding.rule_id for finding in analyse_path(path).findings} + + +def test_timeout_keyword_is_semantic_not_substring_based(tmp_path: Path) -> None: + path = tmp_path / "network.py" + path.write_text( + """ +def requests(): + requests.get(url, timeout=None) + requests.get(url, timeout=1) + requests.get(url, timeout_label="not a timeout") +""", + encoding="utf-8", + ) + network = [finding for finding in analyse_path(path).findings if finding.rule_id == "RLY106"] + assert len(network) == 2 + assert {finding.start_line for finding in network} == {3, 5} + + +def test_duration_expressions_and_unsigned_suffixes(tmp_path: Path) -> None: + path = tmp_path / "timing.cpp" + path.write_text( + """ +const unsigned long BASE_DELAY = 250UL; +void first(){ delay(BASE_DELAY * 2); } +void second(){ delay(min(1000UL, 500)); } +""", + encoding="utf-8", + ) + delays = [finding for finding in analyse_path(path).findings if finding.rule_id == "RLY101"] + assert len(delays) == 2 + assert all("0.500 seconds" in finding.message for finding in delays) + + +def test_latency_contract_is_not_implicitly_emergency_logic(tmp_path: Path) -> None: + path = tmp_path / "service.py" + path.write_text("import time\ndef service():\n time.sleep(1)\n", encoding="utf-8") + config = RelayConfig.from_mapping( + { + "tasks": {"service": {"maximum_latency": "10ms"}}, + "functions": {"service": "service"}, + } + ) + found = {finding.rule_id for finding in analyse_path(path, config).findings} + assert "RLY112" in found + assert "RLY111" not in found + + +def test_impacts_are_limited_to_reachable_task(tmp_path: Path) -> None: + path = tmp_path / "tasks.py" + path.write_text( + """ +import time +def slow_task(): + time.sleep(1) +def unrelated_task(): + return None +""", + encoding="utf-8", + ) + config = RelayConfig.from_mapping( + { + "tasks": { + "slow": {"every": "100ms", "execution_context": "worker-a"}, + "unrelated": {"every": "100ms", "execution_context": "worker-b"}, + }, + "functions": { + "slow": "slow_task", + "unrelated": "unrelated_task", + }, + } + ) + timing = next( + finding for finding in analyse_path(path, config).findings if finding.rule_id == "RLY112" + ) + assert timing.estimated_impact + assert all("slow" in impact for impact in timing.estimated_impact) + assert all("unrelated" not in impact for impact in timing.estimated_impact) + + +def test_generic_read_name_is_not_assumed_to_be_a_sensor(tmp_path: Path) -> None: + path = tmp_path / "reader.py" + path.write_text("while True:\n value = stream.read()\n", encoding="utf-8") + found = {finding.rule_id for finding in analyse_path(path).findings} + assert "RLY115" not in found diff --git a/tests/test_reports.py b/tests/test_reports.py index eb3891b..24dec66 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -11,14 +11,22 @@ def test_json_schema_is_stable() -> None: report = json.loads(render(analyse_path(FIXTURE), "json")) - assert report["schema_version"] == "1.0" - assert set(report) == {"findings", "relay_version", "schema_version", "summary", "warnings"} + assert report["schema_version"] == "2.0" + assert set(report) == { + "analysis_models", + "findings", + "relay_version", + "schema_version", + "summary", + "warnings", + } def test_sarif_shape() -> None: report = json.loads(render(analyse_path(FIXTURE), "sarif")) assert report["version"] == "2.1.0" assert report["runs"][0]["tool"]["driver"]["name"] == "Relay" + assert "relayFinding/v1" in report["runs"][0]["results"][0]["partialFingerprints"] def test_markdown_and_suggestions() -> None: @@ -37,3 +45,4 @@ def test_report_file(tmp_path: Path) -> None: output = tmp_path / "report.md" write_report(output, "# report\n") assert output.read_text(encoding="utf-8") == "# report\n" + assert not list(tmp_path.glob("*.tmp")) diff --git a/tests/test_rules.py b/tests/test_rules.py index 1d355a8..24f1dad 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -63,14 +63,21 @@ def test_timing_and_emergency_reachability(tmp_path: Path) -> None: ) config = RelayConfig.from_mapping( { - "tasks": {"emergency_stop": {"maximum_latency": "10ms"}}, + "tasks": { + "emergency_stop": { + "maximum_latency": "10ms", + "safety_critical": True, + } + }, "functions": {"emergency_stop": "checkEmergencyStop"}, } ) found = set(ids(path, config)) assert {"RLY111", "RLY112"} <= found delay = next(item for item in analyse_path(path, config).findings if item.rule_id == "RLY101") - assert "latency budget exceeded" in delay.estimated_impact[0] + assert delay.estimated_impact == () + timing = next(item for item in analyse_path(path, config).findings if item.rule_id == "RLY112") + assert "latency budget exceeded" in timing.estimated_impact[0] def test_state_without_exit_is_uncertain(tmp_path: Path) -> None: diff --git a/tests/test_security.py b/tests/test_security.py index e425f04..b4b3159 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from relay import RelayConfig, analyse_path @@ -22,6 +24,44 @@ def test_large_file_is_skipped(tmp_path: Path) -> None: assert "exceeds" in result.warnings[0] +def test_aggregate_input_limit_is_enforced(tmp_path: Path) -> None: + (tmp_path / "one.py").write_text("time.sleep(1)\n", encoding="utf-8") + (tmp_path / "two.py").write_text("time.sleep(1)\n", encoding="utf-8") + result = analyse_path( + tmp_path, + RelayConfig(maximum_file_bytes=100, maximum_total_bytes=20), + ) + assert len(result.files) == 1 + assert any("aggregate input limit" in warning for warning in result.warnings) + + +def test_gitignore_negation_and_rooted_patterns(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*.py\n!keep.py\n/root.js\n", encoding="utf-8") + (tmp_path / "drop.py").write_text("time.sleep(1)\n", encoding="utf-8") + (tmp_path / "keep.py").write_text("time.sleep(1)\n", encoding="utf-8") + (tmp_path / "root.js").write_text("Atomics.wait(a, 0, 1)\n", encoding="utf-8") + nested = tmp_path / "nested" + nested.mkdir() + (nested / "root.js").write_text("Atomics.wait(a, 0, 1)\n", encoding="utf-8") + result = analyse_path(tmp_path) + relative = {path.relative_to(tmp_path).as_posix() for path in result.files} + assert relative == {"keep.py", "nested/root.js"} + + +def test_symlink_root_is_refused(tmp_path: Path) -> None: + real = tmp_path / "real" + real.mkdir() + (real / "source.py").write_text("time.sleep(1)\n", encoding="utf-8") + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable") + result = analyse_path(link) + assert result.files == [] + assert any("symlink analysis root" in warning for warning in result.warnings) + + def test_unicode_and_empty_files(tmp_path: Path) -> None: (tmp_path / "控制.py").write_text("time.sleep(1)\n", encoding="utf-8") (tmp_path / "empty.ino").write_text("", encoding="utf-8") diff --git a/timeline.md b/timeline.md deleted file mode 100644 index 616d621..0000000 --- a/timeline.md +++ /dev/null @@ -1,17 +0,0 @@ -# Relay conservative scheduling estimate - -This is a static, conservative estimate; it is not cycle-accurate and does not model hardware. -Window: 0.000s to 5.000s - -- emergency_stop: declared maximum latency 0.010s -- read_distance: scheduled approximately 100 time(s), every 0.050s -- send_telemetry: scheduled approximately 10 time(s), every 0.500s -- update_motors: scheduled approximately 250 time(s), every 0.020s - -## Potential schedule disruptions - -- at source line 8: RLY101 delay() can block progress for approximately 2.000 seconds. - update_motors: approximately 100 missed executions - read_distance: approximately 40 missed executions - send_telemetry: approximately 4 missed executions - emergency_stop: latency budget exceeded by approximately 1.990s diff --git a/timing-summary.md b/timing-summary.md new file mode 100644 index 0000000..c8061a7 --- /dev/null +++ b/timing-summary.md @@ -0,0 +1,16 @@ +# Relay declared timing summary + +This report lists declared release periods and detected blocking findings. It does not simulate scheduling, execution time, priority, pre-emption, cores, interrupts, or hardware. +Reference window: 0.000s to 5.000s + +- emergency_stop: declared maximum latency 0.010s +- read_distance: 100 nominal release point(s), every 0.050s +- send_telemetry: 10 nominal release point(s), every 0.500s +- update_motors: 250 nominal release point(s), every 0.020s + +## Detected blocking findings + +- arduino/blocking_robot.ino:8: RLY101 delay() blocks its current execution context for approximately 2.000 seconds. +- micropython/controller.py:8: RLY102 time.sleep() blocks its current execution context for approximately 0.250 seconds. +- python/network_poll.py:6: RLY106 requests.get() has no obvious explicit timeout. +- python/network_poll.py:7: RLY102 time.sleep() blocks its current execution context for approximately 1.000 seconds.