From 8d9790de00d736c7676ecf3984f651dcd6ce6163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sat, 18 Jul 2026 14:05:29 +0300 Subject: [PATCH] improve kaptan-spec as a build library and move it to libs dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Onur Özkan --- SPECIFICATIONS.md | 88 +----------- kaptan-spec/src/lib.rs | 64 --------- .../tests/ui/fail_empty_explanation.stderr | 7 - .../tests/ui/fail_non_string_argument.stderr | 7 - kaptan-spec/tests/ui/flight_core_contract.rs | 7 - .../tests/ui/flight_core_contract.spec.md | 25 ---- lib/.gitkeep | 0 {kaptan-spec => libs/kaptan-spec}/Cargo.toml | 9 +- libs/kaptan-spec/src/lib.rs | 128 ++++++++++++++++++ libs/kaptan-spec/src/lib.spec.md | 75 ++++++++++ .../kaptan-spec}/tests/spec_macro.rs | 0 .../tests/ui/fail_empty_explanation.rs | 0 .../tests/ui/fail_empty_explanation.stderr | 7 + .../tests/ui/fail_missing_argument.rs | 0 .../tests/ui/fail_missing_argument.stderr | 0 .../tests/ui/fail_non_string_argument.rs | 0 .../tests/ui/fail_non_string_argument.stderr | 20 +++ .../kaptan-spec}/tests/ui/no_spec.rs | 0 18 files changed, 237 insertions(+), 200 deletions(-) delete mode 100644 kaptan-spec/src/lib.rs delete mode 100644 kaptan-spec/tests/ui/fail_empty_explanation.stderr delete mode 100644 kaptan-spec/tests/ui/fail_non_string_argument.stderr delete mode 100644 kaptan-spec/tests/ui/flight_core_contract.rs delete mode 100644 kaptan-spec/tests/ui/flight_core_contract.spec.md delete mode 100644 lib/.gitkeep rename {kaptan-spec => libs/kaptan-spec}/Cargo.toml (65%) create mode 100644 libs/kaptan-spec/src/lib.rs create mode 100644 libs/kaptan-spec/src/lib.spec.md rename {kaptan-spec => libs/kaptan-spec}/tests/spec_macro.rs (100%) rename {kaptan-spec => libs/kaptan-spec}/tests/ui/fail_empty_explanation.rs (100%) create mode 100644 libs/kaptan-spec/tests/ui/fail_empty_explanation.stderr rename {kaptan-spec => libs/kaptan-spec}/tests/ui/fail_missing_argument.rs (100%) rename {kaptan-spec => libs/kaptan-spec}/tests/ui/fail_missing_argument.stderr (100%) rename {kaptan-spec => libs/kaptan-spec}/tests/ui/fail_non_string_argument.rs (100%) create mode 100644 libs/kaptan-spec/tests/ui/fail_non_string_argument.stderr rename {kaptan-spec => libs/kaptan-spec}/tests/ui/no_spec.rs (100%) diff --git a/SPECIFICATIONS.md b/SPECIFICATIONS.md index c5db1fe..3e9c0a4 100644 --- a/SPECIFICATIONS.md +++ b/SPECIFICATIONS.md @@ -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 -``` - -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 ---- - -## TODO -``` - -When the spec evolves, append another entry in the same shape with a new version heading. \ No newline at end of file +The authoritative Kaptan specification policy is [libs/kaptan-spec/src/lib.spec.md](libs/kaptan-spec/src/lib.spec.md). \ No newline at end of file diff --git a/kaptan-spec/src/lib.rs b/kaptan-spec/src/lib.rs deleted file mode 100644 index a6f3415..0000000 --- a/kaptan-spec/src/lib.rs +++ /dev/null @@ -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 { - let tokens: Vec = 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::() - .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::() - .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 -} diff --git a/kaptan-spec/tests/ui/fail_empty_explanation.stderr b/kaptan-spec/tests/ui/fail_empty_explanation.stderr deleted file mode 100644 index f50f814..0000000 --- a/kaptan-spec/tests/ui/fail_empty_explanation.stderr +++ /dev/null @@ -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) diff --git a/kaptan-spec/tests/ui/fail_non_string_argument.stderr b/kaptan-spec/tests/ui/fail_non_string_argument.stderr deleted file mode 100644 index 7b5780f..0000000 --- a/kaptan-spec/tests/ui/fail_non_string_argument.stderr +++ /dev/null @@ -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) diff --git a/kaptan-spec/tests/ui/flight_core_contract.rs b/kaptan-spec/tests/ui/flight_core_contract.rs deleted file mode 100644 index d2b0ca0..0000000 --- a/kaptan-spec/tests/ui/flight_core_contract.rs +++ /dev/null @@ -1,7 +0,0 @@ -use kaptan_spec::spec; - -spec!("flight_core_contract.spec.md"); - -fn main() { - assert_eq!(core::mem::size_of::<()>(), 0); -} \ No newline at end of file diff --git a/kaptan-spec/tests/ui/flight_core_contract.spec.md b/kaptan-spec/tests/ui/flight_core_contract.spec.md deleted file mode 100644 index 2b1140e..0000000 --- a/kaptan-spec/tests/ui/flight_core_contract.spec.md +++ /dev/null @@ -1,25 +0,0 @@ -# --- version: 0.1.0 --- - ---- -title: Flight core contract -status: in-progress -owners: - - Flight Systems ---- - -## Contract - -Initial control contract. - -# --- version: 0.2.0 --- - ---- -title: Flight core contract -status: implemented -owners: - - Flight Systems ---- - -## Contract - -Revised control contract. \ No newline at end of file diff --git a/lib/.gitkeep b/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/kaptan-spec/Cargo.toml b/libs/kaptan-spec/Cargo.toml similarity index 65% rename from kaptan-spec/Cargo.toml rename to libs/kaptan-spec/Cargo.toml index 166942b..6e4c084 100644 --- a/kaptan-spec/Cargo.toml +++ b/libs/kaptan-spec/Cargo.toml @@ -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" \ No newline at end of file diff --git a/libs/kaptan-spec/src/lib.rs b/libs/kaptan-spec/src/lib.rs new file mode 100644 index 0000000..7519a58 --- /dev/null +++ b/libs/kaptan-spec/src/lib.rs @@ -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) { + 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) { + 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") +} diff --git a/libs/kaptan-spec/src/lib.spec.md b/libs/kaptan-spec/src/lib.spec.md new file mode 100644 index 0000000..9ff34c2 --- /dev/null +++ b/libs/kaptan-spec/src/lib.spec.md @@ -0,0 +1,75 @@ +# --- version: 0.1.0 --- + +--- +title: Specification validation library +status: in-progress +owners: + - Onur Özkan +--- + +## 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 +``` + +## 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 +--- + +## TODO +``` \ No newline at end of file diff --git a/kaptan-spec/tests/spec_macro.rs b/libs/kaptan-spec/tests/spec_macro.rs similarity index 100% rename from kaptan-spec/tests/spec_macro.rs rename to libs/kaptan-spec/tests/spec_macro.rs diff --git a/kaptan-spec/tests/ui/fail_empty_explanation.rs b/libs/kaptan-spec/tests/ui/fail_empty_explanation.rs similarity index 100% rename from kaptan-spec/tests/ui/fail_empty_explanation.rs rename to libs/kaptan-spec/tests/ui/fail_empty_explanation.rs diff --git a/libs/kaptan-spec/tests/ui/fail_empty_explanation.stderr b/libs/kaptan-spec/tests/ui/fail_empty_explanation.stderr new file mode 100644 index 0000000..e68dcb8 --- /dev/null +++ b/libs/kaptan-spec/tests/ui/fail_empty_explanation.stderr @@ -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) diff --git a/kaptan-spec/tests/ui/fail_missing_argument.rs b/libs/kaptan-spec/tests/ui/fail_missing_argument.rs similarity index 100% rename from kaptan-spec/tests/ui/fail_missing_argument.rs rename to libs/kaptan-spec/tests/ui/fail_missing_argument.rs diff --git a/kaptan-spec/tests/ui/fail_missing_argument.stderr b/libs/kaptan-spec/tests/ui/fail_missing_argument.stderr similarity index 100% rename from kaptan-spec/tests/ui/fail_missing_argument.stderr rename to libs/kaptan-spec/tests/ui/fail_missing_argument.stderr diff --git a/kaptan-spec/tests/ui/fail_non_string_argument.rs b/libs/kaptan-spec/tests/ui/fail_non_string_argument.rs similarity index 100% rename from kaptan-spec/tests/ui/fail_non_string_argument.rs rename to libs/kaptan-spec/tests/ui/fail_non_string_argument.rs diff --git a/libs/kaptan-spec/tests/ui/fail_non_string_argument.stderr b/libs/kaptan-spec/tests/ui/fail_non_string_argument.stderr new file mode 100644 index 0000000..0fdac3a --- /dev/null +++ b/libs/kaptan-spec/tests/ui/fail_non_string_argument.stderr @@ -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 { + | ^^^^^^^^^^^^^^^^^^ diff --git a/kaptan-spec/tests/ui/no_spec.rs b/libs/kaptan-spec/tests/ui/no_spec.rs similarity index 100% rename from kaptan-spec/tests/ui/no_spec.rs rename to libs/kaptan-spec/tests/ui/no_spec.rs