Skip to main content

harn_vm/
linked_program.rs

1//! Closed-program bytecode artifact and program-scoped module repository.
2//!
3//! Ordinary module artifacts are caller-independent and therefore retain their
4//! complete export surface. A linked program is different: its graph and every
5//! namespace use are closed at build time, so the linker may specialize module
6//! exports without weakening generic cache correctness. The runtime installs
7//! the decoded module templates for one VM execution tree; it never inserts
8//! them into the ordinary source-keyed cache.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use crate::bytecode_cache;
18use crate::chunk::{CachedChunk, Chunk};
19use crate::module_artifact::ModuleArtifact;
20use crate::prepared_module::PreparedModuleArtifact;
21
22pub const LINKED_PROGRAM_SCHEMA_VERSION: u32 = 1;
23pub const LINKED_PROGRAM_ARCHIVE_PATH: &str = "artifacts/program.harnlink";
24pub const LINKER_ALGORITHM_VERSION: u32 = 1;
25const MAGIC: &[u8; 8] = b"HARNLINK";
26
27/// Compile and specialize one closed source graph into the runtime's single
28/// linked-program artifact. Package policy, signing, and source inclusion stay
29/// with the archive caller; graph discovery and bytecode reachability live here.
30pub fn link_program(
31    entrypoint: &Path,
32    project_root: &Path,
33) -> Result<LinkedProgramArtifact, LinkedProgramError> {
34    let entrypoint = harn_modules::canonical_path(entrypoint);
35    let project_root = harn_modules::canonical_path(project_root);
36    let build = harn_modules::build_closed_program(std::slice::from_ref(&entrypoint));
37    let reachability = harn_modules::closed_program_reachability(&build, &entrypoint);
38    let entry_source = build.parsed_sources.get(&entrypoint).ok_or_else(|| {
39        LinkedProgramError::invalid(format!(
40            "entrypoint {} was not parsed by the closed graph",
41            entrypoint.display()
42        ))
43    })?;
44    let imported_enums = build
45        .graph
46        .imported_names_by_kind_for_file(&entrypoint, harn_modules::DefKind::Enum)
47        .unwrap_or_default();
48    let imported_callables = build
49        .graph
50        .imported_callable_names_for_file(&entrypoint)
51        .unwrap_or_default();
52    let entry_chunk = crate::Compiler::new()
53        .with_imported_enum_candidates(imported_enums)
54        .with_imported_source_callable_names(imported_callables)
55        .compile(&entry_source.program)
56        .map_err(|error| {
57            LinkedProgramError::invalid(format!(
58                "entrypoint compile failed for {}: {error}",
59                entrypoint.display()
60            ))
61        })?
62        .freeze_for_cache();
63
64    let entrypoint_rel = entrypoint.strip_prefix(&project_root).map_err(|_| {
65        LinkedProgramError::invalid(format!(
66            "entrypoint {} is outside package root {}",
67            entrypoint.display(),
68            project_root.display()
69        ))
70    })?;
71    let entry_bytes = postcard::to_allocvec(&entry_chunk)
72        .map_err(|error| LinkedProgramError::invalid(format!("entry size failed: {error}")))?;
73    let mut report = LinkReport {
74        linker_algorithm_version: LINKER_ALGORITHM_VERSION,
75        harn_version: bytecode_cache::HARN_VERSION.to_string(),
76        codegen_fingerprint: bytecode_cache::CODEGEN_FINGERPRINT.to_string(),
77        input_bytecode_bytes: entry_bytes.len() as u64,
78        output_bytecode_bytes: entry_bytes.len() as u64,
79        user_input_bytes: entry_bytes.len() as u64,
80        user_output_bytes: entry_bytes.len() as u64,
81        modules: vec![LinkModuleReport {
82            path: entrypoint_rel.to_path_buf(),
83            demand: LinkModuleDemand::WholeNamespace,
84            input_bytes: entry_bytes.len() as u64,
85            output_bytes: entry_bytes.len() as u64,
86            initializer_bytes: 0,
87            type_schema_bytes: 0,
88            widening_reason: None,
89            retained_symbols: vec![LinkSymbolReason {
90                symbol: "<entry>".to_string(),
91                reason: "typed entry chunk".to_string(),
92            }],
93            removed_symbols: Vec::new(),
94        }],
95        ..LinkReport::default()
96    };
97
98    let mut modules = BTreeMap::new();
99    let mut digest_inputs = Vec::new();
100    for path in build.graph.module_paths() {
101        let path = harn_modules::canonical_path(&path);
102        // A closed-program build retains every source that parsed as Harn, so a
103        // graph node with none is an imported non-Harn asset. Assets are archive
104        // payload owned by the packaging caller, not executable modules: they
105        // carry no bytecode to link and their integrity is bound by the SBOM and
106        // archive hashes rather than the program's graph digest.
107        let Some(parsed) = build.parsed_sources.get(&path) else {
108            continue;
109        };
110        let archive_path = archive_module_path(&project_root, &path)?;
111        digest_inputs.push((archive_path.clone(), parsed.source.as_bytes().to_vec()));
112        if path == entrypoint {
113            continue;
114        }
115        let imported_enums = build
116            .graph
117            .imported_names_by_kind_for_file(&path, harn_modules::DefKind::Enum)
118            .unwrap_or_default();
119        let imported_callables = build
120            .graph
121            .imported_callable_names_for_file(&path)
122            .unwrap_or_default();
123        let compile_path = runtime_compile_path(&path);
124        let full =
125            crate::module_artifact::compile_module_artifact_from_source_with_imported_symbols(
126                &compile_path,
127                &parsed.source,
128                imported_enums,
129                imported_callables,
130            )
131            .map_err(|error| {
132                LinkedProgramError::invalid(format!(
133                    "module compile failed for {}: {error}",
134                    path.display()
135                ))
136            })?;
137        let full_symbols = artifact_symbols(&full);
138        let input_bytes = postcard::to_allocvec(&full)
139            .map_err(|error| LinkedProgramError::invalid(format!("module size failed: {error}")))?
140            .len() as u64;
141        let requested = reachability.demand_for(&path);
142        let widening_reason = full.imports.iter().any(|import| import.is_pub).then(|| {
143            "public re-export shares the module's local import projection; retained whole namespace"
144                .to_string()
145        });
146        let effective = if widening_reason.is_some() {
147            harn_modules::ExportDemand::WholeNamespace
148        } else {
149            requested
150        };
151        let selected = crate::module_artifact::specialize_module_artifact(
152            &parsed.program,
153            Some(compile_path.display().to_string()),
154            full,
155            &effective,
156        )
157        .map_err(|error| {
158            LinkedProgramError::invalid(format!(
159                "module specialization failed for {}: {error}",
160                path.display()
161            ))
162        })?;
163        let selected_symbols = artifact_symbols(&selected);
164        let output_bytes = postcard::to_allocvec(&selected)
165            .map_err(|error| LinkedProgramError::invalid(format!("module size failed: {error}")))?
166            .len() as u64;
167        let mut retained_symbols = selected_symbols
168            .iter()
169            .map(|symbol| LinkSymbolReason {
170                symbol: symbol.clone(),
171                reason: if effective.contains(symbol) {
172                    "observable export".to_string()
173                } else {
174                    "initializer or private callable dependency".to_string()
175                },
176            })
177            .collect::<Vec<_>>();
178        let initializer_bytes = selected.init_chunk.as_ref().map_or(0, |chunk| {
179            postcard::to_allocvec(chunk).map_or(0, |bytes| bytes.len() as u64)
180        });
181        let type_schema_bytes = selected
182            .type_schema_init_chunks
183            .iter()
184            .map(|chunk| postcard::to_allocvec(chunk).map_or(0, |bytes| bytes.len() as u64))
185            .sum();
186        if initializer_bytes > 0 {
187            retained_symbols.push(LinkSymbolReason {
188                symbol: "<module_initializer>".to_string(),
189                reason: "module effects are preserved conservatively".to_string(),
190            });
191        }
192        let removed_symbols = full_symbols
193            .difference(&selected_symbols)
194            .cloned()
195            .collect::<Vec<_>>();
196        report.input_bytecode_bytes += input_bytes;
197        report.output_bytecode_bytes += output_bytes;
198        if archive_path
199            .to_str()
200            .is_some_and(|path| path.starts_with("<std>/"))
201        {
202            report.stdlib_input_bytes += input_bytes;
203            report.stdlib_output_bytes += output_bytes;
204        } else {
205            report.user_input_bytes += input_bytes;
206            report.user_output_bytes += output_bytes;
207        }
208        report.retained_symbols += retained_symbols.len() as u64;
209        report.removed_symbols += removed_symbols.len() as u64;
210        report.modules.push(LinkModuleReport {
211            path: archive_path.clone(),
212            demand: match effective {
213                harn_modules::ExportDemand::InitializationOnly => {
214                    LinkModuleDemand::InitializationOnly
215                }
216                harn_modules::ExportDemand::Members(_) => LinkModuleDemand::Members,
217                harn_modules::ExportDemand::WholeNamespace => LinkModuleDemand::WholeNamespace,
218            },
219            input_bytes,
220            output_bytes,
221            initializer_bytes,
222            type_schema_bytes,
223            widening_reason,
224            retained_symbols,
225            removed_symbols,
226        });
227        modules.insert(archive_path, selected);
228    }
229    digest_inputs.sort_by(|left, right| left.0.cmp(&right.0));
230    let graph_digest_blake3 = graph_digest_from_sources(&digest_inputs);
231    report.graph_digest_blake3.clone_from(&graph_digest_blake3);
232    report
233        .modules
234        .sort_by(|left, right| left.path.cmp(&right.path));
235
236    Ok(LinkedProgramArtifact {
237        schema_version: LINKED_PROGRAM_SCHEMA_VERSION,
238        identity: LinkedProgramIdentity::current(graph_digest_blake3),
239        entrypoint: entrypoint_rel.to_path_buf(),
240        entry_chunk,
241        modules,
242        report,
243    })
244}
245
246fn archive_module_path(project_root: &Path, path: &Path) -> Result<PathBuf, LinkedProgramError> {
247    if path.to_str().is_some_and(|path| path.starts_with("<std>/")) {
248        return Ok(path.to_path_buf());
249    }
250    path.strip_prefix(project_root)
251        .map(Path::to_path_buf)
252        .map_err(|_| {
253            LinkedProgramError::invalid(format!(
254                "module {} is outside package root {}",
255                path.display(),
256                project_root.display()
257            ))
258        })
259}
260
261fn runtime_compile_path(path: &Path) -> PathBuf {
262    path.to_str()
263        .and_then(|path| path.strip_prefix("<std>/"))
264        .map_or_else(
265            || path.to_path_buf(),
266            |module| PathBuf::from(format!("<stdlib>/{module}.harn")),
267        )
268}
269
270fn artifact_symbols(artifact: &ModuleArtifact) -> std::collections::BTreeSet<String> {
271    artifact
272        .functions
273        .keys()
274        .chain(artifact.public_exports.keys())
275        .cloned()
276        .collect()
277}
278
279/// Digest the exact normalized source graph compiled into a linked program.
280/// The pack verifier reconstructs this independently from verified archive
281/// sources plus the current embedded stdlib modules before installation.
282pub fn graph_digest_from_sources(sources: &[(PathBuf, Vec<u8>)]) -> String {
283    let mut hasher = blake3::Hasher::new();
284    hasher.update(b"harn-linked-program-graph-v1\0");
285    for (path, source) in sources {
286        hasher.update(path.to_string_lossy().as_bytes());
287        hasher.update(&[0]);
288        hasher.update(&(source.len() as u64).to_le_bytes());
289        hasher.update(source);
290    }
291    format!("blake3:{}", hasher.finalize().to_hex())
292}
293
294/// Reconstruct and verify a linked graph from independently verified user
295/// sources plus the runtime's embedded stdlib. Archive verification and direct
296/// execution share this boundary so neither can accept a self-consistent but
297/// manifest-detached artifact.
298pub fn verify_graph_binding(
299    report: &LinkReport,
300    expected_digest: &str,
301    mut user_source: impl FnMut(&Path) -> Option<Vec<u8>>,
302) -> Result<(), LinkedProgramError> {
303    let mut sources = Vec::new();
304    for module in &report.modules {
305        let bytes = if let Some(name) = module
306            .path
307            .to_str()
308            .and_then(|path| path.strip_prefix("<std>/"))
309        {
310            crate::stdlib_modules::get_stdlib_source(name)
311                .ok_or_else(|| {
312                    LinkedProgramError::invalid(format!(
313                        "runtime has no embedded stdlib module std/{name}"
314                    ))
315                })?
316                .as_bytes()
317                .to_vec()
318        } else {
319            user_source(&module.path).ok_or_else(|| {
320                LinkedProgramError::invalid(format!(
321                    "verified source graph has no {}",
322                    module.path.display()
323                ))
324            })?
325        };
326        sources.push((module.path.clone(), bytes));
327    }
328    sources.sort_by(|left, right| left.0.cmp(&right.0));
329    let actual = graph_digest_from_sources(&sources);
330    if actual != expected_digest {
331        return Err(LinkedProgramError::invalid(format!(
332            "linked graph digest mismatch: manifest {expected_digest}, verified sources {actual}"
333        )));
334    }
335    Ok(())
336}
337
338#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
339pub struct LinkedProgramIdentity {
340    pub graph_digest_blake3: String,
341    pub harn_version: String,
342    pub codegen_fingerprint: String,
343    pub bytecode_schema_version: u32,
344    pub linker_algorithm_version: u32,
345    pub optimizations_enabled: bool,
346}
347
348impl LinkedProgramIdentity {
349    pub fn current(graph_digest_blake3: String) -> Self {
350        Self {
351            graph_digest_blake3,
352            harn_version: bytecode_cache::HARN_VERSION.to_string(),
353            codegen_fingerprint: bytecode_cache::CODEGEN_FINGERPRINT.to_string(),
354            bytecode_schema_version: bytecode_cache::SCHEMA_VERSION,
355            linker_algorithm_version: LINKER_ALGORITHM_VERSION,
356            optimizations_enabled: crate::CompilerOptions::from_env().optimizations_enabled(),
357        }
358    }
359
360    fn validate_current(&self) -> Result<(), LinkedProgramError> {
361        let expected = Self::current(self.graph_digest_blake3.clone());
362        if self.harn_version != expected.harn_version {
363            return Err(LinkedProgramError::incompatible(format!(
364                "linked program was built by harn {}; this runtime is {}",
365                self.harn_version, expected.harn_version
366            )));
367        }
368        if self.codegen_fingerprint != expected.codegen_fingerprint
369            || self.bytecode_schema_version != expected.bytecode_schema_version
370            || self.linker_algorithm_version != expected.linker_algorithm_version
371            || self.optimizations_enabled != expected.optimizations_enabled
372        {
373            return Err(LinkedProgramError::incompatible(
374                "linked program compiler, bytecode, or linker identity does not match this runtime",
375            ));
376        }
377        Ok(())
378    }
379}
380
381#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
382pub struct LinkReport {
383    pub graph_digest_blake3: String,
384    pub linker_algorithm_version: u32,
385    pub harn_version: String,
386    pub codegen_fingerprint: String,
387    pub input_bytecode_bytes: u64,
388    pub output_bytecode_bytes: u64,
389    pub user_input_bytes: u64,
390    pub user_output_bytes: u64,
391    pub stdlib_input_bytes: u64,
392    pub stdlib_output_bytes: u64,
393    pub retained_symbols: u64,
394    pub removed_symbols: u64,
395    pub modules: Vec<LinkModuleReport>,
396}
397
398#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
399pub struct LinkModuleReport {
400    pub path: PathBuf,
401    pub demand: LinkModuleDemand,
402    pub input_bytes: u64,
403    pub output_bytes: u64,
404    pub initializer_bytes: u64,
405    pub type_schema_bytes: u64,
406    pub widening_reason: Option<String>,
407    pub retained_symbols: Vec<LinkSymbolReason>,
408    pub removed_symbols: Vec<String>,
409}
410
411#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
412#[serde(rename_all = "snake_case")]
413pub enum LinkModuleDemand {
414    InitializationOnly,
415    Members,
416    WholeNamespace,
417}
418
419#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
420pub struct LinkSymbolReason {
421    pub symbol: String,
422    pub reason: String,
423}
424
425#[derive(Clone, Debug, Serialize, Deserialize)]
426pub struct LinkedProgramArtifact {
427    pub schema_version: u32,
428    pub identity: LinkedProgramIdentity,
429    /// Archive-relative user source path.
430    pub entrypoint: PathBuf,
431    pub entry_chunk: CachedChunk,
432    /// Archive-relative user source paths or `<std>/<module>` virtual paths.
433    pub modules: BTreeMap<PathBuf, ModuleArtifact>,
434    pub report: LinkReport,
435}
436
437impl LinkedProgramArtifact {
438    pub fn encode(&self) -> Result<Vec<u8>, LinkedProgramError> {
439        let payload = postcard::to_allocvec(self)
440            .map_err(|error| LinkedProgramError::invalid(format!("encode failed: {error}")))?;
441        let mut bytes = Vec::with_capacity(MAGIC.len() + 4 + payload.len());
442        bytes.extend_from_slice(MAGIC);
443        bytes.extend_from_slice(&LINKED_PROGRAM_SCHEMA_VERSION.to_le_bytes());
444        bytes.extend_from_slice(&payload);
445        Ok(bytes)
446    }
447
448    pub fn decode(bytes: &[u8]) -> Result<Self, LinkedProgramError> {
449        let Some((magic, rest)) = bytes.split_at_checked(MAGIC.len()) else {
450            return Err(LinkedProgramError::invalid(
451                "linked program header is truncated",
452            ));
453        };
454        if magic != MAGIC {
455            return Err(LinkedProgramError::invalid(
456                "linked program magic is invalid",
457            ));
458        }
459        let Some((schema, payload)) = rest.split_at_checked(4) else {
460            return Err(LinkedProgramError::invalid(
461                "linked program schema header is truncated",
462            ));
463        };
464        let actual = u32::from_le_bytes(schema.try_into().expect("four-byte schema"));
465        if actual != LINKED_PROGRAM_SCHEMA_VERSION {
466            return Err(LinkedProgramError::incompatible(format!(
467                "linked program schema {actual} is unsupported; expected {LINKED_PROGRAM_SCHEMA_VERSION}"
468            )));
469        }
470        let (artifact, trailing): (Self, &[u8]) = postcard::take_from_bytes(payload)
471            .map_err(|error| LinkedProgramError::invalid(format!("decode failed: {error}")))?;
472        if !trailing.is_empty() {
473            return Err(LinkedProgramError::invalid(
474                "linked program contains trailing bytes",
475            ));
476        }
477        if artifact.schema_version != LINKED_PROGRAM_SCHEMA_VERSION {
478            return Err(LinkedProgramError::invalid(format!(
479                "linked program payload schema {} disagrees with its header",
480                artifact.schema_version
481            )));
482        }
483        artifact.identity.validate_current()?;
484        Ok(artifact)
485    }
486
487    pub fn into_runtime(self, source_root: &Path) -> LinkedProgramRuntime {
488        let modules = self
489            .modules
490            .into_iter()
491            .map(|(path, mut artifact)| {
492                let runtime_path = runtime_module_path(source_root, &path);
493                artifact.bind_source_file(&runtime_path);
494                (
495                    runtime_path,
496                    Arc::new(PreparedModuleArtifact::from_cached(artifact)),
497                )
498            })
499            .collect();
500        LinkedProgramRuntime {
501            digest: self.identity.graph_digest_blake3,
502            entry_chunk: Chunk::from_cached(self.entry_chunk),
503            repository: Arc::new(LinkedProgramRepository { modules }),
504            report: self.report,
505        }
506    }
507}
508
509fn runtime_module_path(source_root: &Path, path: &Path) -> PathBuf {
510    if let Some(path) = path.to_str().and_then(|path| path.strip_prefix("<std>/")) {
511        return PathBuf::from(format!("<stdlib>/{path}.harn"));
512    }
513    let path = source_root.join(path);
514    path.canonicalize().unwrap_or(path)
515}
516
517pub struct LinkedProgramRuntime {
518    pub digest: String,
519    pub entry_chunk: Chunk,
520    pub report: LinkReport,
521    pub(crate) repository: Arc<LinkedProgramRepository>,
522}
523
524pub(crate) struct LinkedProgramRepository {
525    modules: BTreeMap<PathBuf, Arc<PreparedModuleArtifact>>,
526}
527
528impl LinkedProgramRepository {
529    pub(crate) fn get(&self, path: &Path) -> Option<Arc<PreparedModuleArtifact>> {
530        self.modules.get(path).cloned()
531    }
532}
533
534#[derive(Clone, Debug, PartialEq, Eq)]
535pub struct LinkedProgramError {
536    pub code: &'static str,
537    pub message: String,
538}
539
540impl LinkedProgramError {
541    fn invalid(message: impl Into<String>) -> Self {
542        Self {
543            code: "linked_program.invalid",
544            message: message.into(),
545        }
546    }
547
548    fn incompatible(message: impl Into<String>) -> Self {
549        Self {
550            code: "linked_program.incompatible",
551            message: message.into(),
552        }
553    }
554}
555
556impl fmt::Display for LinkedProgramError {
557    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
558        formatter.write_str(&self.message)
559    }
560}
561
562impl std::error::Error for LinkedProgramError {}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use std::fs;
568
569    #[test]
570    fn identity_rejects_codegen_drift() {
571        let mut identity = LinkedProgramIdentity::current("blake3:test".to_string());
572        identity.codegen_fingerprint.push_str("-different");
573        let error = identity.validate_current().unwrap_err();
574        assert_eq!(error.code, "linked_program.incompatible");
575    }
576
577    #[test]
578    fn closed_link_retains_private_callable_closure_and_initializer_roots() {
579        let dir = tempfile::tempdir().unwrap();
580        let library = dir.path().join("library.harn");
581        let entry = dir.path().join("entry.harn");
582        fs::write(
583            &library,
584            r#"
585            fn helper_a() { helper_b() }
586            fn helper_b() { 7 }
587            fn init_helper() { "initialized" }
588            const init_hook = init_helper
589            pub fn kept() { helper_a() }
590            pub fn dead() { "dead" }
591            pub type KeptShape = { value: int }
592            pub type DeadShape = { value: string }
593            "#,
594        )
595        .unwrap();
596        fs::write(
597            &entry,
598            r#"
599            import * as lib from "./library.harn"
600            fn main() { println(lib.kept()) }
601            "#,
602        )
603        .unwrap();
604
605        let linked = link_program(&entry, dir.path()).expect("link succeeds");
606        let library = &linked.modules[Path::new("library.harn")];
607        assert!(library.functions.contains_key("kept"));
608        assert!(library.functions.contains_key("helper_a"));
609        assert!(library.functions.contains_key("helper_b"));
610        assert!(library.functions.contains_key("init_helper"));
611        assert!(!library.functions.contains_key("dead"));
612        assert_eq!(
613            library.public_exports.keys().cloned().collect::<Vec<_>>(),
614            ["kept"]
615        );
616        let report = linked
617            .report
618            .modules
619            .iter()
620            .find(|module| module.path == Path::new("library.harn"))
621            .unwrap();
622        assert!(report.removed_symbols.iter().any(|name| name == "dead"));
623        assert!(report.initializer_bytes > 0);
624        assert!(report.output_bytes < report.input_bytes);
625    }
626
627    #[test]
628    fn selective_type_import_retains_only_its_schema_initializer() {
629        let dir = tempfile::tempdir().unwrap();
630        let library = dir.path().join("types.harn");
631        let entry = dir.path().join("entry.harn");
632        fs::write(
633            &library,
634            r"
635            pub type KeptShape = { value: int }
636            pub type DeadShape = { value: string }
637            ",
638        )
639        .unwrap();
640        fs::write(
641            &entry,
642            r#"
643            import { KeptShape } from "./types.harn"
644            fn accept(value: KeptShape) { value.value }
645            fn main() { accept({ value: 7 }) }
646            "#,
647        )
648        .unwrap();
649
650        let linked = link_program(&entry, dir.path()).expect("link succeeds");
651        let types = &linked.modules[Path::new("types.harn")];
652        assert_eq!(
653            types.public_type_names.iter().cloned().collect::<Vec<_>>(),
654            ["KeptShape"]
655        );
656        assert_eq!(types.type_schema_init_chunks.len(), 1);
657        let report = linked
658            .report
659            .modules
660            .iter()
661            .find(|module| module.path == Path::new("types.harn"))
662            .unwrap();
663        assert!(report.type_schema_bytes > 0);
664        assert!(report
665            .removed_symbols
666            .iter()
667            .any(|name| name == "DeadShape"));
668    }
669
670    #[test]
671    fn public_reexport_records_conservative_widening() {
672        let dir = tempfile::tempdir().unwrap();
673        let inner = dir.path().join("inner.harn");
674        let facade = dir.path().join("facade.harn");
675        let entry = dir.path().join("entry.harn");
676        fs::write(&inner, "pub fn kept() { 7 }\npub fn dead() { 8 }\n").unwrap();
677        fs::write(
678            &facade,
679            r#"
680            pub import { kept } from "./inner.harn"
681            pub fn local_dead() { 9 }
682            "#,
683        )
684        .unwrap();
685        fs::write(
686            &entry,
687            r#"
688            import { kept } from "./facade.harn"
689            fn main() { println(kept()) }
690            "#,
691        )
692        .unwrap();
693
694        let linked = link_program(&entry, dir.path()).expect("link succeeds");
695        let facade_report = linked
696            .report
697            .modules
698            .iter()
699            .find(|module| module.path == Path::new("facade.harn"))
700            .unwrap();
701        assert_eq!(facade_report.demand, LinkModuleDemand::WholeNamespace);
702        assert!(facade_report
703            .widening_reason
704            .as_deref()
705            .is_some_and(|reason| reason.contains("public re-export")));
706        assert!(linked.modules[Path::new("facade.harn")]
707            .functions
708            .contains_key("local_dead"));
709    }
710}