use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use syn::parse::Parse;
use syn::visit::Visit;
use walkdir::WalkDir;
struct ScanAntigenArgs {
name: String,
fingerprint: Option<String>,
family: Option<String>,
summary: Option<String>,
}
impl Parse for ScanAntigenArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
use syn::{Ident, LitStr, Token};
let mut name: Option<String> = None;
let mut fingerprint: Option<String> = None;
let mut family: Option<String> = None;
let mut summary: Option<String> = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"name" => {
let lit: LitStr = input.parse()?;
name = Some(lit.value());
}
"fingerprint" => {
let lit: LitStr = input.parse()?;
fingerprint = Some(lit.value());
}
"family" => {
let lit: LitStr = input.parse()?;
family = Some(lit.value());
}
"summary" => {
let lit: LitStr = input.parse()?;
summary = Some(lit.value());
}
"references" => {
let _arr: syn::ExprArray = input.parse()?;
}
_ => {
let _: syn::Expr = input.parse()?;
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
Ok(Self {
name: name.unwrap_or_default(),
fingerprint,
family,
summary,
})
}
}
struct ScanImmuneArgs {
antigen_type: String,
witness: String,
requires_predicate: Option<String>,
}
impl Parse for ScanImmuneArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
use antigen_attestation::parser::RequiresExpr;
use syn::{Ident, Path, Token};
let antigen_path: Path = input.parse()?;
let antigen_type = antigen_path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
let mut witness = String::new();
let mut requires_predicate: Option<String> = None;
while !input.is_empty() {
input.parse::<Token![,]>()?;
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"witness" => {
use quote::ToTokens;
let val: syn::Expr = input.parse()?;
witness = val.to_token_stream().to_string();
}
"requires" => {
let pred: RequiresExpr = input.parse()?;
requires_predicate = Some(pred.to_json());
}
_ => {
let _: syn::Expr = input.parse()?;
}
}
}
Ok(Self {
antigen_type,
witness,
requires_predicate,
})
}
}
struct ScanToleranceArgs {
antigen_type: String,
rationale: String,
until: Option<String>,
see: Vec<String>,
requires_predicate: Option<String>,
}
impl Parse for ScanToleranceArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
use antigen_attestation::parser::RequiresExpr;
use syn::{Expr, Ident, Lit, LitStr, Path, Token};
let antigen_path: Path = input.parse()?;
let antigen_type = antigen_path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
let mut rationale = String::new();
let mut until: Option<String> = None;
let mut see: Vec<String> = Vec::new();
let mut requires_predicate: Option<String> = None;
while !input.is_empty() {
input.parse::<Token![,]>()?;
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"rationale" => {
let lit: LitStr = input.parse()?;
rationale = lit.value();
}
"until" => {
let lit: LitStr = input.parse()?;
until = Some(lit.value());
}
"see" => {
let arr: syn::ExprArray = input.parse()?;
for elem in &arr.elems {
if let Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = elem
{
see.push(s.value());
}
}
}
"requires" => {
let pred: RequiresExpr = input.parse()?;
requires_predicate = Some(pred.to_json());
}
_ => {
let _: Expr = input.parse()?;
}
}
}
Ok(Self {
antigen_type,
rationale,
until,
see,
requires_predicate,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AntigenDeclaration {
pub name: String,
pub type_name: String,
pub file: PathBuf,
pub line: usize,
pub family: Option<String>,
pub summary: Option<String>,
pub fingerprint: Option<String>,
#[serde(default)]
pub canonical_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ItemTarget {
Struct(String),
Enum(String),
Trait(String),
Fn(String),
TypeAlias(String),
Impl {
trait_path: Option<String>,
target_type: String,
},
ImplFn {
trait_path: Option<String>,
target_type: String,
fn_name: String,
},
TraitFn {
trait_name: String,
fn_name: String,
},
Unknown {
line: usize,
},
}
impl ItemTarget {
#[must_use]
pub fn label(&self) -> String {
match self {
Self::Struct(n) | Self::Enum(n) | Self::Trait(n) | Self::Fn(n) | Self::TypeAlias(n) => {
n.clone()
}
Self::Impl {
trait_path: Some(t),
target_type,
} => format!("impl {t} for {target_type}"),
Self::Impl {
trait_path: None,
target_type,
} => format!("impl {target_type}"),
Self::ImplFn {
trait_path: Some(t),
target_type,
fn_name,
} => format!("<{target_type} as {t}>::{fn_name}"),
Self::ImplFn {
trait_path: None,
target_type,
fn_name,
} => format!("{target_type}::{fn_name}"),
Self::TraitFn {
trait_name,
fn_name,
} => format!("trait {trait_name}::{fn_name}"),
Self::Unknown { line } => format!("<unknown at line {line}>"),
}
}
#[must_use]
#[allow(
clippy::match_same_arms,
reason = "the explicit `Unknown` arm is the load-bearing invariant — \
Unknown items must NEVER match anything, including each other. \
Keeping it explicit (even though it duplicates the `_` wildcard's \
body) makes the invariant readable and refactor-safe."
)]
pub fn addresses(&self, other: &Self) -> bool {
match (self, other) {
(Self::Unknown { .. }, _) | (_, Self::Unknown { .. }) => false,
(Self::Struct(a), Self::Struct(b))
| (Self::Enum(a), Self::Enum(b))
| (Self::Trait(a), Self::Trait(b))
| (Self::Fn(a), Self::Fn(b))
| (Self::TypeAlias(a), Self::TypeAlias(b)) => a == b,
(
Self::Impl {
target_type: t1, ..
},
Self::Impl {
target_type: t2, ..
},
) => normalize_type_name(t1) == normalize_type_name(t2),
(
Self::ImplFn {
target_type: t1,
fn_name: f1,
..
},
Self::ImplFn {
target_type: t2,
fn_name: f2,
..
},
) => normalize_type_name(t1) == normalize_type_name(t2) && f1 == f2,
(
Self::TraitFn {
trait_name,
fn_name: tf,
},
Self::ImplFn {
trait_path: Some(t),
fn_name: imf,
..
},
)
| (
Self::ImplFn {
trait_path: Some(t),
fn_name: imf,
..
},
Self::TraitFn {
trait_name,
fn_name: tf,
},
) => trait_name == t && tf == imf,
(
Self::TraitFn {
trait_name: t1,
fn_name: f1,
},
Self::TraitFn {
trait_name: t2,
fn_name: f2,
},
) => t1 == t2 && f1 == f2,
_ => false,
}
}
}
fn normalize_type_name(rendered: &str) -> String {
let s = rendered.trim();
s.find('<')
.map_or_else(|| s.to_string(), |idx| s[..idx].trim().to_string())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MatchKind {
#[default]
ExplicitMarker,
FingerprintMatch,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ProvenanceEntry {
pub antigen_type: String,
pub canonical_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Presentation {
pub antigen_type: String,
pub file: PathBuf,
pub line: usize,
pub item_kind: String,
pub item_target: ItemTarget,
#[serde(default)]
pub match_kind: MatchKind,
#[serde(default)]
pub canonical_path: Option<String>,
#[serde(default)]
pub inherited_from: Option<Vec<ProvenanceEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Immunity {
pub antigen_type: String,
pub witness: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_predicate: Option<String>,
pub file: PathBuf,
pub line: usize,
pub item_kind: String,
pub item_target: ItemTarget,
#[serde(default)]
pub canonical_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Toleration {
pub antigen_type: String,
pub rationale: String,
pub until: Option<String>,
pub see: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_predicate: Option<String>,
pub file: PathBuf,
pub line: usize,
pub item_kind: String,
pub item_target: ItemTarget,
#[serde(default)]
pub canonical_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParseFailure {
pub file: PathBuf,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageEdge {
pub child: String,
pub parent: String,
pub file: PathBuf,
pub line: usize,
#[serde(default)]
pub parent_canonical_path: Option<String>,
#[serde(default)]
pub child_canonical_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScanReport {
pub antigens: Vec<AntigenDeclaration>,
pub presentations: Vec<Presentation>,
pub immunities: Vec<Immunity>,
#[serde(default)]
pub tolerances: Vec<Toleration>,
#[serde(default)]
pub lineage_edges: Vec<LineageEdge>,
pub files_scanned: usize,
pub parse_failures: Vec<ParseFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnaddressedPresentation {
pub presentation: Presentation,
pub antigen_known: bool,
}
impl ScanReport {
#[must_use]
pub fn unaddressed_presentations(&self) -> Vec<UnaddressedPresentation> {
let known_antigens: std::collections::HashSet<(&str, Option<&str>)> = self
.antigens
.iter()
.map(|a| (a.type_name.as_str(), a.canonical_path.as_deref()))
.collect();
let mut result = Vec::new();
for p in &self.presentations {
let has_matching_immunity =
self.immunities.iter().any(|i| addresses_for_immunity(i, p));
let has_matching_tolerance = self
.tolerances
.iter()
.any(|t| addresses_for_tolerance(t, p));
if !has_matching_immunity && !has_matching_tolerance {
result.push(UnaddressedPresentation {
presentation: p.clone(),
antigen_known: known_antigens
.contains(&(p.antigen_type.as_str(), p.canonical_path.as_deref())),
});
}
}
result
}
#[must_use]
pub fn orphaned_tolerances(&self) -> Vec<&Toleration> {
let known: std::collections::HashSet<(&str, Option<&str>)> = self
.antigens
.iter()
.map(|a| (a.type_name.as_str(), a.canonical_path.as_deref()))
.collect();
self.tolerances
.iter()
.filter(|t| !known.contains(&(t.antigen_type.as_str(), t.canonical_path.as_deref())))
.collect()
}
#[must_use]
pub fn orphaned_lineage_edges(&self) -> Vec<&LineageEdge> {
let known: std::collections::HashSet<(&str, Option<&str>)> = self
.antigens
.iter()
.map(|a| (a.type_name.as_str(), a.canonical_path.as_deref()))
.collect();
self.lineage_edges
.iter()
.filter(|e| !known.contains(&(e.parent.as_str(), e.parent_canonical_path.as_deref())))
.collect()
}
#[must_use]
pub fn dangling_child_lineage_edges(&self) -> Vec<&LineageEdge> {
let known: std::collections::HashSet<(&str, Option<&str>)> = self
.antigens
.iter()
.map(|a| (a.type_name.as_str(), a.canonical_path.as_deref()))
.collect();
self.lineage_edges
.iter()
.filter(|e| !known.contains(&(e.child.as_str(), e.child_canonical_path.as_deref())))
.collect()
}
pub fn stamp_canonical_path(&mut self, crate_id: &str) {
for a in &mut self.antigens {
if a.canonical_path.is_none() {
a.canonical_path = Some(crate_id.to_string());
}
}
for p in &mut self.presentations {
if p.canonical_path.is_none() {
p.canonical_path = Some(crate_id.to_string());
}
}
for i in &mut self.immunities {
if i.canonical_path.is_none() {
i.canonical_path = Some(crate_id.to_string());
}
}
for t in &mut self.tolerances {
if t.canonical_path.is_none() {
t.canonical_path = Some(crate_id.to_string());
}
}
for e in &mut self.lineage_edges {
if e.parent_canonical_path.is_none() {
e.parent_canonical_path = Some(crate_id.to_string());
}
if e.child_canonical_path.is_none() {
e.child_canonical_path = Some(crate_id.to_string());
}
}
}
#[must_use]
pub fn total_declarations(&self) -> usize {
self.antigens.len()
+ self.presentations.len()
+ self.immunities.len()
+ self.tolerances.len()
}
}
fn locus_matches(
a_path: &std::path::Path,
a_canonical: Option<&str>,
b_path: &std::path::Path,
b_canonical: Option<&str>,
) -> bool {
match (a_canonical, b_canonical) {
(None, None) => a_path == b_path,
(Some(x), Some(y)) => x == y,
_ => false,
}
}
fn addresses_for_immunity(i: &Immunity, p: &Presentation) -> bool {
i.antigen_type == p.antigen_type
&& i.canonical_path == p.canonical_path
&& i.item_target.addresses(&p.item_target)
&& locus_matches(
i.file.as_path(),
i.canonical_path.as_deref(),
p.file.as_path(),
p.canonical_path.as_deref(),
)
}
fn addresses_for_tolerance(t: &Toleration, p: &Presentation) -> bool {
t.antigen_type == p.antigen_type
&& t.canonical_path == p.canonical_path
&& t.item_target.addresses(&p.item_target)
&& locus_matches(
t.file.as_path(),
t.canonical_path.as_deref(),
p.file.as_path(),
p.canonical_path.as_deref(),
)
}
pub(crate) const MAX_LINEAGE_DEPTH: usize = 64;
fn dedupe_lineage_edges(edges: &[LineageEdge]) -> (Vec<LineageEdge>, Vec<ParseFailure>) {
use std::collections::{HashMap, HashSet};
type DedupKey<'a> = (&'a str, &'a str, Option<&'a str>, Option<&'a str>);
fn key_of(edge: &LineageEdge) -> DedupKey<'_> {
(
edge.child.as_str(),
edge.parent.as_str(),
edge.child_canonical_path.as_deref(),
edge.parent_canonical_path.as_deref(),
)
}
let mut counts: HashMap<DedupKey<'_>, usize> = HashMap::new();
for edge in edges {
*counts.entry(key_of(edge)).or_insert(0) += 1;
}
let mut emitted: HashSet<DedupKey<'_>> = HashSet::new();
let mut deduped: Vec<LineageEdge> = Vec::with_capacity(edges.len());
let mut failures: Vec<ParseFailure> = Vec::new();
for edge in edges {
let key = key_of(edge);
let count = counts.get(&key).copied().unwrap_or(0);
if emitted.insert(key) {
deduped.push(edge.clone());
if count > 1 {
failures.push(ParseFailure {
file: edge.file.clone(),
error: format!(
"duplicate #[descended_from({})] declarations on `{}` \
(first at line {}); structural lies surface as \
diagnostics rather than being silently collapsed \
(ADR-004 implicit-to-explicit elevation)",
edge.parent, edge.child, edge.line
),
});
}
}
}
(deduped, failures)
}
fn detect_lineage_failures(edges: &[LineageEdge], max_depth: usize) -> Vec<ParseFailure> {
use std::collections::HashMap;
let mut failures: Vec<ParseFailure> = Vec::new();
let mut adjacency: HashMap<&str, Vec<(&str, usize)>> = HashMap::new();
for (idx, edge) in edges.iter().enumerate() {
adjacency
.entry(edge.child.as_str())
.or_default()
.push((edge.parent.as_str(), idx));
}
let mut color: HashMap<&str, u8> = HashMap::new();
let mut reported_cycles: std::collections::HashSet<Vec<String>> =
std::collections::HashSet::new();
let mut roots_in_order: Vec<&str> = Vec::new();
let mut seen_roots: std::collections::HashSet<&str> = std::collections::HashSet::new();
for edge in edges {
let c = edge.child.as_str();
if seen_roots.insert(c) {
roots_in_order.push(c);
}
}
for &root in &roots_in_order {
if color.contains_key(root) {
continue;
}
let mut stack: Vec<(&str, usize)> = Vec::new();
let mut path: Vec<&str> = Vec::new();
color.insert(root, 1);
stack.push((root, 0));
path.push(root);
while let Some(&mut (node, ref mut idx)) = stack.last_mut() {
if path.len() > max_depth {
let leaf = *path.last().unwrap_or(&node);
let anchor = adjacency
.get(leaf)
.and_then(|v| v.first())
.and_then(|(_, edge_idx)| edges.get(*edge_idx))
.map_or_else(PathBuf::new, |e| e.file.clone());
failures.push(ParseFailure {
file: anchor,
error: format!(
"#[descended_from] chain exceeds maximum depth ({max_depth}) at \
`{leaf}`; chain: {}",
path.join(" -> ")
),
});
color.insert(node, 2);
stack.pop();
path.pop();
continue;
}
let children = adjacency.get(node).map_or(&[][..], Vec::as_slice);
if *idx >= children.len() {
color.insert(node, 2);
stack.pop();
path.pop();
continue;
}
let (child, edge_idx) = children[*idx];
*idx += 1;
match color.get(child).copied().unwrap_or(0) {
0 => {
color.insert(child, 1);
path.push(child);
stack.push((child, 0));
}
1 => {
let cycle_start = path.iter().position(|n| *n == child).unwrap_or(0);
let bare_refs: Vec<&str> = path[cycle_start..].to_vec();
let mut cycle_chain: Vec<String> =
bare_refs.iter().map(|s| (*s).to_string()).collect();
cycle_chain.push(child.to_string());
let canonical = canonicalise_cycle(&bare_refs);
if reported_cycles.insert(canonical) {
let edge = edges.get(edge_idx);
let file = edge.map_or_else(PathBuf::new, |e| e.file.clone());
let line = edge.map_or(0, |e| e.line);
failures.push(ParseFailure {
file,
error: format!(
"#[descended_from] forms a cycle (closing edge at line \
{line}): {}",
cycle_chain.join(" -> ")
),
});
}
}
_ => {
}
}
}
}
failures
}
fn canonicalise_cycle(bare: &[&str]) -> Vec<String> {
if bare.is_empty() {
return Vec::new();
}
let n = bare.len();
let mut best_start = 0;
for start in 1..n {
for i in 0..n {
let a = bare[(start + i) % n];
let b = bare[(best_start + i) % n];
if a < b {
best_start = start;
break;
} else if a > b {
break;
}
}
}
(0..n)
.map(|i| bare[(best_start + i) % n].to_string())
.collect()
}
pub fn scan_workspace(root: &Path, excluded_dirs: Option<&[&str]>) -> std::io::Result<ScanReport> {
let default_exclusions = ["target", ".git", "node_modules"];
let exclusions = excluded_dirs.unwrap_or(&default_exclusions);
let mut report = ScanReport::default();
let mut parsed_files: Vec<(PathBuf, syn::File)> = Vec::new();
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_string_lossy();
!exclusions.iter().any(|x| *x == name)
} else {
true
}
})
{
let Ok(entry) = entry else { continue };
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(content) = std::fs::read_to_string(entry.path()) else {
continue;
};
match syn::parse_file(&content) {
Ok(file) => {
let file_path = entry.path().to_path_buf();
let mut visitor = ScanVisitor {
file_path: file_path.clone(),
report: &mut report,
impl_stack: Vec::new(),
trait_stack: Vec::new(),
};
visitor.visit_file(&file);
report.files_scanned += 1;
parsed_files.push((file_path, file));
}
Err(e) => {
report.parse_failures.push(ParseFailure {
file: entry.path().to_path_buf(),
error: e.to_string(),
});
}
}
}
let (deduped_edges, dedup_failures) = dedupe_lineage_edges(&report.lineage_edges);
report.lineage_edges = deduped_edges;
report.parse_failures.extend(dedup_failures);
let lineage_failures = detect_lineage_failures(&report.lineage_edges, MAX_LINEAGE_DEPTH);
report.parse_failures.extend(lineage_failures);
let mut fp_parse_failures: Vec<ParseFailure> = Vec::new();
let fingerprints: Vec<(String, antigen_fingerprint::Fingerprint)> = report
.antigens
.iter()
.filter_map(|ag| {
let raw = ag.fingerprint.as_deref()?;
match antigen_fingerprint::Fingerprint::parse(raw) {
Ok(fp) => Some((ag.type_name.clone(), fp)),
Err(e) => {
fp_parse_failures.push(ParseFailure {
file: ag.file.clone(),
error: format!(
"antigen `{}`: fingerprint failed to re-parse during synthesis: {e}",
ag.type_name
),
});
None
}
}
})
.collect();
report.parse_failures.extend(fp_parse_failures);
if !fingerprints.is_empty() {
synthesis_pass(&parsed_files, &fingerprints, &mut report);
}
synthesize_inherited_presentations(&mut report);
Ok(report)
}
fn synthesize_inherited_presentations(report: &mut ScanReport) {
use std::collections::HashMap;
let antigen_by_key: HashMap<AntigenKey, AntigenDeclaration> = report
.antigens
.iter()
.map(|a| ((a.type_name.clone(), a.canonical_path.clone()), a.clone()))
.collect();
let mut adjacency: LineageAdjacency = LineageAdjacency::new();
for e in &report.lineage_edges {
let child_key = (e.child.clone(), e.child_canonical_path.clone());
let parent_key = (e.parent.clone(), e.parent_canonical_path.clone());
if !antigen_by_key.contains_key(&child_key) || !antigen_by_key.contains_key(&parent_key) {
continue;
}
adjacency.entry(child_key).or_default().push(parent_key);
}
let presentations_snapshot: Vec<Presentation> = report.presentations.clone();
let mut presentations_by_antigen: HashMap<AntigenKey, Vec<usize>> = HashMap::new();
for (idx, p) in presentations_snapshot.iter().enumerate() {
presentations_by_antigen
.entry((p.antigen_type.clone(), p.canonical_path.clone()))
.or_default()
.push(idx);
}
for child_decl in report.antigens.clone() {
let child_key = (
child_decl.type_name.clone(),
child_decl.canonical_path.clone(),
);
if !adjacency.contains_key(&child_key) {
continue;
}
let ancestors_in_order = transitive_ancestors_dfs(&adjacency, &child_key);
propagate_ancestors_to_descendant(
report,
&child_decl,
&ancestors_in_order,
&presentations_snapshot,
&presentations_by_antigen,
);
}
}
type AntigenKey = (String, Option<String>);
type LineageAdjacency = std::collections::HashMap<AntigenKey, Vec<AntigenKey>>;
fn transitive_ancestors_dfs(
adjacency: &LineageAdjacency,
child_key: &AntigenKey,
) -> Vec<AntigenKey> {
use std::collections::HashSet;
let mut visited: HashSet<AntigenKey> = HashSet::new();
let mut stack: Vec<AntigenKey> = adjacency.get(child_key).cloned().unwrap_or_default();
let mut ancestors_in_order: Vec<AntigenKey> = Vec::new();
while let Some(node) = stack.pop() {
if !visited.insert(node.clone()) {
continue;
}
ancestors_in_order.push(node.clone());
if let Some(parents) = adjacency.get(&node) {
for parent in parents.iter().rev() {
if !visited.contains(parent) {
stack.push(parent.clone());
}
}
}
}
ancestors_in_order
}
fn propagate_ancestors_to_descendant(
report: &mut ScanReport,
child_decl: &AntigenDeclaration,
ancestors_in_order: &[AntigenKey],
presentations_snapshot: &[Presentation],
presentations_by_antigen: &std::collections::HashMap<AntigenKey, Vec<usize>>,
) {
use std::collections::BTreeSet;
let descendant_item_target = ItemTarget::Struct(child_decl.type_name.clone());
let descendant_item_kind = "struct".to_string();
for ancestor_key in ancestors_in_order {
let provenance = ProvenanceEntry {
antigen_type: ancestor_key.0.clone(),
canonical_path: ancestor_key.1.clone(),
};
let Some(ancestor_pres_indices) = presentations_by_antigen.get(ancestor_key) else {
continue;
};
for &ancestor_pres_idx in ancestor_pres_indices {
let ancestor_pres = &presentations_snapshot[ancestor_pres_idx];
let existing_idx = report.presentations.iter().position(|p| {
p.antigen_type == ancestor_pres.antigen_type
&& p.canonical_path == ancestor_pres.canonical_path
&& p.item_target.addresses(&descendant_item_target)
&& locus_matches(
p.file.as_path(),
p.canonical_path.as_deref(),
child_decl.file.as_path(),
child_decl.canonical_path.as_deref(),
)
});
if let Some(idx) = existing_idx {
let existing = &mut report.presentations[idx];
let mut chain: BTreeSet<ProvenanceEntry> = existing
.inherited_from
.take()
.unwrap_or_default()
.into_iter()
.collect();
chain.insert(provenance.clone());
existing.inherited_from = Some(chain.into_iter().collect());
} else {
report.presentations.push(Presentation {
antigen_type: ancestor_pres.antigen_type.clone(),
file: child_decl.file.clone(),
line: child_decl.line,
item_kind: descendant_item_kind.clone(),
item_target: descendant_item_target.clone(),
match_kind: ancestor_pres.match_kind.clone(),
canonical_path: ancestor_pres.canonical_path.clone(),
inherited_from: Some(vec![provenance.clone()]),
});
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CrateOrigin {
PathOrWorkspace,
Registry,
Git,
Other(String),
}
impl CrateOrigin {
fn from_source(source: Option<&str>) -> Self {
match source {
None => Self::PathOrWorkspace,
Some(s) if s.starts_with("registry+") => Self::Registry,
Some(s) if s.starts_with("git+") => Self::Git,
Some(s) => Self::Other(s.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepCrateRoot {
pub package_name: String,
pub version: String,
pub crate_root: PathBuf,
pub origin: CrateOrigin,
}
pub fn enumerate_dep_crate_roots(
workspace_root: &Path,
include_path_workspace: bool,
) -> std::io::Result<Vec<DepCrateRoot>> {
use std::process::Command;
let manifest_path = workspace_root.join("Cargo.toml");
let output = Command::new("cargo")
.arg("metadata")
.arg("--format-version")
.arg("1")
.arg("--manifest-path")
.arg(&manifest_path)
.output()
.map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"failed to invoke `cargo metadata` at `{}`: {e} \
(is cargo on PATH?)",
manifest_path.display()
),
)
})?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"`cargo metadata` exited with status {} for manifest `{}`: {}",
output.status,
manifest_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
std::io::Error::other(format!("failed to parse `cargo metadata` JSON output: {e}"))
})?;
let workspace_members: std::collections::HashSet<String> = metadata
.get("workspace_members")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let packages = metadata
.get("packages")
.and_then(|v| v.as_array())
.ok_or_else(|| {
std::io::Error::other(
"`cargo metadata` output missing `packages` array — unexpected schema",
)
})?;
let mut roots: Vec<DepCrateRoot> = Vec::new();
for pkg in packages {
let id = pkg.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if workspace_members.contains(id) {
continue;
}
let package_name = pkg
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let version = pkg
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let source = pkg.get("source").and_then(|v| v.as_str());
let manifest_str = pkg.get("manifest_path").and_then(|v| v.as_str());
let Some(manifest_str) = manifest_str else {
continue;
};
let manifest = PathBuf::from(manifest_str);
let Some(crate_root) = manifest.parent().map(Path::to_path_buf) else {
continue;
};
let origin = CrateOrigin::from_source(source);
if matches!(origin, CrateOrigin::PathOrWorkspace) && !include_path_workspace {
continue;
}
roots.push(DepCrateRoot {
package_name,
version,
crate_root,
origin,
});
}
Ok(roots)
}
fn synthesis_pass(
parsed_files: &[(PathBuf, syn::File)],
fingerprints: &[(String, antigen_fingerprint::Fingerprint)],
report: &mut ScanReport,
) {
for (file_path, parsed) in parsed_files {
for syn_item in &parsed.items {
let Some((kind_str, item_target)) = item_kind_and_target(syn_item) else {
continue;
};
let item_kind_for_dispatch = match syn_item {
syn::Item::Struct(_) => Some(antigen_fingerprint::ItemKind::Struct),
syn::Item::Enum(_) => Some(antigen_fingerprint::ItemKind::Enum),
syn::Item::Trait(_) => Some(antigen_fingerprint::ItemKind::Trait),
syn::Item::Fn(_) => Some(antigen_fingerprint::ItemKind::Fn),
syn::Item::Impl(_) => Some(antigen_fingerprint::ItemKind::Impl),
syn::Item::Type(_) => Some(antigen_fingerprint::ItemKind::Type),
syn::Item::Mod(_) => Some(antigen_fingerprint::ItemKind::Mod),
_ => None,
};
for (antigen_type, fp) in fingerprints {
if let Some(required_kind) = fp.node_kind() {
if item_kind_for_dispatch != Some(required_kind) {
continue;
}
}
if !fp.matches(syn_item) {
continue;
}
let already_covered = report.presentations.iter().any(|p| {
p.match_kind == MatchKind::ExplicitMarker
&& p.antigen_type == *antigen_type
&& p.file == *file_path
&& p.item_target.addresses(&item_target)
}) || report.tolerances.iter().any(|t| {
t.antigen_type == *antigen_type
&& t.file == *file_path
&& t.item_target.addresses(&item_target)
});
if already_covered {
continue;
}
let line = item_line(syn_item);
report.presentations.push(Presentation {
antigen_type: antigen_type.clone(),
file: file_path.clone(),
line,
item_kind: kind_str.to_string(),
item_target: item_target.clone(),
match_kind: MatchKind::FingerprintMatch,
canonical_path: None,
inherited_from: None,
});
}
}
}
}
fn item_kind_and_target(item: &syn::Item) -> Option<(&'static str, ItemTarget)> {
match item {
syn::Item::Struct(s) => Some(("struct", ItemTarget::Struct(s.ident.to_string()))),
syn::Item::Enum(e) => Some(("enum", ItemTarget::Enum(e.ident.to_string()))),
syn::Item::Trait(t) => Some(("trait", ItemTarget::Trait(t.ident.to_string()))),
syn::Item::Fn(f) => Some(("fn", ItemTarget::Fn(f.sig.ident.to_string()))),
syn::Item::Type(t) => Some(("type", ItemTarget::TypeAlias(t.ident.to_string()))),
syn::Item::Impl(i) => {
let trait_path = i.trait_.as_ref().map(|(_, path, _)| render_path(path));
let target_type = render_type(&i.self_ty);
Some((
"impl",
ItemTarget::Impl {
trait_path,
target_type,
},
))
}
_ => None,
}
}
fn item_line(item: &syn::Item) -> usize {
use syn::spanned::Spanned;
item.span().start().line
}
struct ScanVisitor<'a> {
file_path: PathBuf,
report: &'a mut ScanReport,
impl_stack: Vec<(Option<String>, String)>,
trait_stack: Vec<String>,
}
impl ScanVisitor<'_> {
fn line_of_attr(attr: &syn::Attribute) -> usize {
use syn::spanned::Spanned;
attr.span().start().line
}
fn extract_antigen(&mut self, item: &syn::ItemStruct, attr: &syn::Attribute) {
let type_name = item.ident.to_string();
let line = Self::line_of_attr(attr);
if let syn::Meta::List(list) = &attr.meta {
match syn::parse2::<ScanAntigenArgs>(list.tokens.clone()) {
Ok(args) => {
self.report.antigens.push(AntigenDeclaration {
name: args.name,
type_name,
file: self.file_path.clone(),
line,
family: args.family,
summary: args.summary,
fingerprint: args.fingerprint,
canonical_path: None,
});
}
Err(_) => {
self.report.antigens.push(AntigenDeclaration {
name: String::new(),
type_name,
file: self.file_path.clone(),
line,
family: None,
summary: None,
fingerprint: None,
canonical_path: None,
});
}
}
}
}
fn extract_presents(
&mut self,
attr: &syn::Attribute,
item_kind: &str,
item_target: ItemTarget,
) {
let antigen_type = if let syn::Meta::List(list) = &attr.meta {
match syn::parse2::<syn::Path>(list.tokens.clone()) {
Ok(path) => path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
Err(e) => {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: format!("malformed #[presents] attribute: {e}"),
});
return;
}
}
} else {
return;
};
let line = Self::line_of_attr(attr);
self.report.presentations.push(Presentation {
antigen_type,
file: self.file_path.clone(),
line,
item_kind: item_kind.to_string(),
item_target,
match_kind: MatchKind::ExplicitMarker,
canonical_path: None,
inherited_from: None,
});
}
fn extract_immune(
&mut self,
attr: &syn::Attribute,
all_attrs: &[syn::Attribute],
item_kind: &str,
item_target: ItemTarget,
) {
if let syn::Meta::List(list) = &attr.meta {
let args = match syn::parse2::<ScanImmuneArgs>(list.tokens.clone()) {
Ok(args) => args,
Err(e) => {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: format!("malformed #[immune] attribute: {e}"),
});
return;
}
};
let requires_predicate = args
.requires_predicate
.clone()
.or_else(|| extract_requires_predicate_from_attrs(all_attrs));
let line = Self::line_of_attr(attr);
self.report.immunities.push(Immunity {
antigen_type: args.antigen_type,
witness: args.witness,
requires_predicate,
file: self.file_path.clone(),
line,
item_kind: item_kind.to_string(),
item_target,
canonical_path: None,
});
}
}
fn extract_tolerance(
&mut self,
attr: &syn::Attribute,
all_attrs: &[syn::Attribute],
item_kind: &str,
item_target: ItemTarget,
) {
if let syn::Meta::List(list) = &attr.meta {
let args = match syn::parse2::<ScanToleranceArgs>(list.tokens.clone()) {
Ok(args) => args,
Err(e) => {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: format!("malformed #[antigen_tolerance] attribute: {e}"),
});
return;
}
};
if args.rationale.is_empty() {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: "#[antigen_tolerance] requires non-empty rationale".to_string(),
});
return;
}
let requires_predicate = args
.requires_predicate
.clone()
.or_else(|| extract_requires_predicate_from_attrs(all_attrs));
let line = Self::line_of_attr(attr);
self.report.tolerances.push(Toleration {
antigen_type: args.antigen_type,
rationale: args.rationale,
until: args.until,
see: args.see,
requires_predicate,
file: self.file_path.clone(),
line,
item_kind: item_kind.to_string(),
item_target,
canonical_path: None,
});
}
}
fn extract_descended_from(&mut self, attr: &syn::Attribute, item_target: &ItemTarget) {
let child = match item_target {
ItemTarget::Struct(name) | ItemTarget::Enum(name) => name.clone(),
other => {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: format!(
"#[descended_from] on `{}` is not a type declaration; \
this attribute is meaningful only on `struct` and `enum` \
antigen declarations",
other.label()
),
});
return;
}
};
let syn::Meta::List(list) = &attr.meta else {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: "malformed #[descended_from] attribute: expected `(parent)`".to_string(),
});
return;
};
let parent = match syn::parse2::<syn::Path>(list.tokens.clone()) {
Ok(path) => path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
Err(e) => {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: format!("malformed #[descended_from] attribute: {e}"),
});
return;
}
};
if parent.is_empty() {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: "#[descended_from] requires a parent path argument".to_string(),
});
return;
}
let line = Self::line_of_attr(attr);
self.report.lineage_edges.push(LineageEdge {
child,
parent,
file: self.file_path.clone(),
line,
parent_canonical_path: None,
child_canonical_path: None,
});
}
fn check_attrs(&mut self, attrs: &[syn::Attribute], item_kind: &str, item_target: &ItemTarget) {
for attr in attrs {
if attr_is(attr, "presents") {
self.extract_presents(attr, item_kind, item_target.clone());
} else if attr_is(attr, "immune") {
self.extract_immune(attr, attrs, item_kind, item_target.clone());
} else if attr_is(attr, "antigen_tolerance") {
self.extract_tolerance(attr, attrs, item_kind, item_target.clone());
} else if attr_is(attr, "descended_from") {
self.extract_descended_from(attr, item_target);
}
}
}
}
fn render_type(ty: &syn::Type) -> String {
use quote::ToTokens;
ty.to_token_stream().to_string()
}
fn attr_is(attr: &syn::Attribute, name: &str) -> bool {
let path = attr.path();
path.is_ident(name) || path.segments.last().is_some_and(|s| s.ident == name)
}
fn extract_requires_predicate_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
const MARKER_PREFIX: &str = "antigen:requires:v1:";
for attr in attrs {
if !attr.path().is_ident("doc") {
continue;
}
if let syn::Meta::NameValue(nv) = &attr.meta {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
{
let val = s.value();
let trimmed = val.trim();
if let Some(json) = trimmed.strip_prefix(MARKER_PREFIX) {
return Some(json.to_string());
}
}
}
}
None
}
fn render_path(path: &syn::Path) -> String {
use quote::ToTokens;
path.to_token_stream().to_string()
}
impl<'ast> Visit<'ast> for ScanVisitor<'_> {
fn visit_item_struct(&mut self, item: &'ast syn::ItemStruct) {
for attr in &item.attrs {
if attr_is(attr, "antigen") {
self.extract_antigen(item, attr);
}
}
let target = ItemTarget::Struct(item.ident.to_string());
self.check_attrs(&item.attrs, "struct", &target);
syn::visit::visit_item_struct(self, item);
}
fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) {
let trait_path = item.trait_.as_ref().map(|(_, path, _)| render_path(path));
let target_type = render_type(&item.self_ty);
let target = ItemTarget::Impl {
trait_path: trait_path.clone(),
target_type: target_type.clone(),
};
self.check_attrs(&item.attrs, "impl", &target);
self.impl_stack.push((trait_path, target_type));
syn::visit::visit_item_impl(self, item);
self.impl_stack.pop();
}
fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) {
let target = ItemTarget::Fn(item.sig.ident.to_string());
self.check_attrs(&item.attrs, "fn", &target);
syn::visit::visit_item_fn(self, item);
}
fn visit_impl_item_fn(&mut self, item: &'ast syn::ImplItemFn) {
let target = self.impl_stack.last().map_or_else(
|| ItemTarget::Fn(item.sig.ident.to_string()),
|(trait_path, target_type)| ItemTarget::ImplFn {
trait_path: trait_path.clone(),
target_type: target_type.clone(),
fn_name: item.sig.ident.to_string(),
},
);
self.check_attrs(&item.attrs, "impl_fn", &target);
syn::visit::visit_impl_item_fn(self, item);
}
fn visit_item_trait(&mut self, item: &'ast syn::ItemTrait) {
let target = ItemTarget::Trait(item.ident.to_string());
self.check_attrs(&item.attrs, "trait", &target);
self.trait_stack.push(item.ident.to_string());
syn::visit::visit_item_trait(self, item);
self.trait_stack.pop();
}
fn visit_trait_item_fn(&mut self, item: &'ast syn::TraitItemFn) {
let target = self.trait_stack.last().map_or_else(
|| ItemTarget::Fn(item.sig.ident.to_string()),
|trait_name| ItemTarget::TraitFn {
trait_name: trait_name.clone(),
fn_name: item.sig.ident.to_string(),
},
);
self.check_attrs(&item.attrs, "trait_fn", &target);
syn::visit::visit_trait_item_fn(self, item);
}
fn visit_item_type(&mut self, item: &'ast syn::ItemType) {
let target = ItemTarget::TypeAlias(item.ident.to_string());
self.check_attrs(&item.attrs, "type_alias", &target);
syn::visit::visit_item_type(self, item);
}
fn visit_item_enum(&mut self, item: &'ast syn::ItemEnum) {
for attr in &item.attrs {
if attr_is(attr, "antigen") {
self.report.parse_failures.push(ParseFailure {
file: self.file_path.clone(),
error: format!(
"#[antigen] on enum `{}` is not supported in v0.1; \
antigen declarations must be unit structs (e.g., \
`pub struct {};`). Enum-shaped failure-classes are \
tracked by ADR-010 Amendment 1's class-enum operator \
in a future grammar version.",
item.ident, item.ident
),
});
}
}
let target = ItemTarget::Enum(item.ident.to_string());
self.check_attrs(&item.attrs, "enum", &target);
syn::visit::visit_item_enum(self, item);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_scan_report_has_no_unaddressed() {
let report = ScanReport::default();
assert!(report.unaddressed_presentations().is_empty());
}
#[test]
fn antigen_args_parses_name_and_fingerprint() {
let tokens: proc_macro2::TokenStream = r#"
name = "frame-translation",
fingerprint = "item: enum, has_method(\"meet\", \"(Self, Self) -> Self\")"
"#
.parse()
.unwrap();
let args = syn::parse2::<ScanAntigenArgs>(tokens).unwrap();
assert_eq!(args.name, "frame-translation");
let fp = args.fingerprint.unwrap();
assert!(
fp.contains("has_method(\"meet\""),
"fingerprint should contain unescaped double-quotes, got: {fp:?}"
);
assert!(
!fp.contains(r#"\""#),
"fingerprint must not contain raw backslash-quote escape sequences, got: {fp:?}"
);
}
#[test]
fn antigen_args_parses_optional_fields() {
let tokens: proc_macro2::TokenStream =
r#"name = "panicking-in-drop", fingerprint = "impl Drop", family = "boundary-violation", summary = "Drop impl can panic""#
.parse()
.unwrap();
let args = syn::parse2::<ScanAntigenArgs>(tokens).unwrap();
assert_eq!(args.name, "panicking-in-drop");
assert_eq!(args.family.as_deref(), Some("boundary-violation"));
assert_eq!(args.summary.as_deref(), Some("Drop impl can panic"));
}
#[test]
fn immune_args_parses_antigen_type_and_witness() {
let tokens: proc_macro2::TokenStream = r"PanickingInDrop, witness = no_panic_in_drop_test"
.parse()
.unwrap();
let args = syn::parse2::<ScanImmuneArgs>(tokens).unwrap();
assert_eq!(args.antigen_type, "PanickingInDrop");
assert_eq!(args.witness, "no_panic_in_drop_test");
}
#[test]
fn immune_args_parses_path_witness() {
let tokens: proc_macro2::TokenStream =
r"FrameTranslation, witness = clippy :: no_panic_in_drop"
.parse()
.unwrap();
let args = syn::parse2::<ScanImmuneArgs>(tokens).unwrap();
assert_eq!(args.antigen_type, "FrameTranslation");
assert!(args.witness.contains("no_panic_in_drop"));
}
type ScanFixture = (
&'static str,
&'static str,
&'static str,
Option<&'static str>,
Option<&'static str>,
);
const SCAN_PARSER_FIXTURES: &[ScanFixture] = &[
(
r#"name = "panicking-in-drop", fingerprint = "impl Drop with panic""#,
"panicking-in-drop",
"impl Drop with panic",
None,
None,
),
(
r#"name = "frame-translation", fingerprint = "class enum + meet", family = "semantic-drift", summary = "Polarity inverts at the frame boundary""#,
"frame-translation",
"class enum + meet",
Some("semantic-drift"),
Some("Polarity inverts at the frame boundary"),
),
(
r#"name = "x", fingerprint = "item: enum, has_method(\"meet\", \"(Self, Self) -> Self\")""#,
"x",
r#"item: enum, has_method("meet", "(Self, Self) -> Self")"#,
None,
None,
),
(
r#"summary = "S", family = "F", fingerprint = "FP", name = "n""#,
"n",
"FP",
Some("F"),
Some("S"),
),
(
r#"name = "x", fingerprint = "y", references = ["GAP-1", "DEC-2"]"#,
"x",
"y",
None,
None,
),
(
"name = \"multi-line\",\n\tfingerprint = \"shape\",\n\tfamily = \"family\"",
"multi-line",
"shape",
Some("family"),
None,
),
];
#[test]
fn scan_parser_accepts_all_macro_fixtures() {
for (input, exp_name, exp_fp, exp_family, exp_summary) in SCAN_PARSER_FIXTURES {
let tokens: proc_macro2::TokenStream = input
.parse()
.unwrap_or_else(|e| panic!("fixture failed to tokenize: {input:?}: {e}"));
let args = syn::parse2::<ScanAntigenArgs>(tokens)
.unwrap_or_else(|e| panic!("scan parser rejected fixture {input:?}: {e}"));
assert_eq!(&args.name, exp_name, "name mismatch for fixture: {input:?}");
assert_eq!(
args.fingerprint.as_deref(),
Some(*exp_fp),
"fingerprint mismatch for fixture: {input:?}"
);
assert_eq!(
args.family.as_deref(),
*exp_family,
"family mismatch for fixture: {input:?}"
);
assert_eq!(
args.summary.as_deref(),
*exp_summary,
"summary mismatch for fixture: {input:?}"
);
}
}
#[test]
fn scan_parser_tolerates_unknown_fields() {
let tokens: proc_macro2::TokenStream =
r#"name = "x", fingerprint = "y", future_field = "irrelevant""#
.parse()
.unwrap();
let args = syn::parse2::<ScanAntigenArgs>(tokens).unwrap();
assert_eq!(args.name, "x");
assert_eq!(args.fingerprint.as_deref(), Some("y"));
}
#[test]
fn scan_parser_tolerates_missing_required_fields() {
let tokens: proc_macro2::TokenStream = r#"name = "only-name""#.parse().unwrap();
let args = syn::parse2::<ScanAntigenArgs>(tokens).unwrap();
assert_eq!(args.name, "only-name");
assert_eq!(args.fingerprint, None);
}
mod parser_props {
use super::super::*;
use proc_macro2::TokenStream;
use proptest::prelude::*;
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "box", "break", "const", "continue", "crate", "do", "dyn",
"else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop",
"macro", "match", "mod", "move", "mut", "pub", "ref", "return", "self", "static",
"struct", "super", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
"yield", "abstract", "become", "final", "override", "priv", "try",
];
fn valid_kebab() -> impl Strategy<Value = String> {
proptest::collection::vec(
(
proptest::char::range('a', 'z'),
proptest::collection::vec(
prop_oneof![
proptest::char::range('a', 'z'),
proptest::char::range('0', '9'),
],
0..8usize,
),
)
.prop_map(|(first, rest)| {
let mut s = String::with_capacity(rest.len() + 1);
s.push(first);
for c in rest {
s.push(c);
}
s
}),
1..5usize,
)
.prop_map(|segments| segments.join("-"))
}
fn valid_text(max_len: usize) -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
proptest::char::range(' ', '~').prop_filter("excluded chars", |c| {
*c != '\\' && *c != '"' && *c != '\0'
}),
],
1..=max_len,
)
.prop_map(|chars| chars.into_iter().collect())
}
fn lit(s: &str) -> String {
format!("{s:?}")
}
fn render_antigen_body(
name: &str,
fingerprint: &str,
family: Option<&str>,
summary: Option<&str>,
) -> String {
let mut parts = vec![
format!("name = {}", lit(name)),
format!("fingerprint = {}", lit(fingerprint)),
];
if let Some(f) = family {
parts.push(format!("family = {}", lit(f)));
}
if let Some(s) = summary {
parts.push(format!("summary = {}", lit(s)));
}
parts.join(", ")
}
proptest! {
#[test]
fn scan_parser_round_trip_on_macro_inputs(
name in valid_kebab(),
fingerprint in valid_text(64),
family in proptest::option::of(valid_text(32)),
summary in proptest::option::of(valid_text(64)),
) {
let body = render_antigen_body(&name, &fingerprint, family.as_deref(), summary.as_deref());
let tokens: TokenStream = body.parse().expect("body must tokenize");
let args = syn::parse2::<ScanAntigenArgs>(tokens).expect("scan must accept macro-acceptable input");
prop_assert_eq!(&args.name, &name);
prop_assert_eq!(args.fingerprint.as_deref(), Some(fingerprint.as_str()));
prop_assert_eq!(args.family.as_deref(), family.as_deref());
prop_assert_eq!(args.summary.as_deref(), summary.as_deref());
}
#[test]
fn scan_parser_order_invariant(
name in valid_kebab(),
fingerprint in valid_text(48),
family in valid_text(24),
summary in valid_text(48),
) {
let canonical = format!(
"name = {}, fingerprint = {}, family = {}, summary = {}",
lit(&name), lit(&fingerprint), lit(&family), lit(&summary),
);
let reversed = format!(
"summary = {}, family = {}, fingerprint = {}, name = {}",
lit(&summary), lit(&family), lit(&fingerprint), lit(&name),
);
let a: ScanAntigenArgs = syn::parse2(canonical.parse::<TokenStream>().unwrap()).unwrap();
let b: ScanAntigenArgs = syn::parse2(reversed.parse::<TokenStream>().unwrap()).unwrap();
prop_assert_eq!(&a.name, &b.name);
prop_assert_eq!(&a.fingerprint, &b.fingerprint);
prop_assert_eq!(&a.family, &b.family);
prop_assert_eq!(&a.summary, &b.summary);
}
#[test]
fn scan_parser_tolerates_arbitrary_unknown_field(
name in valid_kebab(),
fingerprint in valid_text(32),
unknown in "[a-z][a-z_]{2,12}".prop_filter(
"must not collide with known fields or Rust keywords",
|s| {
!matches!(s.as_str(), "name" | "fingerprint" | "family" | "summary" | "references")
&& !RUST_KEYWORDS.contains(&s.as_str())
},
),
unknown_val in valid_text(16),
) {
let body = format!(
"name = {}, fingerprint = {}, {} = {}",
lit(&name), lit(&fingerprint), unknown, lit(&unknown_val),
);
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<ScanAntigenArgs>(tokens).expect("scan tolerates unknown fields");
prop_assert_eq!(&args.name, &name);
prop_assert_eq!(args.fingerprint.as_deref(), Some(fingerprint.as_str()));
}
#[test]
fn scan_parser_tolerates_missing_fingerprint(
name in valid_kebab(),
) {
let body = format!("name = {}", lit(&name));
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<ScanAntigenArgs>(tokens).expect("scan tolerates missing fingerprint");
prop_assert_eq!(&args.name, &name);
prop_assert_eq!(args.fingerprint, None);
}
#[test]
fn scan_parser_consumes_references_array(
name in valid_kebab(),
fingerprint in valid_text(32),
refs in proptest::collection::vec(valid_text(24), 0..6usize),
) {
let refs_lit: Vec<String> = refs.iter().map(|s| lit(s)).collect();
let body = format!(
"name = {}, fingerprint = {}, references = [{}]",
lit(&name), lit(&fingerprint), refs_lit.join(", "),
);
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<ScanAntigenArgs>(tokens).expect("scan parses references");
prop_assert_eq!(&args.name, &name);
prop_assert_eq!(args.fingerprint.as_deref(), Some(fingerprint.as_str()));
}
#[test]
fn scan_immune_extracts_last_path_segment(
antigen in "[A-Z][A-Za-z0-9]{0,16}",
witness_segments in proptest::collection::vec(
"[a-z][a-z_0-9]{0,8}".prop_filter(
"must not be a Rust keyword",
|s| !RUST_KEYWORDS.contains(&s.as_str()),
),
1..4usize,
),
) {
let witness = witness_segments.join("::");
let body = format!("{antigen}, witness = {witness}");
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<ScanImmuneArgs>(tokens).expect("body parses");
prop_assert_eq!(args.antigen_type.as_str(), antigen.as_str());
let last = witness_segments.last().unwrap();
prop_assert!(args.witness.contains(last.as_str()),
"rendered witness {:?} should contain trailing segment {:?}", args.witness, last);
}
#[test]
fn scan_immune_qualified_antigen_path_extracts_last_segment(
module_segs in proptest::collection::vec(
"[a-z][a-z_0-9]{0,6}".prop_filter(
"must not be a Rust keyword",
|s| !RUST_KEYWORDS.contains(&s.as_str()),
),
1..3usize,
),
antigen in "[A-Z][A-Za-z0-9]{0,12}",
witness in "[a-z][a-z_0-9]{0,12}".prop_filter(
"must not be a Rust keyword",
|s| !RUST_KEYWORDS.contains(&s.as_str()),
),
) {
let qualified = format!("{}::{}", module_segs.join("::"), antigen);
let body = format!("{qualified}, witness = {witness}");
let tokens: TokenStream = body.parse().expect("body tokenizes");
let args = syn::parse2::<ScanImmuneArgs>(tokens).expect("body parses");
prop_assert_eq!(args.antigen_type.as_str(), antigen.as_str(),
"qualified antigen path {:?} must yield bare last-segment antigen_type", qualified);
}
}
}
fn edge(child: &str, parent: &str) -> LineageEdge {
LineageEdge {
child: child.to_string(),
parent: parent.to_string(),
file: PathBuf::from("test.rs"),
line: 1,
parent_canonical_path: None,
child_canonical_path: None,
}
}
#[test]
fn lineage_no_edges_no_failures() {
let failures = detect_lineage_failures(&[], MAX_LINEAGE_DEPTH);
assert!(failures.is_empty());
}
#[test]
fn lineage_acyclic_chain_no_failures() {
let edges = vec![edge("C", "B"), edge("B", "A")];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert!(
failures.is_empty(),
"acyclic chain must produce no failures, got: {failures:?}"
);
}
#[test]
fn lineage_self_loop_detected() {
let edges = vec![edge("A", "A")];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert_eq!(
failures.len(),
1,
"self-loop must report exactly one failure"
);
assert!(
failures[0].error.contains("cycle"),
"self-loop error must mention cycle, got: {}",
failures[0].error
);
assert!(
failures[0].error.contains("A -> A"),
"self-loop error must contain chain `A -> A`, got: {}",
failures[0].error
);
}
#[test]
fn lineage_two_node_cycle_detected() {
let edges = vec![edge("A", "B"), edge("B", "A")];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert_eq!(
failures.len(),
1,
"2-cycle must report one failure, got: {failures:?}"
);
let err = &failures[0].error;
assert!(err.contains("cycle"), "must mention cycle, got: {err}");
assert!(
err.contains("A -> B -> A") || err.contains("B -> A -> B"),
"must contain full cycle chain, got: {err}"
);
}
#[test]
fn lineage_three_node_cycle_detected() {
let edges = vec![edge("A", "B"), edge("B", "C"), edge("C", "A")];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert_eq!(
failures.len(),
1,
"3-cycle must report exactly one failure, got: {failures:?}"
);
}
#[test]
fn lineage_cycle_dedup_across_entry_points() {
let edges = vec![
edge("A", "B"),
edge("B", "C"),
edge("C", "A"),
edge("D", "B"), edge("E", "C"), ];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert_eq!(
failures.len(),
1,
"same cycle entered from multiple roots must dedup, got: {failures:?}"
);
}
#[test]
fn lineage_two_disjoint_cycles_both_reported() {
let edges = vec![
edge("A", "B"),
edge("B", "A"),
edge("X", "Y"),
edge("Y", "X"),
];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert_eq!(
failures.len(),
2,
"two disjoint cycles must produce two failures, got: {failures:?}"
);
}
#[test]
fn lineage_diamond_no_cycle() {
let edges = vec![
edge("A", "B"),
edge("A", "C"),
edge("B", "D"),
edge("C", "D"),
];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert!(
failures.is_empty(),
"DAG diamond must not be reported as cycle, got: {failures:?}"
);
}
#[test]
fn lineage_depth_limit_fires_on_long_linear_chain() {
let edges: Vec<LineageEdge> = (0..10)
.map(|i| edge(&format!("N{i}"), &format!("N{}", i + 1)))
.collect();
let failures = detect_lineage_failures(&edges, 5);
assert!(
failures.iter().any(|f| f.error.contains("maximum depth")),
"depth limit must fire on long linear chain, got: {failures:?}"
);
}
#[test]
fn lineage_canonicalise_cycle_basic() {
let a = canonicalise_cycle(&["A", "B", "C"]);
let b = canonicalise_cycle(&["B", "C", "A"]);
let c = canonicalise_cycle(&["C", "A", "B"]);
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn lineage_canonicalise_cycle_distinguishes_distinct() {
let a = canonicalise_cycle(&["A", "B"]);
let b = canonicalise_cycle(&["A", "C"]);
assert_ne!(a, b);
}
fn antigen_decl(type_name: &str) -> AntigenDeclaration {
AntigenDeclaration {
name: type_name.to_lowercase(),
type_name: type_name.to_string(),
file: PathBuf::from("test.rs"),
line: 1,
family: None,
summary: None,
fingerprint: None,
canonical_path: None,
}
}
#[test]
fn orphaned_lineage_edges_empty_report_returns_empty() {
let report = ScanReport::default();
assert!(report.orphaned_lineage_edges().is_empty());
}
#[test]
fn orphaned_lineage_edges_known_parent_not_orphan() {
let mut report = ScanReport::default();
report.antigens.push(antigen_decl("Parent"));
report.antigens.push(antigen_decl("Child"));
report.lineage_edges.push(edge("Child", "Parent"));
assert!(report.orphaned_lineage_edges().is_empty());
}
#[test]
fn orphaned_lineage_edges_unknown_parent_is_orphan() {
let mut report = ScanReport::default();
report.antigens.push(antigen_decl("Child"));
report.lineage_edges.push(edge("Child", "MissingParent"));
let orphans = report.orphaned_lineage_edges();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].parent, "MissingParent");
}
#[test]
fn atk_a3_dup_duplicate_lineage_edge_is_diagnosed_not_silent() {
let edges = vec![
edge("A", "B"),
edge("A", "B"), ];
let (deduped, failures) = dedupe_lineage_edges(&edges);
assert_eq!(
deduped.len(),
1,
"duplicate edges must collapse to one in the deduped output"
);
assert!(
!failures.is_empty(),
"duplicate lineage edge (A->B twice) must produce at least one \
diagnostic; got: {failures:?}"
);
}
#[test]
fn dedupe_distinguishes_edges_by_canonical_path() {
let edge_v1 = LineageEdge {
child: "Child".to_string(),
parent: "Parent".to_string(),
file: PathBuf::from("test.rs"),
line: 1,
parent_canonical_path: Some("foo@1.0.0".to_string()),
child_canonical_path: None,
};
let edge_v2 = LineageEdge {
child: "Child".to_string(),
parent: "Parent".to_string(),
file: PathBuf::from("test.rs"),
line: 2,
parent_canonical_path: Some("foo@2.0.0".to_string()),
child_canonical_path: None,
};
let (deduped, failures) = dedupe_lineage_edges(&[edge_v1, edge_v2]);
assert_eq!(
deduped.len(),
2,
"edges differing in parent_canonical_path are distinct identities, \
not duplicates"
);
assert!(
failures.is_empty(),
"no dedup failure should fire for cross-version edges; got: {failures:?}"
);
}
#[test]
fn atk_a3_shared_two_cycles_sharing_a_node_both_reported() {
let edges = vec![
edge("A", "B"),
edge("B", "A"),
edge("A", "C"),
edge("C", "A"),
];
let failures = detect_lineage_failures(&edges, MAX_LINEAGE_DEPTH);
assert_eq!(
failures.len(),
2,
"two distinct cycles sharing node A must both be reported; \
got: {failures:?}"
);
let texts: Vec<&str> = failures.iter().map(|f| f.error.as_str()).collect();
assert!(
texts.iter().any(|t| t.contains('A') && t.contains('B')),
"one failure must name the A-B cycle; texts: {texts:?}"
);
assert!(
texts.iter().any(|t| t.contains('A') && t.contains('C')),
"one failure must name the A-C cycle; texts: {texts:?}"
);
}
#[test]
fn atk_a3_combined_cycle_and_depth_exceeded_both_reported() {
let depth = 3_usize;
let mut edges = vec![edge("X", "Y"), edge("Y", "X")];
for i in 0..=(depth + 2) {
edges.push(edge(&format!("N{i}"), &format!("N{}", i + 1)));
}
let failures = detect_lineage_failures(&edges, depth);
assert!(
failures.iter().any(|f| f.error.contains("cycle")),
"cycle X->Y->X must be detected even when long chain is also present; \
all failures: {failures:?}"
);
assert!(
failures.iter().any(|f| f.error.contains("maximum depth")),
"depth limit must fire on long chain even when cycle is also present; \
all failures: {failures:?}"
);
}
#[test]
fn atk_a3_orphan_child_without_antigen_declaration_is_surfaced() {
let mut report = ScanReport::default();
report.antigens.push(antigen_decl("Parent"));
report.lineage_edges.push(edge("OrphanChild", "Parent"));
let orphans = report.orphaned_lineage_edges();
let dangling = report.dangling_child_lineage_edges();
assert!(
!orphans.is_empty() || !dangling.is_empty() || !report.parse_failures.is_empty(),
"lineage edge whose child has no #[antigen] declaration must be \
surfaced via orphaned_lineage_edges, dangling_child_lineage_edges, or \
parse_failures; got orphans: {orphans:?}, dangling: {dangling:?}"
);
assert!(
orphans.is_empty(),
"child-missing case must NOT appear in orphaned_lineage_edges \
(that channel is for parent-missing); got: {orphans:?}"
);
assert_eq!(
dangling.len(),
1,
"child-missing must appear in dangling_child_lineage_edges, exactly one"
);
assert_eq!(dangling[0].child, "OrphanChild");
assert_eq!(dangling[0].parent, "Parent");
}
#[test]
fn stamp_canonical_path_sets_none_to_some() {
let mut report = ScanReport::default();
report.antigens.push(antigen_decl("Foo"));
report.lineage_edges.push(edge("Child", "Parent"));
report.stamp_canonical_path("crate-a@1.0.0");
assert_eq!(
report.antigens[0].canonical_path.as_deref(),
Some("crate-a@1.0.0"),
"antigens with canonical_path: None must be stamped"
);
assert_eq!(
report.lineage_edges[0].parent_canonical_path.as_deref(),
Some("crate-a@1.0.0")
);
assert_eq!(
report.lineage_edges[0].child_canonical_path.as_deref(),
Some("crate-a@1.0.0")
);
}
#[test]
fn stamp_canonical_path_does_not_overwrite_some() {
let mut a = antigen_decl("Foo");
a.canonical_path = Some("crate-a@1.0.0".to_string());
let mut report = ScanReport::default();
report.antigens.push(a);
report.stamp_canonical_path("crate-b@2.0.0");
assert_eq!(
report.antigens[0].canonical_path.as_deref(),
Some("crate-a@1.0.0"),
"pre-stamped Some(_) must NOT be overwritten by a later stamp call"
);
}
#[test]
fn stamp_canonical_path_is_idempotent() {
let mut report = ScanReport::default();
report.antigens.push(antigen_decl("Foo"));
report.stamp_canonical_path("crate-a@1.0.0");
let after_first = report.clone();
report.stamp_canonical_path("crate-a@1.0.0");
assert_eq!(
report.antigens[0].canonical_path, after_first.antigens[0].canonical_path,
"stamping with same crate_id twice must be idempotent"
);
}
}