Skip to main content

harn_modules/
fingerprint.rs

1//! Interface fingerprints — a stable hash of a module's public surface.
2//!
3//! The fingerprint covers exactly the parts of a module that downstream
4//! importers can observe:
5//!
6//! * public functions, pipelines, tools, skills (name + signature)
7//! * public structs, enums, type aliases, interfaces (full shape)
8//! * `pub import` re-exports (target path + selective names)
9//!
10//! It intentionally excludes anything internal — function bodies,
11//! comments, private helpers, local variable bindings — so an edit that
12//! changes only the implementation of a public function leaves the
13//! fingerprint stable and dependents stay valid.
14//!
15//! The hash is BLAKE3 over a canonical textual rendering of the surface
16//! (alphabetized, single source of truth so trivial reorderings don't
17//! flip the fingerprint either).
18
19use std::fmt::Write as _;
20use std::path::Path;
21
22use harn_parser::{
23    peel_attributes, AssociatedType, EnumVariant, InterfaceMethod, Node, Parser, SNode, ShapeField,
24    StructField, TypeExpr, TypeParam, TypedParam, Variance, WhereClause,
25};
26
27use crate::read_module_source;
28
29/// A 32-byte BLAKE3 digest of a module's public surface.
30pub type Fingerprint = [u8; 32];
31
32/// Compute the interface fingerprint for `program` (the parsed top-level
33/// statements of a module). Returns the BLAKE3 digest of a canonical
34/// textual rendering — see the module docs for what's included.
35pub fn fingerprint_program(program: &[SNode]) -> Fingerprint {
36    let canonical = canonicalize_program(program);
37    blake3::hash(canonical.as_bytes()).into()
38}
39
40/// Convenience: parse `path` (real file or `<std>` virtual path) and
41/// fingerprint its public surface. Returns `None` when the source can't
42/// be read or doesn't lex/parse — callers can treat that as "no
43/// fingerprint" rather than erroring.
44pub fn fingerprint_file(path: &Path) -> Option<Fingerprint> {
45    let source = read_module_source(path)?;
46    fingerprint_source(&source)
47}
48
49/// Fingerprint an already-loaded source string. Lex + parse failures
50/// return `None`.
51pub fn fingerprint_source(source: &str) -> Option<Fingerprint> {
52    let mut lexer = harn_lexer::Lexer::new(source);
53    let tokens = lexer.tokenize().ok()?;
54    let program = Parser::new(tokens).parse().ok()?;
55    Some(fingerprint_program(&program))
56}
57
58/// Hex-encode a fingerprint for human-readable output (NDJSON events,
59/// logs, etc.).
60pub fn fingerprint_hex(fp: &Fingerprint) -> String {
61    let mut out = String::with_capacity(fp.len() * 2);
62    for b in fp {
63        write!(&mut out, "{b:02x}").expect("write to String is infallible");
64    }
65    out
66}
67
68fn canonicalize_program(program: &[SNode]) -> String {
69    let mut lines: Vec<String> = program.iter().filter_map(canonicalize_top_level).collect();
70    lines.sort();
71    lines.join("\n")
72}
73
74fn canonicalize_top_level(snode: &SNode) -> Option<String> {
75    let (_attrs, inner) = peel_attributes(snode);
76    match &inner.node {
77        Node::FnDecl {
78            name,
79            type_params,
80            params,
81            return_type,
82            type_predicate,
83            where_clauses,
84            is_pub,
85            is_stream,
86            ..
87        } => is_pub.then(|| {
88            format!(
89                "fn{stream}:{name}{generics}({params}){ret}{wheres}",
90                stream = if *is_stream { "*" } else { "" },
91                generics = format_type_params(type_params),
92                params = format_typed_params(params),
93                ret = format_return_contract(return_type, type_predicate.as_ref()),
94                wheres = format_where_clauses(where_clauses),
95            )
96        }),
97        Node::Pipeline {
98            name,
99            params,
100            return_type,
101            is_pub,
102            extends,
103            ..
104        } => is_pub.then(|| {
105            format!(
106                "pipeline:{name}({params}){ret}{extends}",
107                params = format_typed_params(params),
108                ret = format_return(return_type),
109                extends = extends
110                    .as_deref()
111                    .map(|e| format!(" extends {e}"))
112                    .unwrap_or_default(),
113            )
114        }),
115        Node::ToolDecl {
116            name,
117            params,
118            return_type,
119            is_pub,
120            ..
121        } => is_pub.then(|| {
122            format!(
123                "tool:{name}({params}){ret}",
124                params = format_typed_params(params),
125                ret = format_return(return_type),
126            )
127        }),
128        Node::SkillDecl { name, is_pub, .. } => {
129            // Skill bodies are configuration that downstream importers
130            // observe by reading the resulting registry dict, but a
131            // skill is identified by its name from a typing perspective.
132            // The conservative thing is to hash the name only and let
133            // body edits propagate via runtime registration rather than
134            // type-time invalidation.
135            is_pub.then(|| format!("skill:{name}"))
136        }
137        Node::StructDecl {
138            name,
139            type_params,
140            fields,
141            is_pub,
142        } => is_pub.then(|| {
143            format!(
144                "struct:{name}{generics}{{{fields}}}",
145                generics = format_type_params(type_params),
146                fields = format_struct_fields(fields),
147            )
148        }),
149        Node::EnumDecl {
150            name,
151            type_params,
152            variants,
153            is_pub,
154        } => is_pub.then(|| {
155            format!(
156                "enum:{name}{generics}{{{variants}}}",
157                generics = format_type_params(type_params),
158                variants = format_enum_variants(variants),
159            )
160        }),
161        Node::InterfaceDecl {
162            name,
163            type_params,
164            associated_types,
165            methods,
166        } => Some(format!(
167            "interface:{name}{generics}{{assoc=[{assoc}]methods=[{methods}]}}",
168            generics = format_type_params(type_params),
169            assoc = format_associated_types(associated_types),
170            methods = format_interface_methods(methods),
171        )),
172        Node::TypeDecl {
173            name,
174            type_params,
175            type_expr,
176            is_pub,
177        } => is_pub.then(|| {
178            format!(
179                "type:{name}{generics}={ty}",
180                generics = format_type_params(type_params),
181                ty = format_type_expr(type_expr),
182            )
183        }),
184        Node::ImportDecl { path, is_pub } => is_pub.then(|| format!("pub_import_wildcard:{path}")),
185        Node::SelectiveImport {
186            names,
187            path,
188            is_pub,
189        } => is_pub.then(|| {
190            let mut sorted = names.clone();
191            sorted.sort();
192            format!("pub_import_selective:{path}::{}", sorted.join(","))
193        }),
194        Node::NamespaceImport {
195            alias,
196            path,
197            is_pub,
198        } => is_pub.then(|| format!("pub_import_namespace:{path}::{alias}")),
199        _ => None,
200    }
201}
202
203fn format_type_params(params: &[TypeParam]) -> String {
204    if params.is_empty() {
205        return String::new();
206    }
207    let parts: Vec<String> = params
208        .iter()
209        .map(|p| {
210            let var = match p.variance {
211                Variance::Invariant => "",
212                Variance::Covariant => "out ",
213                Variance::Contravariant => "in ",
214            };
215            format!("{var}{}", p.name)
216        })
217        .collect();
218    format!("<{}>", parts.join(","))
219}
220
221fn format_typed_params(params: &[TypedParam]) -> String {
222    params
223        .iter()
224        .map(|p| {
225            let mut s = String::new();
226            if p.rest {
227                s.push_str("...");
228            }
229            s.push_str(&p.name);
230            if let Some(ty) = &p.type_expr {
231                s.push(':');
232                s.push_str(&format_type_expr(ty));
233            }
234            // Default values reference expressions whose shape we don't
235            // walk into; presence-only is enough — adding a default to
236            // a public parameter changes the callable contract.
237            if p.default_value.is_some() {
238                s.push_str("=?");
239            }
240            s
241        })
242        .collect::<Vec<_>>()
243        .join(",")
244}
245
246fn format_return(ret: &Option<TypeExpr>) -> String {
247    match ret {
248        Some(ty) => format!("->{}", format_type_expr(ty)),
249        None => String::new(),
250    }
251}
252
253fn format_return_contract(
254    ret: &Option<TypeExpr>,
255    predicate: Option<&harn_parser::TypePredicate>,
256) -> String {
257    match predicate {
258        Some(predicate) => format!(
259            "->{}{} is {}",
260            if predicate.one_sided { "implies " } else { "" },
261            predicate.parameter,
262            format_type_expr(&predicate.type_expr)
263        ),
264        None => format_return(ret),
265    }
266}
267
268fn format_where_clauses(clauses: &[WhereClause]) -> String {
269    if clauses.is_empty() {
270        return String::new();
271    }
272    let mut parts: Vec<String> = clauses
273        .iter()
274        .map(|w| format!("{}:{}", w.type_name, format_type_expr(&w.bound)))
275        .collect();
276    parts.sort();
277    format!(" where {}", parts.join(","))
278}
279
280fn format_struct_fields(fields: &[StructField]) -> String {
281    let mut rendered: Vec<String> = fields
282        .iter()
283        .map(|f| {
284            let opt = if f.optional { "?" } else { "" };
285            let ty = f
286                .type_expr
287                .as_ref()
288                .map(format_type_expr)
289                .unwrap_or_default();
290            format!("{}{opt}:{ty}", f.name)
291        })
292        .collect();
293    rendered.sort();
294    rendered.join(",")
295}
296
297fn format_enum_variants(variants: &[EnumVariant]) -> String {
298    let mut rendered: Vec<String> = variants
299        .iter()
300        .map(|v| format!("{}({})", v.name, format_typed_params(&v.fields)))
301        .collect();
302    rendered.sort();
303    rendered.join(",")
304}
305
306/// Renders name and default only: a fingerprint captures an interface's public
307/// shape, which does not change when a member merely moves in the source.
308fn format_associated_types(items: &[AssociatedType]) -> String {
309    let mut rendered: Vec<String> = items
310        .iter()
311        .map(|item| match &item.default {
312            Some(ty) => format!("{}:{}", item.name, format_type_expr(ty)),
313            None => item.name.clone(),
314        })
315        .collect();
316    rendered.sort();
317    rendered.join(",")
318}
319
320fn format_interface_methods(methods: &[InterfaceMethod]) -> String {
321    let mut rendered: Vec<String> = methods
322        .iter()
323        .map(|m| {
324            format!(
325                "{}{}({}){}",
326                m.name,
327                format_type_params(&m.type_params),
328                format_typed_params(&m.params),
329                format_return(&m.return_type),
330            )
331        })
332        .collect();
333    rendered.sort();
334    rendered.join(",")
335}
336
337fn format_type_expr(ty: &TypeExpr) -> String {
338    match ty {
339        TypeExpr::Named(name) => name.clone(),
340        TypeExpr::Union(parts) => {
341            let mut rendered: Vec<String> = parts.iter().map(format_type_expr).collect();
342            rendered.sort();
343            format!("({})", rendered.join("|"))
344        }
345        TypeExpr::Intersection(parts) => {
346            let mut rendered: Vec<String> = parts.iter().map(format_type_expr).collect();
347            rendered.sort();
348            format!("({})", rendered.join("&"))
349        }
350        TypeExpr::Shape(fields) => format!("{{{}}}", format_shape_fields(fields)),
351        TypeExpr::OpenShape { fields, rests } => {
352            let tails: Vec<String> = rests
353                .iter()
354                .map(|r| format!("...{}", format_type_expr(r)))
355                .collect();
356            format!("{{{}|{}}}", format_shape_fields(fields), tails.join(","))
357        }
358        TypeExpr::List(inner) => format!("list<{}>", format_type_expr(inner)),
359        TypeExpr::Tuple(items) => {
360            let rendered: Vec<String> = items.iter().map(format_type_expr).collect();
361            format!("tuple<{}>", rendered.join(","))
362        }
363        TypeExpr::DictType(k, v) => {
364            format!("dict<{},{}>", format_type_expr(k), format_type_expr(v))
365        }
366        TypeExpr::Iter(inner) => format!("iter<{}>", format_type_expr(inner)),
367        TypeExpr::Generator(inner) => format!("Generator<{}>", format_type_expr(inner)),
368        TypeExpr::Stream(inner) => format!("Stream<{}>", format_type_expr(inner)),
369        TypeExpr::Applied { name, args } => {
370            let rendered: Vec<String> = args.iter().map(format_type_expr).collect();
371            format!("{name}<{}>", rendered.join(","))
372        }
373        TypeExpr::FnType {
374            params,
375            return_type,
376        } => {
377            let rendered: Vec<String> = params.iter().map(format_type_expr).collect();
378            format!(
379                "fn({})->{}",
380                rendered.join(","),
381                format_type_expr(return_type)
382            )
383        }
384        TypeExpr::Never => "Never".to_string(),
385        TypeExpr::LitString(s) => format!("\"{s}\""),
386        TypeExpr::LitInt(n) => n.to_string(),
387        TypeExpr::Owned(inner) => format!("owned<{}>", format_type_expr(inner)),
388    }
389}
390
391fn format_shape_fields(fields: &[ShapeField]) -> String {
392    let mut rendered: Vec<String> = fields
393        .iter()
394        .map(|f| {
395            let opt = if f.optional { "?" } else { "" };
396            format!("{}{opt}:{}", f.name, format_type_expr(&f.type_expr))
397        })
398        .collect();
399    rendered.sort();
400    rendered.join(",")
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn fp(source: &str) -> Fingerprint {
408        fingerprint_source(source).expect("source parses")
409    }
410
411    #[test]
412    fn private_body_change_does_not_flip_fingerprint() {
413        let before = fp("pub fn add(a: int, b: int) -> int { a + b }\n");
414        let after = fp("pub fn add(a: int, b: int) -> int { const s = a + b; s }\n");
415        assert_eq!(before, after);
416    }
417
418    #[test]
419    fn private_helper_does_not_flip_fingerprint() {
420        let before = fp("pub fn entry() { internal() }\nfn internal() { 1 }\n");
421        let after = fp("pub fn entry() { internal() }\nfn internal() { 2 }\nfn extra() { 3 }\n");
422        assert_eq!(before, after);
423    }
424
425    #[test]
426    fn reordering_public_decls_does_not_flip_fingerprint() {
427        let a = fp("pub fn alpha() {}\npub fn beta() {}\n");
428        let b = fp("pub fn beta() {}\npub fn alpha() {}\n");
429        assert_eq!(a, b);
430    }
431
432    #[test]
433    fn changing_public_signature_flips_fingerprint() {
434        let before = fp("pub fn add(a: int, b: int) -> int { a + b }\n");
435        let after = fp("pub fn add(a: int, b: int, c: int) -> int { a + b + c }\n");
436        assert_ne!(before, after);
437    }
438
439    #[test]
440    fn changing_public_return_type_flips_fingerprint() {
441        let before = fp("pub fn make() -> string { \"x\" }\n");
442        let after = fp("pub fn make() -> int { 1 }\n");
443        assert_ne!(before, after);
444    }
445
446    #[test]
447    fn changing_public_type_predicate_flips_fingerprint() {
448        let before = fp("pub fn check(value: string | int) -> value is string { return true }\n");
449        let after =
450            fp("pub fn check(value: string | int) -> implies value is string { return true }\n");
451        assert_ne!(before, after);
452    }
453
454    #[test]
455    fn changing_public_pipeline_parameter_type_flips_fingerprint() {
456        let before = fp("pub pipeline deploy(config: string) -> bool { true }\n");
457        let after = fp("pub pipeline deploy(config: dict) -> bool { true }\n");
458        assert_ne!(before, after);
459    }
460
461    #[test]
462    fn adding_pub_struct_field_flips_fingerprint() {
463        let before = fp("pub struct Point { x: int, y: int }\n");
464        let after = fp("pub struct Point { x: int, y: int, z: int }\n");
465        assert_ne!(before, after);
466    }
467
468    #[test]
469    fn pub_re_export_change_flips_fingerprint() {
470        let before = fp("pub import { foo } from \"./a\"\n");
471        let after = fp("pub import { foo, bar } from \"./a\"\n");
472        assert_ne!(before, after);
473    }
474
475    #[test]
476    fn adding_pub_decl_flips_fingerprint() {
477        let before = fp("pub fn alpha() {}\n");
478        let after = fp("pub fn alpha() {}\npub fn beta() {}\n");
479        assert_ne!(before, after);
480    }
481
482    #[test]
483    fn changing_only_non_pub_imports_does_not_flip_fingerprint() {
484        let before = fp("import \"./a\"\npub fn entry() {}\n");
485        let after = fp("import \"./b\"\npub fn entry() {}\n");
486        // Private imports affect what _this_ module sees but not what
487        // downstreams see, so they're outside the public surface.
488        assert_eq!(before, after);
489    }
490}