Skip to content

Static call analysis - #130

Draft
benh wants to merge 14 commits into
riley/reboot-inspectfrom
caller-static-analysis
Draft

Static call analysis#130
benh wants to merge 14 commits into
riley/reboot-inspectfrom
caller-static-analysis

Conversation

@benh

@benh benh commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

No description provided.

rileysdev and others added 14 commits August 13, 2026 22:14
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
benh force-pushed the caller-static-analysis branch from 2a93878 to 02c27d4 Compare August 15, 2026 03:20
@rileysdev
rileysdev force-pushed the riley/reboot-inspect branch 4 times, most recently from 5f669a8 to 0a7c6b1 Compare August 18, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants