This document is a proposed direction for Sayid, not a commitment. It captures why the project exists and the handful of changes that would make it deliver on that promise for real-world code rather than blog-post examples.
Sayid's defensible edge is not a prettier UI - FlowStorm already owns the slick stepping-debugger experience. Sayid is different in three ways worth leaning into deliberately:
trace a ns, a fn, a dir), so it composes
with scripts, fixtures, and CI.Every initiative below should push Sayid further toward "the execution of your program, as data, that you can script." When a design choice is ambiguous, that's the tie-breaker.
~4,000 lines of Clojure. Much of the accreted-rewrite cruft has been cleared out:
| namespace | LOC | role |
|---|---|---|
nrepl_middleware | 650 | wire protocol / nREPL ops (data + rendered) |
string-output | 625 | rendering to text + text-property spans (legacy path) |
core | 620 | public REPL API / engine hub |
trace | 491 | outer tracing + recording bounds |
query | 458 | the query DSL |
util/other | 237 | grab-bag utilities |
inner-ast | 202 | inner tracing via the analyzed AST |
workspace / recording / shelf | 173 / 89 / 47 | the in-memory capture store |
view / profiling / sayid_multifn | 109 / 109 / 56 | views, profiling, AOT multimethod support |
The version-suffixed names (query2, string_output2, inner_trace3) are gone,
the layers are decoupled (initiative 1), the wire protocol returns data
(initiative 3), and the fragile source-rewriting inner tracer has been replaced by
an AST-based one and deleted (initiative 4). The main entanglement that remains is
the legacy text renderer (string-output + the rendered ops + the tamarin
dependency), now that the client renders from data - see initiative 6.
Sequenced so each one unblocks the next. Start at the foundation.
Goal: a core where engine, wire protocol, and rendering are cleanly separated, with no version-suffixed namespaces.
Why first: it's the lowest-risk work (mostly mechanical restructuring, no new behavior) and it's the prerequisite that makes initiatives 2 and 3 tractable. We gave the Emacs client this treatment recently; the Clojure core deserves the same pass.
Approach:
Done. Locked core's public surface with a clojure.test characterization
suite before touching anything - the safety net for the moves below.
Done. Dropped the version suffixes: query2 -> query, string_output2 ->
string-output, inner_trace3 -> inner-trace. No aliases; Sayid has no
external clients.
Done. Broke up util/other: deleted the dead code and split the
symbol/namespace and source-reading helpers into util.sym and util.source.
Done. Drew a hard line between three layers and stopped letting them reach across it:
trace, inner-ast, workspace, recording, shelfquery, string-output, viewnrepl_middleware, which calls the engine and returns data
(see initiative 3).The engine has no upward deps; removing a stray unused require and a dead scratch
block stopped query/view/string-output reaching into it; and the last knot
*->text-prop-pair render
API in string-output instead of composing rendering steps itself.Risks: sayid_multifn is AOT-compiled and the namespace names are part of the
deployed contract; renames need deprecated forwarders. Inner-trace rewriting reads
namespace/source by name, so any move there must keep symbol resolution intact.
Done when: no \d-suffixed namespaces remain, the layers don't have circular
requires, and the characterization suite is green.
Goal: tracing a whole namespace and running a test suite should never OOM or capture values that mutate out from under you.
Why: this is the adoption blocker. The workspace holds every call (and every inner expression value) with no cap, sampling, or eviction, and it captures live references rather than snapshots. That's why Sayid feels like a toy-example tool.
Approach:
*suppress-recording* flag is the foundation: skipping a call now
runs it (and its whole subtree) untraced without the nested calls leaking in as
spurious roots, and inner tracing honours it, which also closed a latent
nil-parent NPE. On top of it, a full set of bounds: trace/*record-limit*
(default 50k) bounds the recorded top-level calls (breadth), trace/*max-trace-depth*
(default nil) bounds recording depth, trace/*sample-rate* (default 1) records
one in every N top-level calls for hot entry points, trace/*per-fn-limit*
(default nil) caps how many calls of any single function are recorded, and
trace/*evict-old-calls* switches the record limit from keep-first-N to a
keep-last-N ring (the root end-trace is looked up by node id, not position, so
eviction is safe even with concurrent root calls).*print-length* / *print-level*
(and a configurable size budget), so a fat map, a mutable object, or a lazy/
infinite seq can't blow the heap or change after the fact. First cut done at
the serialization boundary: the data ops print captured values under bounded
*print-length*/*print-level* (*value-print-length*/*value-print-level*),
so an infinite seq can't hang the serializer or produce a runaway payload.
Capture-time snapshotting (to also cap heap and freeze reference types) is still
to do.foo")
rather than silently truncating. (Done for the global cap.)Risks: snapshotting changes what users see for reference types; it must be opt-outable for the cases where you genuinely want the live value. Bounds interact with the query layer (a truncated tree must still be queryable).
Done when: trace-ns + a realistic test run completes in bounded memory, and
capturing an infinite seq doesn't hang.
Goal: the middleware returns the trace tree as data; clients render.
Why: today sayid-get-workspace ships a [text text-properties query-args]
triple - the server owns presentation and the Emacs client is a dumb terminal.
That single decision is why there's one client and why the data can't flow into
the modern Clojure inspection ecosystem.
Approach:
sayid-get-workspace-data,
sayid-query-data and friends), and moved rendering composition into a small
*->text-prop-pair API in string-output, so the middleware no longer owns it.
The Emacs client now renders the workspace and traced-fn views from these data
ops on CIDER's cider-tree-view, with value inspection via CIDER's inspector.form and value, so the data ops expose the inner-expression
tree, not just the outer calls.(->> (ws-deref) (filter ...)) with plain Clojure and the existing query DSL.Risks: value encoding over bencode (the clj->nrepl rules) needs to round-trip
the new shape without forcing clients to re-read strings - the same class of bug as
the #29 fix. Keep the rendered ops until clients migrate.
Done when: an editor-agnostic client (or a REPL one-liner) can fetch and navigate a workspace without any Sayid-specific rendering.
Goal: inner tracing that doesn't detonate on missing source, reader conditionals, or the next macro nobody tested.
Why: inner-trace re-reads the .clj from disk and rewrites raw forms,
which is why it throws on NO_SOURCE_FILE and needed a fix just for letfn. It's
the least trustworthy part of an otherwise solid tool (and the inner_trace3 it
grew out of had been rewritten twice already).
Approach:
tools.analyzer.jvm in
sayid.inner-ast: it walks the analyzed AST, wraps each call-like
sub-expression with a capture call, and emits the result. Macros and special
forms are already expanded in the AST, so there's no per-macro special-casing.
Two analyzer facts replace machinery the legacy code reinvents by hand -
:raw-forms recovers the surface syntax for display, and node context marks
tail position so recur is left legal. The runtime capture re-raises after
recording, so it's exception-transparent (an inner-traced try/catch now sees
the exception, which the legacy rewriter swallowed).sayid.inner-ast-test): instrumenting never changes a fn's result, and
the AST impl captures a superset of the old one's sub-expression values. Then
made it the default and deleted the legacy rewriter outright - the whole
sayid.inner-trace namespace, with its disk-source reading and path-symbol
correlation machinery, is gone.Risks: this is the largest single chunk and the highest-cleverness code in the
project. It must preserve evaluation semantics exactly (no double-eval of
side-effecting forms, correct recur/loop/letfn handling).
Done when: inner tracing works on a corpus that breaks the current rewriter, with no special-case patches per macro. (Met: the AST impl handles the corpus uniformly, is the default, and the legacy rewriter has been deleted.)
Goal: turn the data-first foundation into reach and a clear identity.
Why: once the execution is safe (2), exposed as data (3), and the engine is clean (1), the strategic moves become possible rather than aspirational.
Approach (pick based on appetite):
Done when: Sayid has at least one capability (golden-trace or cljs) that is clearly its own, not a worse version of what FlowStorm or a debugger offers.
Goal: finish the data-first migration by removing the server-side text renderer, so rendering lives entirely in the client.
Why: with the Emacs client rendering from the data ops (initiative 3), the
text-output path - string-output, the rendered nREPL ops, and the tamarin
value-pretty-printer dependency - is now largely legacy. It's the last place the
protocol layer owns presentation, and the biggest chunk of code the data-first
direction makes redundant.
Approach:
sayid-get-workspace buffer, sayid-query, sayid-pprint-value / c p,
sayid-def-value / c d).c i).string-output, the rendered ops, and drop tamarin.Risks: it's a user-visible change - some people may rely on the text buffer or the pprint/def-value commands; those need a data-op-backed replacement before the old path goes.
Done when: the middleware returns only data, tamarin is gone, and no Emacs
command depends on a server-rendered op.
(1) decouple the core ─┬─► (3) data, not strings ──► (5) position & extend
[done] │ [done] [in progress]
└─► (4) AST inner tracing ──► (6) retire text renderer
[done] [proposed]
(2) bound the recording ────────────────────────────► (5)
[done]
Initiatives 1-4 are done: the core is decoupled, the recording is bounded, the protocol returns data, and inner tracing runs off the AST. What's left is the payoff and the last cleanup. Initiative 5 (position & extend) is the point of the whole revival - a capability that's clearly Sayid's own - and golden-trace testing is the first move. Initiative 6 (retire the legacy text renderer) is the natural close of the data-first migration and can happen whenever; it's independent of 5.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |