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 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
279pub 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
294pub 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 pub entrypoint: PathBuf,
431 pub entry_chunk: CachedChunk,
432 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}