Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .claude/hooks/adr-boundary-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ if [[ -z "$CONTENT" ]]; then
fi

# Skip non-source files (docs, configs, this hook itself, lockfiles, manifests)
# and skip the architecture-tests crate, which contains the banned patterns
# as literal strings by design in order to enforce them.
case "$FILE_PATH" in
*/docs/*|*/.claude/*|*/.github/*|*/scripts/*|*.md|*.yaml|*.yml|*.json|*.toml|*.lock|*.txt|*.sh)
*/docs/*|*/.claude/*|*/.github/*|*/scripts/*|*/crates/capcom-arch-tests/*|*.md|*.yaml|*.yml|*.json|*.toml|*.lock|*.txt|*.sh)
exit 0
;;
esac
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Cargo workspace structure with two crates:
- Cargo workspace structure with three crates:
- `crates/capcom/` -- library crate, placeholder kernel API
- `crates/capcom-engine/` -- binary crate, placeholder for the future
co-located out-of-process engine node (ADR-012)
- `crates/capcom-arch-tests/` -- architecture-test crate enforcing
workspace-level invariants via `cargo test`. Three test suites:
`no_distributed_code` (ADR-003/005/009), `no_banned_engines`
(ADR-001), and `kernel_boundary` (public API surface + engine
dependency closure)
- `rust-toolchain.toml` pinning the stable channel with `rustfmt` and
`clippy` components (minimal profile)
- `rustfmt.toml` setting edition 2024
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/capcom", "crates/capcom-engine"]
members = ["crates/capcom", "crates/capcom-engine", "crates/capcom-arch-tests"]

[workspace.package]
version = "0.1.0"
Expand Down
16 changes: 16 additions & 0 deletions crates/capcom-arch-tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "capcom-arch-tests"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Architecture tests for the capcom workspace -- enforced via cargo test"
publish = false

[lints]
workspace = true

[dev-dependencies]
capcom = { path = "../capcom" }
116 changes: 116 additions & 0 deletions crates/capcom-arch-tests/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Architecture tests for the capcom workspace.
//!
//! Integration tests in `tests/` enforce workspace-level invariants via
//! `cargo test`. This module exposes shared helpers used by those tests:
//! a recursive source-file walker and a per-line matcher that understands
//! Rust comments and `adr-override:` escape annotations.

use std::fs;
use std::path::{Path, PathBuf};

/// Returns the absolute path to the workspace root.
///
/// Derives from the compile-time `CARGO_MANIFEST_DIR` of this crate
/// (`crates/capcom-arch-tests`) by walking up two parents.
///
/// # Panics
///
/// Panics if this crate is somehow not under `crates/<name>/` at the
/// workspace root. The architecture-tests crate is hard-coded to live at
/// `crates/capcom-arch-tests/` and any move must update this function in
/// the same change.
#[must_use]
pub fn workspace_root() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.expect("capcom-arch-tests should live under crates/")
.parent()
.expect("crates/ should live under the workspace root")
.to_path_buf()
}

/// Collects every `.rs` file and every `Cargo.toml` under `crates/`,
/// excluding the architecture-tests crate itself (which contains banned
/// patterns as literal strings by design).
///
/// # Panics
///
/// Panics if the workspace's `crates/` directory is missing, unreadable,
/// or contains unreadable entries. Both conditions indicate a broken
/// checkout and should fail the test suite loudly rather than silently
/// returning an empty result.
#[must_use]
pub fn collect_workspace_sources() -> Vec<PathBuf> {
let crates_dir = workspace_root().join("crates");
let mut files = Vec::new();
let entries = fs::read_dir(&crates_dir).expect("workspace has a crates/ directory");
for entry in entries {
let entry = entry.expect("readable crates/ entry");
let crate_path = entry.path();
let Some(crate_name) = crate_path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if crate_name == "capcom-arch-tests" {
continue;
}
let cargo_toml = crate_path.join("Cargo.toml");
if cargo_toml.is_file() {
files.push(cargo_toml);
}
let src_dir = crate_path.join("src");
if src_dir.is_dir() {
collect_rust_files(&src_dir, &mut files);
}
let tests_dir = crate_path.join("tests");
if tests_dir.is_dir() {
collect_rust_files(&tests_dir, &mut files);
}
}
files
}

fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
let entries =
fs::read_dir(dir).unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()));
for entry in entries {
let entry =
entry.unwrap_or_else(|e| panic!("failed to read entry in {}: {e}", dir.display()));
let path = entry.path();
if path.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}

/// Returns `true` if the line is a Rust comment line: `//`, `///`, `//!`,
/// `/*`, or a block-comment continuation (`*` at start). TOML comments
/// (`#` at start) are also treated as comment lines.
#[must_use]
pub fn is_comment_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with("*/")
|| trimmed.starts_with("* ")
|| trimmed == "*"
|| trimmed.starts_with('#')
}

/// Returns `true` if the line carries an `adr-override:` annotation that
/// names any of the given ADR IDs (`ADR-NNN`).
#[must_use]
pub fn has_override(line: &str, adrs: &[&str]) -> bool {
line.contains("adr-override:") && adrs.iter().any(|adr| line.contains(adr))
}

