Dependency kinds
Every package’s rut.toml names the packages it relates to, and the
kind of each relation decides how — and whether — it mounts. Three
tables, one grammar:
[deps]— the transitively mounted dependencies.[peer-deps]— required by default: not transitively pulled; the consumer must supply the peer. Withoptional = true, presence in the consumer’s closure mounts the integration; absence is inert.[dev-deps]— mounted only when building/testing the package itself (the program root), never in a consumer’s world.
Peers are a presence relation, not a pull relation: the gate looks at
what the program’s closure already contains and never adds a package. A
package is either pulled transitively ([deps]) or required of the
consumer / held for development ([peer-deps]/[dev-deps]) — never
both.
The grammar
# rut/json/rut.toml — the reference shape
name = "json"
entry.lib = "./json.rut"
inline = true
[deps]
strbuild = { path = "../strbuild" }
[peer-deps]
pouch = { path = "../pouch", optional = true, lib = "./group-pouch.rut" }
nmapset = { path = "../nmapset", optional = true, lib = "./group-nmapset.rut" }
[dev-deps]
pouch = { path = "../pouch" }
nmapset = { path = "../nmapset" }
- Keys are bare package names — the same
[a-zA-Z0-9_]+law asname; a dep-table key outside it is a line-targeted manifest error. [peer-deps]/[dev-deps]descriptors accept exactly three keys:path(string, directory-time),optional(the one bool the inline grammar learns), andlib(string; the peer-gated integration file). Anything else is the strict-manifest error.[deps]keeps string-valued descriptors and rejectsoptional— it has no options.optionaldefaults tofalse— required by default. The zero-dep spelling{ path = ".." }is valid in all three tables.- A
libending in.d.rutis a load error: the integration must be a.rutsource — a declaration surface does not gate. - The cross-table law: a name riding
[deps]beside[peer-deps]or[dev-deps]is a manifest error naming both rows —pouchappears in both[deps]and[peer-deps]— a package is either pulled transitively or required of the consumer, never both. Peer + dev together is the sanctioned pairing: integration-if-present for consumers, always-present while developing the package itself.
Semantics
| table | who supplies it | transitive? | missing behavior |
|---|---|---|---|
[deps] | the declarer’s own graph | yes — walked recursively | mount error |
[peer-deps] (required, the default) | the consumer’s closure | never pulled | loud resolution error at mount |
[peer-deps] with optional = true | the consumer’s closure, if anywhere | never pulled | inert; referencing the integration is the dedicated missing-peer diagnostic |
[dev-deps] | the pkg’s own self-build | root only — never walked for a dep | n/a (they exist to be there) |
The mount law — four passes, one gate
All loader-owned; the session stays I/O-free and the compile graph never learns what a peer is.
- The
[deps]walk — unchanged. Recursive, name order, first-mount-wins, cycle guard, name-mismatch error. While walking, the loader records each mounted package’s[peer-deps]into the session’s peer registry (it reads every dep’s manifest anyway). - The dev pass — root only. The program root’s
[dev-deps]mount exactly like[deps]. A dep’s dev table is never walked — a consumer’s world never contains another package’s dev table. Embedder mounting (mount_dir) offers a package to someone else’s program: it mounts no dev-deps and runs no gate (its peer declarations are still recorded). - The peer gate — one post-closure pass. After the full closure
exists, for every mounted package and every
[peer-deps]entry:- required: the peer must resolve in the session, else the D1 error. Never auto-pulled.
- optional, present (any reason): the declarer’s group file (the
descriptor’s
lib, an impl-only.rut) is appended to itsModule.source— groups in peer-name order after the base. The combined text stays one source string, so every consumer of module sources (the splice, bundles, wasm mounts) is untouched. - optional, absent: inert — the group simply never mounts.
- paths: the program root’s own peer paths are read and name-checked even when dev-deps already supplied presence — a broken path is the loud D3 packaging-bug error at the package’s own build. A dep’s peer paths are never read: presence is by name, so a broken peer path is inert for an optional peer and unreachable for a required one. The gate is post-closure because a peer may mount after its declarer alphabetically; groups add no package names, so one pass is a fixpoint.
- Compile — unchanged. The graph splices each compilation unit from its origin-deduplicated leaf list (first position wins, topological order preserved); in the no-collision case the composed text is byte-identical to the pre-peer law.
Groups are impl-only. A group file contains impl blocks (and their
private helpers); it declares no new public names — the base owns the
trait and every public surface. This is what makes the diagnostic matrix
total: the only ways to reference the integration are trait-method
dispatch or a use of the peer’s package name, and both have dedicated
peer-aware diagnostics. A group appended to a module with no rut body (a
.d.rut surface) is a load error — declaration surfaces do not gate.
The missing-peer matrix
| case | behavior |
|---|---|
| required peer absent from the consumer’s closure | D1 — loud at mount: names the package, the peer, and the fix. Not silent, not auto-pulled. |
| optional peer absent, integration never touched | nothing — silent success; that is the feature |
| optional peer absent, integration referenced | D2 — the dedicated missing-peer diagnostic; never a bare unresolved name |
| peer present (any reason) | the integration mounts automatically — presence-based resolution |
| self-build / dev mode | dev-deps guarantee presence; no missing case exists |
a [peer-deps] path that does not resolve | D3 — loud manifest error at the pkg’s own build; for consumers, required ⇒ D1, optional ⇒ inert |
The four diagnostics, verbatim shapes (all load/resolution-time errors, never runtime traps):
- D1 —
pkg \json` requires the peer `nmapset`, and `nmapset` is not in this program’s closure — peers are not pulled transitively: add `nmapset = { path = “..” }` to your `rut.toml` `[deps]`` - D2 —
cannot resolve \pouch` — `json`’s pouch integration is not mounted because the optional peer `pouch` is absent from this program’s closure; add `pouch = { path = “..” }` to your `rut.toml` `[deps]``. Declaring packages scan in mount order, so the diagnostic is deterministic when several packages declare the same peer. Required peers never reach this path — D1 fires at mount. - D3 — three shapes, all a packaging bug in json: cannot read a
manifest at the peer path; the manifest there names it
other; cannot read the group file. - D4 — the cross-table collision (both texts quoted above).
Interplay
- Placement and orphans — the gate precedes the check. The peer gate runs at load; impl placement and pair-uniqueness run at compile/link. Peer absent ⇒ the group text was never assembled ⇒ there is no impl anywhere to place. Peer present ⇒ the group’s impl is a trait impl in the declaring package — legal placement, since trait impls may live in any module (Traits and dispatch).
- The duplicate-pair link error is the consumer-side guard: a
consumer who hand-writes the same
impl Trait for Typein a world where the group mounted gets the link-time duplicate — loud and correct; the group already provides it. - Binary format untouched. Impl-only groups declare no host fns (no binding obligations appear or vanish), add no binary sections, and touch nothing the verifier reads. A consumer’s binary differs only by which impls registered — ordinary program content.
- Dev-deps are invisible to bundles-as-consumers. A packed package carries its peer groups; a consumer packing their app never pulls the package’s dev table (pass 2 is root-only), so dev-only convenience packages cannot leak into consumer worlds (Module bundles).
- The LSP completes group impls unconditionally. Peer gating is a resolution-time concept the language server does not model; it embeds the toolchain packages’ sources and never parses manifests. Recorded as accepted: the LSP is advisory; the compiler is the law.
Consuming a peer-gated package
# an app that wants json's pouch integration
[deps]
json = { path = "vendor/json" }
pouch = { path = "vendor/pouch" } # presence is the only requirement
The group mounts because pouch is anywhere in the closure — no extra
declaration beyond having the package. Drop the pouch row and json
mounts light; the first use pouch:: or trait dispatch into the
integration produces D2 instead of a mystery.