//! 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, params: Vec, tags: Vec, feature: String, } struct OntoApiParam { name: String, kind: String, constraint: String, description: String, } fn expand_onto_api(args: TokenStream, input: TokenStream) -> syn::Result { 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 = syn::parse2::(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::::parse_terminated, args, )?; let mut method: Option = None; let mut path: Option = None; let mut description: Option = None; let mut auth = "none".to_owned(); let mut actors: Vec = Vec::new(); let mut params_raw: Option = None; let mut tags: Vec = 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 { 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> { 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 = attr .actors .iter() .map(|a| LitStr::new(a, Span::call_site())) .collect(); let tag_lits: Vec = 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 { let item = proc_macro2::TokenStream::from(input); let kv_args = syn::parse::Parser::parse( Punctuated::::parse_terminated, args, )?; let mut name: Option = None; let mut description: Option = None; let mut category = String::new(); let mut params_raw: Option = 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, name: Option, level: Option, pole: Option, description: Option, adrs: Vec, paths: Vec, invariant: Option, } /// Parse `key = "value"` pairs from a `#[onto(k = "v", ...)]` attribute. fn parse_onto_attr(attr: &syn::Attribute) -> syn::Result { let mut out = OntoAttr::default(); let args = attr.parse_args_with(Punctuated::::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 { 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 { if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = expr { Some(s.value()) } else { None } } fn lit_bool(expr: &Expr) -> Option { 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 { // 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 = 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 = merged .adrs .iter() .filter(|s| !s.is_empty()) .map(|s| LitStr::new(s, Span::call_site())) .collect(); let path_lits: Vec = 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 { 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::::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 { // Parse #[config_section(id = "...", ncl_file = "...")] let mut section_id: Option = None; let mut ncl_file: Option = None; for attr in ast .attrs .iter() .filter(|a| a.path().is_ident("config_section")) { let args = attr.parse_args_with(Punctuated::::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 = 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 = 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 { let item = proc_macro2::TokenStream::from(input); // Parse key=value pairs from the attribute args. let kv_args = syn::parse::Parser::parse( Punctuated::::parse_terminated, args, )?; let mut practice_id: Option = None; let mut adr_id: Option = 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/.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 { ... } /// ``` #[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, precondition: String, pre_validators: Vec, inline_validators: Vec, post_validators: Vec, effects: Vec, render_paths: Vec, witness_shape: String, crdt_strategy: String, feature: String, } fn expand_onto_operation( args: proc_macro2::TokenStream, input: proc_macro2::TokenStream, ) -> syn::Result { let parsed_fn = syn::parse2::(input.clone()).ok(); let doc_desc: Option = 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::::parse_terminated, args, )?; let mut id: Option = None; let mut description: Option = None; let mut validation_sla: Option = None; let mut actor_policy: Vec = Vec::new(); let mut precondition = String::new(); let mut pre_validators: Vec = Vec::new(); let mut inline_validators: Vec = Vec::new(); let mut post_validators: Vec = Vec::new(); let mut effects: Vec = Vec::new(); let mut render_paths: Vec = 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 { 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 = attr .actor_policy .iter() .map(|a| LitStr::new(a, Span::call_site())) .collect(); let pre_validator_lits: Vec = attr .pre_validators .iter() .map(|v| LitStr::new(v, Span::call_site())) .collect(); let inline_validator_lits: Vec = attr .inline_validators .iter() .map(|v| LitStr::new(v, Span::call_site())) .collect(); let post_validator_lits: Vec = attr .post_validators .iter() .map(|v| LitStr::new(v, Span::call_site())) .collect(); let effect_lits: Vec = attr .effects .iter() .map(|e| LitStr::new(e, Span::call_site())) .collect(); let render_path_lits: Vec = 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 { let parsed_fn = syn::parse2::(input.clone()).ok(); let doc_desc: Option = parsed_fn .as_ref() .and_then(|fn_item| fn_item.attrs.iter().cloned().find_map(doc_attr_text)); let fn_name: Option = parsed_fn .as_ref() .map(|fn_item| fn_item.sig.ident.to_string()); let kv_args = syn::parse::Parser::parse2( Punctuated::::parse_terminated, args, )?; let mut id: Option = None; let mut description: Option = None; let mut category: Option = None; let mut slice_query = String::new(); let mut predicate_ref: Option = 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" ); } } } }