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