Skip to content

Repository files navigation

墨斗 / Modou

語意引路,墨斗定界。 — 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.)

Thesis

  • 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.

Why reaction, not instruction

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.

How drift is detected

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.

Division of labor

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 declared boundary

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-uncovered

A 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.json

For 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 json

To 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 -- list

Adopting Modou in your project

The 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 modou binary (cargo install modou) is a demo bound to that sample constitution — it governs a crate named example-core and 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.

Relationship to Tianheng

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 via syn), and the runtime 漏刻 (louke, TypeId), composed into one constitution with assert_boundary!, probe-coverage auditing, SARIF output, and the warn → baseline → enforce adoption ladder. Heavier (it compiles syn); 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.

Roadmap

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.

Non-goals

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.

Drift law

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.

License

Licensed under either of

at your option.

Contribution

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.

About

Like cargo-deny, but for architecture — declare crate & module boundaries in Rust, enforce them by a CI reaction.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages