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            where_clauses,
83            is_pub,
84            is_stream,
85            ..
86        } => is_pub.then(|| {
87            format!(
88                "fn{stream}:{name}{generics}({params}){ret}{wheres}",
89                stream = if *is_stream { "*" } else { "" },
90                generics = format_type_params(type_params),
91                params = format_typed_params(params),
92                ret = format_return(return_type),
93                wheres = format_where_clauses(where_clauses),
94            )
95        }),
96        Node::Pipeline {
97            name,
98            params,
99            return_type,
100            is_pub,
101            extends,
102            ..
103        } => is_pub.then(|| {
104            format!(
105                "pipeline:{name}({params}){ret}{extends}",
106                params = format_typed_params(params),
107                ret = format_return(return_type),
108                extends = extends
109                    .as_deref()
110                    .map(|e| format!(" extends {e}"))
111                    .unwrap_or_default(),
112            )
113        }),
114        Node::ToolDecl {
115            name,
116            params,
117            return_type,
118            is_pub,
119            ..
120        } => is_pub.then(|| {
121            format!(
122                "tool:{name}({params}){ret}",
123                params = format_typed_params(params),
124                ret = format_return(return_type),
125            )
126        }),
127        Node::SkillDecl { name, is_pub, .. } => {
128            // Skill bodies are configuration that downstream importers
129            // observe by reading the resulting registry dict, but a
130            // skill is identified by its name from a typing perspective.
131            // The conservative thing is to hash the name only and let
132            // body edits propagate via runtime registration rather than
133            // type-time invalidation.
134            is_pub.then(|| format!("skill:{name}"))
135        }
136        Node::StructDecl {
137            name,
138            type_params,
139            fields,
140            is_pub,
141        } => is_pub.then(|| {
142            format!(
143                "struct:{name}{generics}{{{fields}}}",
144                generics = format_type_params(type_params),
145                fields = format_struct_fields(fields),
146            )
147        }),
148        Node::EnumDecl {
149            name,
150            type_params,
151            variants,
152            is_pub,
153        } => is_pub.then(|| {
154            format!(
155                "enum:{name}{generics}{{{variants}}}",
156                generics = format_type_params(type_params),
157                variants = format_enum_variants(variants),
158            )
159        }),
160        Node::InterfaceDecl {
161            name,
162            type_params,
163            associated_types,
164            methods,
165        } => Some(format!(
166            "interface:{name}{generics}{{assoc=[{assoc}]methods=[{methods}]}}",
167            generics = format_type_params(type_params),
168            assoc = format_associated_types(associated_types),
169            methods = format_interface_methods(methods),
170        )),
171        Node::TypeDecl {
172            name,
173            type_params,
174            type_expr,
175            is_pub,
176        } => is_pub.then(|| {
177            format!(
178                "type:{name}{generics}={ty}",
179                generics = format_type_params(type_params),
180                ty = format_type_expr(type_expr),
181            )
182        }),
183        Node::ImportDecl { path, is_pub } => is_pub.then(|| format!("pub_import_wildcard:{path}")),
184        Node::SelectiveImport {
185            names,
186            path,
187            is_pub,
188        } => is_pub.then(|| {
189            let mut sorted = names.clone();
190            sorted.sort();
191            format!("pub_import_selective:{path}::{}", sorted.join(","))
192        }),
193        Node::NamespaceImport {
194            alias,
195            path,
196            is_pub,
197        } => is_pub.then(|| format!("pub_import_namespace:{path}::{alias}")),
198        _ => None,
199    }
200}
201
202fn format_type_params(params: &[TypeParam]) -> String {
203    if params.is_empty() {
204        return String::new();
205    }
206    let parts: Vec<String> = params
207        .iter()
208        .map(|p| {
209            let var = match p.variance {
210                Variance::Invariant => "",
211                Variance::Covariant => "out ",
212                Variance::Contravariant => "in ",
213            };
214            format!("{var}{}", p.name)
215        })
216        .collect();
217    format!("<{}>", parts.join(","))
218}
219
220fn format_typed_params(params: &[TypedParam]) -> String {
221    params
222        .iter()
223        .map(|p| {
224            let mut s = String::new();
225            if p.rest {
226                s.push_str("...");
227            }
228            s.push_str(&p.name);
229            if let Some(ty) = &p.type_expr {
230                s.push(':');
231                s.push_str(&format_type_expr(ty));
232            }
233            // Default values reference expressions whose shape we don't
234            // walk into; presence-only is enough — adding a default to
235            // a public parameter changes the callable contract.
236            if p.default_value.is_some() {
237                s.push_str("=?");
238            }
239            s
240        })
241        .collect::<Vec<_>>()
242        .join(",")
243}
244
245fn format_return(ret: &Option<TypeExpr>) -> String {
246    match ret {
247        Some(ty) => format!("->{}", format_type_expr(ty)),
248        None => String::new(),
249    }
250}
251
252fn format_where_clauses(clauses: &[WhereClause]) -> String {
253    if clauses.is_empty() {
254        return String::new();
255    }
256    let mut parts: Vec<String> = clauses
257        .iter()
258        .map(|w| format!("{}:{}", w.type_name, format_type_expr(&w.bound)))
259        .collect();
260    parts.sort();
261    format!(" where {}", parts.join(","))
262}
263
264fn format_struct_fields(fields: &[StructField]) -> String {
265    let mut rendered: Vec<String> = fields
266        .iter()
267        .map(|f| {
268            let opt = if f.optional { "?" } else { "" };
269            let ty = f
270                .type_expr
271                .as_ref()
272                .map(format_type_expr)
273                .unwrap_or_default();
274            format!("{}{opt}:{ty}", f.name)
275        })
276        .collect();
277    rendered.sort();
278    rendered.join(",")
279}
280
281fn format_enum_variants(variants: &[EnumVariant]) -> String {
282    let mut rendered: Vec<String> = variants
283        .iter()
284        .map(|v| format!("{}({})", v.name, format_typed_params(&v.fields)))
285        .collect();
286    rendered.sort();
287    rendered.join(",")
288}
289
290/// Renders name and default only: a fingerprint captures an interface's public
291/// shape, which does not change when a member merely moves in the source.
292fn format_associated_types(items: &[AssociatedType]) -> String {
293    let mut rendered: Vec<String> = items
294        .iter()
295        .map(|item| match &item.default {
296            Some(ty) => format!("{}:{}", item.name, format_type_expr(ty)),
297            None => item.name.clone(),
298        })
299        .collect();
300    rendered.sort();
301    rendered.join(",")
302}
303
304fn format_interface_methods(methods: &[InterfaceMethod]) -> String {
305    let mut rendered: Vec<String> = methods
306        .iter()
307        .map(|m| {
308            format!(
309                "{}{}({}){}",
310                m.name,
311                format_type_params(&m.type_params),
312                format_typed_params(&m.params),
313                format_return(&m.return_type),
314            )
315        })
316        .collect();
317    rendered.sort();
318    rendered.join(",")
319}
320
321fn format_type_expr(ty: &TypeExpr) -> String {
322    match ty {
323        TypeExpr::Named(name) => name.clone(),
324        TypeExpr::Union(parts) => {
325            let mut rendered: Vec<String> = parts.iter().map(format_type_expr).collect();
326            rendered.sort();
327            format!("({})", rendered.join("|"))
328        }
329        TypeExpr::Intersection(parts) => {
330            let mut rendered: Vec<String> = parts.iter().map(format_type_expr).collect();
331            rendered.sort();
332            format!("({})", rendered.join("&"))
333        }
334        TypeExpr::Shape(fields) => format!("{{{}}}", format_shape_fields(fields)),
335        TypeExpr::OpenShape { fields, rests } => {
336            let tails: Vec<String> = rests
337                .iter()
338                .map(|r| format!("...{}", format_type_expr(r)))
339                .collect();
340            format!("{{{}|{}}}", format_shape_fields(fields), tails.join(","))
341        }
342        TypeExpr::List(inner) => format!("list<{}>", format_type_expr(inner)),
343        TypeExpr::Tuple(items) => {
344            let rendered: Vec<String> = items.iter().map(format_type_expr).collect();
345            format!("tuple<{}>", rendered.join(","))
346        }
347        TypeExpr::DictType(k, v) => {
348            format!("dict<{},{}>", format_type_expr(k), format_type_expr(v))
349        }
350        TypeExpr::Iter(inner) => format!("iter<{}>", format_type_expr(inner)),
351        TypeExpr::Generator(inner) => format!("Generator<{}>", format_type_expr(inner)),
352        TypeExpr::Stream(inner) => format!("Stream<{}>", format_type_expr(inner)),
353        TypeExpr::Applied { name, args } => {
354            let rendered: Vec<String> = args.iter().map(format_type_expr).collect();
355            format!("{name}<{}>", rendered.join(","))
356        }
357        TypeExpr::FnType {
358            params,
359            return_type,
360        } => {
361            let rendered: Vec<String> = params.iter().map(format_type_expr).collect();
362            format!(
363                "fn({})->{}",
364                rendered.join(","),
365                format_type_expr(return_type)
366            )
367        }
368        TypeExpr::Never => "Never".to_string(),
369        TypeExpr::LitString(s) => format!("\"{s}\""),
370        TypeExpr::LitInt(n) => n.to_string(),
371        TypeExpr::Owned(inner) => format!("owned<{}>", format_type_expr(inner)),
372    }
373}
374
375fn format_shape_fields(fields: &[ShapeField]) -> String {
376    let mut rendered: Vec<String> = fields
377        .iter()
378        .map(|f| {
379            let opt = if f.optional { "?" } else { "" };
380            format!("{}{opt}:{}", f.name, format_type_expr(&f.type_expr))
381        })
382        .collect();
383    rendered.sort();
384    rendered.join(",")
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn fp(source: &str) -> Fingerprint {
392        fingerprint_source(source).expect("source parses")
393    }
394
395    #[test]
396    fn private_body_change_does_not_flip_fingerprint() {
397        let before = fp("pub fn add(a: int, b: int) -> int { a + b }\n");
398        let after = fp("pub fn add(a: int, b: int) -> int { const s = a + b; s }\n");
399        assert_eq!(before, after);
400    }
401
402    #[test]
403    fn private_helper_does_not_flip_fingerprint() {
404        let before = fp("pub fn entry() { internal() }\nfn internal() { 1 }\n");
405        let after = fp("pub fn entry() { internal() }\nfn internal() { 2 }\nfn extra() { 3 }\n");
406        assert_eq!(before, after);
407    }
408
409    #[test]
410    fn reordering_public_decls_does_not_flip_fingerprint() {
411        let a = fp("pub fn alpha() {}\npub fn beta() {}\n");
412        let b = fp("pub fn beta() {}\npub fn alpha() {}\n");
413        assert_eq!(a, b);
414    }
415
416    #[test]
417    fn changing_public_signature_flips_fingerprint() {
418        let before = fp("pub fn add(a: int, b: int) -> int { a + b }\n");
419        let after = fp("pub fn add(a: int, b: int, c: int) -> int { a + b + c }\n");
420        assert_ne!(before, after);
421    }
422
423    #[test]
424    fn changing_public_return_type_flips_fingerprint() {
425        let before = fp("pub fn make() -> string { \"x\" }\n");
426        let after = fp("pub fn make() -> int { 1 }\n");
427        assert_ne!(before, after);
428    }
429
430    #[test]
431    fn changing_public_pipeline_parameter_type_flips_fingerprint() {
432        let before = fp("pub pipeline deploy(config: string) -> bool { true }\n");
433        let after = fp("pub pipeline deploy(config: dict) -> bool { true }\n");
434        assert_ne!(before, after);
435    }
436
437    #[test]
438    fn adding_pub_struct_field_flips_fingerprint() {
439        let before = fp("pub struct Point { x: int, y: int }\n");
440        let after = fp("pub struct Point { x: int, y: int, z: int }\n");
441        assert_ne!(before, after);
442    }
443
444    #[test]
445    fn pub_re_export_change_flips_fingerprint() {
446        let before = fp("pub import { foo } from \"./a\"\n");
447        let after = fp("pub import { foo, bar } from \"./a\"\n");
448        assert_ne!(before, after);
449    }
450
451    #[test]
452    fn adding_pub_decl_flips_fingerprint() {
453        let before = fp("pub fn alpha() {}\n");
454        let after = fp("pub fn alpha() {}\npub fn beta() {}\n");
455        assert_ne!(before, after);
456    }
457
458    #[test]
459    fn changing_only_non_pub_imports_does_not_flip_fingerprint() {
460        let before = fp("import \"./a\"\npub fn entry() {}\n");
461        let after = fp("import \"./b\"\npub fn entry() {}\n");
462        // Private imports affect what _this_ module sees but not what
463        // downstreams see, so they're outside the public surface.
464        assert_eq!(before, after);
465    }
466
467    #[test]
468    fn hex_is_64_chars() {
469        let h = fingerprint_hex(&fp("pub fn x() {}\n"));
470        assert_eq!(h.len(), 64);
471        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
472    }
473}