/// Formats `path` as a workspace-relative string if possible, falling back
/// to the absolute representation.
#[must_use]
pub fn workspace_relative(path: &Path) -> String {
let root = workspace_root();
path.strip_prefix(&root)
.map_or_else(|_| path.display().to_string(), |p| p.display().to_string())
}
121 changes: 121 additions & 0 deletions crates/capcom-arch-tests/tests/kernel_boundary.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! Kernel boundary tests.
//!
//! capcom is the Rust kernel for Aphelion. Its public API surface is the
//! contract consumed by `capcom-engine` and any future product-layer
//! adapter (per ADR-012 / ADR-014). These tests enforce two invariants:
//!
//! 1. **Public API surface is explicit.** Every public item exported by
//! the `capcom` crate must be acknowledged here. When new public items
//! land, this test forces an explicit edit -- unreviewed API surface
//! growth is the bug we want to catch.
//!
//! 2. **`capcom-engine` depends only on `capcom`.** The engine binary is
//! a thin entry point for the co-located out-of-process engine node;
//! adding other runtime dependencies requires architectural review
//! (aphelion ADR-006 dependency discipline).
//!
//! Rust's visibility model already enforces "consumers only see `pub`
//! items", so a module-level leak test would be redundant with the
//! compiler. These tests target the things the compiler does *not*
//! catch on its own: deliberate review of the public surface, and
//! deliberate review of the engine binary's dependency closure.

use std::fs;

use capcom_arch_tests::workspace_root;

#[test]
fn kernel_public_api_surface_is_minimal() {
// Compile-time assertion: `capcom::version` exists with the expected
// signature. Adding a new public item will break compilation of this
// test (via the missing binding) and force an explicit update, which
// is exactly the review gate we want.
let version_fn: fn() -> &'static str = capcom::version;
assert!(
!version_fn().is_empty(),
"capcom::version() must return a non-empty version string"
);

// When the kernel's public API grows, add each new item below as a
// typed binding. Each addition should land in the same change that
// adds the item, and should be reviewed against the compatibility
// surface registry (aphelion ADR-010) when that registry exists for
// capcom.
//
// Example future entries:
// let _open: fn(&Path) -> Result<Database, Error> = capcom::open;
// let _begin: fn(&Database) -> Transaction<'_> = Database::begin;
}

#[test]
fn engine_crate_depends_only_on_capcom() {
let engine_manifest = workspace_root().join("crates/capcom-engine/Cargo.toml");
let content = fs::read_to_string(&engine_manifest)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", engine_manifest.display()));

let runtime_deps = collect_dependency_keys(&content, "dependencies");
assert_eq!(
runtime_deps,
vec!["capcom".to_string()],
"capcom-engine must depend only on the capcom kernel library. Adding any \
other runtime dependency requires architectural review (aphelion ADR-006 \
dependency discipline). Found runtime dependencies: {runtime_deps:?}"
);

// Build and dev dependencies are intentionally not constrained here --
// they are developer tooling, not runtime surface.
}

/// Parses a Cargo.toml string and returns the ordered list of dependency
/// keys under the given top-level table (e.g. `"dependencies"`).
///
/// This is a minimal parser that handles the subset of TOML the capcom
/// workspace uses. It is sufficient for the boundary test without pulling
/// in an external TOML crate (aphelion ADR-006 would require reviewing any
/// new dependency, and a test-only toml crate is not justified yet).
fn collect_dependency_keys(content: &str, section: &str) -> Vec<String> {
let target_header = format!("[{section}]");
let mut in_section = false;
let mut keys: Vec<String> = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line == target_header {
in_section = true;
continue;
}
if in_section && line.starts_with('[') {
break;
}
if !in_section {
continue;
}
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, _)) = line.split_once('=') {
let key = key.trim();
if !key.is_empty() {
keys.push(key.to_string());
}
}
}
keys
}

#[test]
fn workspace_root_resolves() {
let root = workspace_root();
assert!(
root.join("Cargo.toml").is_file(),
"workspace_root() should point to the dir containing the root Cargo.toml; got {}",
root.display()
);
assert!(
root.join("crates/capcom").is_dir(),
"workspace should contain crates/capcom"
);
assert!(
root.join("crates/capcom-engine").is_dir(),
"workspace should contain crates/capcom-engine"
);
}
59 changes: 59 additions & 0 deletions crates/capcom-arch-tests/tests/no_banned_engines.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! ADR-001: capcom is the product-owned engine kernel for Aphelion.
//! The alternative engines evaluated and rejected by that ADR must not
//! be referenced anywhere under `crates/`.
//!
//! This test walks every `.rs` and `Cargo.toml` file under `crates/`
//! (excluding this crate, which holds the banned list as data by design)
//! and fails if any banned engine name appears, even in comments -- the
//! point is that we don't want these names anywhere in the kernel.
//!
//! Lines carrying `// adr-override: ADR-001` are permitted as a
//! deliberate escape hatch and must include a rationale.

use std::fs;

use capcom_arch_tests::{collect_workspace_sources, has_override, workspace_relative};

const BANNED_ENGINES: &[&str] = &["millenniumdb", "graphflow"];

const ENGINE_OVERRIDE_ADRS: &[&str] = &["ADR-001"];

#[test]
fn no_banned_engines_in_workspace() {
let files = collect_workspace_sources();
assert!(
!files.is_empty(),
"expected at least one source file under crates/; the walker found none"
);

let mut violations: Vec<String> = Vec::new();
for file in &files {
let content = fs::read_to_string(file)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", file.display()));
for (idx, line) in content.lines().enumerate() {
let lower = line.to_lowercase();
if has_override(&lower, ENGINE_OVERRIDE_ADRS) {
continue;
}
for pattern in BANNED_ENGINES {
if lower.contains(pattern) {
violations.push(format!(
"{}:{}: matches `{}`: {}",
workspace_relative(file),
idx + 1,
pattern,
line.trim()
));
}
}
}
}

assert!(
violations.is_empty(),
"ADR-001 violation -- banned graph engine references detected:\n{}\n\n\
ADR-001 selected capcom as the product-owned engine kernel.\n\
The alternatives were evaluated and rejected.",
violations.join("\n")
);
}
Loading
Loading