Skip to main content

harn_vm/
bytecode_cache.rs

1//! Content-addressed on-disk cache for compiled `.harn` pipelines.
2//!
3//! Cold-start `harn run` re-parses, type-checks, and compiles the entry
4//! pipeline before the VM gets a single instruction to execute. For short
5//! Harn subcommands that wrap a few `llm_call`s in a small pipeline, that
6//! compile cost dominates wall-clock time.
7//!
8//! This module persists [`Chunk`] bytecode under
9//! `$HARN_CACHE_DIR/<source-hash>.harnbc` (XDG-aware). The cache key is
10//! derived from the source plus its compilation context. Entry chunks include
11//! the content of every transitively-imported user file because they compile
12//! the complete program. Module artifacts compile exactly one file and retain
13//! unresolved import specs, so their context includes compiler and embedded
14//! stdlib identity but deliberately excludes user dependencies. Any change to
15//! an artifact's actual compilation inputs flips the key and recompiles it.
16//!
17//! File layout — little-endian throughout:
18//!
19//! ```text
20//! magic        : [u8; 8]   = "HARNBC\0\0"
21//! schema_ver   : u32       = SCHEMA_VERSION
22//! version_len  : u32
23//! harn_version : [u8; version_len]
24//! compiler_tag : u8        bitmask of active CompilerOptions
25//! kind         : u8        1 = entry chunk, 2 = module artifact
26//! source_hash  : [u8; 32]
27//! context_hash : [u8; 32]
28//! payload      : postcard-serialized payload for `kind`
29//! ```
30//!
31//! The header lets a stale binary detect a future-version artifact
32//! without crashing: a magic mismatch, schema mismatch, or version
33//! mismatch is returned as `Ok(None)` so the caller transparently
34//! recompiles. Real I/O errors propagate.
35//!
36//! Concurrency: writes are atomic (write-tmp-then-rename), and parallel
37//! invocations on a cache miss race safely — the last writer wins, but
38//! every reader observes a consistent file because the rename is atomic
39//! on every supported filesystem.
40
41use std::fs;
42use std::io::{self, Read as _, Write as _};
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45
46use serde::{de::DeserializeOwned, Serialize};
47use sha2::{Digest, Sha256};
48
49use crate::chunk::{CachedChunk, Chunk};
50use crate::compiler::CompilerOptions;
51use crate::module_artifact::ModuleArtifact;
52
53struct ImportScan {
54    content: Arc<str>,
55    imports: Vec<Arc<str>>,
56}
57
58type SharedImportScan = Arc<ImportScan>;
59type ImportsFileMemoKey = (PathBuf, u64, i128);
60type ImportsFileMemo =
61    std::sync::Mutex<std::collections::HashMap<ImportsFileMemoKey, SharedImportScan>>;
62
63/// Header magic for all bytecode-cache artifact families.
64pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
65
66/// On-disk format version. Bump when [`CachedChunk`] or the header
67/// layout changes in a backwards-incompatible way.
68/// v5: `ModuleArtifact` gained `public_type_names` (`pub type` exports).
69/// v6: payload encoding replaced with postcard.
70pub const SCHEMA_VERSION: u32 = 6;
71
72/// Compile-time Harn release. Cache files written by a different release
73/// are rejected on load.
74pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
75
76/// Build-time fingerprint of the compiler front-end — the lexer, parser, IR,
77/// and code generator — computed in `build.rs` from those crates' source and
78/// baked in via `cargo:rustc-env`. Folded into the cache key so a compiler
79/// change that alters emitted bytecode for unchanged source invalidates stale
80/// entries automatically, within a single version, with no manual cache wipe.
81/// `HARN_VERSION` only busts the cache across release bumps; this closes the
82/// same gap for the within-version compiler edits that masked #2610. See #2621.
83pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
84
85/// Conventional extension for entry-chunk cache files.
86pub const CACHE_EXTENSION: &str = "harnbc";
87
88/// Conventional extension for module-artifact cache files. Distinct from
89/// [`CACHE_EXTENSION`] so the same `.harn` source can have both shipped
90/// adjacent if needed (e.g. when a file is both an executable entry and
91/// imported by other files).
92pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
93
94/// On-disk discriminant for a [`Chunk`] payload.
95const KIND_ENTRY_CHUNK: u8 = 1;
96/// On-disk discriminant for a [`ModuleArtifact`] payload.
97const KIND_MODULE_ARTIFACT: u8 = 2;
98
99/// Environment override for the cache directory. When set, takes
100/// precedence over the XDG and home-directory fallbacks.
101pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
102
103/// Environment override that turns the cache off entirely. Setting this
104/// to `0`, `false`, `no`, or `off` skips both reads and writes; useful
105/// when debugging compiler changes.
106pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
107
108/// Result of a cache lookup. Carries the precomputed key so the caller
109/// can write it back on a miss without rehashing.
110pub struct LookupOutcome {
111    pub key: CacheKey,
112    pub chunk: Option<Chunk>,
113}
114
115/// Cache key components for a single pipeline source. Equality of all
116/// fields is necessary and sufficient for cache reuse.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct CacheKey {
119    pub source_hash: [u8; 32],
120    pub context_hash: [u8; 32],
121    pub harn_version: &'static str,
122    /// Compact tag for active [`CompilerOptions`]. Flipping
123    /// `HARN_DISABLE_OPTIMIZATIONS` between runs would otherwise reuse a
124    /// chunk compiled under the wrong setting.
125    pub compiler_tag: u8,
126}
127
128impl CacheKey {
129    /// Compute the cache key for a `.harn` source file plus its transitive
130    /// user imports. `source` is the entry-file contents; the import
131    /// graph is walked from disk relative to `source_path`.
132    pub fn from_source(source_path: &Path, source: &str) -> Self {
133        let source_hash = sha256(source.as_bytes());
134        let context_hash = hash_transitive_user_imports(source_path, source);
135        Self {
136            source_hash,
137            context_hash,
138            harn_version: HARN_VERSION,
139            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
140        }
141    }
142
143    /// Compute the cache key for one independently-compiled module artifact.
144    ///
145    /// A [`ModuleArtifact`] stores unresolved import specs and never compiles
146    /// dependency contents into the parent artifact. Walking the transitive
147    /// graph here therefore adds cold-start I/O without protecting correctness:
148    /// the runtime loads every dependency under its own source-local key.
149    /// Diagnostic paths are rebound when the artifact is loaded, so adjacent
150    /// and packaged artifacts remain relocatable without aliasing attribution.
151    pub fn from_module_source(source: &str) -> Self {
152        Self {
153            source_hash: sha256(source.as_bytes()),
154            context_hash: module_compilation_context_hash(),
155            harn_version: HARN_VERSION,
156            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
157        }
158    }
159
160    /// Entry-chunk filename for this key. We hash by source content
161    /// alone so two invocations of the same source from different paths
162    /// share a cache entry; the header's compilation-context hash still gates
163    /// reuse on a per-load basis.
164    pub fn filename(&self) -> String {
165        format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
166    }
167
168    /// Module-artifact filename for this complete compilation key. Diagnostic
169    /// source paths are rebound at load time, so identical source and compiler
170    /// inputs share one relocatable artifact across paths.
171    pub fn module_filename(&self) -> String {
172        let mut hasher = Sha256::new();
173        hasher.update(self.source_hash);
174        hasher.update(self.context_hash);
175        hasher.update(self.harn_version.as_bytes());
176        hasher.update([self.compiler_tag]);
177        let identity: [u8; 32] = hasher.finalize().into();
178        format!("{}.{}", hex(&identity), MODULE_CACHE_EXTENSION)
179    }
180}
181
182/// Returns the directory the shared cache lives in. Honors
183/// `$HARN_CACHE_DIR`, then `$XDG_CACHE_HOME/harn/bytecode`, then
184/// `$HOME/.cache/harn/bytecode`. The directory is *not* created here —
185/// [`store`] creates it lazily on write so read-only environments don't
186/// pay an mkdir cost.
187pub fn cache_dir() -> PathBuf {
188    if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
189        return PathBuf::from(custom);
190    }
191    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
192        let xdg = PathBuf::from(xdg);
193        if !xdg.as_os_str().is_empty() {
194            return xdg.join("harn").join("bytecode");
195        }
196    }
197    if let Some(home) = crate::user_dirs::home_dir() {
198        return home.join(".cache").join("harn").join("bytecode");
199    }
200    // Final fallback: a directory beside the binary's working dir. Mostly
201    // hit in tests that scrub HOME from the environment.
202    PathBuf::from(".harn-cache").join("bytecode")
203}
204
205/// Root for `.harnpack` archives unpacked by `harn run <bundle.harnpack>`.
206/// Each verified bundle is replayed into `<root>/<sanitized-bundle-hash>/`
207/// so re-runs reuse the unpacked tree. Honors `$HARN_CACHE_DIR/packs`
208/// when set, otherwise XDG / `$HOME/.cache/harn/packs`.
209pub fn packs_cache_dir() -> PathBuf {
210    if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
211        return PathBuf::from(custom).join("packs");
212    }
213    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
214        let xdg = PathBuf::from(xdg);
215        if !xdg.as_os_str().is_empty() {
216            return xdg.join("harn").join("packs");
217        }
218    }
219    if let Some(home) = crate::user_dirs::home_dir() {
220        return home.join(".cache").join("harn").join("packs");
221    }
222    PathBuf::from(".harn-cache").join("packs")
223}
224
225/// True when the cache is enabled by the current environment.
226pub fn cache_enabled() -> bool {
227    match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
228        Some(value) => !matches!(
229            value.to_ascii_lowercase().as_str(),
230            "0" | "false" | "no" | "off"
231        ),
232        None => true,
233    }
234}
235
236/// Try to load a cached chunk for `source_path` whose contents are
237/// `source`. Returns the key alongside the (optional) chunk so callers
238/// avoid recomputing the key on miss.
239pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
240    let key = CacheKey::from_source(source_path, source);
241    if !cache_enabled() {
242        return LookupOutcome { key, chunk: None };
243    }
244    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
245    if let Some(adjacent) = adjacent_cache_path(source_path) {
246        candidates.push(adjacent);
247    }
248    candidates.push(cache_dir().join(key.filename()));
249    for path in candidates {
250        match read_chunk_if_matches(&path, &key) {
251            Ok(Some(chunk)) => {
252                return LookupOutcome {
253                    key,
254                    chunk: Some(chunk),
255                }
256            }
257            Ok(None) => continue,
258            Err(_) => continue,
259        }
260    }
261    LookupOutcome { key, chunk: None }
262}
263
264/// Persist `chunk` to the shared cache directory under `key`. Atomic: a
265/// temp file is written then renamed into place. Concurrent invocations
266/// on the same key race safely.
267pub fn store(key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
268    if !cache_enabled() {
269        return Ok(());
270    }
271    let dir = cache_dir();
272    fs::create_dir_all(&dir)?;
273    write_atomic_chunk(&dir.join(key.filename()), key, chunk)
274}
275
276/// Write a precompiled entry-chunk artifact to an explicit path, for
277/// use by the `harn precompile` subcommand. The header still records
278/// the key, so adjacent artifacts shipped with source are validated
279/// like any other cache hit.
280pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
281    ensure_parent_dir(path)?;
282    write_atomic_chunk(path, key, chunk)
283}
284
285/// Look up the [`ModuleArtifact`] for `source_path` (whose contents are
286/// `source`). Mirrors [`load`] but for the `.harnmod` family.
287pub fn load_module(source_path: &Path, source: &str) -> ModuleLookupOutcome {
288    let key = CacheKey::from_module_source(source);
289    if !cache_enabled() {
290        return ModuleLookupOutcome {
291            key,
292            artifact: None,
293        };
294    }
295    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
296    if let Some(adjacent) = adjacent_module_cache_path(source_path) {
297        candidates.push(adjacent);
298    }
299    candidates.push(cache_dir().join(key.module_filename()));
300    for path in candidates {
301        match read_module_if_matches(&path, &key, source_path) {
302            Ok(Some(artifact)) => {
303                return ModuleLookupOutcome {
304                    key,
305                    artifact: Some(artifact),
306                }
307            }
308            Ok(None) => continue,
309            Err(_) => continue,
310        }
311    }
312    ModuleLookupOutcome {
313        key,
314        artifact: None,
315    }
316}
317
318/// Persist `artifact` to the shared cache under `key`. Atomic;
319/// concurrent invocations race safely.
320pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
321    if !cache_enabled() {
322        return Ok(());
323    }
324    let dir = cache_dir();
325    fs::create_dir_all(&dir)?;
326    write_atomic_module(&dir.join(key.module_filename()), key, artifact)
327}
328
329/// Write a module artifact to an explicit path.
330pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
331    ensure_parent_dir(path)?;
332    write_atomic_module(path, key, artifact)
333}
334
335/// Result of a [`load_module`] lookup. Carries the precomputed key so
336/// the caller can write it back on a miss without rehashing.
337pub struct ModuleLookupOutcome {
338    pub key: CacheKey,
339    pub artifact: Option<ModuleArtifact>,
340}
341
342/// Path to the adjacent precompiled entry-chunk artifact for
343/// `source_path`. `foo.harn` → `foo.harnbc`.
344pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
345    adjacent_path_with_extension(source_path, CACHE_EXTENSION)
346}
347
348/// Path to the adjacent precompiled module-artifact for `source_path`.
349/// `foo.harn` → `foo.harnmod`.
350pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
351    adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
352}
353
354fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
355    let stem = source_path.file_stem()?;
356    if stem.is_empty() {
357        return None;
358    }
359    let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
360    let mut out = parent.join(stem);
361    out.set_extension(ext);
362    Some(out)
363}
364
365fn ensure_parent_dir(path: &Path) -> io::Result<()> {
366    if let Some(parent) = path.parent() {
367        if !parent.as_os_str().is_empty() {
368            fs::create_dir_all(parent)?;
369        }
370    }
371    Ok(())
372}
373
374fn write_atomic_chunk(target: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
375    let buf = serialize_chunk_artifact(key, chunk)?;
376    write_atomic(target, &buf)
377}
378
379fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
380    let buf = serialize_module_artifact(key, artifact)?;
381    write_atomic(target, &buf)
382}
383
384/// Serialize an entry-chunk artifact (header + payload) to bytes. The
385/// resulting buffer is byte-identical to the file [`store_at`] would
386/// have written for the same `(key, chunk)`. Use this when packaging
387/// artifacts into a container (e.g. `harn pack`) without going through
388/// the filesystem.
389pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
390    let cached = chunk.freeze_for_cache();
391    let payload = serialize_cache_payload(&cached)?;
392    Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
393}
394
395/// Serialize a module artifact (header + payload) to bytes. Companion
396/// to [`serialize_chunk_artifact`] for the `.harnmod` family.
397pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
398    let payload = serialize_cache_payload(artifact)?;
399    Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
400}
401
402fn serialize_cache_payload<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
403    postcard::to_allocvec(value)
404        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
405}
406
407fn deserialize_cache_payload<T: DeserializeOwned>(payload: &[u8]) -> Result<T, String> {
408    let (value, remaining) = postcard::take_from_bytes(payload).map_err(|err| err.to_string())?;
409    if remaining.is_empty() {
410        Ok(value)
411    } else {
412        Err("cache payload contains trailing bytes".to_string())
413    }
414}
415
416fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
417    let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
418    buf.extend_from_slice(MAGIC);
419    buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
420    let version_bytes = HARN_VERSION.as_bytes();
421    buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
422    buf.extend_from_slice(version_bytes);
423    buf.push(key.compiler_tag);
424    buf.push(kind);
425    buf.extend_from_slice(&key.source_hash);
426    buf.extend_from_slice(&key.context_hash);
427    buf.extend_from_slice(payload);
428    buf
429}
430
431fn write_atomic(target: &Path, buf: &[u8]) -> io::Result<()> {
432    let tmp_path = atomic_tmp_path(target);
433    let mut tmp_file = fs::File::create(&tmp_path)?;
434    tmp_file.write_all(buf)?;
435    tmp_file.sync_all()?;
436    drop(tmp_file);
437    match fs::rename(&tmp_path, target) {
438        Ok(()) => Ok(()),
439        Err(err) => {
440            let _ = fs::remove_file(&tmp_path);
441            Err(err)
442        }
443    }
444}
445
446fn atomic_tmp_path(target: &Path) -> PathBuf {
447    static NEXT_TMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
448    let id = NEXT_TMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
449    let tmp_name = match target.file_name() {
450        Some(name) => format!(
451            ".{}.{}.{}.tmp",
452            name.to_string_lossy(),
453            std::process::id(),
454            id
455        ),
456        None => format!(".harn-cache.{}.{}.tmp", std::process::id(), id),
457    };
458    target.with_file_name(tmp_name)
459}
460
461/// Parsed cache header. Read by both the chunk and module loaders so the
462/// header-validation logic stays in one place.
463struct ParsedHeader {
464    kind: u8,
465    payload: Vec<u8>,
466}
467
468fn read_header_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ParsedHeader>> {
469    let mut file = match fs::File::open(path) {
470        Ok(f) => f,
471        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
472        Err(err) => return Err(err),
473    };
474    let mut header = [0u8; 8 + 4 + 4];
475    if file.read_exact(&mut header).is_err() {
476        return Ok(None);
477    }
478    if &header[..8] != MAGIC {
479        return Ok(None);
480    }
481    let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
482    if schema != SCHEMA_VERSION {
483        return Ok(None);
484    }
485    let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
486    if version_len > 256 {
487        // Bound the alloc so a corrupted file cannot force an unbounded read.
488        return Ok(None);
489    }
490    let mut version_buf = vec![0u8; version_len];
491    if file.read_exact(&mut version_buf).is_err() {
492        return Ok(None);
493    }
494    if version_buf != key.harn_version.as_bytes() {
495        return Ok(None);
496    }
497    let mut compiler_and_kind = [0u8; 2];
498    if file.read_exact(&mut compiler_and_kind).is_err() {
499        return Ok(None);
500    }
501    if compiler_and_kind[0] != key.compiler_tag {
502        return Ok(None);
503    }
504    let kind = compiler_and_kind[1];
505    let mut hashes = [0u8; 64];
506    if file.read_exact(&mut hashes).is_err() {
507        return Ok(None);
508    }
509    if hashes[..32] != key.source_hash || hashes[32..] != key.context_hash {
510        return Ok(None);
511    }
512    let mut payload = Vec::new();
513    if file.read_to_end(&mut payload).is_err() {
514        return Ok(None);
515    }
516    Ok(Some(ParsedHeader { kind, payload }))
517}
518
519fn read_chunk_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<Chunk>> {
520    let Some(header) = read_header_if_matches(path, key)? else {
521        return Ok(None);
522    };
523    if header.kind != KIND_ENTRY_CHUNK {
524        return Ok(None);
525    }
526    let cached: CachedChunk = match deserialize_cache_payload(&header.payload) {
527        Ok(c) => c,
528        Err(_) => return Ok(None),
529    };
530    Ok(Some(Chunk::from_cached(cached)))
531}
532
533fn read_module_if_matches(
534    path: &Path,
535    key: &CacheKey,
536    source_path: &Path,
537) -> io::Result<Option<ModuleArtifact>> {
538    let Some(header) = read_header_if_matches(path, key)? else {
539        return Ok(None);
540    };
541    if header.kind != KIND_MODULE_ARTIFACT {
542        return Ok(None);
543    }
544    match deserialize_cache_payload::<ModuleArtifact>(&header.payload) {
545        Ok(mut artifact) => {
546            artifact.bind_source_file(source_path);
547            Ok(Some(artifact))
548        }
549        Err(_) => Ok(None),
550    }
551}
552
553/// Compact representation of [`CompilerOptions`] for the cache header.
554/// Independent flags get distinct bits so adding a new flag never
555/// silently changes existing keys when an old binary reads a new
556/// artifact — the header check will fail-closed before we get there
557/// anyway, but mapping to bits also keeps the tag a stable function
558/// of the option set.
559fn compiler_options_tag(options: CompilerOptions) -> u8 {
560    let mut tag: u8 = 0;
561    if options.optimizations_enabled() {
562        tag |= 0b0000_0001;
563    }
564    tag
565}
566
567fn sha256(bytes: &[u8]) -> [u8; 32] {
568    let mut hasher = Sha256::new();
569    hasher.update(bytes);
570    hasher.finalize().into()
571}
572
573fn hex(bytes: &[u8]) -> String {
574    let mut out = String::with_capacity(bytes.len() * 2);
575    for byte in bytes {
576        out.push_str(&format!("{byte:02x}"));
577    }
578    out
579}
580
581/// Lightweight regex-free scan that surfaces user imports without paying
582/// a full lex+parse. False positives only increase cache churn, never
583/// correctness; comments and string literals are skipped so neither a
584/// commented-out import nor a `"import …"` value appearing inside an
585/// unrelated string gates the hash.
586fn collect_user_imports(source: &str) -> Vec<String> {
587    let scrubbed = strip_comments(source);
588    let mut out: Vec<String> = Vec::new();
589    let bytes = scrubbed.as_bytes();
590    let mut i = 0;
591    while i < bytes.len() {
592        if bytes[i] == b'"' {
593            // Skip past any string literal so identifiers inside string
594            // values cannot trigger the keyword match below.
595            match read_string_literal(bytes, i) {
596                Some((_, end)) => {
597                    i = end;
598                    continue;
599                }
600                None => {
601                    i += 1;
602                    continue;
603                }
604            }
605        }
606        if !matches_keyword(bytes, i, b"import") {
607            i += 1;
608            continue;
609        }
610        // Skip past `import` and any selective `{ ... } from` clause; we
611        // only need the source-position of the path string literal.
612        let mut j = i + b"import".len();
613        let mut depth = 0i32;
614        while j < bytes.len() {
615            match bytes[j] {
616                b'"' => {
617                    if let Some((path, end)) = read_string_literal(bytes, j) {
618                        if !path.starts_with("std/") {
619                            out.push(path);
620                        }
621                        i = end;
622                        break;
623                    }
624                    j += 1;
625                }
626                b'{' => {
627                    depth += 1;
628                    j += 1;
629                }
630                b'}' => {
631                    depth -= 1;
632                    j += 1;
633                }
634                b'\n' if depth == 0 => {
635                    // No string literal on this logical line; bail and
636                    // continue scanning after the keyword to avoid an
637                    // infinite loop.
638                    i = j;
639                    break;
640                }
641                _ => j += 1,
642            }
643        }
644        if j >= bytes.len() {
645            break;
646        }
647        if i < j {
648            // Defensive: ensure forward progress when the inner loop
649            // exited without setting `i`.
650            i = j;
651        }
652    }
653    out
654}
655
656fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
657    let end = at + keyword.len();
658    if end > bytes.len() {
659        return false;
660    }
661    if &bytes[at..end] != keyword {
662        return false;
663    }
664    if at > 0 && is_ident_char(bytes[at - 1]) {
665        return false;
666    }
667    if end < bytes.len() && is_ident_char(bytes[end]) {
668        return false;
669    }
670    true
671}
672
673fn is_ident_char(b: u8) -> bool {
674    b.is_ascii_alphanumeric() || b == b'_'
675}
676
677fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
678    debug_assert_eq!(bytes[at], b'"');
679    let mut out = String::new();
680    let mut i = at + 1;
681    while i < bytes.len() {
682        match bytes[i] {
683            b'"' => return Some((out, i + 1)),
684            b'\\' => {
685                if i + 1 >= bytes.len() {
686                    return None;
687                }
688                match bytes[i + 1] {
689                    b'"' => out.push('"'),
690                    b'\\' => out.push('\\'),
691                    b'n' => out.push('\n'),
692                    b'r' => out.push('\r'),
693                    b't' => out.push('\t'),
694                    other => out.push(other as char),
695                }
696                i += 2;
697            }
698            b'\n' => return None,
699            byte => {
700                out.push(byte as char);
701                i += 1;
702            }
703        }
704    }
705    None
706}
707
708fn strip_comments(source: &str) -> String {
709    let bytes = source.as_bytes();
710    let mut out = String::with_capacity(source.len());
711    let mut i = 0;
712    while i < bytes.len() {
713        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
714            while i < bytes.len() && bytes[i] != b'\n' {
715                i += 1;
716            }
717            continue;
718        }
719        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
720            i += 2;
721            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
722                i += 1;
723            }
724            i = (i + 2).min(bytes.len());
725            continue;
726        }
727        if bytes[i] == b'"' {
728            if let Some((_, end)) = read_string_literal(bytes, i) {
729                out.push_str(&source[i..end]);
730                i = end;
731                continue;
732            }
733        }
734        out.push(bytes[i] as char);
735        i += 1;
736    }
737    out
738}
739
740/// Stable digest over every embedded stdlib source. Folded into the
741/// user-file cache key so that bumping a stdlib module (changing its
742/// embedded `.harn` content) invalidates cached user bytecode that may
743/// reference stale function-pool layouts from a prior stdlib snapshot.
744/// `HARN_VERSION` already busts the cache across release bumps; this
745/// closes the same gap for within-version stdlib edits (a frequent
746/// pattern during local development).
747///
748/// Cached in a `OnceLock` because `STDLIB_SOURCES` is a static `const`
749/// slice — the digest is identical for the lifetime of the process.
750fn embedded_stdlib_digest() -> &'static [u8; 32] {
751    use std::sync::OnceLock;
752    static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
753    DIGEST.get_or_init(|| {
754        let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
755            .iter()
756            .map(|src| (src.module, src.source))
757            .collect();
758        entries.sort_by(|a, b| a.0.cmp(b.0));
759        let mut hasher = Sha256::new();
760        for (module, source) in entries {
761            hasher.update(module.as_bytes());
762            hasher.update(b"\0");
763            hasher.update(source.as_bytes());
764            hasher.update(b"\0");
765        }
766        hasher.finalize().into()
767    })
768}
769
770/// Stable compilation context for a source-local module artifact.
771///
772/// Module compilation does not inspect user dependencies. Artifact-local
773/// compiler and stdlib identity belongs in the key; the source path does not,
774/// because it is load context and is rebound after deserialization.
775fn module_compilation_context_hash() -> [u8; 32] {
776    module_compilation_context_hash_fingerprinted(CODEGEN_FINGERPRINT)
777}
778
779fn module_compilation_context_hash_fingerprinted(codegen_fingerprint: &str) -> [u8; 32] {
780    let mut hasher = Sha256::new();
781    hasher.update(b"module-artifact-source-local-v3\0");
782    hasher.update(b"stdlib-digest\0");
783    hasher.update(embedded_stdlib_digest());
784    hasher.update(b"\0codegen-fingerprint\0");
785    hasher.update(codegen_fingerprint.as_bytes());
786    hasher.finalize().into()
787}
788
789/// Walk the user-import graph rooted at `source_path` and produce a
790/// stable hash of every transitively-reachable file. The hash is
791/// order-independent: each visited file is keyed by canonical path and
792/// emitted in sorted order, so reordering imports inside a file does
793/// not invalidate the cache while changing any file's content does.
794///
795/// Embedded stdlib content is folded into the hash too — `collect_user_imports`
796/// deliberately skips `std/*` paths (they resolve to in-binary sources, not
797/// disk files), so without this fold a stdlib edit between development
798/// builds would leave user-file caches pinned to a stale stdlib snapshot.
799fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
800    hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
801}
802
803/// Process-wide memo of `(file content, collect_user_imports(content))` keyed by
804/// the resolved file path plus its stat identity `(len, mtime_ns)`. Walking a
805/// large pipeline's import graph re-encounters the same shared library files for
806/// nearly every module, so without this memo `from_source` re-reads and
807/// re-scans those files hundreds of times in a single cold run. Because the key
808/// includes `(len, mtime_ns)`, any on-disk edit produces a fresh key and the
809/// stale entry is never reused — a warm long-lived process recompiles edited
810/// pipelines correctly. Source and import strings stay shared across graph
811/// walks, while the returned bytes remain identical to the un-memoized path, so
812/// cache keys are byte-for-byte unchanged.
813fn imports_file_memo() -> &'static ImportsFileMemo {
814    use std::sync::OnceLock;
815    static MEMO: OnceLock<ImportsFileMemo> = OnceLock::new();
816    MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
817}
818
819/// Process-wide memo of `Path::canonicalize`. The import-graph walk canonicalizes
820/// the same resolved module paths hundreds of times across a cold `from_source`
821/// fan-out, and each call is a `realpath(3)` syscall. A successful
822/// canonicalization is stable for the process lifetime (the pipeline tree is not
823/// moved mid-run), so it is memoized. A *failed* canonicalization (the path does
824/// not exist yet) is NOT memoized: a file that later appears — or a symlink that
825/// is created — must canonicalize freshly so the folded path key matches what a
826/// cold process would produce. This keeps the memo a pure speed optimization with
827/// byte-identical output.
828fn canonicalize_cached(path: &Path) -> PathBuf {
829    use std::sync::OnceLock;
830    static MEMO: OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>> =
831        OnceLock::new();
832    let memo = MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
833    if let Some(hit) = memo.lock().unwrap().get(path).cloned() {
834        return hit;
835    }
836    match path.canonicalize() {
837        Ok(canonical) => {
838            memo.lock()
839                .unwrap()
840                .insert(path.to_path_buf(), canonical.clone());
841            canonical
842        }
843        // Unresolved path: fall back to the input, but do not memoize, so a file
844        // that appears later canonicalizes correctly on the next walk.
845        Err(_) => path.to_path_buf(),
846    }
847}
848
849fn file_stat_identity(path: &Path) -> Option<(u64, i128)> {
850    let meta = fs::metadata(path).ok()?;
851    let len = meta.len();
852    // Nanosecond mtime where available; fall back to coarse seconds. Any change
853    // to either component on disk invalidates the memo entry.
854    let mtime_ns = meta
855        .modified()
856        .ok()
857        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
858        .map(|d| d.as_nanos() as i128)
859        .unwrap_or(0);
860    Some((len, mtime_ns))
861}
862
863fn scan_imports(content: String) -> SharedImportScan {
864    let imports = collect_user_imports(&content)
865        .into_iter()
866        .map(Arc::from)
867        .collect();
868    Arc::new(ImportScan {
869        content: Arc::from(content),
870        imports,
871    })
872}
873
874/// Read `path` and scan its user imports, memoized by stat identity. On an I/O
875/// error, returns the `ErrorKind` string the un-memoized path folded in (errors
876/// are not memoized — a transient failure should not be sticky).
877fn read_and_scan_imports_cached(path: &Path) -> Result<SharedImportScan, String> {
878    if let Some((len, mtime_ns)) = file_stat_identity(path) {
879        let key = (path.to_path_buf(), len, mtime_ns);
880        if let Some(hit) = imports_file_memo().lock().unwrap().get(&key).cloned() {
881            return Ok(hit);
882        }
883        match fs::read_to_string(path) {
884            Ok(content) => {
885                let entry = scan_imports(content);
886                imports_file_memo()
887                    .lock()
888                    .unwrap()
889                    .insert(key, Arc::clone(&entry));
890                Ok(entry)
891            }
892            Err(err) => Err(err.kind().to_string()),
893        }
894    } else {
895        // No stat (file vanished between resolve and read): fall back to a direct
896        // read so behavior matches the un-memoized path exactly.
897        match fs::read_to_string(path) {
898            Ok(content) => Ok(scan_imports(content)),
899            Err(err) => Err(err.kind().to_string()),
900        }
901    }
902}
903
904/// Inner form of [`hash_transitive_user_imports`] parameterized on the compiler
905/// fingerprint so tests can vary it; production always passes
906/// [`CODEGEN_FINGERPRINT`].
907fn hash_transitive_user_imports_fingerprinted(
908    source_path: &Path,
909    source: &str,
910    codegen_fingerprint: &str,
911) -> [u8; 32] {
912    let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
913        std::collections::BTreeMap::new();
914    let mut frontier: Vec<(PathBuf, Arc<str>)> = collect_user_imports(source)
915        .into_iter()
916        .map(|import| (source_path.to_path_buf(), Arc::from(import)))
917        .collect();
918
919    while let Some((anchor, import)) = frontier.pop() {
920        let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
921            // Unresolved imports get a sentinel keyed by their resolution
922            // anchor so that dropping a real file under that anchor later
923            // produces a different key.
924            let sentinel = anchor.join(format!("__unresolved__/{import}"));
925            visited
926                .entry(sentinel)
927                .or_insert(ImportNode::Unresolved { import });
928            continue;
929        };
930        let canonical = canonicalize_cached(&resolved);
931        if visited.contains_key(&canonical) {
932            continue;
933        }
934        // Per-file read + import-scan is memoized process-wide, keyed by the
935        // file's identity stat `(len, mtime)`. The same handful of core library
936        // modules (`lib/host/*`, `lib/runtime/*`, ...) sit on the import graph of
937        // nearly every module, so a cold `from_source` over a large pipeline used
938        // to re-read and re-scan the same files hundreds of times across the
939        // module-load fan-out. The memo is invalidated automatically the moment a
940        // file's stat changes on disk, so a warm long-lived process still recompiles
941        // edited pipelines correctly. The folded hash bytes are byte-identical to
942        // the un-memoized path (same content + same `collect_user_imports` output),
943        // so cache keys are unchanged. See `imports_file_memo`.
944        match read_and_scan_imports_cached(&resolved) {
945            Ok(scan) => {
946                visited.insert(
947                    canonical.clone(),
948                    ImportNode::Resolved {
949                        content: Arc::clone(&scan.content),
950                    },
951                );
952                for nested_import in &scan.imports {
953                    frontier.push((resolved.clone(), Arc::clone(nested_import)));
954                }
955            }
956            Err(kind) => {
957                visited.insert(canonical, ImportNode::IoError { kind });
958            }
959        }
960    }
961
962    let mut hasher = Sha256::new();
963    hasher.update(b"stdlib-digest\0");
964    hasher.update(embedded_stdlib_digest());
965    hasher.update(b"\0");
966    // Fold in the compiler's code-generation fingerprint so a compiler change
967    // that alters emitted bytecode for unchanged source busts stale cache
968    // entries within a single version — the gap that masked the #2610 fix until
969    // the cache was cleared by hand. See `build.rs` and `CODEGEN_FINGERPRINT`.
970    hasher.update(b"codegen-fingerprint\0");
971    hasher.update(codegen_fingerprint.as_bytes());
972    hasher.update(b"\0");
973    for (path, node) in &visited {
974        hasher.update(path.to_string_lossy().as_bytes());
975        hasher.update(b"\0");
976        match node {
977            ImportNode::Resolved { content } => {
978                hasher.update(b"resolved\0");
979                hasher.update(content.as_bytes());
980            }
981            ImportNode::Unresolved { import } => {
982                hasher.update(b"unresolved\0");
983                hasher.update(import.as_bytes());
984            }
985            ImportNode::IoError { kind } => {
986                hasher.update(b"ioerror\0");
987                hasher.update(kind.as_bytes());
988            }
989        }
990        hasher.update(b"\0");
991    }
992    hasher.finalize().into()
993}
994
995enum ImportNode {
996    Resolved { content: Arc<str> },
997    Unresolved { import: Arc<str> },
998    IoError { kind: String },
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use crate::compile_source;
1005
1006    #[test]
1007    fn header_round_trips_chunk() {
1008        let chunk = compile_source("__io_println(\"hello\")").expect("compile");
1009        let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
1010        let tmp = tempfile::tempdir().unwrap();
1011        let path = tmp.path().join("entry.harnbc");
1012        store_at(&path, &key, &chunk).expect("write");
1013        let loaded = read_chunk_if_matches(&path, &key).unwrap();
1014        assert!(loaded.is_some(), "expected cached chunk to load");
1015    }
1016
1017    #[test]
1018    fn serialize_chunk_artifact_matches_store_at() {
1019        // `serialize_chunk_artifact` packages an artifact into a buffer for
1020        // in-memory consumers (e.g. `harn pack` writing into a tar.zst
1021        // bundle). The contract is: the resulting bytes match what
1022        // `store_at` would have written for the same key+chunk, so the
1023        // shipped artifact is byte-identical to the on-disk cache form.
1024        let chunk = compile_source("__io_println(\"hi\")").expect("compile");
1025        let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
1026        let tmp = tempfile::tempdir().unwrap();
1027        let on_disk = tmp.path().join("pack.harnbc");
1028        store_at(&on_disk, &key, &chunk).expect("write");
1029        let on_disk_bytes = std::fs::read(&on_disk).unwrap();
1030        let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
1031        assert_eq!(in_memory_bytes, on_disk_bytes);
1032    }
1033
1034    #[test]
1035    fn cache_payload_rejects_trailing_bytes() {
1036        let chunk = compile_source("1 + 1").expect("compile");
1037        let cached = chunk.freeze_for_cache();
1038        let mut payload = serialize_cache_payload(&cached).expect("serialize");
1039        payload.push(0xFF);
1040
1041        assert!(deserialize_cache_payload::<CachedChunk>(&payload).is_err());
1042    }
1043
1044    #[test]
1045    fn atomic_temp_paths_are_unique_within_process() {
1046        let target = Path::new("entry.harnbc");
1047        let first = atomic_tmp_path(target);
1048        let second = atomic_tmp_path(target);
1049        assert_ne!(
1050            first, second,
1051            "same-process concurrent cache writes must not share a temp file"
1052        );
1053    }
1054
1055    #[test]
1056    fn header_mismatch_returns_none() {
1057        let chunk = compile_source("1 + 1").expect("compile");
1058        let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
1059        let tmp = tempfile::tempdir().unwrap();
1060        let path = tmp.path().join("a.harnbc");
1061        store_at(&path, &key, &chunk).expect("write");
1062        let other = CacheKey {
1063            source_hash: [0xAB; 32],
1064            context_hash: key.context_hash,
1065            harn_version: HARN_VERSION,
1066            compiler_tag: key.compiler_tag,
1067        };
1068        assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
1069    }
1070
1071    #[test]
1072    fn schema_mismatch_returns_none() {
1073        let chunk = compile_source("1 + 1").expect("compile");
1074        let key = CacheKey::from_source(Path::new("/tmp/schema.harn"), "1 + 1");
1075        let tmp = tempfile::tempdir().unwrap();
1076        let path = tmp.path().join("schema.harnbc");
1077        store_at(&path, &key, &chunk).expect("write");
1078
1079        let mut bytes = std::fs::read(&path).expect("read cache");
1080        bytes[8..12].copy_from_slice(&(SCHEMA_VERSION - 1).to_le_bytes());
1081        std::fs::write(&path, bytes).expect("rewrite cache");
1082
1083        assert!(read_chunk_if_matches(&path, &key).unwrap().is_none());
1084    }
1085
1086    #[test]
1087    fn compiler_tag_mismatch_returns_none() {
1088        let chunk = compile_source("1 + 1").expect("compile");
1089        let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
1090        let tmp = tempfile::tempdir().unwrap();
1091        let path = tmp.path().join("b.harnbc");
1092        store_at(&path, &key, &chunk).expect("write");
1093        let other = CacheKey {
1094            compiler_tag: key.compiler_tag ^ 0xFF,
1095            ..key
1096        };
1097        assert!(
1098            read_chunk_if_matches(&path, &other).unwrap().is_none(),
1099            "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
1100             compiled under the opposite setting"
1101        );
1102    }
1103
1104    #[test]
1105    fn codegen_fingerprint_is_populated() {
1106        // In-workspace builds always hash real compiler sources, so the
1107        // fingerprint must be a non-empty digest; an empty value would silently
1108        // disable the within-version compiler-staleness guard.
1109        assert!(!CODEGEN_FINGERPRINT.is_empty());
1110    }
1111
1112    #[test]
1113    fn codegen_fingerprint_changes_cache_key() {
1114        // A compiler whose code-generation source differs must produce a
1115        // different cache key for the *same* user source, so a stale artifact
1116        // compiled by a prior compiler at the same version misses on load
1117        // rather than being replayed (#2621). The fingerprint is a compile-time
1118        // constant, so exercise the parameterized inner hash directly.
1119        let tmp = tempfile::tempdir().unwrap();
1120        let entry = tmp.path().join("entry.harn");
1121        std::fs::write(&entry, "__io_println(\"hi\")\n").unwrap();
1122        let source = std::fs::read_to_string(&entry).unwrap();
1123        let a = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
1124        let b = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-B");
1125        let a_again = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
1126        assert_ne!(
1127            a, b,
1128            "differing compiler fingerprints must change the cache key"
1129        );
1130        assert_eq!(
1131            a, a_again,
1132            "an unchanged compiler fingerprint must be stable"
1133        );
1134    }
1135
1136    #[test]
1137    fn module_context_hash_tracks_codegen_fingerprint() {
1138        let first = module_compilation_context_hash_fingerprinted("compiler-A");
1139        let second = module_compilation_context_hash_fingerprinted("compiler-B");
1140        assert_ne!(
1141            first, second,
1142            "module artifacts must miss when compiler code generation changes"
1143        );
1144        assert_eq!(
1145            first,
1146            module_compilation_context_hash_fingerprinted("compiler-A"),
1147            "an unchanged module compilation context must be stable"
1148        );
1149    }
1150
1151    #[test]
1152    fn module_key_excludes_dependency_graph_while_entry_key_tracks_it() {
1153        let tmp = tempfile::tempdir().unwrap();
1154        let dependency = tmp.path().join("value.harn");
1155        let importer = tmp.path().join("reader.harn");
1156        let importer_source =
1157            "import { value } from \"./value\"\npub fn read() { return value() }\n";
1158        std::fs::write(&dependency, "pub fn value() { return 1 }\n").unwrap();
1159        std::fs::write(&importer, importer_source).unwrap();
1160
1161        let entry_before = CacheKey::from_source(&importer, importer_source);
1162        let module_before = CacheKey::from_module_source(importer_source);
1163        let dependency_before =
1164            CacheKey::from_module_source(&std::fs::read_to_string(&dependency).unwrap());
1165
1166        std::fs::write(&dependency, "pub fn value() { return 2 }\n").unwrap();
1167        let future = std::fs::metadata(&dependency).unwrap().modified().unwrap()
1168            + std::time::Duration::from_secs(10);
1169        std::fs::OpenOptions::new()
1170            .write(true)
1171            .open(&dependency)
1172            .unwrap()
1173            .set_times(std::fs::FileTimes::new().set_modified(future))
1174            .unwrap();
1175
1176        let entry_after = CacheKey::from_source(&importer, importer_source);
1177        let module_after = CacheKey::from_module_source(importer_source);
1178        let dependency_after =
1179            CacheKey::from_module_source(&std::fs::read_to_string(&dependency).unwrap());
1180
1181        assert_ne!(
1182            entry_before, entry_after,
1183            "entry chunks compile the full graph and must track dependency edits"
1184        );
1185        assert_eq!(
1186            module_before, module_after,
1187            "a parent module artifact must not be invalidated by dependency contents"
1188        );
1189        assert_ne!(
1190            dependency_before, dependency_after,
1191            "the edited dependency must invalidate its own module artifact"
1192        );
1193    }
1194
1195    #[test]
1196    fn module_artifact_is_relocatable_and_rebinds_exact_source_path() {
1197        let source = "pub fn answer() { fn inner() { return 42 }; return inner() }\n";
1198        let first_path = Path::new("/workspace/first/module.harn");
1199        let second_path = Path::new("/workspace/second/module.harn");
1200        let key = CacheKey::from_module_source(source);
1201
1202        let artifact =
1203            crate::module_artifact::compile_module_artifact_from_source(first_path, source)
1204                .expect("compile module");
1205        let first_source_file = first_path.display().to_string();
1206        let second_source_file = second_path.display().to_string();
1207        assert_eq!(
1208            artifact.functions["answer"].chunk.source_file.as_deref(),
1209            Some(first_source_file.as_str())
1210        );
1211
1212        let tmp = tempfile::tempdir().unwrap();
1213        let cache_path = tmp.path().join(key.module_filename());
1214        store_module_at(&cache_path, &key, &artifact).expect("store module");
1215        let first_loaded = read_module_if_matches(&cache_path, &key, first_path)
1216            .expect("read first module")
1217            .expect("first module key matches");
1218        let second_loaded = read_module_if_matches(&cache_path, &key, second_path)
1219            .expect("read second module")
1220            .expect("second module key matches");
1221        assert_eq!(
1222            first_loaded.functions["answer"]
1223                .chunk
1224                .source_file
1225                .as_deref(),
1226            Some(first_source_file.as_str())
1227        );
1228        assert_eq!(
1229            second_loaded.functions["answer"]
1230                .chunk
1231                .source_file
1232                .as_deref(),
1233            Some(second_source_file.as_str())
1234        );
1235        let nested = second_loaded.functions["answer"]
1236            .chunk
1237            .functions
1238            .first()
1239            .expect("nested function survives artifact roundtrip");
1240        assert_eq!(
1241            nested.chunk.source_file.as_deref(),
1242            Some(second_source_file.as_str()),
1243            "rebinding must reach nested compiled functions"
1244        );
1245    }
1246
1247    #[test]
1248    fn source_local_module_artifact_round_trips() {
1249        let source = "import \"./dependency\"\npub fn answer() { return 42 }\n";
1250        let source_path = Path::new("/tmp/source-local-module.harn");
1251        let artifact =
1252            crate::module_artifact::compile_module_artifact_from_source(source_path, source)
1253                .expect("compile module");
1254        let key = CacheKey::from_module_source(source);
1255        let tmp = tempfile::tempdir().unwrap();
1256        let path = tmp.path().join("source-local-module.harnmod");
1257
1258        store_module_at(&path, &key, &artifact).expect("write module artifact");
1259        let loaded = read_module_if_matches(&path, &key, source_path)
1260            .expect("read module artifact")
1261            .expect("matching artifact");
1262
1263        assert_eq!(loaded.imports.len(), 1);
1264        assert_eq!(loaded.imports[0].path, "./dependency");
1265        assert!(loaded.public_names.contains("answer"));
1266    }
1267
1268    #[test]
1269    fn module_artifact_payload_round_trips() {
1270        let source = "pub fn answer() { fn inner() { return 42 }; return inner() }\n";
1271        let source_path = Path::new("/tmp/module-payload.harn");
1272        let artifact =
1273            crate::module_artifact::compile_module_artifact_from_source(source_path, source)
1274                .expect("compile module");
1275
1276        let payload = serialize_cache_payload(&artifact).expect("serialize module artifact");
1277        let round_tripped: ModuleArtifact =
1278            deserialize_cache_payload(&payload).expect("deserialize module artifact");
1279
1280        assert!(round_tripped.public_names.contains("answer"));
1281        assert!(round_tripped.functions["answer"]
1282            .chunk
1283            .functions
1284            .iter()
1285            .any(|function| function.name == "inner"));
1286    }
1287
1288    #[test]
1289    fn collect_user_imports_ignores_stdlib_and_comments() {
1290        let source = r#"
1291            // import "comment/should/be/ignored"
1292            import "std/agents"
1293            import { foo } from "pkg/bar"
1294            import "./relative/path"
1295        "#;
1296        let imports = collect_user_imports(source);
1297        assert_eq!(
1298            imports,
1299            vec!["pkg/bar".to_string(), "./relative/path".to_string()]
1300        );
1301    }
1302
1303    #[test]
1304    fn cache_enabled_respects_env() {
1305        std::env::set_var(CACHE_ENABLED_ENV, "0");
1306        assert!(!cache_enabled());
1307        std::env::set_var(CACHE_ENABLED_ENV, "1");
1308        assert!(cache_enabled());
1309        std::env::remove_var(CACHE_ENABLED_ENV);
1310        assert!(cache_enabled());
1311    }
1312
1313    #[test]
1314    fn import_path_inside_string_literal_is_ignored() {
1315        let source = r#"
1316            const payload = "import { foo } from \"./other\""
1317            import "./real"
1318        "#;
1319        let imports = collect_user_imports(source);
1320        assert_eq!(imports, vec!["./real".to_string()]);
1321    }
1322
1323    #[test]
1324    fn import_hash_is_stable_across_import_order() {
1325        let tmp = tempfile::tempdir().unwrap();
1326        std::fs::write(
1327            tmp.path().join("a.harn"),
1328            "pub fn a() -> int { return 1 }\n",
1329        )
1330        .unwrap();
1331        std::fs::write(
1332            tmp.path().join("b.harn"),
1333            "pub fn b() -> int { return 2 }\n",
1334        )
1335        .unwrap();
1336        let ab = tmp.path().join("entry_ab.harn");
1337        std::fs::write(
1338            &ab,
1339            "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
1340        )
1341        .unwrap();
1342        let ba = tmp.path().join("entry_ba.harn");
1343        std::fs::write(
1344            &ba,
1345            "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
1346        )
1347        .unwrap();
1348        let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
1349        let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
1350        assert_eq!(
1351            hash_ab, hash_ba,
1352            "import-graph hash must be order-independent so reordering imports \
1353             does not bust the cache"
1354        );
1355    }
1356
1357    #[test]
1358    fn import_hash_picks_up_nested_imports() {
1359        let tmp = tempfile::tempdir().unwrap();
1360        std::fs::write(
1361            tmp.path().join("leaf.harn"),
1362            "pub fn x() -> int { return 1 }\n",
1363        )
1364        .unwrap();
1365        std::fs::write(
1366            tmp.path().join("mid.harn"),
1367            "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
1368        )
1369        .unwrap();
1370        let entry = tmp.path().join("entry.harn");
1371        std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
1372
1373        let before =
1374            hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1375        std::fs::write(
1376            tmp.path().join("leaf.harn"),
1377            "pub fn x() -> int { return 999 }\n",
1378        )
1379        .unwrap();
1380        let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1381        assert_ne!(
1382            before, after,
1383            "editing a transitively-imported file must change the import-graph hash"
1384        );
1385    }
1386
1387    #[test]
1388    fn import_hash_busts_on_same_length_edit_in_same_process() {
1389        // The per-file read/scan memo is keyed by `(path, len, mtime_ns)`. The
1390        // hardest case for that key is an edit that preserves byte length: only
1391        // the mtime distinguishes the two versions. Guard that a same-length edit
1392        // to a transitively-imported file, recomputed in the SAME process so the
1393        // memo is warm, still busts the import-graph hash. Without a working
1394        // staleness check a warm long-lived process would replay stale bytecode.
1395        let tmp = tempfile::tempdir().unwrap();
1396        let leaf = tmp.path().join("leaf.harn");
1397        std::fs::write(&leaf, "pub fn x() -> int { return 111 }\n").unwrap();
1398        let entry = tmp.path().join("entry.harn");
1399        std::fs::write(&entry, "import \"./leaf\"\n__io_println(\"hi\")\n").unwrap();
1400
1401        let before =
1402            hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1403
1404        // Same byte length (`111` -> `222`), so the memo must rely on mtime.
1405        // Instead of sleeping out the coarsest plausible mtime granularity,
1406        // push the rewritten file's mtime deterministically into the future so
1407        // the `(path, len, mtime_ns)` stat key changes instantly on every
1408        // filesystem this runs on.
1409        std::fs::write(&leaf, "pub fn x() -> int { return 222 }\n").unwrap();
1410        // Bump from the file's own current mtime by a fixed margin instead of
1411        // sleeping or using a large absolute timestamp literal.
1412        let future = std::fs::metadata(&leaf).unwrap().modified().unwrap()
1413            + std::time::Duration::from_secs(10);
1414        std::fs::OpenOptions::new()
1415            .write(true)
1416            .open(&leaf)
1417            .unwrap()
1418            .set_times(std::fs::FileTimes::new().set_modified(future))
1419            .unwrap();
1420        assert_eq!(
1421            std::fs::metadata(&leaf).unwrap().len(),
1422            33,
1423            "the two leaf versions must be the same byte length for this test to \
1424             exercise the mtime path"
1425        );
1426
1427        let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1428        assert_ne!(
1429            before, after,
1430            "a same-length edit to a transitively-imported file must still change \
1431             the import-graph hash when recomputed in a warm process"
1432        );
1433    }
1434
1435    #[test]
1436    fn import_scan_memo_shares_source_and_import_allocations() {
1437        let tmp = tempfile::tempdir().unwrap();
1438        let source_path = tmp.path().join("module.harn");
1439        std::fs::write(
1440            &source_path,
1441            "import \"./first\"\nimport \"./second\"\npub fn value() -> int { return 7 }\n",
1442        )
1443        .unwrap();
1444
1445        let first = read_and_scan_imports_cached(&source_path).unwrap();
1446        let second = read_and_scan_imports_cached(&source_path).unwrap();
1447
1448        assert!(
1449            std::sync::Arc::ptr_eq(&first, &second),
1450            "a memo hit must reuse the complete scan instead of copying its source and imports"
1451        );
1452        assert_eq!(first.imports.len(), 2);
1453    }
1454
1455    #[test]
1456    fn import_hash_stable_across_repeated_calls_same_process() {
1457        // The memo must be a pure speed optimization: repeated `from_source`
1458        // calls over an unchanged tree (the cold-start module-load fan-out
1459        // pattern) must return byte-identical hashes.
1460        let tmp = tempfile::tempdir().unwrap();
1461        std::fs::write(
1462            tmp.path().join("dep.harn"),
1463            "pub fn d() -> int { return 7 }\n",
1464        )
1465        .unwrap();
1466        let entry = tmp.path().join("entry.harn");
1467        std::fs::write(&entry, "import \"./dep\"\n__io_println(\"hi\")\n").unwrap();
1468        let src = std::fs::read_to_string(&entry).unwrap();
1469        let first = hash_transitive_user_imports(&entry, &src);
1470        for _ in 0..50 {
1471            assert_eq!(
1472                hash_transitive_user_imports(&entry, &src),
1473                first,
1474                "repeated import-graph hashing over an unchanged tree must be stable"
1475            );
1476        }
1477    }
1478}