語意引路,墨斗定界。 — Let semantics guide. Let Modou mark the line.
A carpenter's 墨斗 (ink-line) snaps one straight reference line; anything off it is visibly off. Modou snaps the architectural line, then reacts when the code crosses it. Govern by reaction, not instruction.
Modou is the Rust-native, static-only reactive-governance CLI — the lightweight,
syn-free face of the Tianheng (天衡) family, built on the 圭表 (guibiao) static core
(itself derived from Modou). It does not run your app and it does not instruct your
agent. Developers and agents propose change; Modou uses a compiler/CI reaction to keep
architectural shape from drifting. Observation and comparison live in guibiao; Modou owns
the CLI reaction — the flags, the reports, and the exit code. (See
Relationship to Tianheng for when to reach for the full
framework instead.)
- Governance is the framework.
- Reaction is the control surface.
- Rust is the constitution — TOML, Markdown, and reports are projections, not the source of truth. The compiler/CI reaction comes first.
Architectural intent — "the core must not depend on adapters" — used to live in
human understanding and code review. An AI agent writes fluent, locally-plausible
code without holding that intent, so it erodes the shape it does not understand, and
instructing it ("keep the core clean") cannot bind an agent that has no
understanding to follow. Modou's answer is not to give the agent understanding: it
crystallizes the human's intent into a non-bypassable reaction, so neither the
agent nor Modou needs to understand for the law to hold — the understanding is
front-loaded into the human-authored constitution. Modou is to architectural
boundaries what cargo-deny is to the supply chain: the same govern-by-reaction
discipline, on the layer Cargo cannot see.
Drift is a policy-aware diff, not an AI judgment: declare the intended shape in Rust -> observe the real shape from the project -> compare -> classify (pass / warning / violation). Modou never guesses whether a change is "reasonable"; it only reacts when observed reality diverges from the declared constitution.
Human owns the invariant. Modou owns the reaction. Agent owns the change.
The human steward keeps a small set of must-not-drift boundaries; Modou reacts to violations; the agent repairs using the report. A wrong boundary is changed by a human-reviewed amendment — never by weakening the constitution to make CI pass.
A Rust-declared boundary plus one CI reaction.
use modou::prelude::*;
fn constitution() -> Constitution {
Constitution::new("example").boundary(
CrateBoundary::crate_("example-core")
.deny_external_dependencies()
.because("example-core must stay dependency-light"),
)
}Crate dependency rules share one observation source (cargo metadata). They can
deny external packages, forbid named dependencies, or restrict all or only workspace
dependencies to a closed allowlist. Feature rules constrain the features a target
directly requests on a named dependency, including whether Cargo's default features
are enabled:
CrateBoundary::crate_("example-core")
.deny_external_dependencies()
.allow_external(["serde"])
.because("example-core may use serde, nothing else external");
CrateBoundary::crate_("core")
.forbid_dependency_on(["adapters"])
.because("core must not depend on adapters");
CrateBoundary::crate_("domain")
.restrict_dependencies_to(["serde", "domain-types"])
.because("the domain may depend on only serde and its own types");
CrateBoundary::crate_("backend")
.restrict_workspace_dependencies_to(["core"])
.because("a backend may depend on only the core workspace crate");
CrateBoundary::crate_("backend")
.restrict_features_of("serde", ["derive"])
.because("backend requests only serde's derive feature");
CrateBoundary::crate_("backend")
.forbid_feature("tokio", "default")
.because("backend disables Tokio's default feature set");The two restrict rules differ in scope, on purpose: restrict_dependencies_to
governs all normal dependencies — external crates (serde, tokio) included — so
anything off its allowlist is a violation. restrict_workspace_dependencies_to
governs only dependencies on other workspace members and ignores external crates;
forbid_all_workspace_dependencies() is the empty-allowlist shorthand. Because
workspace membership is observed rather than hand-listed, adding a new workspace
crate cannot silently slip past the rule.
By default a crate rule observes the normal [dependencies] table. Append
.dependency_kind(DependencyKind::Dev) (or Build) to point any crate rule at the
[dev-dependencies] or [build-dependencies] table instead — so "a backend may not
pull another backend in as a normal dependency, but a dev-dependency is fine" is
expressible directly:
CrateBoundary::crate_("backend")
.forbid_dependency_on(["other-backend"])
.dependency_kind(DependencyKind::Dev)
.because("a backend may not pull another backend in as a dev-dependency");A boundary defaults to enforce (a violation fails CI). Mark it .warn() to make
it advisory — its violations are reported but do not fail — so a dirty project can
observe a boundary before ratcheting it to enforce:
CrateBoundary::crate_("legacy")
.deny_external_dependencies()
.warn()
.because("legacy is not clean yet; observe before enforcing");A module boundary governs the intra-crate import graph Cargo cannot see — observed
from the crate's own use declarations (see the scanner decision in
PROJECT.md):
ModuleBoundary::in_crate("app")
.module("crate::kernel")
.must_not_import("crate::projection")
.because("the kernel must not depend on a projection");Module rules use guibiao's static resolver for reachable source modules. In addition
to outbound and inbound import restrictions, rules can limit which modules may import
a protected module, confine one external crate's use statements to a module subtree,
and confine statically observed calls under a symbol path. The resolver accounts for
lexical scope, imports, aliases, globs, raw and canonically equivalent Unicode
identifiers, and supported #[path] remaps. Scan refusals fail with exit 2 rather
than silently omitting an unresolved source.
cargo run -p modou -- check --manifest-path path/to/Cargo.toml--manifest-path is optional: omit it and check resolves the nearest Cargo.toml
by walking up from the current directory, like cargo itself.
Exits 0 (clean / warn-only / fully baselined), 1 (enforced violation), or 2
(constitution/scan error — including when no Cargo.toml can be found). The reaction
is proven against in-repo fixtures (crates/modou/tests/fixtures/), so the repo is
self-contained: it references no external directory.
check also reports workspace coverage — how many workspace crates have no
boundary at all — as an always-on line (and a coverage field under --format json), so a fully-covered clean run reads differently from one where crates are
simply unchecked. Add --warn-uncovered to surface each ungoverned crate as a
warn-severity advisory; like all advisories, it never changes the exit code:
cargo run -p modou -- check --manifest-path path/to/Cargo.toml --warn-uncoveredA dirty project can adopt a boundary without first fixing every violation: record
the current ones as a baseline, then gate only on new ones. The baseline is a
generated snapshot (a projection), not policy — see
PROJECT.md.
Modou 0.5 baselines use structured rule and fact identities; pre-0.5 numeric-version
baselines are incompatible. Review the findings from the new observer, then regenerate
the file explicitly with --write-baseline. Rewriting a compatible structured baseline
preserves owner and tracker annotations for identities that remain.
# pin current violations
cargo run -p modou -- check --manifest-path path/to/Cargo.toml --write-baseline modou-baseline.json
# fail CI only on violations new since that baseline
cargo run -p modou -- check --manifest-path path/to/Cargo.toml --baseline modou-baseline.jsonFor an AI repair loop, --format json prints the outcome as a structured document
on stdout (human text stays the default). Each violation carries structured rule and
fact identities alongside its kind (crate / module), source and polarity metadata
where available; the boundary's reason remains the repair hint:
cargo run -p modou -- check --manifest-path path/to/Cargo.toml --format jsonTo see the law itself — every boundary's target, rule, severity, and reason —
list prints the declared constitution. It is a projection of the Rust source, not
a reaction: it observes nothing, needs no --manifest-path, and always exits 0
(--format json is also accepted). Useful in a CI log or when a steward reviews an
amendment:
cargo run -p modou -- listThe constitution is Rust, so you declare yours in your own code and get the entire
check contract — the flags above, the baseline gate, the JSON report, and the
0 / 1 / 2 exit-code mapping — from one library call. Depend on modou, then
expose a tiny binary:
use modou::prelude::*;
fn constitution() -> Constitution {
Constitution::new("my-project").boundary(
CrateBoundary::crate_("my-core")
.deny_external_dependencies()
.because("my-core must stay dependency-light"),
)
}
fn main() -> std::process::ExitCode {
modou::run(&constitution(), std::env::args())
}This exact snippet ships as a compiled example —
examples/adoption.rs —
so the adoption surface cannot silently rot (cargo run --example adoption -- check --manifest-path path/to/Cargo.toml).
Now your-binary check --manifest-path path/to/Cargo.toml [...] reacts against
your constitution with the identical contract — no argument parsing, baseline
handling, or exit-code logic to reimplement. The bundled modou binary is itself
just this one-liner over the repo's sample constitution.
Note: the published
modoubinary (cargo install modou) is a demo bound to that sample constitution — it governs a crate namedexample-coreand will report a constitution error on any other project. Modou is consumed as a library: declare your own constitution and expose your own binary as above.
See docs/adoption.md for a copy-paste quick start and the full
walkthrough — dependency setup, CI wiring, gradual adoption via baselines, and
protecting your constitution.
Modou is the static instrument of the Tianheng (天衡) family, built on 圭表 (guibiao) — Tianheng's static observation core, itself derived from Modou. Lineage, not succession: 墨斗 marks the line, 圭表 measures the shadow, and Modou lives on as the family's lightweight static face.
Which should I use?
- Modou — one dimension (static: crate + module boundaries),
syn-free, minimal dependencies, an easy CLI on-ramp. Reach for it when you want static architectural governance in CI and nothing more. - Tianheng — the complete three-instrument framework: the static 圭表, the semantic
渾儀 (
hunyi, AST viasyn), and the runtime 漏刻 (louke,TypeId), composed into one constitution withassert_boundary!, probe-coverage auditing, SARIF output, and the warn → baseline → enforce adoption ladder. Heavier (it compilessyn); reach for it when you need more than the static dimension.
Both share the same guibiao static core, so a boundary means the same thing in either — Modou is simply the first rung of the ladder.
Modou detects crate dependency drift through cargo metadata, including declared
dependency sources and feature requests, and module-boundary drift through guibiao's
scope-aware static source resolver. Module rules cover outbound and inbound imports,
external-crate confinement, and inline-call confinement. Each crate rule can target
normal, dev, or build dependencies, and check reports workspace coverage. Modou stays
static-only by design: the semantic (AST) and runtime dimensions are realized as
sibling instruments in the Tianheng family (渾儀 hunyi / 漏刻 louke), not added here
(see Relationship to Tianheng).
Static-refinement phases — each with its own observation source, each its own OpenSpec
change — remain deferred in
BACKLOG.md. Nothing is
named or built before its reaction exists.
Not a schema crate, document generator, app framework, agent framework, universal
graph registry, or runtime policy engine. No TOML/Markdown for the constitution, no
SHAPE.md, no in-tool amendment system (the amendment flow is harness convention —
CODEOWNERS + steward review, see PROJECT.md),
no procedural macros, no multi-crate split, no universal graph API — until a real
reaction earns them.
No target type or name without a reaction. A name is not claimed for a reaction
that does not yet exist. The crate boundary was named Boundary while it was the
only kind; once the module reaction landed, the earned rename followed —
Boundary -> CrateBoundary, with Boundary now the umbrella over CrateBoundary
and ModuleBoundary.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.