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