Static call analysis - #130
Draft
benh wants to merge 14 commits into
Draft
Conversation
mypy 1.18 rejects `if x is Any:` as `comparison-overlap` when `x` is declared as a union of concrete types. The comparison is correct at runtime: `get_args` on an annotation such as `dict[str, Any]` hands back the `typing.Any` object itself, so the converters meet it as a value. Only the declared parameter types disagree, since they do not mention `Any`. Route the comparison through `is_annotation_any`, whose parameter is typed `object` — the honest domain of what typing introspection returns. The converters' declared unions stay as they are, and the runtime behavior is identical: the same `is` comparison, one call deeper. Until now this error failed the build of every target whose closure reaches `reboot/api.py` under a fresh mypy run, which is how it was found: Bazel's remote cache had been serving stale mypy results, so CI never re-ran mypy over these files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `description=` was only set on `McpMethodOptions.description`, so a Reader, Writer, Transaction or Workflow that was not also an MCP tool didn't have it. It is now set on `MethodOptions.description` for every method, and MCP tool and resource descriptions read from there. `McpMethodOptions.description` is deprecated but still read as a fallback, so protos that already set it keep their descriptions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first of several changes, split so that each can be reviewed on its own and so the dashboard can be tried out while the rest is written. It is not a documented feature yet: nothing opens a page by itself, so seeing one means passing `--open-dashboard` or visiting the URL. `rbt dev run` now starts a second Reboot application alongside the developer's, with its own state store, holding what the dashboard needs. The companion watches the developer's `api/` directory and records what those files declare, so the dashboard can describe an application. It also serves the page itself. The page reads that schema reactively and renders one section per state type: its fields, and each method's kind, whether it constructs, whether it is reachable over MCP, its signature and the errors it raises. Dashboard state, such as which detail views are open and which are closed, is saved in Reboot state, so it survives a hot reload and an `rbt dev run` restart. Auto-open is complete but off. `_AUTO_OPEN_DASHBOARD` is False, so only `--open-dashboard` opens a page. We don't reopen the dashboard if the developer already has it open, and we use the `Presence` library to determine whether they do. Note that presence does not drain through a DevPod workstation's port forward, which is filed separately. This will eventually supersede the inspect dashboard at `/__/inspect`, which lists state instances and their values. It does not replace it yet and both exist meanwhile: this describes an application's API, its state types, their fields and their methods, and cannot yet show the data behind them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A method can say what it does, but a state type is the sum of its state and its methods, and its name alone does not say what it is for. `Type` now takes a description, which the dashboard shows beside the state type's name and file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The manylinux images build every CPython with `--disable-shared` and delete even the static `libpython` archives. The `reboot-dev-reboot` genrule links `reboot_native.node` with `-lpython3.10`, a flag emitted by `python3.10-config --ldflags --embed`. That link has never been able to succeed inside these images. CI stayed green only while Bazel's remote cache served the genrule's outputs. The first cache miss made every platform fail deterministically. On x86_64 that miss came from a runner hardware swap: it changed the `lscpu` portion of `the_environment.txt`, and with it the whole cache scope. Point `python`/`python3` at a python-build-standalone CPython 3.10, which ships `libpython3.10.so`. It is the same build `reboot/nodejs/prepare_environment.sh` downloads. `pip`/`pip3` stay on the manylinux interpreter, whose layout `auditwheel` and the wheel builds expect. `python3` and `pip` therefore deliberately name different installations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard shows what each state type declares, but not how the state types relate: which of them a given method actually calls. That relationship only exists in the servicer implementations. This reads them. A call is a chain -- an entry (`Account.ref(id)`, a constructor, `self.ref()`), any number of modifiers (`.idempotently()`, `.schedule(when=)`, `.spawn()`, `.reactively()`, `.until(alias)`, ...), and the method at the end -- so the analysis walks that chain over the `ast`, tracking what each name is worth as it goes. Reading rather than importing, deliberately: importing would need the application's `sys.path` and its generated `_rbt` modules, which is to say a tree that builds, and the dashboard is meant to work before one does. Nothing here needs the generated code anyway, since `from bank.v1.account_rbt import Account` names `bank.v1.Account` outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A method rarely makes all of its calls itself. It hands its context to a helper, and that helper makes the call -- so a context reaching a function is exactly what makes that function worth reading, and a function that never gets one cannot call anything at all. Followed across files, into methods a servicer keeps on itself, and into functions written inside the method, which reach their context by closing over it rather than by being given it. A visited set stops a helper that calls itself. What cannot be followed is now said rather than silently dropped: a context reaching a function that cannot be read, a reference stored somewhere it cannot be tracked out of, or a method named only at run time. The discipline is that only what there is reason to believe is Reboot-related is recorded -- ordinary Python the analysis never claimed to follow is not, or the list would be every line in the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Importing a state class from its own generated module is only the
plainest of the ways an application reaches one, and until now it was
the only way this recognised. Real applications also:
- import the standard library's states from the `reboot.std.` module
that wraps each one and re-exports it, rather than from the
`rbt.std....._rbt` module the wrapper stands in for;
- import a state class from another of their own files, which
imported it first;
- bind a whole generated module and reach the states through it, as
`from rbt.thirdparty.mailgun.v1 import mailgun_rbt as mailgun`
then `mailgun.Message`;
- keep a reference behind a property, as
`return SortedMap.ref(INDEX_ID)`, and call through `self`.
All four now resolve, which is what turns a handful of "could not be
followed" entries on real applications into the calls they always
were. A property is also no longer mistaken for a method of the state.
Docstrings are dropped when a file is parsed: the analysis reads what
the code does, and a property whose body is one `return` is still one
`return` with prose above it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Reading a whole tree on every keystroke costs what it costs; reading the one method somebody just edited need not. Each method is hashed by `ast.dump` with `include_attributes=False`, which leaves out line and column numbers, so reflowing an argument list or moving a method down a file gives back the same hash. Comments never reach the tree at all, and docstrings are already stripped when the file is parsed, so neither makes a method look changed when it is not. Hashing the method alone would go stale, because its answer depends on everything the analysis walked through to reach it. So what is recorded is the hash of the method *and* of every function followed, however many calls away: a change to a helper two files over invalidates the method that reaches it, and nothing else does. What produced a result is a hash of this module's own source, so an analysis that has since changed cannot have its old conclusions mistaken for what it would say now -- without anybody having to remember to raise a number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Turns the analysis of one parsed tree into the analysis of one directory: every file the developer wrote, generated code left out. The whole tree at once rather than a file at a time, because a call crosses files -- a method may reach a state through a helper written somewhere else entirely, and reading that helper's file alone would say nothing about the method that calls it. A file that will not parse is named rather than raised: a half-written file is the normal case while somebody is typing, so the files that did parse are still analyzed and the rest are reported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
The dashboard knows where the API files are, and nothing else about the developer's tree. But what a method calls is written where the method is implemented, not where its API is declared, and those are different directories: `api/` against `backend/src/`. So `--source-directory`, spelled and passed the way `--api-directory` already is, so that a file can be shown as `backend/src/bank_servicer.py`. Optional, unlike `--api-directory`: an existing invocation keeps working, and a Node.js application, whose servicers this cannot read, is not made to name a directory that buys it nothing. Nothing reads the variable yet; the next commit does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Watches the source directory the way `Watch` already watches the API directory, and writes what it finds into the dashboard's own state, so that a browser can read it without reaching the application being developed -- which need not be running, or even built. Its own workflow rather than more of `Watch`, for two reasons: a workflow may have only one loop, and a developer who named no source directory leaves this one with nothing to do while `Watch` still has its files. Its own writer and its own pair of fields on `API`, rather than a field inside `StateTypeInfo`, because a different watcher on a different directory produces it. One writer replacing the other's field would lose whichever wrote first -- the shape `Preferences` already uses for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Under each method's signature, which is what comes in and out, goes what the method sends on to the rest of the application: the state type and method it calls, and the verb that says how -- "calls", "schedules", "spawns", "watches", "waits on". Each call links to the state type it names, which the page already renders in a section carrying that name as its id, so the page reads as the graph it describes. What the analysis could not follow is shown after the calls, phrased as what is not known, so the list above never reads as everything there is. The block sits inside the detail that a state type's "Expand details" already opens, so it needs no state of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A dashboard that starts again re-reads and re-analyzes a tree nobody has touched, which on the largest application here costs 84ms of parsing to arrive at exactly what the last one already knew. So a pass writes down what it saw -- each file's modified time and size, and per method the hashes it depended on -- beside what it concluded. A pass whose files are all as the last one left them says so before opening any of them, and costs 0.7ms. The timestamp is compared for being *different* rather than newer, because checking out an older branch moves a file's time backwards and that is as much an edit as any other; size comes along because two edits a moment apart can land on one timestamp. The two levels compose in one direction only, which is the safe one: a timestamp that changes when the code did not costs a parse and nothing more, because the method hashes then find the methods identical. That is why a timestamp is enough here and a content hash is not needed. None of it reaches `APIGetResponse`; a browser has no use for any of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
benh
force-pushed
the
caller-static-analysis
branch
from
August 15, 2026 03:20
2a93878 to
02c27d4
Compare
rileysdev
force-pushed
the
riley/reboot-inspect
branch
4 times, most recently
from
August 18, 2026 22:25
5f669a8 to
0a7c6b1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.