Substantive changes across the crate tree (cargo check --workspace: clean): - ontoref-daemon: UI handlers/pages, MCP surface, api, domain query delegation - ontoref-ops: runtime - ontoref-ontology / ontoref-ontology-content / oplog / triples / commit: supporting - domains/rustelo: schema surface sync
1675 lines
59 KiB
Rust
1675 lines
59 KiB
Rust
//! Proc-macro crate for the ontoref protocol.
|
|
//!
|
|
//! Provides `#[onto_api(...)]` — an attribute macro for daemon HTTP handler
|
|
//! functions that registers each endpoint in the `api_catalog` at link time
|
|
//! via `inventory::submit!`. Metadata declared in the attribute (auth level,
|
|
//! actor set, tags, params) is exported to `artifacts/api-catalog-*.ncl` by
|
|
//! `just export-api-catalog`, making the full HTTP surface queryable as typed
|
|
//! NCL without requiring a running daemon (ADR-007).
|
|
|
|
use proc_macro::TokenStream;
|
|
use proc_macro2::Span;
|
|
use quote::quote;
|
|
use syn::{
|
|
parse_macro_input, punctuated::Punctuated, DeriveInput, Expr, ExprLit, ItemFn, Lit, LitStr,
|
|
MetaNameValue, Token,
|
|
};
|
|
|
|
// ── #[onto_api(...)]
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
/// Attribute macro for daemon HTTP handler functions.
|
|
///
|
|
/// Registers the endpoint in the `api_catalog` at link time via
|
|
/// `inventory::submit!`. The annotated function is emitted unchanged.
|
|
///
|
|
/// # Required keys
|
|
/// - `method = "GET"` — HTTP verb
|
|
/// - `path = "/graph/impact"` — URL path pattern (axum syntax)
|
|
/// - `description = "..."` — one-line description (optional if a `///` doc
|
|
/// comment is present; explicit attribute value takes priority over the doc
|
|
/// comment)
|
|
///
|
|
/// # Optional keys
|
|
/// - `auth = "none"` — authentication level: "none" | "viewer" | "admin"
|
|
/// (default: "none")
|
|
/// - `actors = "agent, developer"` — comma-separated actor contexts
|
|
/// - `params = "name:type:constraint:desc; ..."` — semicolon-separated param
|
|
/// entries
|
|
/// - `tags = "graph, federation"` — comma-separated semantic tags
|
|
/// - `feature = "db"` — feature flag required for this endpoint (empty = always
|
|
/// available)
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// #[onto_api(
|
|
/// method = "GET", path = "/graph/impact",
|
|
/// description = "Cross-project impact graph from an ontology node",
|
|
/// auth = "viewer", actors = "agent, developer",
|
|
/// params = "node:string:required:Ontology node id; depth:u32:default=2:Max BFS hops",
|
|
/// tags = "graph, federation",
|
|
/// )]
|
|
/// async fn graph_impact(...) { ... }
|
|
/// ```
|
|
#[proc_macro_attribute]
|
|
pub fn onto_api(args: TokenStream, input: TokenStream) -> TokenStream {
|
|
match expand_onto_api(args, input) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
/// Parsed fields from `#[onto_api(...)]`.
|
|
struct OntoApiAttr {
|
|
method: String,
|
|
path: String,
|
|
description: String,
|
|
auth: String,
|
|
actors: Vec<String>,
|
|
params: Vec<OntoApiParam>,
|
|
tags: Vec<String>,
|
|
feature: String,
|
|
}
|
|
|
|
struct OntoApiParam {
|
|
name: String,
|
|
kind: String,
|
|
constraint: String,
|
|
description: String,
|
|
}
|
|
|
|
fn expand_onto_api(args: TokenStream, input: TokenStream) -> syn::Result<proc_macro2::TokenStream> {
|
|
let item = proc_macro2::TokenStream::from(input);
|
|
|
|
// Extract first non-empty `///` doc comment from the annotated function.
|
|
// `/// text` compiles to `#[doc = " text"]` before the macro sees it.
|
|
let doc_desc: Option<String> = syn::parse2::<ItemFn>(item.clone())
|
|
.ok()
|
|
.and_then(|fn_item| fn_item.attrs.into_iter().find_map(doc_attr_text));
|
|
|
|
let kv_args = syn::parse::Parser::parse(
|
|
Punctuated::<MetaNameValue, Token![,]>::parse_terminated,
|
|
args,
|
|
)?;
|
|
|
|
let mut method: Option<String> = None;
|
|
let mut path: Option<String> = None;
|
|
let mut description: Option<String> = None;
|
|
let mut auth = "none".to_owned();
|
|
let mut actors: Vec<String> = Vec::new();
|
|
let mut params_raw: Option<String> = None;
|
|
let mut tags: Vec<String> = Vec::new();
|
|
let mut feature = String::new();
|
|
|
|
for kv in &kv_args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
let val = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
|
|
match key.as_str() {
|
|
"method" => method = Some(val),
|
|
"path" => path = Some(val),
|
|
"description" => description = Some(val),
|
|
"auth" => match val.as_str() {
|
|
"none" | "viewer" | "bearer" | "admin" => auth = val,
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.value,
|
|
format!(
|
|
"unknown auth level '{other}'; expected none | viewer | bearer | admin"
|
|
),
|
|
))
|
|
}
|
|
},
|
|
"actors" => actors = split_csv(&val),
|
|
"params" => params_raw = Some(val),
|
|
"tags" => tags = split_csv(&val),
|
|
"feature" => feature = val,
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!("unknown onto_api key: {other}"),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
let method = method.ok_or_else(|| {
|
|
syn::Error::new(Span::call_site(), "#[onto_api] requires method = \"...\"")
|
|
})?;
|
|
let path = path
|
|
.ok_or_else(|| syn::Error::new(Span::call_site(), "#[onto_api] requires path = \"...\""))?;
|
|
let desc = description.or(doc_desc).ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_api] requires description = \"...\" or a /// doc comment on the function",
|
|
)
|
|
})?;
|
|
|
|
let params = parse_params(params_raw.as_deref().unwrap_or(""))?;
|
|
|
|
let attr = OntoApiAttr {
|
|
method,
|
|
path,
|
|
description: desc,
|
|
auth,
|
|
actors,
|
|
params,
|
|
tags,
|
|
feature,
|
|
};
|
|
let ts = emit_onto_api(attr, item);
|
|
Ok(ts)
|
|
}
|
|
|
|
fn split_csv(s: &str) -> Vec<String> {
|
|
s.split(',')
|
|
.map(|p| p.trim().to_owned())
|
|
.filter(|p| !p.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
/// Parse `"name:type:constraint:description; ..."` param string.
|
|
/// Separator between params: `;`. Fields within a param: `:` (max 4 splits).
|
|
fn parse_params(raw: &str) -> syn::Result<Vec<OntoApiParam>> {
|
|
if raw.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
raw.split(';')
|
|
.map(|entry| {
|
|
let parts: Vec<&str> = entry.trim().splitn(4, ':').collect();
|
|
if parts.len() < 3 {
|
|
return Err(syn::Error::new(
|
|
Span::call_site(),
|
|
format!("param entry '{entry}' must have at least name:type:constraint"),
|
|
));
|
|
}
|
|
Ok(OntoApiParam {
|
|
name: parts[0].trim().to_owned(),
|
|
kind: parts[1].trim().to_owned(),
|
|
constraint: parts[2].trim().to_owned(),
|
|
description: parts.get(3).map(|s| s.trim()).unwrap_or("").to_owned(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn emit_onto_api(attr: OntoApiAttr, item: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
|
|
let method = LitStr::new(&attr.method, Span::call_site());
|
|
let path = LitStr::new(&attr.path, Span::call_site());
|
|
let desc = LitStr::new(&attr.description, Span::call_site());
|
|
let auth = LitStr::new(&attr.auth, Span::call_site());
|
|
let feature = LitStr::new(&attr.feature, Span::call_site());
|
|
|
|
let actor_lits: Vec<LitStr> = attr
|
|
.actors
|
|
.iter()
|
|
.map(|a| LitStr::new(a, Span::call_site()))
|
|
.collect();
|
|
|
|
let tag_lits: Vec<LitStr> = attr
|
|
.tags
|
|
.iter()
|
|
.map(|t| LitStr::new(t, Span::call_site()))
|
|
.collect();
|
|
|
|
let param_exprs: Vec<_> = attr
|
|
.params
|
|
.iter()
|
|
.map(|p| {
|
|
let n = LitStr::new(&p.name, Span::call_site());
|
|
let k = LitStr::new(&p.kind, Span::call_site());
|
|
let c = LitStr::new(&p.constraint, Span::call_site());
|
|
let d = LitStr::new(&p.description, Span::call_site());
|
|
quote! {
|
|
::ontoref_ontology::api::ApiParam { name: #n, kind: #k, constraint: #c, description: #d }
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Unique ident derived from path+method to prevent duplicate statics.
|
|
let unique = {
|
|
let s = format!("{}{}", attr.method, attr.path);
|
|
s.bytes()
|
|
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64))
|
|
};
|
|
let static_ident = syn::Ident::new(
|
|
&format!("__ONTOREF_API_ROUTE_{unique:x}"),
|
|
Span::call_site(),
|
|
);
|
|
|
|
quote! {
|
|
::inventory::submit! {
|
|
::ontoref_ontology::api::ApiRouteEntry {
|
|
method: #method,
|
|
path: #path,
|
|
description: #desc,
|
|
auth: #auth,
|
|
actors: &[#(#actor_lits),*],
|
|
params: &[#(#param_exprs),*],
|
|
tags: &[#(#tag_lits),*],
|
|
feature: #feature,
|
|
// file!() expands at call site — the .rs file where #[onto_api] is placed.
|
|
source_file: file!(),
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
#[allow(non_upper_case_globals, dead_code)]
|
|
static #static_ident: () = ();
|
|
|
|
#item
|
|
}
|
|
}
|
|
|
|
// ── #[onto_mcp_tool(...)]
|
|
// ──────────────────────────────────────────────────
|
|
|
|
/// Attribute macro for MCP tool unit-structs in ontoref-daemon.
|
|
///
|
|
/// Registers the tool in the MCP catalog at link time via
|
|
/// `inventory::submit!(McpToolEntry{...})`. The annotated item is emitted
|
|
/// unchanged — `ToolBase` and `AsyncTool` impls below the struct continue to
|
|
/// own the executable behaviour (ADR-015).
|
|
///
|
|
/// # Required keys
|
|
/// - `name = "ontoref_xxx"` — must equal `ToolBase::name()` for the same struct
|
|
/// - `description = "..."` — one-line agent-facing description
|
|
///
|
|
/// # Optional keys
|
|
/// - `category = "discovery"` — semantic grouping (discovery | ontology |
|
|
/// knowledge | validation | config). Empty by default.
|
|
/// - `params = "name:type:constraint:desc; ..."` — same grammar as
|
|
/// `#[onto_api(params = ...)]`; semicolon-separated, four-field entries.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// #[onto_mcp_tool(
|
|
/// name = "ontoref_search",
|
|
/// description = "Free-text search across nodes, ADRs, and modes.",
|
|
/// category = "discovery",
|
|
/// params = "query:string:required:Search term; project:string:optional:Project slug",
|
|
/// )]
|
|
/// struct SearchTool;
|
|
/// ```
|
|
#[proc_macro_attribute]
|
|
pub fn onto_mcp_tool(args: TokenStream, input: TokenStream) -> TokenStream {
|
|
match expand_onto_mcp_tool(args, input) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
fn expand_onto_mcp_tool(
|
|
args: TokenStream,
|
|
input: TokenStream,
|
|
) -> syn::Result<proc_macro2::TokenStream> {
|
|
let item = proc_macro2::TokenStream::from(input);
|
|
|
|
let kv_args = syn::parse::Parser::parse(
|
|
Punctuated::<MetaNameValue, Token![,]>::parse_terminated,
|
|
args,
|
|
)?;
|
|
|
|
let mut name: Option<String> = None;
|
|
let mut description: Option<String> = None;
|
|
let mut category = String::new();
|
|
let mut params_raw: Option<String> = None;
|
|
|
|
for kv in &kv_args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
let val = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
|
|
match key.as_str() {
|
|
"name" => name = Some(val),
|
|
"description" => description = Some(val),
|
|
"category" => category = val,
|
|
"params" => params_raw = Some(val),
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!(
|
|
"unknown onto_mcp_tool key: {other}; expected name, description, \
|
|
category, params"
|
|
),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
let name = name.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_mcp_tool] requires name = \"ontoref_xxx\"",
|
|
)
|
|
})?;
|
|
let description = description.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_mcp_tool] requires description = \"...\"",
|
|
)
|
|
})?;
|
|
|
|
let params = parse_params(params_raw.as_deref().unwrap_or(""))?;
|
|
|
|
let name_lit = LitStr::new(&name, Span::call_site());
|
|
let desc_lit = LitStr::new(&description, Span::call_site());
|
|
let category_lit = LitStr::new(&category, Span::call_site());
|
|
|
|
let param_exprs: Vec<_> = params
|
|
.iter()
|
|
.map(|p| {
|
|
let n = LitStr::new(&p.name, Span::call_site());
|
|
let k = LitStr::new(&p.kind, Span::call_site());
|
|
let c = LitStr::new(&p.constraint, Span::call_site());
|
|
let d = LitStr::new(&p.description, Span::call_site());
|
|
quote! {
|
|
::ontoref_ontology::ApiParam { name: #n, kind: #k, constraint: #c, description: #d }
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let unique = name
|
|
.bytes()
|
|
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64));
|
|
let static_ident =
|
|
syn::Ident::new(&format!("__ONTOREF_MCP_TOOL_{unique:x}"), Span::call_site());
|
|
|
|
Ok(quote! {
|
|
::inventory::submit! {
|
|
::ontoref_ontology::McpToolEntry {
|
|
name: #name_lit,
|
|
description: #desc_lit,
|
|
category: #category_lit,
|
|
params: &[#(#param_exprs),*],
|
|
source_file: file!(),
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
#[allow(non_upper_case_globals, dead_code)]
|
|
static #static_ident: () = ();
|
|
|
|
#item
|
|
})
|
|
}
|
|
|
|
// ── Attribute parsing
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/// Parsed contents of a single `#[onto(...)]` attribute.
|
|
#[derive(Default)]
|
|
struct OntoAttr {
|
|
id: Option<String>,
|
|
name: Option<String>,
|
|
level: Option<String>,
|
|
pole: Option<String>,
|
|
description: Option<String>,
|
|
adrs: Vec<String>,
|
|
paths: Vec<String>,
|
|
invariant: Option<bool>,
|
|
}
|
|
|
|
/// Parse `key = "value"` pairs from a `#[onto(k = "v", ...)]` attribute.
|
|
fn parse_onto_attr(attr: &syn::Attribute) -> syn::Result<OntoAttr> {
|
|
let mut out = OntoAttr::default();
|
|
|
|
let args = attr.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)?;
|
|
|
|
for kv in &args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
|
|
match key.as_str() {
|
|
"id" | "name" | "level" | "pole" | "description" => {
|
|
let s = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
match key.as_str() {
|
|
"id" => out.id = Some(s),
|
|
"name" => out.name = Some(s),
|
|
"level" => out.level = Some(s),
|
|
"pole" => out.pole = Some(s),
|
|
"description" => out.description = Some(s),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
"adrs" => {
|
|
// adrs = "adr-001, adr-002" — comma-separated list in a single string
|
|
let s = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
out.adrs.extend(s.split(',').map(|a| a.trim().to_owned()));
|
|
}
|
|
"paths" => {
|
|
// paths = "crates/foo/, docs/foo.md" — comma-separated artifact paths
|
|
let s = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
out.paths.extend(
|
|
s.split(',')
|
|
.map(|p| p.trim().to_owned())
|
|
.filter(|p| !p.is_empty()),
|
|
);
|
|
}
|
|
"invariant" => {
|
|
out.invariant =
|
|
Some(lit_bool(&kv.value).ok_or_else(|| {
|
|
syn::Error::new_spanned(&kv.value, "expected bool literal")
|
|
})?);
|
|
}
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!(
|
|
"unknown onto key: {other}; expected id, name, level, pole, description, \
|
|
adrs, paths, invariant"
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Extract the text of a `#[doc = "..."]` attribute, or `None` if it is empty
|
|
/// or not a doc attribute.
|
|
fn doc_attr_text(attr: syn::Attribute) -> Option<String> {
|
|
if !attr.path().is_ident("doc") {
|
|
return None;
|
|
}
|
|
let syn::Meta::NameValue(mnv) = attr.meta else {
|
|
return None;
|
|
};
|
|
let Expr::Lit(ExprLit {
|
|
lit: Lit::Str(s), ..
|
|
}) = mnv.value
|
|
else {
|
|
return None;
|
|
};
|
|
let t = s.value().trim().to_owned();
|
|
if t.is_empty() {
|
|
None
|
|
} else {
|
|
Some(t)
|
|
}
|
|
}
|
|
|
|
fn lit_str(expr: &Expr) -> Option<String> {
|
|
if let Expr::Lit(ExprLit {
|
|
lit: Lit::Str(s), ..
|
|
}) = expr
|
|
{
|
|
Some(s.value())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn lit_bool(expr: &Expr) -> Option<bool> {
|
|
if let Expr::Lit(ExprLit {
|
|
lit: Lit::Bool(b), ..
|
|
}) = expr
|
|
{
|
|
Some(b.value())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
// ── #[derive(OntologyNode)]
|
|
// ───────────────────────────────────────────────────
|
|
|
|
/// Derive macro that registers a Rust type as a
|
|
/// `NodeContribution` (see `ontoref_ontology::contrib::NodeContribution`).
|
|
///
|
|
/// The `#[onto(...)]` attribute declares the node's identity in the ontology
|
|
/// DAG. All `#[onto]` helper attributes on the type are merged in declaration
|
|
/// order — later keys overwrite earlier ones, except `adrs` which concatenates.
|
|
///
|
|
/// # Required attributes
|
|
/// - `id = "my-node-id"` — unique node identifier (must match NCL convention)
|
|
/// - `level = "Practice"` — `AbstractionLevel` variant name
|
|
/// - `pole = "Yang"` — `Pole` variant name
|
|
///
|
|
/// # Optional attributes
|
|
/// - `name = "Human Name"` — display name (defaults to `id` if absent)
|
|
/// - `description = "..."` — one-line description; omit to fall back to the
|
|
/// `///` doc comment on the type
|
|
/// - `adrs = "adr-001, adr-002"` — comma-separated ADR references (accumulates
|
|
/// across multiple `#[onto]` attributes)
|
|
/// - `paths = "crates/foo/, docs/bar.md"` — comma-separated artifact paths
|
|
/// (accumulates across multiple `#[onto]` attributes)
|
|
/// - `invariant = true` — mark node as invariant (default: false)
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// /// Caches nickel export results to avoid re-eval on unchanged files.
|
|
/// #[derive(OntologyNode)]
|
|
/// #[onto(id = "ncl-cache", name = "NCL Cache", level = "Practice", pole = "Yang")]
|
|
/// #[onto(adrs = "adr-002, adr-004", paths = "crates/ontoref-daemon/src/cache.rs")]
|
|
/// pub struct NclCache { /* ... */ }
|
|
/// ```
|
|
#[proc_macro_derive(OntologyNode, attributes(onto))]
|
|
pub fn derive_ontology_node(input: TokenStream) -> TokenStream {
|
|
let ast = parse_macro_input!(input as DeriveInput);
|
|
match expand_ontology_node(ast) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
fn expand_ontology_node(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
|
// Merge all #[onto(...)] attributes on the type.
|
|
let mut merged = OntoAttr::default();
|
|
for attr in ast.attrs.iter().filter(|a| a.path().is_ident("onto")) {
|
|
let parsed = parse_onto_attr(attr)?;
|
|
if parsed.id.is_some() {
|
|
merged.id = parsed.id;
|
|
}
|
|
if parsed.level.is_some() {
|
|
merged.level = parsed.level;
|
|
}
|
|
if parsed.pole.is_some() {
|
|
merged.pole = parsed.pole;
|
|
}
|
|
if parsed.description.is_some() {
|
|
merged.description = parsed.description;
|
|
}
|
|
if parsed.invariant.is_some() {
|
|
merged.invariant = parsed.invariant;
|
|
}
|
|
merged.adrs.extend(parsed.adrs);
|
|
merged.paths.extend(parsed.paths);
|
|
if parsed.name.is_some() {
|
|
merged.name = parsed.name;
|
|
}
|
|
}
|
|
|
|
let id = merged.id.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(OntologyNode)] requires #[onto(id = \"...\")]",
|
|
)
|
|
})?;
|
|
let level_str = merged.level.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(OntologyNode)] requires #[onto(level = \"...\")]",
|
|
)
|
|
})?;
|
|
let pole_str = merged.pole.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(OntologyNode)] requires #[onto(pole = \"...\")]",
|
|
)
|
|
})?;
|
|
|
|
// Validate level and pole at compile time via known variant names.
|
|
let level_variant = match level_str.as_str() {
|
|
"Axiom" => quote! { ::ontoref_ontology::AbstractionLevel::Axiom },
|
|
"Tension" => quote! { ::ontoref_ontology::AbstractionLevel::Tension },
|
|
"Practice" => quote! { ::ontoref_ontology::AbstractionLevel::Practice },
|
|
"Project" => quote! { ::ontoref_ontology::AbstractionLevel::Project },
|
|
"Moment" => quote! { ::ontoref_ontology::AbstractionLevel::Moment },
|
|
other => {
|
|
return Err(syn::Error::new(
|
|
Span::call_site(),
|
|
format!(
|
|
"unknown AbstractionLevel: {other}; expected one of Axiom, Tension, Practice, \
|
|
Project, Moment"
|
|
),
|
|
))
|
|
}
|
|
};
|
|
|
|
let pole_variant = match pole_str.as_str() {
|
|
"Yang" => quote! { ::ontoref_ontology::Pole::Yang },
|
|
"Yin" => quote! { ::ontoref_ontology::Pole::Yin },
|
|
"Spiral" => quote! { ::ontoref_ontology::Pole::Spiral },
|
|
other => {
|
|
return Err(syn::Error::new(
|
|
Span::call_site(),
|
|
format!("unknown Pole: {other}; expected one of Yang, Yin, Spiral"),
|
|
))
|
|
}
|
|
};
|
|
|
|
// description: explicit attribute wins; fall back to /// doc comment on the
|
|
// type.
|
|
let doc_desc_type: Option<String> = ast.attrs.iter().cloned().find_map(doc_attr_text);
|
|
let description = merged.description.or(doc_desc_type).unwrap_or_default();
|
|
|
|
// name: explicit attribute wins; fall back to id.
|
|
let name = merged.name.unwrap_or_else(|| id.clone());
|
|
|
|
let invariant = merged.invariant.unwrap_or(false);
|
|
let adrs: Vec<LitStr> = merged
|
|
.adrs
|
|
.iter()
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| LitStr::new(s, Span::call_site()))
|
|
.collect();
|
|
let path_lits: Vec<LitStr> = merged
|
|
.paths
|
|
.iter()
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| LitStr::new(s, Span::call_site()))
|
|
.collect();
|
|
|
|
let id_lit = LitStr::new(&id, Span::call_site());
|
|
let name_lit = LitStr::new(&name, Span::call_site());
|
|
let description_lit = LitStr::new(&description, Span::call_site());
|
|
|
|
// Derive a unique identifier for the inventory submission from the type name.
|
|
let type_name = &ast.ident;
|
|
let submission_ident = syn::Ident::new(
|
|
&format!("__ONTOREF_NODE_CONTRIB_{}", type_name),
|
|
Span::call_site(),
|
|
);
|
|
|
|
Ok(quote! {
|
|
#[automatically_derived]
|
|
impl #type_name {
|
|
/// Returns the ontology node declared by `#[derive(OntologyNode)]`.
|
|
pub fn ontology_node() -> ::ontoref_ontology::Node {
|
|
::ontoref_ontology::Node {
|
|
id: #id_lit.to_owned(),
|
|
name: #name_lit.to_owned(),
|
|
pole: #pole_variant,
|
|
level: #level_variant,
|
|
description: #description_lit.to_owned(),
|
|
invariant: #invariant,
|
|
artifact_paths: vec![#(#path_lits.to_owned()),*],
|
|
adrs: vec![#(#adrs.to_owned()),*],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "derive")]
|
|
::inventory::submit! {
|
|
::ontoref_ontology::NodeContribution {
|
|
supplier: <#type_name>::ontology_node,
|
|
}
|
|
}
|
|
|
|
// Unique static to prevent duplicate submissions at link time.
|
|
#[cfg(feature = "derive")]
|
|
#[doc(hidden)]
|
|
static #submission_ident: () = ();
|
|
})
|
|
}
|
|
|
|
/// Extract a `#[serde(rename = "...")]` value from a field's attributes.
|
|
/// Returns `None` if no serde rename is present.
|
|
fn serde_rename_of(field: &syn::Field) -> Option<String> {
|
|
use syn::punctuated::Punctuated;
|
|
use syn::MetaNameValue;
|
|
for attr in &field.attrs {
|
|
if !attr.path().is_ident("serde") {
|
|
continue;
|
|
}
|
|
let Ok(args) =
|
|
attr.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)
|
|
else {
|
|
continue;
|
|
};
|
|
let renamed = args
|
|
.iter()
|
|
.find(|kv| kv.path.is_ident("rename"))
|
|
.and_then(|kv| lit_str(&kv.value));
|
|
if renamed.is_some() {
|
|
return renamed;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ── #[derive(ConfigFields)]
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
/// Derive macro that extracts serde field names from a config struct and
|
|
/// registers them via `inventory::submit!` at link time.
|
|
///
|
|
/// Annotate the struct with `#[config_section(id = "...", ncl_file = "...")]`
|
|
/// to declare which NCL section this struct reads. The macro then emits an
|
|
/// `inventory::submit!(ConfigFieldsEntry { ... })` for each annotated struct,
|
|
/// allowing ontoref to compare declared Rust fields against NCL section exports
|
|
/// without running the daemon.
|
|
///
|
|
/// Respects `#[serde(rename = "...")]` — the registered field name is the
|
|
/// JSON key serde would deserialize, not the Rust identifier.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// #[derive(serde::Deserialize, ConfigFields)]
|
|
/// #[config_section(id = "server", ncl_file = "config/server.ncl")]
|
|
/// pub struct ServerConfig {
|
|
/// pub host: String,
|
|
/// #[serde(rename = "listen_port")]
|
|
/// pub port: u16,
|
|
/// }
|
|
/// // Registers: section_id="server", fields=["host","listen_port"]
|
|
/// ```
|
|
#[proc_macro_derive(ConfigFields, attributes(config_section))]
|
|
pub fn derive_config_fields(input: TokenStream) -> TokenStream {
|
|
let ast = parse_macro_input!(input as DeriveInput);
|
|
match expand_config_fields(ast) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
fn expand_config_fields(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
|
// Parse #[config_section(id = "...", ncl_file = "...")]
|
|
let mut section_id: Option<String> = None;
|
|
let mut ncl_file: Option<String> = None;
|
|
|
|
for attr in ast
|
|
.attrs
|
|
.iter()
|
|
.filter(|a| a.path().is_ident("config_section"))
|
|
{
|
|
let args =
|
|
attr.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)?;
|
|
for kv in &args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
let val = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
match key.as_str() {
|
|
"id" => section_id = Some(val),
|
|
"ncl_file" => ncl_file = Some(val),
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!("unknown config_section key: {other}; expected id or ncl_file"),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let section_id = section_id.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(ConfigFields)] requires #[config_section(id = \"...\", ncl_file = \"...\")]",
|
|
)
|
|
})?;
|
|
let ncl_file = ncl_file.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(ConfigFields)] requires #[config_section(ncl_file = \"...\")]",
|
|
)
|
|
})?;
|
|
|
|
// Extract named fields, respecting #[serde(rename = "...")].
|
|
let fields = match &ast.data {
|
|
syn::Data::Struct(s) => match &s.fields {
|
|
syn::Fields::Named(named) => &named.named,
|
|
_ => {
|
|
return Err(syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(ConfigFields)] requires a struct with named fields",
|
|
))
|
|
}
|
|
},
|
|
_ => {
|
|
return Err(syn::Error::new(
|
|
Span::call_site(),
|
|
"#[derive(ConfigFields)] can only be used on structs",
|
|
))
|
|
}
|
|
};
|
|
|
|
let field_names: Vec<String> = fields
|
|
.iter()
|
|
.map(|f| {
|
|
serde_rename_of(f)
|
|
.unwrap_or_else(|| f.ident.as_ref().map(|i| i.to_string()).unwrap_or_default())
|
|
})
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
|
|
let field_lits: Vec<LitStr> = field_names
|
|
.iter()
|
|
.map(|s| LitStr::new(s, Span::call_site()))
|
|
.collect();
|
|
|
|
let section_lit = LitStr::new(§ion_id, Span::call_site());
|
|
let ncl_file_lit = LitStr::new(&ncl_file, Span::call_site());
|
|
|
|
let type_name = &ast.ident;
|
|
let struct_name_lit = LitStr::new(&type_name.to_string(), Span::call_site());
|
|
|
|
let unique = {
|
|
let s = format!("{section_id}{ncl_file}");
|
|
s.bytes()
|
|
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64))
|
|
};
|
|
let static_ident = syn::Ident::new(
|
|
&format!("__ONTOREF_CONFIG_FIELDS_{unique:x}"),
|
|
Span::call_site(),
|
|
);
|
|
|
|
Ok(quote! {
|
|
::inventory::submit! {
|
|
::ontoref_ontology::ConfigFieldsEntry {
|
|
section_id: #section_lit,
|
|
ncl_file: #ncl_file_lit,
|
|
struct_name: #struct_name_lit,
|
|
fields: &[#(#field_lits),*],
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
#[allow(non_upper_case_globals, dead_code)]
|
|
static #static_ident: () = ();
|
|
})
|
|
}
|
|
|
|
// ── #[onto_validates]
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/// Attribute macro for test functions: registers which ontology practices and
|
|
/// ADRs the test validates.
|
|
///
|
|
/// Only active under `#[cfg(test)]` — zero production binary impact.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// #[onto_validates(practice = "ncl-cache", adr = "adr-002")]
|
|
/// #[test]
|
|
/// fn cache_returns_stale_on_missing_file() { /* ... */ }
|
|
/// ```
|
|
#[proc_macro_attribute]
|
|
pub fn onto_validates(args: TokenStream, input: TokenStream) -> TokenStream {
|
|
match expand_onto_validates(args, input) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
fn expand_onto_validates(
|
|
args: TokenStream,
|
|
input: TokenStream,
|
|
) -> syn::Result<proc_macro2::TokenStream> {
|
|
let item = proc_macro2::TokenStream::from(input);
|
|
|
|
// Parse key=value pairs from the attribute args.
|
|
let kv_args = syn::parse::Parser::parse(
|
|
Punctuated::<MetaNameValue, Token![,]>::parse_terminated,
|
|
args,
|
|
)?;
|
|
|
|
let mut practice_id: Option<String> = None;
|
|
let mut adr_id: Option<String> = None;
|
|
|
|
for kv in &kv_args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
match key.as_str() {
|
|
"practice" => {
|
|
practice_id = Some(
|
|
lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string"))?,
|
|
)
|
|
}
|
|
"adr" => {
|
|
adr_id = Some(
|
|
lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string"))?,
|
|
)
|
|
}
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!("unknown onto_validates key: {other}; expected 'practice' or 'adr'"),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
let practice_tokens = match &practice_id {
|
|
Some(p) => quote! { ::core::option::Option::Some(#p) },
|
|
None => quote! { ::core::option::Option::None },
|
|
};
|
|
let adr_tokens = match &adr_id {
|
|
Some(a) => quote! { ::core::option::Option::Some(#a) },
|
|
None => quote! { ::core::option::Option::None },
|
|
};
|
|
|
|
// We need a unique ident for the inventory submission per call site.
|
|
// Use a uuid-like approach via the args hash to avoid collisions.
|
|
let hash = {
|
|
let s = format!(
|
|
"{}{}",
|
|
practice_id.as_deref().unwrap_or(""),
|
|
adr_id.as_deref().unwrap_or("")
|
|
);
|
|
// Simple djb2 hash for uniqueness in the ident.
|
|
s.bytes()
|
|
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64))
|
|
};
|
|
let submission_ident = syn::Ident::new(
|
|
&format!("__ONTOREF_TEST_COVERAGE_{hash:x}"),
|
|
Span::call_site(),
|
|
);
|
|
|
|
Ok(quote! {
|
|
#[cfg(all(test, feature = "derive"))]
|
|
::inventory::submit! {
|
|
::ontoref_ontology::TestCoverage {
|
|
practice_id: #practice_tokens,
|
|
adr_id: #adr_tokens,
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, feature = "derive"))]
|
|
#[doc(hidden)]
|
|
static #submission_ident: () = ();
|
|
|
|
// Emit the original item unchanged.
|
|
#item
|
|
})
|
|
}
|
|
|
|
// ── #[onto_operation(...)]
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
/// Attribute macro for operation handlers in the operations runtime (ADR-024).
|
|
///
|
|
/// Registers the operation in `ontoref_ops::registry` at link time via
|
|
/// `inventory::submit!(OperationEntry { ... })`. The annotated function is
|
|
/// emitted unchanged.
|
|
///
|
|
/// Required keys: `id`, `validation_sla`. Optional: `description` (falls
|
|
/// back to the function's `///` doc comment), `actor_policy`, `precondition`,
|
|
/// `effects`, `render_paths`, `witness_shape`, `crdt_strategy`, `feature`.
|
|
///
|
|
/// Values are strings. Comma-separated and semicolon-separated forms are
|
|
/// allowed for list-shaped fields (`actor_policy`, `effects`, `render_paths`).
|
|
/// Validation against the `catalog/operations/<id>.ncl` declaration is
|
|
/// performed by the runtime, not the macro — coherence checks live in O6.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// #[onto_operation(
|
|
/// id = "move_fsm_state",
|
|
/// description = "Transition an FSM dimension's current_state",
|
|
/// actor_policy = "admin, developer",
|
|
/// validation_sla = "Synchronous",
|
|
/// effects = "Update:FsmDimension:current_state:adoption",
|
|
/// render_paths = ".ontology/state.ncl",
|
|
/// witness_shape = "state_transition",
|
|
/// )]
|
|
/// fn move_fsm_state(inputs: MoveFsmStateInputs, ctx: &OpContext) -> Result<OpOutput> { ... }
|
|
/// ```
|
|
#[proc_macro_attribute]
|
|
pub fn onto_operation(args: TokenStream, input: TokenStream) -> TokenStream {
|
|
match expand_onto_operation(args.into(), input.into()) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
struct OntoOperationAttr {
|
|
id: String,
|
|
description: String,
|
|
validation_sla: String,
|
|
actor_policy: Vec<String>,
|
|
precondition: String,
|
|
pre_validators: Vec<String>,
|
|
inline_validators: Vec<String>,
|
|
post_validators: Vec<String>,
|
|
effects: Vec<String>,
|
|
render_paths: Vec<String>,
|
|
witness_shape: String,
|
|
crdt_strategy: String,
|
|
feature: String,
|
|
}
|
|
|
|
fn expand_onto_operation(
|
|
args: proc_macro2::TokenStream,
|
|
input: proc_macro2::TokenStream,
|
|
) -> syn::Result<proc_macro2::TokenStream> {
|
|
let parsed_fn = syn::parse2::<ItemFn>(input.clone()).ok();
|
|
let doc_desc: Option<String> = parsed_fn
|
|
.as_ref()
|
|
.and_then(|fn_item| fn_item.attrs.iter().cloned().find_map(doc_attr_text));
|
|
let fn_ident_name = parsed_fn
|
|
.as_ref()
|
|
.map(|fn_item| fn_item.sig.ident.to_string());
|
|
|
|
let kv_args = syn::parse::Parser::parse2(
|
|
Punctuated::<MetaNameValue, Token![,]>::parse_terminated,
|
|
args,
|
|
)?;
|
|
|
|
let mut id: Option<String> = None;
|
|
let mut description: Option<String> = None;
|
|
let mut validation_sla: Option<String> = None;
|
|
let mut actor_policy: Vec<String> = Vec::new();
|
|
let mut precondition = String::new();
|
|
let mut pre_validators: Vec<String> = Vec::new();
|
|
let mut inline_validators: Vec<String> = Vec::new();
|
|
let mut post_validators: Vec<String> = Vec::new();
|
|
let mut effects: Vec<String> = Vec::new();
|
|
let mut render_paths: Vec<String> = Vec::new();
|
|
let mut witness_shape = String::new();
|
|
let mut crdt_strategy = String::new();
|
|
let mut feature = String::new();
|
|
|
|
for kv in &kv_args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
let val = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
|
|
match key.as_str() {
|
|
"id" => id = Some(val),
|
|
"description" => description = Some(val),
|
|
"validation_sla" => match val.as_str() {
|
|
"Synchronous" | "Background" | "EventualWithin" => validation_sla = Some(val),
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.value,
|
|
format!(
|
|
"unknown validation_sla '{other}'; expected Synchronous | \
|
|
EventualWithin | Background"
|
|
),
|
|
));
|
|
}
|
|
},
|
|
"actor_policy" => actor_policy = split_csv(&val),
|
|
"precondition" => precondition = val,
|
|
"pre_validators" => pre_validators = split_csv(&val),
|
|
"inline_validators" => inline_validators = split_csv(&val),
|
|
"post_validators" => post_validators = split_csv(&val),
|
|
"effects" => effects = split_semicolon(&val),
|
|
"render_paths" => render_paths = split_csv(&val),
|
|
"witness_shape" => witness_shape = val,
|
|
"crdt_strategy" => crdt_strategy = val,
|
|
"feature" => feature = val,
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!("unknown onto_operation key: {other}"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
let id = id.ok_or_else(|| {
|
|
syn::Error::new(Span::call_site(), "#[onto_operation] requires id = \"...\"")
|
|
})?;
|
|
let validation_sla = validation_sla.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_operation] requires validation_sla = \"Synchronous\" | \"EventualWithin\" | \
|
|
\"Background\"",
|
|
)
|
|
})?;
|
|
let desc = description.or(doc_desc).ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_operation] requires description = \"...\" or a /// doc comment on the function",
|
|
)
|
|
})?;
|
|
|
|
let attr = OntoOperationAttr {
|
|
id,
|
|
description: desc,
|
|
validation_sla,
|
|
actor_policy,
|
|
precondition,
|
|
pre_validators,
|
|
inline_validators,
|
|
post_validators,
|
|
effects,
|
|
render_paths,
|
|
witness_shape,
|
|
crdt_strategy,
|
|
feature,
|
|
};
|
|
let fn_name = fn_ident_name.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_operation] must be applied to a function — the function pointer is used as \
|
|
the op's execute field",
|
|
)
|
|
})?;
|
|
Ok(emit_onto_operation(attr, &fn_name, input))
|
|
}
|
|
|
|
fn split_semicolon(s: &str) -> Vec<String> {
|
|
s.split(';')
|
|
.map(|p| p.trim().to_owned())
|
|
.filter(|p| !p.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn emit_onto_operation(
|
|
attr: OntoOperationAttr,
|
|
fn_name: &str,
|
|
item: proc_macro2::TokenStream,
|
|
) -> proc_macro2::TokenStream {
|
|
let id = LitStr::new(&attr.id, Span::call_site());
|
|
let desc = LitStr::new(&attr.description, Span::call_site());
|
|
let sla = LitStr::new(&attr.validation_sla, Span::call_site());
|
|
let precondition = LitStr::new(&attr.precondition, Span::call_site());
|
|
let witness_shape = LitStr::new(&attr.witness_shape, Span::call_site());
|
|
let crdt_strategy = LitStr::new(&attr.crdt_strategy, Span::call_site());
|
|
let feature = LitStr::new(&attr.feature, Span::call_site());
|
|
|
|
let actor_lits: Vec<LitStr> = attr
|
|
.actor_policy
|
|
.iter()
|
|
.map(|a| LitStr::new(a, Span::call_site()))
|
|
.collect();
|
|
let pre_validator_lits: Vec<LitStr> = attr
|
|
.pre_validators
|
|
.iter()
|
|
.map(|v| LitStr::new(v, Span::call_site()))
|
|
.collect();
|
|
let inline_validator_lits: Vec<LitStr> = attr
|
|
.inline_validators
|
|
.iter()
|
|
.map(|v| LitStr::new(v, Span::call_site()))
|
|
.collect();
|
|
let post_validator_lits: Vec<LitStr> = attr
|
|
.post_validators
|
|
.iter()
|
|
.map(|v| LitStr::new(v, Span::call_site()))
|
|
.collect();
|
|
let effect_lits: Vec<LitStr> = attr
|
|
.effects
|
|
.iter()
|
|
.map(|e| LitStr::new(e, Span::call_site()))
|
|
.collect();
|
|
let render_path_lits: Vec<LitStr> = attr
|
|
.render_paths
|
|
.iter()
|
|
.map(|p| LitStr::new(p, Span::call_site()))
|
|
.collect();
|
|
|
|
let unique = attr
|
|
.id
|
|
.bytes()
|
|
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64));
|
|
let static_ident = syn::Ident::new(
|
|
&format!("__ONTOREF_OPERATION_{unique:x}"),
|
|
Span::call_site(),
|
|
);
|
|
let execute_ident = syn::Ident::new(fn_name, Span::call_site());
|
|
|
|
quote! {
|
|
#item
|
|
|
|
::inventory::submit! {
|
|
::ontoref_ops::registry::OperationEntry {
|
|
id: #id,
|
|
description: #desc,
|
|
validation_sla: #sla,
|
|
precondition: #precondition,
|
|
pre_validators: &[#(#pre_validator_lits),*],
|
|
inline_validators: &[#(#inline_validator_lits),*],
|
|
post_validators: &[#(#post_validator_lits),*],
|
|
actor_policy: &[#(#actor_lits),*],
|
|
effects: &[#(#effect_lits),*],
|
|
render_paths: &[#(#render_path_lits),*],
|
|
witness_shape: #witness_shape,
|
|
crdt_strategy: #crdt_strategy,
|
|
feature: #feature,
|
|
source_file: file!(),
|
|
execute: #execute_ident,
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
#[allow(non_upper_case_globals, dead_code)]
|
|
static #static_ident: () = ();
|
|
}
|
|
}
|
|
|
|
// ── #[onto_validator(...)]
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
/// Attribute macro for validator functions in the validation framework
|
|
/// (ADR-026 / D13).
|
|
///
|
|
/// Registers the validator in `ontoref_ops::validation` at link time via
|
|
/// `inventory::submit!(ValidatorEntry { ... })`. The annotated function
|
|
/// is emitted unchanged. The function signature MUST match
|
|
/// `fn(&Slice, &ValidationCtx) -> Verdict`.
|
|
///
|
|
/// Required keys: `id`, `category` (`"Structural"` or `"Contextual"`).
|
|
/// Optional: `description` (falls back to the function's `///` doc
|
|
/// comment), `slice_query`, `predicate_ref` (defaults to the function's
|
|
/// own name), `feature`.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// #[onto_validator(
|
|
/// id = "fsm_transition_allowed",
|
|
/// description = "Reject move_fsm_state when the target is not in the dimension's transition graph",
|
|
/// category = "Contextual",
|
|
/// slice_query = "fsm_dimension_by_id",
|
|
/// )]
|
|
/// fn fsm_transition_allowed(slice: &Slice, ctx: &ValidationCtx) -> Verdict { ... }
|
|
/// ```
|
|
#[proc_macro_attribute]
|
|
pub fn onto_validator(args: TokenStream, input: TokenStream) -> TokenStream {
|
|
match expand_onto_validator(args.into(), input.into()) {
|
|
Ok(ts) => ts.into(),
|
|
Err(err) => err.to_compile_error().into(),
|
|
}
|
|
}
|
|
|
|
struct OntoValidatorAttr {
|
|
id: String,
|
|
description: String,
|
|
category: String,
|
|
slice_query: String,
|
|
predicate_ref: String,
|
|
feature: String,
|
|
}
|
|
|
|
fn expand_onto_validator(
|
|
args: proc_macro2::TokenStream,
|
|
input: proc_macro2::TokenStream,
|
|
) -> syn::Result<proc_macro2::TokenStream> {
|
|
let parsed_fn = syn::parse2::<ItemFn>(input.clone()).ok();
|
|
let doc_desc: Option<String> = parsed_fn
|
|
.as_ref()
|
|
.and_then(|fn_item| fn_item.attrs.iter().cloned().find_map(doc_attr_text));
|
|
let fn_name: Option<String> = parsed_fn
|
|
.as_ref()
|
|
.map(|fn_item| fn_item.sig.ident.to_string());
|
|
|
|
let kv_args = syn::parse::Parser::parse2(
|
|
Punctuated::<MetaNameValue, Token![,]>::parse_terminated,
|
|
args,
|
|
)?;
|
|
|
|
let mut id: Option<String> = None;
|
|
let mut description: Option<String> = None;
|
|
let mut category: Option<String> = None;
|
|
let mut slice_query = String::new();
|
|
let mut predicate_ref: Option<String> = None;
|
|
let mut feature = String::new();
|
|
|
|
for kv in &kv_args {
|
|
let key = kv
|
|
.path
|
|
.get_ident()
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.path, "expected identifier"))?
|
|
.to_string();
|
|
let val = lit_str(&kv.value)
|
|
.ok_or_else(|| syn::Error::new_spanned(&kv.value, "expected string literal"))?;
|
|
|
|
match key.as_str() {
|
|
"id" => id = Some(val),
|
|
"description" => description = Some(val),
|
|
"category" => match val.as_str() {
|
|
"Structural" | "Contextual" => category = Some(val),
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.value,
|
|
format!("unknown category '{other}'; expected Structural | Contextual"),
|
|
));
|
|
}
|
|
},
|
|
"slice_query" => slice_query = val,
|
|
"predicate_ref" => predicate_ref = Some(val),
|
|
"feature" => feature = val,
|
|
other => {
|
|
return Err(syn::Error::new_spanned(
|
|
&kv.path,
|
|
format!("unknown onto_validator key: {other}"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
let id = id.ok_or_else(|| {
|
|
syn::Error::new(Span::call_site(), "#[onto_validator] requires id = \"...\"")
|
|
})?;
|
|
let category = category.ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_validator] requires category = \"Structural\" | \"Contextual\"",
|
|
)
|
|
})?;
|
|
let desc = description.or(doc_desc).ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_validator] requires description = \"...\" or a /// doc comment on the function",
|
|
)
|
|
})?;
|
|
let predicate_ref = predicate_ref.or(fn_name).ok_or_else(|| {
|
|
syn::Error::new(
|
|
Span::call_site(),
|
|
"#[onto_validator] could not infer predicate_ref — supply it explicitly when applied \
|
|
to a non-fn item",
|
|
)
|
|
})?;
|
|
|
|
let attr = OntoValidatorAttr {
|
|
id,
|
|
description: desc,
|
|
category,
|
|
slice_query,
|
|
predicate_ref,
|
|
feature,
|
|
};
|
|
Ok(emit_onto_validator(attr, input))
|
|
}
|
|
|
|
fn emit_onto_validator(
|
|
attr: OntoValidatorAttr,
|
|
item: proc_macro2::TokenStream,
|
|
) -> proc_macro2::TokenStream {
|
|
let id = LitStr::new(&attr.id, Span::call_site());
|
|
let desc = LitStr::new(&attr.description, Span::call_site());
|
|
let category = LitStr::new(&attr.category, Span::call_site());
|
|
let slice_query = LitStr::new(&attr.slice_query, Span::call_site());
|
|
let predicate_ref_lit = LitStr::new(&attr.predicate_ref, Span::call_site());
|
|
let predicate_ident = syn::Ident::new(&attr.predicate_ref, Span::call_site());
|
|
let _feature = LitStr::new(&attr.feature, Span::call_site());
|
|
|
|
let unique = attr
|
|
.id
|
|
.bytes()
|
|
.fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64));
|
|
let static_ident = syn::Ident::new(
|
|
&format!("__ONTOREF_VALIDATOR_{unique:x}"),
|
|
Span::call_site(),
|
|
);
|
|
|
|
quote! {
|
|
#item
|
|
|
|
::inventory::submit! {
|
|
::ontoref_ops::validation::ValidatorEntry {
|
|
id: #id,
|
|
description: #desc,
|
|
category: #category,
|
|
slice_query: #slice_query,
|
|
predicate_ref: #predicate_ref_lit,
|
|
source_file: file!(),
|
|
predicate: #predicate_ident,
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
#[allow(non_upper_case_globals, dead_code)]
|
|
static #static_ident: () = ();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
mod onto_operation {
|
|
mod expand {
|
|
use quote::quote;
|
|
|
|
use crate::expand_onto_operation;
|
|
|
|
fn must_expand(
|
|
args: proc_macro2::TokenStream,
|
|
input: proc_macro2::TokenStream,
|
|
) -> String {
|
|
expand_onto_operation(args, input)
|
|
.expect("expansion succeeds")
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_attrs_emit_inventory_submit() {
|
|
let args = quote! {
|
|
id = "test_op",
|
|
description = "minimal test",
|
|
validation_sla = "Synchronous"
|
|
};
|
|
let input = quote! { fn handler(input: TestInput) {} };
|
|
let s = must_expand(args, input);
|
|
|
|
assert!(s.contains("inventory"), "must reference inventory crate");
|
|
assert!(s.contains("submit"), "must call submit! macro");
|
|
assert!(
|
|
s.contains("OperationEntry"),
|
|
"must construct OperationEntry"
|
|
);
|
|
assert!(
|
|
s.contains("ontoref_ops :: registry"),
|
|
"must use ontoref-ops registry path"
|
|
);
|
|
assert!(s.contains("\"test_op\""), "must embed the op id");
|
|
assert!(s.contains("\"minimal test\""), "must embed description");
|
|
assert!(s.contains("\"Synchronous\""), "must embed validation_sla");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_attr_key() {
|
|
let args = quote! {
|
|
id = "x",
|
|
description = "y",
|
|
validation_sla = "Background",
|
|
bogus = "z"
|
|
};
|
|
let input = quote! { fn h() {} };
|
|
let err = expand_onto_operation(args, input).expect_err("unknown key rejected");
|
|
assert!(err.to_string().contains("unknown onto_operation key"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_sla_value() {
|
|
let args = quote! {
|
|
id = "x",
|
|
description = "y",
|
|
validation_sla = "Never"
|
|
};
|
|
let input = quote! { fn h() {} };
|
|
let err = expand_onto_operation(args, input).expect_err("bad sla rejected");
|
|
assert!(err.to_string().contains("unknown validation_sla"));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_id_and_sla() {
|
|
let args = quote! { description = "y" };
|
|
let input = quote! { fn h() {} };
|
|
let err = expand_onto_operation(args, input).expect_err("missing id");
|
|
let s = err.to_string();
|
|
assert!(s.contains("requires id"));
|
|
}
|
|
|
|
#[test]
|
|
fn description_falls_back_to_doc_comment() {
|
|
let args = quote! {
|
|
id = "doc_fallback",
|
|
validation_sla = "Synchronous"
|
|
};
|
|
let input = quote! {
|
|
/// Operation that demonstrates doc comment fallback
|
|
fn h() {}
|
|
};
|
|
let s = must_expand(args, input);
|
|
assert!(
|
|
s.contains("Operation that demonstrates doc comment fallback"),
|
|
"must lift /// doc comment as description fallback"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn list_fields_split_correctly() {
|
|
let args = quote! {
|
|
id = "list_test",
|
|
description = "x",
|
|
validation_sla = "Synchronous",
|
|
actor_policy = "admin, developer",
|
|
render_paths = ".ontology/state.ncl, adrs/adr-001.ncl",
|
|
effects = "Update:FsmDimension:current_state:adoption; Insert:OndaodEvaluation::"
|
|
};
|
|
let input = quote! { fn h() {} };
|
|
let s = must_expand(args, input);
|
|
// actor_policy entries appear as separate string literals
|
|
assert!(s.contains("\"admin\""));
|
|
assert!(s.contains("\"developer\""));
|
|
assert!(s.contains("\".ontology/state.ncl\""));
|
|
assert!(s.contains("\"adrs/adr-001.ncl\""));
|
|
assert!(s.contains("\"Update:FsmDimension:current_state:adoption\""));
|
|
}
|
|
}
|
|
}
|
|
|
|
mod onto_validator {
|
|
mod expand {
|
|
use quote::quote;
|
|
|
|
use crate::expand_onto_validator;
|
|
|
|
fn must_expand(
|
|
args: proc_macro2::TokenStream,
|
|
input: proc_macro2::TokenStream,
|
|
) -> String {
|
|
expand_onto_validator(args, input)
|
|
.expect("expansion succeeds")
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_attrs_emit_inventory_submit() {
|
|
let args = quote! {
|
|
id = "fsm_transition_allowed",
|
|
description = "Reject when target is not in the dimension's transition graph",
|
|
category = "Contextual",
|
|
slice_query = "fsm_dimension_by_id"
|
|
};
|
|
let input = quote! {
|
|
fn fsm_transition_allowed(slice: &Slice, ctx: &ValidationCtx) -> Verdict {
|
|
Verdict::Accept
|
|
}
|
|
};
|
|
let s = must_expand(args, input);
|
|
|
|
assert!(s.contains("inventory"), "must reference inventory crate");
|
|
assert!(s.contains("submit"), "must call submit! macro");
|
|
assert!(
|
|
s.contains("ValidatorEntry"),
|
|
"must construct ValidatorEntry"
|
|
);
|
|
assert!(
|
|
s.contains("ontoref_ops :: validation"),
|
|
"must use ontoref-ops validation path"
|
|
);
|
|
assert!(
|
|
s.contains("\"fsm_transition_allowed\""),
|
|
"must embed the validator id"
|
|
);
|
|
assert!(
|
|
s.contains("\"Contextual\""),
|
|
"must embed the validator category"
|
|
);
|
|
assert!(
|
|
s.contains("\"fsm_dimension_by_id\""),
|
|
"must embed the slice query"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn predicate_ref_defaults_to_fn_name() {
|
|
let args = quote! {
|
|
id = "v",
|
|
description = "x",
|
|
category = "Structural"
|
|
};
|
|
let input = quote! {
|
|
fn my_validator(_: &Slice, _: &ValidationCtx) -> Verdict {
|
|
Verdict::Accept
|
|
}
|
|
};
|
|
let s = must_expand(args, input);
|
|
// The predicate field is filled with the function's own ident.
|
|
assert!(
|
|
s.contains("predicate : my_validator"),
|
|
"predicate must be the fn ident; got: {s}"
|
|
);
|
|
// predicate_ref string defaults to the same identifier.
|
|
assert!(s.contains("\"my_validator\""));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_category() {
|
|
let args = quote! {
|
|
id = "x",
|
|
description = "y",
|
|
category = "Probabilistic"
|
|
};
|
|
let input =
|
|
quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
|
let err = expand_onto_validator(args, input).expect_err("bad category rejected");
|
|
assert!(err.to_string().contains("unknown category"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_attr_key() {
|
|
let args = quote! {
|
|
id = "x",
|
|
description = "y",
|
|
category = "Structural",
|
|
bogus = "z"
|
|
};
|
|
let input =
|
|
quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
|
let err = expand_onto_validator(args, input).expect_err("unknown key rejected");
|
|
assert!(err.to_string().contains("unknown onto_validator key"));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_id_and_category() {
|
|
let args = quote! { description = "y" };
|
|
let input =
|
|
quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
|
let err = expand_onto_validator(args, input).expect_err("missing id");
|
|
assert!(err.to_string().contains("requires id"));
|
|
}
|
|
|
|
#[test]
|
|
fn description_falls_back_to_doc_comment() {
|
|
let args = quote! {
|
|
id = "from_doc",
|
|
category = "Structural"
|
|
};
|
|
let input = quote! {
|
|
/// Validator that exercises doc-comment fallback for description
|
|
fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept }
|
|
};
|
|
let s = must_expand(args, input);
|
|
assert!(
|
|
s.contains("Validator that exercises doc-comment fallback for description"),
|
|
"must lift /// doc comment as description"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|