Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/boundaries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: boundaries

# Architecture guard (dev/CI only — no runtime code).
# Enforces the golden rule for the core: AuthGate depends on nothing.
# The kernel must not import any satellite layer (fdk / banking / robotics / quantum).
on: [push, pull_request]

jobs:
enforce-boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: AuthGate core imports no satellite layer
run: PYTHONPATH=tools python -m boundary_guard check src attack_harness --policy tools/policy.bgpolicy
23 changes: 23 additions & 0 deletions tools/boundary_guard/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""boundary-guard — architecture-boundary enforcement for separate repositories.

A dev-time framework. It never imports, wraps, or runs the systems it guards;
it only reads their source and enforces separation. Stdlib only, no runtime deps.
"""

from .policy import Policy
from .graph import ImportGraph, ImportEdge
from .enforce import Finding, check
from .profiles import Profile, restrict_policy

__version__ = "0.2.0"

__all__ = [
"__version__",
"Policy",
"ImportGraph",
"ImportEdge",
"Finding",
"check",
"Profile",
"restrict_policy",
]
5 changes: 5 additions & 0 deletions tools/boundary_guard/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import sys

from .cli import main

sys.exit(main())
128 changes: 128 additions & 0 deletions tools/boundary_guard/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Command-line interface — the surface CI calls.

python -m boundary_guard check <paths...> --policy P [--profile F] [--strict]
python -m boundary_guard graph <paths...> --policy P
python -m boundary_guard baseline <paths...> --policy P --out baseline.json
python -m boundary_guard drift <paths...> --policy P --baseline baseline.json
python -m boundary_guard viz --policy P [--format mermaid|dot]
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

from . import __version__
from . import drift as drift_mod
from . import enforce, profiles, viz
from .graph import ImportGraph
from .policy import Policy


def _load_policy(path) -> Policy:
try:
policy = Policy.from_file(path)
except (OSError, ValueError) as e:
print(f"error: cannot load policy {path!r}: {e}", file=sys.stderr)
raise SystemExit(2)
errors = policy.validate()
if errors:
print(f"INVALID POLICY ({path}):", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
raise SystemExit(2)
return policy


def _resolve(args):
policy = _load_policy(args.policy)
paths = list(args.paths)
if getattr(args, "profile", None):
prof = profiles.Profile.from_file(args.profile)
policy = profiles.restrict_policy(policy, prof.visible_layers)
paths = prof.roots
missing = [p for p in paths if not Path(p).exists()]
if missing:
print(f"error: path(s) not found: {', '.join(missing)}", file=sys.stderr)
raise SystemExit(2)
return policy, paths


def cmd_check(args) -> int:
policy, paths = _resolve(args)
graph = ImportGraph.from_sources(paths, policy.root_to_layer)
findings = enforce.check(graph, policy, strict=args.strict)
if not findings:
print(f"OK — boundaries hold under {', '.join(paths)}")
return 0
print(f"BOUNDARY FINDINGS ({len(findings)}):\n")
for f in findings:
print(f" [{f.kind}] {f.src_layer} -> {f.dst_layer}")
print(f" {f.reason}")
for loc in f.locations:
print(f" at {loc}")
print()
return 1


def cmd_graph(args) -> int:
policy, paths = _resolve(args)
graph = ImportGraph.from_sources(paths, policy.root_to_layer)
for s, d in sorted(graph.layer_edges()):
print(f"{s} -> {d}")
return 0


def cmd_baseline(args) -> int:
policy, paths = _resolve(args)
graph = ImportGraph.from_sources(paths, policy.root_to_layer)
drift_mod.write_baseline(graph, args.out)
print(f"baseline written to {args.out} ({len(graph.layer_edges())} edges)")
return 0


def cmd_drift(args) -> int:
policy, paths = _resolve(args)
graph = ImportGraph.from_sources(paths, policy.root_to_layer)
added, removed = drift_mod.diff(args.baseline, graph)
for e in added:
print(f"+ {e} (new cross-layer dependency)")
for e in removed:
print(f"- {e} (dependency removed)")
if not added and not removed:
print("no drift — graph matches baseline")
return 0
return 1 if added else 0


def cmd_viz(args) -> int:
policy = _load_policy(args.policy)
print(viz.to_dot(policy) if args.format == "dot" else viz.to_mermaid(policy))
return 0


def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="boundary-guard")
p.add_argument("--version", action="version", version=f"boundary-guard {__version__}")
sub = p.add_subparsers(dest="cmd", required=True)

def add_common(sp, with_paths=True):
if with_paths:
sp.add_argument("paths", nargs="*", default=["."])
sp.add_argument("--policy", required=True)
sp.add_argument("--profile")

c = sub.add_parser("check"); add_common(c); c.add_argument("--strict", action="store_true"); c.set_defaults(func=cmd_check)
g = sub.add_parser("graph"); add_common(g); g.set_defaults(func=cmd_graph)
b = sub.add_parser("baseline"); add_common(b); b.add_argument("--out", default="boundaries.baseline.json"); b.set_defaults(func=cmd_baseline)
d = sub.add_parser("drift"); add_common(d); d.add_argument("--baseline", default="boundaries.baseline.json"); d.set_defaults(func=cmd_drift)
v = sub.add_parser("viz"); v.add_argument("--policy", required=True); v.add_argument("--format", choices=["mermaid", "dot"], default="mermaid"); v.set_defaults(func=cmd_viz, paths=[], profile=None)
return p


def main(argv=None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
args = build_parser().parse_args(argv)
return args.func(args)
30 changes: 30 additions & 0 deletions tools/boundary_guard/drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Component 5 — Drift Detection.

A baseline is the set of cross-layer edges that existed at a known-good point.
Drift = edges that appeared since (new structural coupling), or disappeared.
New edges are the dangerous ones: they are how an architecture quietly rots even
when no single edge is yet forbidden.
"""

