Skip to main content

provable_contracts/ontology/extract/
code.rs

1//! ONT-001 §3.7, §5 ONT-4b2 — `extract:code`: the Rust symbols the bindings name become `ont:Symbol` nodes.
2//!
3//! The focus objects are the **bound** symbols — every `module_path` + `function` a `binding.yaml` under the
4//! contract dir declares — not every function in the workspace (3.9 M lines; a graph of every `fn` would be a
5//! second copy of the tree, and no contract constrains a symbol nothing binds). Each is resolved by a `syn`
6//! **module-tree walk**: the crate root from the workspace's `Cargo.toml`s (`[package] name` and `[lib] name`,
7//! both spellings), then one `mod` per path segment — inline, `mod x;` as `x.rs` / `x/mod.rs` / `#[path]`, or a
8//! `pub use` re-export followed once — and finally a free `fn` or an `impl` method of that name. Only the files
9//! on the walk are parsed, so the cost is bindings × depth, not the workspace.
10//!
11//! **Fail-closed, said in the graph.** A symbol the walk cannot find is still a node — `sym:resolved false`
12//! with `sym:unresolvedReason` naming the file and the segment — so a shape can require `sym:resolved true`
13//! and a ghost binding (ONT-3a's thesis) is a violation naming the symbol, not a silently smaller graph.
14//! Nothing is inferred: the module path is the binding's own text, normalized only by dropping a trailing
15//! segment equal to the function (the registries write both `a::b::f` + `f` and `a::b` + `f`).
16//!
17//! IRI: `https://ont.paiml.dev/v1alpha1/symbol/<crate>::<module>::<function>`. Vocabulary `sym:*`. Attributes
18//! are recorded by path (`inline`, `kernel`, …) so ONT-4c3 can type a `#[kernel]` symbol `ont:Kernel` from the
19//! same walk. Deterministic: registries and bindings are visited in byte order and the graph is a set (R-15).
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use crate::binding::{parse_binding, BindingRegistry, ImplStatus};
25use crate::ontology::rdf::{iri, ont, Graph, Term, PROV_ENTITY, RDF_TYPE};
26
27/// A `sym:*` vocabulary term.
28#[must_use]
29pub fn sym(name: &str) -> String {
30    ont(&format!("sym/{name}"))
31}
32
33/// What the walk found for one bound symbol.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Resolved {
36    /// Repo-relative file the item is defined in.
37    pub file: String,
38    /// `pub`, `pub(crate)`, `pub(super)`, `pub(in …)` or `private`.
39    pub visibility: String,
40    /// `fn` or `method`.
41    pub kind: String,
42    /// Attribute paths on the item, in source order (`inline`, `kernel`, `cfg`, …).
43    pub attributes: Vec<String>,
44}
45
46/// Why the walk stopped.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Unresolved {
49    pub reason: String,
50}
51
52/// Counts the extractor reports beside the graph.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct CodeStats {
55    pub registries: usize,
56    pub symbols: usize,
57    pub resolved: usize,
58    pub unresolved: usize,
59    pub files_parsed: usize,
60}
61
62/// One bound symbol as a registry states it.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Bound {
65    pub contract: String,
66    pub equation: String,
67    pub module_path: String,
68    pub function: String,
69    pub status: ImplStatus,
70}
71
72/// Every `binding.yaml` under `contract_dir` (any depth, byte order), parsed. Walked here, not through
73/// `collect_yaml_files`, which excludes registries by name because they are not contracts. A file that does not
74/// parse as a registry is skipped: registries are validated by `pv validate` (BINDING-001..006), not here.
75#[must_use]
76pub fn registries(contract_dir: &Path) -> Vec<(PathBuf, BindingRegistry)> {
77    let mut files = Vec::new();
78    let mut stack = vec![contract_dir.to_path_buf()];
79    while let Some(dir) = stack.pop() {
80        let Ok(entries) = std::fs::read_dir(&dir) else {
81            continue;
82        };
83        for path in entries.flatten().map(|e| e.path()) {
84            if path.is_dir() {
85                stack.push(path);
86            } else if path.file_name().is_some_and(|n| n == "binding.yaml") {
87                files.push(path);
88            }
89        }
90    }
91    files.sort();
92    files
93        .into_iter()
94        .filter_map(|f| parse_binding(&f).ok().map(|r| (f, r)))
95        .collect()
96}
97
98/// The bound symbols of one registry, with the module path normalized (a trailing segment equal to the function
99/// is dropped). Bindings without a `function` bind nothing and are skipped.
100#[must_use]
101pub fn bound_of(registry: &BindingRegistry) -> Vec<Bound> {
102    registry
103        .bindings
104        .iter()
105        .filter_map(|b| {
106            let function = b.function.clone()?;
107            let mut module_path = b
108                .module_path
109                .clone()
110                .unwrap_or_else(|| registry.target_crate.clone());
111            // `function: tokenize::run` under `module_path: apr_cli::commands::tokenize` — the registries
112            // also write a qualified function; the qualifier joins the module path (once, if it is not
113            // already its tail) and the function is the last segment.
114            let function = match function.rsplit_once("::") {
115                Some((qualifier, name)) => {
116                    if !module_path.ends_with(&format!("::{qualifier}")) && module_path != qualifier
117                    {
118                        module_path = format!("{module_path}::{qualifier}");
119                    }
120                    name.to_string()
121                }
122                None => function,
123            };
124            let module_path = module_path
125                .strip_suffix(&format!("::{function}"))
126                .unwrap_or(&module_path)
127                .to_string();
128            Some(Bound {
129                contract: crate::binding::normalize_contract_id(&b.contract).to_string(),
130                equation: b.equation.clone(),
131                module_path,
132                function,
133                status: b.status,
134            })
135        })
136        .collect()
137}
138
139/// The crates of a workspace: every `Cargo.toml` under `root` (skipping build and vcs dirs), keyed by both the
140/// package name and the `[lib] name`, `-` spelled `_`, mapped to the crate root files that claim the name —
141/// more than one when a facade package and the library it re-exports share it (aprender's root `aprender`
142/// is `pub use aprender_ml::*` over `crates/aprender-core`, whose `[lib] name` is also `aprender`). The walk
143/// tries each in byte order of the manifest path and keeps the first that resolves.
144#[derive(Debug, Default)]
145pub struct Workspace {
146    pub root: PathBuf,
147    pub crates: BTreeMap<String, Vec<PathBuf>>,
148}
149
150impl Workspace {
151    /// Scan `root` for manifests. A manifest without a `[package]` name (a virtual workspace root) contributes
152    /// nothing.
153    #[must_use]
154    pub fn scan(root: &Path) -> Self {
155        let mut ws = Self {
156            root: root.to_path_buf(),
157            crates: BTreeMap::new(),
158        };
159        let mut stack = vec![root.to_path_buf()];
160        while let Some(dir) = stack.pop() {
161            let Ok(entries) = std::fs::read_dir(&dir) else {
162                continue;
163            };
164            let mut children: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
165            children.sort();
166            for path in children {
167                if path.is_dir() {
168                    let skip = matches!(
169                        path.file_name().and_then(|n| n.to_str()),
170                        Some("target" | ".git" | ".lake" | "node_modules")
171                    );
172                    if !skip {
173                        stack.push(path);
174                    }
175                } else if path.file_name().is_some_and(|n| n == "Cargo.toml") {
176                    ws.index_manifest(&path);
177                }
178            }
179        }
180        ws
181    }
182
183    fn index_manifest(&mut self, manifest: &Path) {
184        let Ok(text) = std::fs::read_to_string(manifest) else {
185            return;
186        };
187        let m = manifest_names(&text);
188        let dir = manifest.parent().unwrap_or(manifest);
189        let lib_path = m
190            .lib_path
191            .map_or_else(|| dir.join("src/lib.rs"), |p| dir.join(p));
192        for name in [m.package, m.lib].into_iter().flatten() {
193            let roots = self.crates.entry(name.replace('-', "_")).or_default();
194            if !roots.contains(&lib_path) {
195                roots.push(lib_path.clone());
196            }
197        }
198    }
199}
200
201#[derive(Debug, Default)]
202struct ManifestNames {
203    package: Option<String>,
204    lib: Option<String>,
205    lib_path: Option<String>,
206}
207
208/// `[package] name`, `[lib] name` and `[lib] path` by a section-aware line scan — the two keys this walk needs,
209/// read without a TOML crate.
210fn manifest_names(text: &str) -> ManifestNames {
211    let mut out = ManifestNames::default();
212    let mut section = String::new();
213    for line in text.lines() {
214        let t = line.trim();
215        if t.starts_with('[') && t.ends_with(']') {
216            section = t.trim_matches(['[', ']']).trim().to_string();
217            continue;
218        }
219        let Some((k, v)) = t.split_once('=') else {
220            continue;
221        };
222        let k = k.trim();
223        let v = v.trim().trim_matches('"').to_string();
224        match (section.as_str(), k) {
225            ("package", "name") => out.package = Some(v),
226            ("lib", "name") => out.lib = Some(v),
227            ("lib", "path") => out.lib_path = Some(v),
228            _ => {}
229        }
230    }
231    out
232}
233
234/// The `syn` walker with a per-file parse cache.
235pub struct Resolver<'a> {
236    ws: &'a Workspace,
237    cache: BTreeMap<PathBuf, Option<std::rc::Rc<syn::File>>>,
238}
239
240/// One module on the walk: its items, the file they came from, and the directory its child files live in.
241struct Module {
242    items: Vec<syn::Item>,
243    file: PathBuf,
244    child_dir: PathBuf,
245}
246
247impl<'a> Resolver<'a> {
248    #[must_use]
249    pub fn new(ws: &'a Workspace) -> Self {
250        Self {
251            ws,
252            cache: BTreeMap::new(),
253        }
254    }
255
256    /// Files parsed so far (each once).
257    #[must_use]
258    pub fn files_parsed(&self) -> usize {
259        self.cache.values().filter(|f| f.is_some()).count()
260    }
261
262    fn parse(&mut self, file: &Path) -> Option<std::rc::Rc<syn::File>> {
263        if let Some(hit) = self.cache.get(file) {
264            return hit.clone();
265        }
266        let parsed = std::fs::read_to_string(file)
267            .ok()
268            .and_then(|src| syn::parse_file(&src).ok())
269            .map(std::rc::Rc::new);
270        self.cache.insert(file.to_path_buf(), parsed.clone());
271        parsed
272    }
273
274    /// The crate root files that claim `krate`, in manifest order.
275    fn crate_roots(&self, krate: &str) -> Result<Vec<PathBuf>, Unresolved> {
276        let roots: Vec<PathBuf> = self
277            .ws
278            .crates
279            .get(&krate.replace('-', "_"))
280            .cloned()
281            .unwrap_or_default();
282        if roots.is_empty() {
283            return Err(Unresolved {
284                reason: format!("crate `{krate}` is not a workspace member"),
285            });
286        }
287        Ok(roots)
288    }
289
290    fn file_module(&mut self, file: &Path, child_dir: PathBuf) -> Result<Module, Unresolved> {
291        let Some(ast) = self.parse(file) else {
292            return Err(Unresolved {
293                reason: format!("`{}` is missing or does not parse", self.rel(file)),
294            });
295        };
296        Ok(Module {
297            items: ast.items.clone(),
298            file: file.to_path_buf(),
299            child_dir,
300        })
301    }
302
303    /// Resolve `module_path::function` to its definition. `depth` bounds re-export chasing.
304    pub fn resolve(&mut self, module_path: &str, function: &str) -> Result<Resolved, Unresolved> {
305        self.resolve_depth(module_path, function, 0)
306    }
307
308    fn resolve_depth(
309        &mut self,
310        module_path: &str,
311        function: &str,
312        depth: usize,
313    ) -> Result<Resolved, Unresolved> {
314        if depth > 8 {
315            return Err(Unresolved {
316                reason: format!("`{module_path}::{function}`: re-export chain deeper than 8"),
317            });
318        }
319        let mut segs = module_path.split("::").filter(|s| !s.is_empty());
320        let Some(krate) = segs.next() else {
321            return Err(Unresolved {
322                reason: "empty module path".to_string(),
323            });
324        };
325        let segs: Vec<&str> = segs.collect();
326        let mut last = Unresolved {
327            reason: String::new(),
328        };
329        for root in self.crate_roots(krate)? {
330            match self.resolve_in_root(&root, &segs, function, depth) {
331                Ok(r) => return Ok(r),
332                Err(e) => last = e,
333            }
334        }
335        Err(last)
336    }
337
338    /// The walk from one crate root file.
339    fn resolve_in_root(
340        &mut self,
341        root: &Path,
342        segs: &[&str],
343        function: &str,
344        depth: usize,
345    ) -> Result<Resolved, Unresolved> {
346        let mut module = self.file_module(root, root.parent().unwrap_or(root).to_path_buf())?;
347        for (i, seg) in segs.iter().enumerate() {
348            match self.step(&module, seg)? {
349                Step::Module(next) => module = next,
350                Step::ReExport(target) => {
351                    let rest = segs[i + 1..].join("::");
352                    let path = if rest.is_empty() {
353                        target
354                    } else {
355                        format!("{target}::{rest}")
356                    };
357                    return self.resolve_depth(&path, function, depth + 1);
358                }
359            }
360        }
361        match find_item(&module.items, function) {
362            Some(mut r) => {
363                r.file = self.rel(&module.file);
364                Ok(r)
365            }
366            None => match use_target(&module.items, function) {
367                Some(target) => {
368                    let target = absolute_use(&target, &module, self.ws);
369                    self.resolve_by_use(&target, depth)
370                }
371                None => Err(Unresolved {
372                    reason: format!(
373                        "no `fn {function}` (free or in an impl) in `{}`",
374                        self.rel(&module.file)
375                    ),
376                }),
377            },
378        }
379    }
380
381    fn resolve_by_use(&mut self, target: &str, depth: usize) -> Result<Resolved, Unresolved> {
382        let (path, name) = target.rsplit_once("::").ok_or_else(|| Unresolved {
383            reason: format!("re-export `{target}` has no module path"),
384        })?;
385        self.resolve_depth(path, name, depth + 1)
386    }
387
388    fn step(&mut self, module: &Module, seg: &str) -> Result<Step, Unresolved> {
389        for item in &module.items {
390            if let syn::Item::Mod(m) = item {
391                if m.ident != seg {
392                    continue;
393                }
394                if let Some((_, content)) = &m.content {
395                    return Ok(Step::Module(Module {
396                        items: content.clone(),
397                        file: module.file.clone(),
398                        child_dir: module.child_dir.join(seg),
399                    }));
400                }
401                let (file, child_dir) = child_file(&module.child_dir, seg, &m.attrs)?;
402                return self.file_module(&file, child_dir).map(Step::Module);
403            }
404        }
405        if let Some(target) = use_target(&module.items, seg) {
406            let target = absolute_use(&target, module, self.ws);
407            return Ok(Step::ReExport(target));
408        }
409        Err(Unresolved {
410            reason: format!(
411                "no `mod {seg}` or `use … {seg}` in `{}`",
412                self.rel(&module.file)
413            ),
414        })
415    }
416
417    fn rel(&self, file: &Path) -> String {
418        file.strip_prefix(&self.ws.root)
419            .unwrap_or(file)
420            .to_string_lossy()
421            .replace('\\', "/")
422    }
423}
424
425enum Step {
426    Module(Module),
427    ReExport(String),
428}
429
430/// `mod seg;` → `#[path = "…"]` relative to the child dir, else `seg.rs`, else `seg/mod.rs`. The child dir of
431/// the new module is `<dir>/seg/` either way.
432fn child_file(
433    child_dir: &Path,
434    seg: &str,
435    attrs: &[syn::Attribute],
436) -> Result<(PathBuf, PathBuf), Unresolved> {
437    for a in attrs {
438        if a.path().is_ident("path") {
439            if let syn::Meta::NameValue(nv) = &a.meta {
440                if let syn::Expr::Lit(syn::ExprLit {
441                    lit: syn::Lit::Str(s),
442                    ..
443                }) = &nv.value
444                {
445                    let f = child_dir.join(s.value());
446                    return Ok((f.clone(), f.parent().unwrap_or(&f).to_path_buf()));
447                }
448            }
449        }
450    }
451    let flat = child_dir.join(format!("{seg}.rs"));
452    if flat.is_file() {
453        return Ok((flat, child_dir.join(seg)));
454    }
455    let nested = child_dir.join(seg).join("mod.rs");
456    if nested.is_file() {
457        return Ok((nested, child_dir.join(seg)));
458    }
459    Err(Unresolved {
460        reason: format!(
461            "`mod {seg};` declared but neither `{}` nor `{}` exists",
462            flat.display(),
463            nested.display()
464        ),
465    })
466}
467
468/// A free `fn name` or an `impl … { fn name }` among `items`.
469fn find_item(items: &[syn::Item], name: &str) -> Option<Resolved> {
470    items.iter().find_map(|item| match item {
471        syn::Item::Fn(f) if f.sig.ident == name => Some(found("fn", &f.vis, &f.attrs)),
472        syn::Item::Impl(im) => find_method(&im.items, name),
473        _ => None,
474    })
475}
476
477/// The `fn name` of one `impl` block.
478fn find_method(items: &[syn::ImplItem], name: &str) -> Option<Resolved> {
479    items.iter().find_map(|ii| match ii {
480        syn::ImplItem::Fn(f) if f.sig.ident == name => Some(found("method", &f.vis, &f.attrs)),
481        _ => None,
482    })
483}
484
485/// A resolution with the file filled in by the caller that knows it.
486fn found(kind: &str, vis: &syn::Visibility, attrs: &[syn::Attribute]) -> Resolved {
487    Resolved {
488        file: String::new(),
489        visibility: visibility_of(vis),
490        kind: kind.to_string(),
491        attributes: attr_paths(attrs),
492    }
493}
494
495/// The path a `use` item binds `name` to, as written (`crate::a::b`, `self::x`, `super::y`, `other::z`), when
496/// one does; renames (`as`) are honoured.
497fn use_target(items: &[syn::Item], name: &str) -> Option<String> {
498    for item in items {
499        if let syn::Item::Use(u) = item {
500            if let Some(t) = use_tree_target(&u.tree, name, "") {
501                return Some(t);
502            }
503        }
504    }
505    None
506}
507
508fn use_tree_target(tree: &syn::UseTree, name: &str, prefix: &str) -> Option<String> {
509    let join = |p: &str, s: &str| {
510        if p.is_empty() {
511            s.to_string()
512        } else {
513            format!("{p}::{s}")
514        }
515    };
516    match tree {
517        syn::UseTree::Path(p) => {
518            use_tree_target(&p.tree, name, &join(prefix, &p.ident.to_string()))
519        }
520        syn::UseTree::Name(n) if n.ident == name => Some(join(prefix, &n.ident.to_string())),
521        syn::UseTree::Rename(r) if r.rename == name => Some(join(prefix, &r.ident.to_string())),
522        syn::UseTree::Group(g) => g
523            .items
524            .iter()
525            .find_map(|t| use_tree_target(t, name, prefix)),
526        _ => None,
527    }
528}
529
530/// Make a `use` path absolute from the crate root: `crate::` → the crate's name; `self::` → this module;
531/// `super::` → its parent; a bare leading segment is first tried as a workspace crate, else as a sibling module.
532fn absolute_use(target: &str, module: &Module, ws: &Workspace) -> String {
533    let here = module_path_of(module, ws);
534    let krate = here.split("::").next().unwrap_or_default().to_string();
535    let (head, rest) = target.split_once("::").unwrap_or((target, ""));
536    let base = match head {
537        "crate" => krate,
538        "self" => here.clone(),
539        "super" => here
540            .rsplit_once("::")
541            .map_or(here.clone(), |(p, _)| p.to_string()),
542        other if ws.crates.contains_key(&other.replace('-', "_")) => other.to_string(),
543        other => format!("{here}::{other}"),
544    };
545    if rest.is_empty() {
546        base
547    } else {
548        format!("{base}::{rest}")
549    }
550}
551
552/// The module path of a module on the walk, from its child dir relative to the crate's `src/`:
553/// `<crate>` for the root, `<crate>::a::b` below it.
554fn module_path_of(module: &Module, ws: &Workspace) -> String {
555    // The crate whose `src/` is the LONGEST prefix of the module's dir owns it (a workspace root package's
556    // `src/` is a prefix of nothing under `crates/`, but nested crates exist).
557    let mut best: Option<(usize, String)> = None;
558    for (name, roots) in &ws.crates {
559        for root in roots {
560            let Some(src) = root.parent() else { continue };
561            let Ok(rel) = module.child_dir.strip_prefix(src) else {
562                continue;
563            };
564            let depth = src.components().count();
565            if best.as_ref().is_some_and(|(d, _)| *d >= depth) {
566                continue;
567            }
568            let tail: Vec<String> = rel
569                .components()
570                .map(|c| c.as_os_str().to_string_lossy().to_string())
571                .collect();
572            let path = if tail.is_empty() {
573                name.clone()
574            } else {
575                format!("{name}::{}", tail.join("::"))
576            };
577            best = Some((depth, path));
578        }
579    }
580    best.map(|(_, p)| p).unwrap_or_default()
581}
582
583fn visibility_of(v: &syn::Visibility) -> String {
584    match v {
585        syn::Visibility::Public(_) => "pub".to_string(),
586        syn::Visibility::Restricted(r) => {
587            let p = r
588                .path
589                .segments
590                .iter()
591                .map(|s| s.ident.to_string())
592                .collect::<Vec<_>>()
593                .join("::");
594            format!("pub({p})")
595        }
596        syn::Visibility::Inherited => "private".to_string(),
597    }
598}
599
600fn attr_paths(attrs: &[syn::Attribute]) -> Vec<String> {
601    attrs
602        .iter()
603        .map(|a| {
604            a.path()
605                .segments
606                .iter()
607                .map(|s| s.ident.to_string())
608                .collect::<Vec<_>>()
609                .join("::")
610        })
611        .collect()
612}
613
614/// The symbol IRI of a bound symbol.
615#[must_use]
616pub fn symbol_iri(b: &Bound) -> String {
617    iri("symbol", &format!("{}::{}", b.module_path, b.function))
618}
619
620/// One bound symbol into `g`, resolved or not.
621pub fn emit(g: &mut Graph, b: &Bound, found: &Result<Resolved, Unresolved>) {
622    let s = symbol_iri(b);
623    g.insert(s.clone(), RDF_TYPE, Term::iri(ont("Symbol")));
624    g.insert(s.clone(), RDF_TYPE, Term::iri(PROV_ENTITY));
625    let krate = b.module_path.split("::").next().unwrap_or_default();
626    g.insert(s.clone(), sym("crate"), Term::string(krate));
627    g.insert(s.clone(), sym("module"), Term::string(&b.module_path));
628    g.insert(s.clone(), sym("name"), Term::string(&b.function));
629    g.insert(
630        s.clone(),
631        sym("implements"),
632        Term::iri(iri("contract", &b.contract)),
633    );
634    g.insert(s.clone(), sym("equation"), Term::string(&b.equation));
635    g.insert(
636        s.clone(),
637        sym("bindingStatus"),
638        Term::string(format!("{:?}", b.status).to_lowercase()),
639    );
640    match found {
641        Ok(r) => {
642            g.insert(s.clone(), sym("resolved"), Term::boolean(true));
643            g.insert(s.clone(), sym("file"), Term::string(&r.file));
644            g.insert(s.clone(), sym("visibility"), Term::string(&r.visibility));
645            g.insert(s.clone(), sym("kind"), Term::string(&r.kind));
646            for a in &r.attributes {
647                g.insert(s.clone(), sym("attribute"), Term::string(a));
648            }
649        }
650        Err(u) => {
651            g.insert(s.clone(), sym("resolved"), Term::boolean(false));
652            g.insert(s, sym("unresolvedReason"), Term::string(&u.reason));
653        }
654    }
655}
656
657/// Every bound symbol of every registry under `contract_dir`, resolved against the workspace at the contract
658/// dir's parent, into `g`.
659pub fn extract(contract_dir: &Path, g: &mut Graph) -> CodeStats {
660    let root_buf = super::repo_root(contract_dir);
661    let root = root_buf.as_path();
662    let ws = Workspace::scan(root);
663    let mut resolver = Resolver::new(&ws);
664    let mut stats = CodeStats::default();
665    for (_file, registry) in registries(contract_dir) {
666        stats.registries += 1;
667        for b in bound_of(&registry) {
668            let found = resolver.resolve(&b.module_path, &b.function);
669            if found.is_ok() {
670                stats.resolved += 1;
671            } else {
672                stats.unresolved += 1;
673            }
674            stats.symbols += 1;
675            emit(g, &b, &found);
676        }
677    }
678    stats.files_parsed = resolver.files_parsed();
679    stats
680}
681
682/// Positive control (`pc_extract.code`, run in memory every gate run): the resolver must find a function that
683/// is there and must refuse one that is not — a walker that resolved everything would make every ghost binding
684/// a Pass.
685#[must_use]
686pub fn positive_control() -> bool {
687    let src = "pub mod m { pub fn present() {} }\npub use m::present as alias;";
688    let Ok(ast) = syn::parse_file(src) else {
689        return false;
690    };
691    let present = find_item(&ast.items, "present").is_none()
692        && match ast.items.first() {
693            Some(syn::Item::Mod(m)) => m
694                .content
695                .as_ref()
696                .is_some_and(|(_, items)| find_item(items, "present").is_some()),
697            _ => false,
698        };
699    let ghost =
700        find_item(&ast.items, "absent").is_none() && use_target(&ast.items, "absent").is_none();
701    let aliased = use_target(&ast.items, "alias").as_deref() == Some("m::present");
702    present && ghost && aliased
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    fn fixture() -> PathBuf {
710        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/ont/code-bound")
711    }
712
713    #[test]
714    fn the_manifest_scan_indexes_package_and_lib_names() {
715        let ws = Workspace::scan(&fixture());
716        assert!(ws.crates.contains_key("kern"), "{:?}", ws.crates);
717        assert!(ws.crates.contains_key("kern_crate"), "{:?}", ws.crates);
718        assert!(ws.crates["kern"][0].ends_with("crates/kern/src/lib.rs"));
719    }
720
721    #[test]
722    fn the_walk_resolves_files_inline_modules_methods_and_re_exports_and_refuses_a_ghost() {
723        let ws = Workspace::scan(&fixture());
724        let mut r = Resolver::new(&ws);
725        let softmax = r
726            .resolve("kern::nn::functional", "softmax")
727            .expect("softmax");
728        assert_eq!(softmax.file, "crates/kern/src/nn/functional.rs");
729        assert_eq!(softmax.visibility, "pub");
730        assert_eq!(softmax.kind, "fn");
731        assert_eq!(softmax.attributes, vec!["inline".to_string()]);
732        let relu = r.resolve("kern::nn::functional", "relu").expect("relu");
733        assert_eq!(relu.visibility, "pub(crate)");
734        let forward = r.resolve("kern::nn", "forward").expect("forward");
735        assert_eq!(forward.kind, "method");
736        assert_eq!(forward.file, "crates/kern/src/nn.rs");
737        let helper = r
738            .resolve("kern", "helper")
739            .expect("helper via pub use … as");
740        assert_eq!(helper.file, "crates/kern/src/lib.rs");
741        assert_eq!(helper.visibility, "pub");
742        let kernel = r
743            .resolve("kern::nn::functional", "gated_rmsnorm")
744            .expect("kernel");
745        assert_eq!(kernel.attributes, vec!["kernel".to_string()]);
746        let ghost = r
747            .resolve("kern::nn::functional", "no_such_function")
748            .unwrap_err();
749        assert!(
750            ghost.reason.contains("no `fn no_such_function`"),
751            "{}",
752            ghost.reason
753        );
754        assert!(
755            ghost.reason.contains("crates/kern/src/nn/functional.rs"),
756            "{}",
757            ghost.reason
758        );
759        let no_crate = r.resolve("nowhere::x", "f").unwrap_err();
760        assert!(no_crate.reason.contains("not a workspace member"));
761        let no_mod = r.resolve("kern::nope", "f").unwrap_err();
762        assert!(no_mod.reason.contains("no `mod nope`"), "{}", no_mod.reason);
763        assert_eq!(r.files_parsed(), 3);
764    }
765
766    #[test]
767    fn the_graph_carries_every_bound_symbol_resolved_or_not_and_is_deterministic() {
768        let dir = fixture().join("contracts");
769        let mut g = Graph::new();
770        let stats = extract(&dir, &mut g);
771        assert_eq!(stats.registries, 1);
772        assert_eq!(stats.symbols, 6);
773        assert_eq!(stats.resolved, 5);
774        assert_eq!(stats.unresolved, 1);
775        let nt = g.to_ntriples();
776        assert!(nt.contains("/symbol/kern::nn::functional::softmax> <https://ont.paiml.dev/v1alpha1/sym/resolved> \"true\""), "{nt}");
777        assert!(nt.contains("/symbol/kern::nn::functional::no_such_function> <https://ont.paiml.dev/v1alpha1/sym/resolved> \"false\""), "{nt}");
778        assert!(
779            nt.contains("sym/unresolvedReason> \"no `fn no_such_function`"),
780            "{nt}"
781        );
782        assert!(
783            nt.contains(
784                "sym/implements> <https://ont.paiml.dev/v1alpha1/contract/softmax-kernel-v1>"
785            ),
786            "{nt}"
787        );
788        assert!(nt.contains("sym/attribute> \"kernel\""), "{nt}");
789        assert!(!nt.contains("_:"));
790        let mut g2 = Graph::new();
791        extract(&dir, &mut g2);
792        assert_eq!(nt, g2.to_ntriples());
793    }
794
795    #[test]
796    fn a_trailing_segment_equal_to_the_function_is_dropped_from_the_module_path() {
797        let reg = parse_binding(&fixture().join("contracts/binding.yaml")).expect("registry");
798        let bound = bound_of(&reg);
799        let softmax = bound
800            .iter()
801            .find(|b| b.function == "softmax")
802            .expect("softmax");
803        assert_eq!(softmax.module_path, "kern::nn::functional");
804        assert_eq!(
805            symbol_iri(softmax),
806            "https://ont.paiml.dev/v1alpha1/symbol/kern::nn::functional::softmax"
807        );
808    }
809
810    #[test]
811    fn the_positive_control_fires() {
812        assert!(positive_control());
813    }
814
815    #[test]
816    fn manifest_names_reads_package_and_lib_sections_only() {
817        let m = manifest_names("[package]\nname = \"a-b\"\n[dependencies]\nname = \"x\"\n[lib]\nname = \"ab\"\npath = \"src/x.rs\"\n");
818        assert_eq!(m.package.as_deref(), Some("a-b"));
819        assert_eq!(m.lib.as_deref(), Some("ab"));
820        assert_eq!(m.lib_path.as_deref(), Some("src/x.rs"));
821    }
822}