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