from __future__ import annotations

import json
from pathlib import Path

from .graph import ImportGraph


def snapshot(graph: ImportGraph) -> list[str]:
return sorted(f"{s} -> {d}" for (s, d) in graph.layer_edges())


def write_baseline(graph: ImportGraph, path) -> None:
Path(path).write_text(
json.dumps({"edges": snapshot(graph)}, indent=2) + "\n", encoding="utf-8"
)


def diff(baseline_path, graph: ImportGraph) -> tuple[list[str], list[str]]:
base = set(json.loads(Path(baseline_path).read_text(encoding="utf-8")).get("edges", []))
current = set(snapshot(graph))
return sorted(current - base), sorted(base - current)
67 changes: 67 additions & 0 deletions tools/boundary_guard/enforce.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Component 3 — Enforcement (hardened).

Judges the graph against the policy and emits findings:

- forbidden: an edge the policy explicitly forbids (always a failure)
- cycle: a layer dependency cycle (always a failure)
- unanalyzable: a source file that could not be parsed (a blind spot; --strict)
- undeclared: a cross-layer edge that is neither allowed nor forbidden
(only reported in --strict; this is creeping drift)

Edges from the synthetic `(unscoped)` source (glue code outside any declared
layer) are judged only by explicit forbids — never reported as "undeclared" —
so laundering a forbidden dependency through a non-layer module is still caught
without drowning the report in noise.
"""

from __future__ import annotations

from dataclasses import dataclass

from .graph import UNSCOPED, ImportGraph
from .policy import Policy


@dataclass(frozen=True)
class Finding:
kind: str
src_layer: str
dst_layer: str
reason: str
locations: tuple[str, ...] = ()


def check(graph: ImportGraph, policy: Policy, strict: bool = False) -> list[Finding]:
locs: dict[tuple[str, str], list[str]] = {}
for e in graph.edges:
tag = " [dynamic]" if e.dynamic else ""
locs.setdefault((e.src_layer, e.dst_layer), []).append(
f"{e.src_file}:{e.lineno} (import {e.module}{tag})"
)

findings: list[Finding] = []
for (src, dst), locations in sorted(locs.items()):
reason = policy.is_forbidden(src, dst)
if reason is not None:
findings.append(Finding("forbidden", src, dst, reason or "forbidden by policy", tuple(locations)))
elif src == UNSCOPED:
continue # glue code: only explicit forbids apply, never "undeclared"
elif policy.is_allowed(src, dst):
continue
elif strict:
findings.append(Finding(
"undeclared", src, dst,
"cross-layer dependency not declared in policy", tuple(locations),
))

for cyc in graph.cycles():
findings.append(Finding("cycle", cyc[0], cyc[-1], "layer cycle: " + " -> ".join(cyc)))

if strict:
for path in graph.skipped:
findings.append(Finding(
"unanalyzable", UNSCOPED, "-",
f"source could not be parsed — a blind spot: {path}", (path,),
))

return findings
Loading
Loading