improve kaptan-spec as a build library and move it to libs dir

Signed-off-by: Onur Özkan <work@onurozkan.dev>
This commit is contained in:
Onur Özkan committed 2026-07-18 14:06:42 +03:00
1 parent 85559bd2d5
commit 8d9790de00
18 files changed
+237 -200

No files matched your search

+1 -87
View File
@@ -1,89 +1,3 @@
# Specifications
Formal Kaptan specifications live next to the file-backed module they govern.
Examples:
- `kaptan-runtime/src/runtime.rs`
- `kaptan-runtime/src/runtime.spec.md`
- `kaptan-runtime/src/scheduler.rs`
- `kaptan-runtime/src/scheduler.spec.md`
## Automatic Sidecar Checks
Kaptan build checks require a same-name `.spec.md` sidecar for every file-backed Rust module under a checked crate's `src/` tree.
For example, `control_loop.rs` requires `control_loop.spec.md` in the same directory. A checked module must have exactly one of:
- its sidecar specification; or
- a top-level `no_spec!("reason");` declaration.
`no_spec!` is only for module files that intentionally have no formal specification, such as a crate root that only declares child modules. It must provide a non-empty reason and must not be used for types, functions, or individual items.
Example for a crate root without standalone runtime behavior:
```rust
use kaptan_spec::no_spec;
no_spec!("Crate root declares child modules only.");
```
The build fails if a required sidecar is missing or a module contains both a sidecar and `no_spec!(...)`.
## Spec Format
Formal spec files are append-only records for one governed module or package.
Each file may contain one or more spec entries. Each entry starts with a version heading, followed by YAML frontmatter and a Markdown body.
Use this exact heading form for each entry:
```markdown
# --- version: 0.1.0 ---
```
Each spec entry must then declare these keys in YAML frontmatter:
- `title`
- `status`
- `owners`
Allowed statuses are:
- `in-progress`
- `implemented`
- `deprecated`
Owner entries must use full name and email address in this format:
```text
- Full Name <name@example.com>
```
The spec path is the identifier for linkage and traceability.
- The code path plus the `#[spec("...")]` reference identifies the governing spec.
- The spec file path identifies the governed module or package.
- Individual spec revisions are identified by the version heading.
- A single spec file can carry multiple revisions for the same governed module.
- New revisions should be appended as new spec entries in the same file instead of creating new filenames.
- Within the body of a spec entry, `#` is reserved for version headings. Spec content should start at `##` and below.
## Template
Use this starter when creating a colocated spec entry:
```markdown
# --- version: 0.1.0 ---
---
title: Replace with title
status: deprecated
owners:
- Full Name <name@example.com>
---
## TODO
```
When the spec evolves, append another entry in the same shape with a new version heading.
The authoritative Kaptan specification policy is [libs/kaptan-spec/src/lib.spec.md](libs/kaptan-spec/src/lib.spec.md).
-64
View File
@@ -1,64 +0,0 @@
use proc_macro::{Literal, TokenStream, TokenTree};
#[proc_macro]
pub fn no_spec(input: TokenStream) -> TokenStream {
match parse_no_spec_reason(input) {
Ok(reason) => expand_no_spec_reason(&reason),
Err(message) => compile_error(&message),
}
}
fn parse_no_spec_reason(input: TokenStream) -> Result<String, String> {
let tokens: Vec<TokenTree> = input.into_iter().collect();
match tokens.as_slice() {
[TokenTree::Literal(path)] if is_string_literal(path) => {
if has_non_empty_string(path) {
Ok(path.to_string())
} else {
Err("no-spec explanation must not be empty; use no_spec!(\"reason\");".into())
}
}
[] => Err("missing no-spec explanation; use no_spec!(\"reason\");".into()),
_ => Err("invalid no-spec invocation; use no_spec!(\"reason\");".into()),
}
}
fn is_string_literal(literal: &Literal) -> bool {
let value = literal.to_string();
value.starts_with('"') && value.ends_with('"')
}
fn has_non_empty_string(literal: &Literal) -> bool {
let value = literal.to_string();
!value[1..value.len() - 1].trim().is_empty()
}
fn expand_no_spec_reason(reason: &str) -> TokenStream {
format!("const _: &str = {reason};")
.parse::<TokenStream>()
.expect("no_spec macro must generate valid Rust tokens")
}
fn compile_error(message: &str) -> TokenStream {
let escaped = escape_string(message);
format!("compile_error!(\"{escaped}\");")
.parse::<TokenStream>()
.expect("spec macro must generate valid compile_error tokens")
}
fn escape_string(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
_ => escaped.push(ch),
}
}
escaped
}
@@ -1,7 +0,0 @@
error: no-spec explanation must not be empty; use no_spec!("reason");
--> tests/ui/fail_empty_explanation.rs:3:1
|
3 | no_spec!(" ");
| ^^^^^^^^^^^^^^^
|
= note: this error originates in the macro `no_spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,7 +0,0 @@
error: invalid no-spec invocation; use no_spec!("reason");
--> tests/ui/fail_non_string_argument.rs:3:1
|
3 | no_spec!(42);
| ^^^^^^^^^^^^
|
= note: this error originates in the macro `no_spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,7 +0,0 @@
use kaptan_spec::spec;
spec!("flight_core_contract.spec.md");
fn main() {
assert_eq!(core::mem::size_of::<()>(), 0);
}
@@ -1,25 +0,0 @@
# --- version: 0.1.0 ---
---
title: Flight core contract
status: in-progress
owners:
- Flight Systems <flight@example.com>
---
## Contract
Initial control contract.
# --- version: 0.2.0 ---
---
title: Flight core contract
status: implemented
owners:
- Flight Systems <flight@example.com>
---
## Contract
Revised control contract.
View File
Whitespace-only changes.
@@ -1,13 +1,16 @@
[package]
name = "kaptan-spec"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
[lib]
proc-macro = true
[features]
std = ["dep:syn"]
[dependencies]
syn = { version = "2", features = ["full"], optional = true }
[dev-dependencies]
trybuild = "1.0"
+128
View File
@@ -0,0 +1,128 @@
//! Kaptan formal-specification infrastructure.
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(feature = "std")]
use std::{env, fs, path::Path};
#[cfg(feature = "std")]
use syn::Item;
/// Returns whether an exception explanation contains a non-whitespace byte.
#[doc(hidden)]
pub const fn has_non_whitespace(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if !bytes[index].is_ascii_whitespace() {
return true;
}
index += 1;
}
false
}
/// Declares that the containing module intentionally has no sidecar spec.
#[macro_export]
macro_rules! no_spec {
() => {
compile_error!("missing no-spec explanation; use no_spec!(\"reason\");");
};
($reason:literal) => {
const _: &str = $reason;
const _: () = assert!(
$crate::has_non_whitespace($reason),
"no-spec explanation must not be empty; use no_spec!(\"reason\");"
);
};
($($invalid:tt)+) => {
compile_error!("invalid no-spec invocation; use no_spec!(\"reason\");");
};
}
/// Validates the Rust modules in the package identified by Cargo's build-script
/// environment.
///
/// Every file-backed module under `src/` must have exactly one of a same-name
/// `.spec.md` sidecar or a top-level `no_spec!("reason");` declaration.
#[cfg(feature = "std")]
pub fn validate_package() {
let source_directory =
Path::new(&env::var("CARGO_MANIFEST_DIR").expect("manifest directory")).join("src");
let mut violations = Vec::new();
check_directory(&source_directory, &mut violations);
if !violations.is_empty() {
panic!(
"Kaptan module specification check failed:\n{}",
violations.join("\n")
);
}
}
#[cfg(feature = "std")]
fn check_directory(directory: &Path, violations: &mut Vec<String>) {
let entries = fs::read_dir(directory).unwrap_or_else(|error| {
panic!(
"could not read module directory {}: {error}",
directory.display()
)
});
for entry in entries {
let path = entry
.unwrap_or_else(|error| panic!("could not read module entry: {error}"))
.path();
if path.is_dir() {
check_directory(&path, violations);
} else if path.extension().is_some_and(|extension| extension == "rs") {
check_module_file(&path, violations);
}
}
}
#[cfg(feature = "std")]
fn check_module_file(module_path: &Path, violations: &mut Vec<String>) {
println!("cargo:rerun-if-changed={}", module_path.display());
let source = fs::read_to_string(module_path)
.unwrap_or_else(|error| panic!("could not read module {}: {error}", module_path.display()));
let module = syn::parse_file(&source).unwrap_or_else(|error| {
panic!("could not parse module {}: {error}", module_path.display())
});
let sidecar_path = module_path.with_extension("spec.md");
let has_sidecar = sidecar_path.is_file();
let has_no_spec = module.items.iter().any(
|item| matches!(item, Item::Macro(item_macro) if is_no_spec_macro(&item_macro.mac.path)),
);
if has_sidecar {
println!("cargo:rerun-if-changed={}", sidecar_path.display());
}
match (has_sidecar, has_no_spec) {
(false, false) => violations.push(format!(
"- {} is missing {}; add the sidecar or no_spec!(\"reason\").",
module_path.display(),
sidecar_path.display()
)),
(true, true) => violations.push(format!(
"- {} has both {} and no_spec!(...); keep exactly one declaration.",
module_path.display(),
sidecar_path.display()
)),
_ => {}
}
}
#[cfg(feature = "std")]
fn is_no_spec_macro(path: &syn::Path) -> bool {
path.segments
.last()
.is_some_and(|segment| segment.ident == "no_spec")
}
+75
View File
@@ -0,0 +1,75 @@
# --- version: 0.1.0 ---
---
title: Specification validation library
status: in-progress
owners:
- Onur Özkan <work@onurozkan.dev>
---
## Scope
This module defines Kaptan's reusable package build-time validation of formal-specification sidecars and the no_std `no_spec!` exception macro.
Formal Kaptan specifications live next to the file-backed module they govern. For example, `control_loop.rs` is governed by `control_loop.spec.md` in the same directory.
## Sidecar Requirements
Every file-backed Rust module under a validated package's `src/` tree must have exactly one same-name `.spec.md` sidecar or one top-level `no_spec!("reason");` declaration. Missing and duplicate declarations fail the package build.
A package build script invokes `kaptan_spec::validate_package()` with the `std` feature enabled. The validator emits Cargo change-tracking directives for every checked source module and existing sidecar.
`kaptan-spec` is the bootstrap exception because it cannot use itself as a build dependency. Every other Kaptan package must invoke the validator from its build script.
## No-Spec Exceptions
`no_spec!("reason");` is only for a file-backed module that intentionally has no standalone formal specification, such as a crate root that only declares child modules. It applies at module scope, not to types, functions, or individual items.
The exception requires one non-empty, non-whitespace string literal explanation. It expands to compile-time constants and has no runtime cost. A module must not contain both a sidecar and `no_spec!(...)`.
## Specification Record Format
Each sidecar is an append-only record for one governed module or package. An entry starts with this exact version heading:
```markdown
# --- version: 0.1.0 ---
```
The heading is followed by YAML frontmatter containing these required keys:
- `title`
- `status`
- `owners`
Allowed statuses are:
- `in-progress`
- `implemented`
- `deprecated`
Each owner entry uses a full name and email address:
```text
- Full Name <name@example.com>
```
## Traceability and Revisions
The sidecar path identifies the governed module or package. Individual revisions are identified by their version heading. New revisions append a complete new entry to the stable sidecar file; they do not create a new filename.
Within a record entry, `#` is reserved for version headings. Content headings start at `##` or below.
## Entry Template
```markdown
# --- version: 0.1.0 ---
---
title: Replace with title
status: deprecated
owners:
- Full Name <name@example.com>
---
## TODO
```
File renamed without changes.
@@ -0,0 +1,7 @@
error[E0080]: evaluation panicked: no-spec explanation must not be empty; use no_spec!("reason");
--> tests/ui/fail_empty_explanation.rs:3:1
|
3 | no_spec!(" ");
| ^^^^^^^^^^^^^^^ evaluation of `_` failed here
|
= note: this error originates in the macro `$crate::panic::panic_2021` which comes from the expansion of the macro `no_spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -0,0 +1,20 @@
error[E0308]: mismatched types
--> tests/ui/fail_non_string_argument.rs:3:10
|
3 | no_spec!(42);
| ^^ expected `&str`, found integer
error[E0308]: mismatched types
--> tests/ui/fail_non_string_argument.rs:3:10
|
3 | no_spec!(42);
| ---------^^-
| | |
| | expected `&str`, found integer
| arguments to this function are incorrect
|
note: function defined here
--> src/lib.rs
|
| pub const fn has_non_whitespace(value: &str) -> bool {
| ^^^^^^^^^^^^^^^^^^
File renamed without changes.