Architecture¶
paredit-cli is a Cargo workspace with a thin composition root and packages
under packages/core/ and packages/feature/. Package ownership determines
where a change belongs.
The two kinds of package¶
| Owns | Depends on | |
|---|---|---|
packages/core/* |
Vocabulary every feature shares: parsing, semantics, editing primitives, the lint engine, workspace discovery, CLI I/O conventions | Other core packages only |
packages/feature/* |
One user-facing capability, whole: its rules, its orchestration, its subcommand | Core, and occasionally another feature |
| the root crate | The composition root: the clap command tree, the dispatch match, and the lint REGISTRY |
Everything |
core/syntax ──▶ core/semantics ──▶ core/edit ──▶ core/cli
└──▶ core/workspace core/lint-engine ──┘
│
▼
feature/* (mostly independent of each other)
│
▼
paredit-cli (command tree, dispatch, REGISTRY)
The direction is enforced by Cargo.toml, not by convention. A core package
that names a feature fails a contract test; so does clap appearing outside a
cli module.
One feature is one directory¶
Inside a feature package the layers survive as names, not directories:
packages/feature/similarity/src/
├── form_similarity.rs shared within the package
├── similarity_report/
│ ├── domain/ the rules
│ ├── usecase/ orchestration behind a source port
│ └── cli/ args, workflow, render
└── duplicate_report/
├── domain.rs
├── usecase.rs
└── cli/
This is the point of the split. Changing one feature means opening one
directory, not three trees. Do not create domain/, application/ or
presentation/ directories at the top level of a package — that reproduces
the old problem one level down. A slice grows a subdirectory per layer only
when that layer has more than one file, and a slice need not have all three.
What the root crate still owns¶
Almost nothing, which is the point:
src/
├── lib.rs, main.rs entry points
├── lint/ the registry, and the pass that runs it
├── semantic_coverage.rs a development harness
└── presentation/ the clap tree, dispatch, and the protocol servers
A contract test walks src/ and refuses anything else.
The lint REGISTRY is the canonical example of what must live here. It names
all lint rules, and every rule depends on the engine; putting the registry in
either would be a cycle. So the engine takes a RuleCatalog as an argument and
never learns which rules exist, the rules never learn the registry does, and
the registry sits in the root reaching feature packages for their
META and RULE. That is the criterion: a module that enumerates or
aggregates several features belongs in neither core nor any one feature.
There is no top-level domain, application or infrastructure module.
Callers name the package that owns each type, and the infrastructure layer
lives in packages/core/workspace; duplicating those boundaries in src/
would create competing namespaces.
paredit_cli::{dialect, sexpr} still resolve, re-exported in lib.rs from
paredit-core-syntax directly.
Where the detail lives¶
This document owns relationships between packages. Each package's
README.md owns its own boundary — what it is for, what it refuses, why
each dependency exists, and where a change of a given kind belongs. The two do
not repeat each other. When you want to know what a package does, read its
README; when you want to know how packages fit together, read this.
Layers, as names inside a slice¶
| Layer | Where it lives now | Responsibility |
|---|---|---|
| Domain | <slice>/domain |
Core Lisp parsing, dialect detection, and semantic refactoring rules. Independent of CLI delivery and filesystems. |
| Application | <slice>/usecase |
Orchestrates typed domain operations into agent-facing reports, plans, and refactor workflows. |
| Infrastructure | core/workspace |
Turns filesystems and workspace discovery into inputs the application layer can consume. There is no src/infrastructure; this is it. |
| Presentation | <slice>/cli |
Maps commands, flags, and output modes onto application services; renders reports and chooses exit codes. |
Within a slice, cli calls usecase, which calls domain, never the reverse.
The crate graph and contract tests enforce this direction: a slice's
domain.rs cannot reach its cli/ without declaring the dependency, clap
outside a cli path fails a contract test, and a feature dependency in a core
package fails another.
Domain: typed values, not primitives¶
The domain closes invalid states at the type level rather than validating
primitives at call sites. Byte positions are ByteOffset/ByteSpan, tree
addresses are ExpressionPath, symbol tokens are SymbolName, and a parsed
document is a SyntaxTree aggregate that stays internally consistent. Report
and decision types keep their fields private and expose semantic getters, so a
value like a similarity ratio (0.0..=1.0, finite) or a refactor plan's
automation decision cannot be constructed in a contradictory state.
Prefer this discipline when extending the domain: a validated newtype or a
semantic enum (ReportLimit::{Complete, Limited(NonZeroUsize)},
SimilarityGateDecision) over a bag of correlated bool/usize fields.
Derive redundant presentation values (booleans, counts) at the serialization
boundary instead of storing them.
Lint rules: one trait, one registry line, forty packages¶
The lint suite is the clearest example of the split's shape, and the most frequently extended part of the tree.
paredit-core-lint-engine owns the mechanism and nothing else:
| Module | Role |
|---|---|
rule |
The LintRule trait, RuleEntry, and RuleCatalog. A rule declares which nodes it wants (head_filter) and what to say about one (check); it never walks the tree itself. |
model |
Vocabulary shared by every rule — Severity, RuleCategory, Fixability, RuleMeta, LintFinding, RuleFix. |
policy |
Dialect scope, rule selection and gate decisions: logic that needs no tree. |
engine |
The single pass, which walks the document once and dispatches each node to every rule whose head_filter matches. |
340 of the 345 shipped rules live in thirty-nine themed packages, split seven
ways. A fortieth, feature/lint-custom, holds no rules at all: it is the
pattern language and the second pass that run the rules a project writes for
itself.
The remaining five — macro-variable-capture, macro-multiple-evaluation,
macro-parameter-reordering, macro-deep-quasiquote-nesting and
elisp-macro-missing-declare — live in feature/lisp-analysis instead: not a
themed lint package, but the one that already owned the detection as the
standalone inspect macro-hygiene report. They follow the same
rule//domain.rs/usecase.rs/cli/ split used by the subsystem-oriented packages
below, for the same reason: the rules and the report share one detection
rather than each keeping its own. rule/ is a directory rather than a file
there because one detection pass yields five distinct risks, and a project
must be able to deny, fail on, suppress and baseline each of them separately.
Six are split by the Lisp syntax they are about —
feature/lint-{conditional,sequence,numeric,control-flow,form-shape,string-char}.
feature/emacs-lisp is split by dialect: its rules are about Emacs
Lisp's own file conventions (lexical-binding, ;;;###autoload, defcustom
options, the cl.el names Emacs 27 removed) rather than about S-expression
shape, so none of them has a Common Lisp counterpart to share a theme with.
Four —
feature/lint-{performance,portability,safety,convention} — are split by the
kind of claim the rule makes rather than by the syntax it reads: cost,
environment assumptions, what the form does to the world outside it, and what a
definition says about itself. Grouping those by operator would scatter each
argument across six packages.
The twelfth, feature/lint-repl-debug, is split by provenance rather than
syntax or claim: its eight rules all flag the same thing — an interactive
REPL session's leftovers (print/trace/break/time/step/... calls, a
DEBUG-marked format, a pasted transcript in a comment) accidentally
committed — which cuts across every one of the other four groupings and does
not belong in any of them.
Three — feature/lint-{object-system,condition-system,iteration-flow} — are
split by the language subsystem whose contract the rule encodes: CLOS's class
and method protocol (8 rules), the condition system's signalling, handling and
restart protocol (7), and the iteration macros' clause grammar — loop,
dotimes, dolist (6). Each of those is a self-contained CLHS chapter with
its own vocabulary and its own failure modes, and a rule in one is unreadable
without that chapter's rules around it. All 21 are Common Lisp only and
ReportOnly: each reports a judgment the tool cannot make for the author —
inserting a call-next-method, choosing a :report string, or reordering
loop clauses all change what the code means, not merely how it reads.
The last three — feature/lint-{testing,concurrency,build-system} — are split
by the program-level concern the code serves, which cuts across syntax and
across the CLHS: what a test has to do to be worth running (6 rules), what
state shared between threads requires (7), and what a system definition and
package declaration must say for a project to build and namespace itself (4).
A rule in any of the three reads ordinary forms — let, defmethod, a
function call — and is about the role those forms play, not their shape.
These three are not uniformly Common Lisp. Multi-dialect rules already exist:
feature/emacs-lisp is not Common Lisp at all,
and lint-repl-debug's leftover-print-debug declares eight dialects.
lint-testing's six rules span Common Lisp, Emacs Lisp and Clojure, because a
test framework's vocabulary is per-dialect and the same deftest spelling
means different things in two of them. Two of lint-concurrency's seven —
atom-swap-with-side-effect and future-promise-never-realized — are
Clojure only. Rules scoped away from Common Lisp are not new either: ten
are already EMACS_LISP_ONLY. The narrower distinction is that these two rules
target constructs with no Common Lisp counterpart, so there is nothing to
generalize atoms, futures and promises to. All 17 are ReportOnly.
The seventh grouping contains
feature/lint-{call-shape,documentation,contract-annotation,introspection} —
split by what the rule reads instead of the operator. Every grouping above
keys on the form's head; these four do not. lint-call-shape (5 rules) reads
the size and nesting of an argument list rather than what is being called;
lint-documentation (4) reads the prose in a docstring or comment and checks it
against the code beside it; lint-contract-annotation (2) reads a separate
annotation form — Typed Racket's (: name (-> …)) and Clojure's :pre/:post
— and compares it with the definition it describes; lint-introspection (3)
reads a name that will not exist until
run time, built by intern or looked up by find-symbol, which is precisely
the case no other rule can follow.
This group is the least Common-Lisp-centric in the tree, and it is where the
dialect matrix gains cases it did not have. typed-racket-arity-mismatch is
the first built-in rule scoped to Racket alone; the other of
lint-contract-annotation's two is Clojure only, leaving no Common Lisp
rule in that package — which matters because Common Lisp is the
RuleDialectScope trait default, so a rule there that lost its scope override
would silently start walking every .lisp file.
todo-fixme-no-attribution goes the other way and
declares all eleven dialects, because a TODO with nobody's name on it
reads the same in every one. All 14 are ReportOnly, and 8 of them are tagged
pedantic — a threshold on parameter count or lambda nesting, and a house
style for docstrings and TODOs, are conventions a codebase either adopted or
did not, so recommended withholds them and --preset pedantic turns them on.
A rule declares its dialect_scope, and the dispatcher skips one whose scope
excludes the file's dialect before walking anything. inspect capabilities'
dialect matrix reads that same declaration, so a rule's standalone command
reports support for exactly the dialects the rule runs on.
Package layout follows the rule's consumers. A lint-only rule may be a single
module. A rule whose detection needs independent tests uses rule.rs and
domain.rs; a standalone inspect <rule> command adds usecase.rs and
cli/. The layout and standalone-command decisions are independent.
Dialect-specific rules must declare their scope through
[LintRule::dialect_scope]. The declaration drives both dispatch and
inspect capabilities, and contract.rs checks the corresponding command
matrix. For example, nested-get-chain and
redundant-into-empty-collection are Clojure rules, while
division-result-precision-loss is specific to Emacs Lisp integer division.
The Clojure and Scheme idiom packages group rules by language semantics rather than syntax shape. Scheme fixes preserve formatting by replacing a head symbol or copying an existing inner span verbatim.
Three Scheme rules declare
[Scheme, Racket], because begin, let* and the named let read
identically in both. Only scheme-memq-assq-literal-key is Scheme alone:
Racket's memq is eq?-based too, but Racket
specifies the two cases R7RS 6.4 leaves open — fixnums compare eq? by
guarantee and characters have been normatively eq? since 9.0.0.10 — so every
finding there would complain about code the language promises will work.
REGISTRY is in neither. It names all 320 rules, and every rule depends on
the engine, so putting it in the engine or in a rule package would be a cycle.
It sits in the root crate, and the engine receives a RuleCatalog as an
argument — which is why the engine can be a package at all.
Custom rules cannot be in REGISTRY either, for a different reason:
RuleCatalog holds &'static [RuleEntry] so the four derived arrays can be
computed at compile time, and a rule read from a file at startup has no
'static lifetime to offer. They run as a second pass whose findings are
merged into the report, and the merge is two functions in
src/presentation/cli/lint_report/workflow.rs. The two passes share the
finding type — so every output mode renders both — and nothing else.
Adding a rule is three places of design and several more of bookkeeping. The design:
- Add
packages/feature/lint-<theme>/src/your_rule/withrule.rsanddomain.rs. - Add one
RuleEntry::new(...)line toREGISTRYinsrc/lint/registry/mod.rs, and bumpRULE_COUNTwith it. - Add one integration test in
tests/cli/lint_report.rs, or a fixture pair undertests/fixtures/lint_goldenfor the golden test.
The bookkeeping is deliberate — the suite's shape is pinned so that a rule
cannot appear or vanish unremarked — but it is not free, and it is what makes
"three places" misleading. Adding the five inspect macro-hygiene rules
touched sixteen files. Budget for all of these:
- The const assertions beside
RULE_COUNTinsrc/lint/registry/catalog.rs:fixable_count(),warning_count(),EXPERIMENTAL_RULES, andPEDANTIC_RULES.len(). These are compile errors, so they cannot be missed — but each one has to be recomputed, not merely incremented. - The pinned counts in the integration tests:
rule_countand the warning-severity tally intests/cli/lint_report.rs, and the rule count in the docstring oftests/cli/determinism_contract.rs. These fail at test time, and the prose beside them goes stale silently. - The goldens under
tests/fixtures/lint_golden/expected/. Every rule appears in each fixture's per-rule tally and in the SARIFrulesarray, so a new rule rewrites all twelve golden files even when it finds nothing.UPDATE_LINT_GOLDEN=1regenerates them; read the diff rather than accepting it. - The rule counts written into prose:
docs/src/reference/api.mdand this file. Nothing checks these, which is exactly why they drift.
A rule id is public API from the moment it is released, so settle on the name
before any of the above: renaming one afterwards means a breaking change to
every lint.deny, lint.fail-on, baseline entry and paredit:ignore comment
in every downstream project.
Semantics: read-only tables beside the tree¶
packages/core/semantics lets a rule reason about what code means rather than
how it is spelled. It is why zero-divisor flags (let ((z 0)) (/ x z)) and
not just (/ x 0).
Nothing here rewrites the tree. Formatting survives a refactor because every
edit is a byte-span replacement over untouched source, and that discipline only
holds while the tree stays authoritative — so the analyses hang beside it as
side tables keyed by NodeKey, never as annotations on it.
| Context | Answers |
|---|---|
binding |
Which binding does the name at this position mean? Built once per file, from the same knowledge lexical_scope uses to answer the inverse question. |
value |
What does this expression provably evaluate to? |
typing |
What type is this, at a coarse CLHS granularity? Common Lisp only. |
project |
Which package owns this symbol, so app:run and test:run are two things? |
Each context splits into model (vocabulary), policy (dialect tables), and
service (the pass that builds a table). They stack — values need bindings,
types need values — and link downward by id, never by borrow: a
ValueTable holding a &BindingTable would make them one self-referential
struct. A rule reaches them through RuleContext, which builds each on first
use, so a run whose rules ask for none pays for none.
Two rules hold throughout, and both cost deductions on purpose:
- A fact is recorded only when it is provable. Anything uncertain is absent
rather than guessed, because a rule that trusts a wrong
Knownreports a bug in working code. - An unknown head is opaque. A macro can expand into an assignment that appears nowhere in the source, so propagation stops at any head whose semantics are not registered. Ordinary function calls and standard control forms are exempt — a function cannot reach the caller's lexical environment at all, and a control form evaluates its subforms where they are written, so any assignment inside is visible.
Application: use cases behind source ports¶
Each non-trivial CLI workflow is an application use case that owns the whole orchestration — discovery, decoding, parsing, analysis, gate precedence, and error typing — and depends on the outside world only through a source port trait it defines itself. The recurring shape is request in, plan out:
Request (input DTO)
│
▼
use case ──uses──▶ SourcePort (trait, defined in application)
│
▼
Plan (output aggregate: report + inventory + typed errors + gate decision)
Three ports carry the pattern today:
| Use case | Source port | Plan / output |
|---|---|---|
usecase::similarity_report::workflow |
SimilarityReportSourcePort |
SimilarityReportPlan |
usecase::workspace_report::workflow |
WorkspaceReportSourcePort |
WorkspaceReportPlan |
usecase::remove_definition |
DefinitionSourcePort |
edit plan + write policy |
Because the port is an interface, the use case is filesystem- and
CLI-agnostic: tests drive it with an in-memory adapter, while production wires
in the real one. A port models discover-before-load explicitly — for
example SimilarityReportSourcePort resolves each file's dialect during
discover and returns bytes from load, so dialect is never smuggled
alongside a failed read. Adapter state or ordering failures return through
Result; they never panic.
A port names its adapter's error as an associated type.
pub trait DefinitionSourcePort {
type Error: Into<CliError>;
fn load(&mut self, file: &Path) -> Result<LoadedDefinitionSource, Self::Error>;
}
These methods returned anyhow::Result until the typed-error pass, justified
by the port's whole purpose: the use case must not know what an adapter can
fail with. That reasoning is right and anyhow::Error was the wrong way to say
it — it does not express "some error I do not name", it expresses "no error
type at all", and the failure's classification went with it. An associated type
says the intended thing in the type system. The Into<CliError> bound is the
one requirement, because whatever an adapter fails with has to be reportable at
the CLI boundary with an error code.
The Plan an application use case returns is the contract with presentation:
it holds the domain report, a discovery inventory, per-file typed errors, and a
single computed gate decision. Presentation reads the plan; it never
re-derives the decision.
Infrastructure: discovery adapters¶
src/infrastructure/workspace implements source discovery: it walks directory
roots, applies hidden/generated/symlink/exclude filters, and yields the file
set the application ports request. fs_identity captures file identity for the
apply-time "changed on disk" guard. Infrastructure depends on the domain (for
dialect types) and nothing above it.
Presentation: adapters, rendering, exit codes¶
src/presentation/cli is a thin edge. For each workflow it:
- Converts CLI arguments into an application
Request. - Implements the use case's source port (e.g.
CliSimilarityReportSource impl SimilarityReportSourcePort) by delegating to the infrastructurediscover_workspace_files/WorkspaceDiscoveryadapter. - Calls the use case and renders the returned
Planas text or JSON. - Maps the plan's gate decision to a process exit code (see the agent interface for the code table).
Keeping request conversion, rendering, and gate-to-exit mapping here — and
everything else in the application and domain layers — is what lets the same
report logic serve both a human --output text reader and a machine
--output json consumer without duplication.
The failure a command returns¶
A command entry point returns CommandResult — Result<(), CommandFailure>
— and CommandFailure has exactly two variants:
| Variant | Means |
|---|---|
Error(CliError) |
the command could not do its work |
Gate(GateFailure) |
it did its work, printed its report, and a requested --fail-on-* gate tripped |
Those are different answers and they earn different exit codes (1 and 3), so
diagnosis::classify is a total match over the pair. The closed sum prevents
an unhandled refusal from being classified as internal.unclassified; adding
a variant requires updating the match.
The set of error codes is closed and documented; the set of feature errors is
open, because CliError naming all 29 feature packages would invert the
dependency direction. FeatureRefusal bridges the two: a feature converts its
own rich error at its cli/ boundary and must supply the code it earns.
The source() chain flattens into the message there — the last point at which
anything reads it — while the classification stays a type.
How the layers map to the three namespaces¶
The command model — inspect, edit, refactor, query,
fix, migrate — is a presentation-level grouping. Underneath, an inspect
report and a refactor plan are both application use cases over the same
domain SyntaxTree; the namespace only reflects whether the command writes
and what the caller was trying to do. This is why a report and the refactor
that consumes it always agree on paths, spans, and symbol identity: they share
the domain, not just a serialization format.
The three added namespaces make the point sharply, because each is a new address onto machinery that already existed rather than new machinery:
| Namespace | What it exposes | Where the logic lives |
|---|---|---|
query |
the pattern language, at workspace scope | paredit_core_syntax::selector::{pattern, matcher, rewrite} |
fix |
the lint auto-fixer | the same engine inspect lint --fix runs, called with the arguments that spelling would have produced |
migrate |
ordered, dialect-scoped recipes | selector::rewrite again, sequenced by paredit-feature-migrate |
selector::rewrite sits in core, not in paredit-feature-query, for one
concrete reason: paredit-feature-migrate needs it too, and a feature package
depending on a feature package is the dependency direction this split exists
to prevent.
Where a change belongs¶
| Change | Layer |
|---|---|
| New parsing rule, dialect capability, or refactor safety check | domain |
| New lint rule | domain/lint/rules plus one registry line |
| New static fact about values, types, or bindings | domain/semantics |
| New report, plan, or multi-file workflow orchestration | application |
| New way to discover or read sources | infrastructure |
| New command, flag, output format, or exit-code mapping | presentation |
When a change spans layers, add it from the inside out: model it in the domain, orchestrate it in an application use case behind a port, then expose it through a presentation adapter. The development guide covers the verification gate that keeps these boundaries — and the documentation that describes them — honest.