Skip to main content

zsh/extensions/
provenance.rs

1//! Provenance engine — value lineage across bytecode execution.
2//!
3//! **zshrs-original — no C zsh counterpart.** C zsh has no notion of
4//! where a parameter's bytes came from; `typeset -p` shows the current
5//! value and nothing about the chain of expansions, substitutions and
6//! assignments that produced it. This module is the ledger that answers
7//! "where did this value come from?" for a running shell.
8//!
9//! Ported from stryke's `strykelang/provenance.rs` (the `mark` /
10//! `provenance` / `unmark` builtin trio). Three things change in the
11//! shell port:
12//!
13//! 1. **Two key spaces instead of one.** stryke keys purely on the
14//!    `Arc<HeapObject>` pointer of a value, because every stryke value
15//!    stays an `Arc` from creation to use. A shell value does not: the
16//!    VM/host boundary (`ShellHost::exec`, `::cmd_subst`, `::glob`)
17//!    passes `String`, and the parameter table
18//!    (`ported::params::assignsparam`) stores `String`, so the `Arc`
19//!    identity dies at the first assignment. stryke documents that hole
20//!    for its own string results ("the VM's scalar-return path re-Arcs
21//!    the string"); in a shell it is the common case, not the corner
22//!    case. So this port keys on three things:
23//!      * `Ptr`     — `Arc` identity of an in-flight `fusevm::Value`
24//!                    (`Str(Arc<String>)` / `Array(Arc<Vec<Value>>)`),
25//!                    the direct stryke mechanism, valid inside a chunk.
26//!      * `Name`    — a tracked parameter name; survives every
27//!                    assignment/expansion round trip.
28//!      * `Content` — the exact bytes of a value that crossed a
29//!                    `String`-typed host boundary, so a command
30//!                    substitution's output can still be recognised when
31//!                    it lands in `assignsparam` a few ops later.
32//! 2. **Origins are created by shell events, not by a `mark()` call on a
33//!    heap object.** `$(...)`, `<(...)`, glob expansion and an explicitly
34//!    tracked parameter are the origin kinds.
35//! 3. **The engine is off unless armed.** `PROV_ACTIVE` is an
36//!    `AtomicBool` flipped on by `provenance -m NAME` (stryke flips it in
37//!    `mark`). Every hook is a single relaxed load when nobody armed it,
38//!    and the whole engine can be disabled outright in
39//!    `~/.zshrs/zshrs.toml` (`[provenance] enabled = false`) or with
40//!    `ZSHRS_PROVENANCE=0`, in which case arming is refused and the
41//!    hooks can never fire.
42//!
43//! Staleness handling is stryke's v1.1 design, ported as-is: a `Ptr`
44//! entry stores a `Weak` alongside the raw address, and a lookup that
45//! cannot upgrade the `Weak` (or upgrades it to a *different* address)
46//! reaps the entry instead of reporting a lineage that belongs to a
47//! long-dead allocation the allocator has since recycled.
48//!
49//! `Content` entries are speculative — recorded before anyone knows
50//! whether the value will ever reach a tracked name — so they are
51//! bounded by a FIFO of [`CONTENT_CAP`] entries. `Name` and `Ptr`
52//! entries are not speculative and are only created for values that are
53//! already tracked.
54//!
55//! Independent of the PFA-SMR recorder (`src/recorder/`): no daemon, no
56//! catalog, no bundle emission. The recorder answers "what state did
57//! this shell define?"; this answers "how was this value built?".
58
59use chrono::{Local, NaiveDate, SecondsFormat, TimeZone};
60use std::collections::hash_map::DefaultHasher;
61use std::collections::{HashMap, HashSet, VecDeque};
62use std::hash::{Hash, Hasher};
63use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
64use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak};
65use std::time::{SystemTime, UNIX_EPOCH};
66
67use fusevm::Value;
68
69/// Maximum number of speculative content-keyed entries kept alive.
70/// Each command substitution, process substitution and glob expansion
71/// records one while the engine is armed; the FIFO drops the oldest
72/// past this bound so a long-running armed shell cannot grow the ledger
73/// without limit.
74pub const CONTENT_CAP: usize = 8192;
75
76/// Maximum ops kept on one chain. A tracked parameter that the shell
77/// itself reads on every prompt (`PATH`, `PS1`) would otherwise grow an
78/// unbounded chain; past the cap the ops are counted, not stored.
79pub const MAX_OPS: usize = 256;
80
81/// Ceiling on names [`track_all`] may arm by itself. A shell that runs
82/// long enough touches an unbounded number of parameters and functions;
83/// past the ceiling new names are counted and ignored, so a
84/// track-everything session cannot grow the ledger without limit.
85pub const MAX_AUTO_NAMES: usize = 4096;
86
87/// Parameters [`track_all`] never arms: the shell rewrites these on its
88/// own, once or more per command, so their chains would record the
89/// shell's own bookkeeping and nothing the user did. Positional
90/// parameters (`1`, `2`, …) are skipped by the same rule, tested
91/// numerically rather than listed.
92const VOLATILE: &[&str] = &[
93    "_",
94    "?",
95    "!",
96    "$",
97    "#",
98    "COLUMNS",
99    "EPOCHREALTIME",
100    "EPOCHSECONDS",
101    "HISTCMD",
102    "LINENO",
103    "LINES",
104    "RANDOM",
105    "SECONDS",
106    "pipestatus",
107    "status",
108];
109
110/// Longest value prefix stored as a content key. Content keying hashes
111/// the whole string, but the *summary* strings kept in the ledger are
112/// truncated to this so the ledger stays small next to the values it
113/// describes.
114const SUMMARY_MAX: usize = 64;
115
116/// Flipped to `true` the first time a name is tracked or a value is
117/// marked. Every hook checks it via [`active`] — one inlined relaxed
118/// load on the universal path where nobody armed the engine.
119static PROV_ACTIVE: AtomicBool = AtomicBool::new(false);
120
121/// Track-everything mode: every parameter write and every shell function
122/// arms itself. Set from `[provenance] track_all` at startup, or by
123/// `provenance -a` at runtime.
124static TRACK_ALL: AtomicBool = AtomicBool::new(false);
125
126/// Last `$LINENO` seen by [`note_line`]. Updated from the
127/// `BUILTIN_SET_LINENO` bytecode handler while armed, so lineage
128/// records carry a source line without any hook having to take the
129/// parameter-table lock (which the param write hooks run inside).
130static CURRENT_LINE: AtomicUsize = AtomicUsize::new(0);
131
132/// Where and when a tap fired. Every origin and every op carries one, so
133/// a chain reads as a timeline: which file and line produced the bytes,
134/// and at what wall-clock instant.
135#[derive(Debug, Clone, PartialEq, Eq, Default)]
136pub struct Site {
137    /// `$LINENO` as last mirrored by [`note_line`], or 0 when unknown.
138    pub line: usize,
139    /// File the line belongs to: the file a shell function was defined
140    /// in while one is on the stack, otherwise the script being run or
141    /// sourced. `None` for `-c` input and interactive lines, which have
142    /// no file.
143    pub file: Option<String>,
144    /// Shell function the op ran inside, when there is one. Without it
145    /// a line number taken inside a function — which zsh counts from the
146    /// function's own first line — has nothing to anchor it.
147    pub func: Option<String>,
148    /// Wall clock, milliseconds since the Unix epoch.
149    pub time_ms: i64,
150}
151
152impl Site {
153    /// Capture the current site. Called at hook entry, before the ledger
154    /// lock is taken, so the `scriptfilename` read never nests inside it.
155    fn now() -> Self {
156        // Inside a function `$LINENO` counts from the function's own
157        // first line, so the line is only meaningful next to the file
158        // the function was defined in, offset by where that definition
159        // starts — `funcstack`'s `f->prev->flineno + f->lineno`
160        // (`Src/Modules/parameter.c:747`), the same sum `funcfiletrace`
161        // reports. Outside one, `$LINENO` already indexes the script.
162        let frame = current_function();
163        let (func, file, line) = match frame {
164            Some((name, file, flineno)) => {
165                (Some(name), file, current_line() + flineno.max(0) as usize)
166            }
167            None => (
168                None,
169                crate::ported::utils::scriptfilename_get(),
170                current_line(),
171            ),
172        };
173        Self {
174            line,
175            // `zsh -c` stamps the literal "zsh" into `scriptfilename`
176            // (`Src/init.c:479`), which is a shell name, not a file. A
177            // chain says `line N` there rather than naming a file that
178            // does not exist.
179            file: file.filter(|f| f != "zsh"),
180            func,
181            time_ms: SystemTime::now()
182                .duration_since(UNIX_EPOCH)
183                .map(|d| d.as_millis() as i64)
184                .unwrap_or(0),
185        }
186    }
187
188    /// Same source position — the comparison [`ProvNode::push_op`] uses
189    /// to collapse an immediate repeat, which must ignore the clock or
190    /// no two ops would ever compare equal.
191    fn same_position(&self, other: &Self) -> bool {
192        self.line == other.line && self.file == other.file && self.func == other.func
193    }
194
195    /// `file:line` when the line belongs to a file, `line N` otherwise,
196    /// with the enclosing function appended when the op ran inside one.
197    pub fn location(&self) -> String {
198        let base = match &self.file {
199            Some(f) => format!("{}:{}", f, self.line),
200            None => format!("line {}", self.line),
201        };
202        match &self.func {
203            Some(fun) => format!("{} ({})", base, fun),
204            None => base,
205        }
206    }
207
208    /// Local wall clock. `with_date` spells out the day as well as the
209    /// time — used on the origin line, and on any op that happened on a
210    /// different day than the origin.
211    pub fn clock(&self, with_date: bool) -> String {
212        let fmt = if with_date {
213            "%Y-%m-%d %H:%M:%S%.3f"
214        } else {
215            "%H:%M:%S%.3f"
216        };
217        match Local.timestamp_millis_opt(self.time_ms).single() {
218            Some(dt) => dt.format(fmt).to_string(),
219            None => String::new(),
220        }
221    }
222
223    /// RFC 3339 local timestamp with millisecond precision, for `-j`.
224    pub fn rfc3339(&self) -> String {
225        match Local.timestamp_millis_opt(self.time_ms).single() {
226            Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, false),
227            None => String::new(),
228        }
229    }
230
231    /// Calendar day, for deciding whether an op line needs the date.
232    fn day(&self) -> Option<NaiveDate> {
233        Local
234            .timestamp_millis_opt(self.time_ms)
235            .single()
236            .map(|dt| dt.date_naive())
237    }
238}
239
240/// One operation in a value's lineage chain.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct ProvOp {
243    /// Operation name — `cmdsubst`, `glob`, `expand`, `assign`, `exec`,
244    /// `function`, `origin`, `unset`.
245    pub op: String,
246    /// Short summaries of the operands, in argument order.
247    pub args: Vec<String>,
248    /// Where and when the op ran.
249    pub site: Site,
250}
251
252/// Lineage record for a single value.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct ProvNode {
255    /// How the value entered the shell — e.g. `cmdsubst "date +%s"`,
256    /// `glob "*.rs"`, `param FOO`.
257    pub origin: String,
258    /// Where and when the value entered the shell.
259    pub origin_site: Site,
260    /// Append-only chain of operations that touched the value after the
261    /// origin. Latest op last.
262    pub ops: Vec<ProvOp>,
263    /// Tracked parameter this chain belongs to, when it has one. Ops
264    /// recorded against a derived value also extend the owner's node, so
265    /// `provenance NAME` shows what happened to the value after it left
266    /// the parameter.
267    pub owner: Option<String>,
268    /// Ops that were not stored because the chain hit [`MAX_OPS`].
269    pub dropped_ops: usize,
270}
271
272impl ProvNode {
273    /// New origin node with an empty op chain.
274    fn origin(origin: impl Into<String>, site: Site) -> Self {
275        Self {
276            origin: origin.into(),
277            origin_site: site,
278            ops: Vec::new(),
279            owner: None,
280            dropped_ops: 0,
281        }
282    }
283
284    /// Append an op, collapsing an immediate repeat (same op, operands
285    /// and line — a value read several times while evaluating one
286    /// statement) and counting instead of storing past [`MAX_OPS`].
287    fn push_op(&mut self, op: ProvOp) {
288        if self
289            .ops
290            .last()
291            .is_some_and(|l| l.op == op.op && l.args == op.args && l.site.same_position(&op.site))
292        {
293            return;
294        }
295        if self.ops.len() >= MAX_OPS {
296            self.dropped_ops += 1;
297            return;
298        }
299        self.ops.push(op);
300    }
301}
302
303/// Weak half of a `Ptr` entry. `fusevm::Value`'s heap variants carry
304/// different payload types, so the weak reference is per-variant rather
305/// than the single `Weak<HeapObject>` stryke gets from its uniform heap.
306enum ValueWeak {
307    /// Weak half of a `Value::Str(Arc<String>)`.
308    Str(Weak<String>),
309    /// Weak half of a `Value::Array(Arc<Vec<Value>>)`.
310    Array(Weak<Vec<Value>>),
311}
312
313impl ValueWeak {
314    /// True when the weak reference still resolves to the same address
315    /// the entry was keyed on. False means the original allocation is
316    /// gone — the address may since have been recycled by an unrelated
317    /// value, which is exactly the false positive this check exists to
318    /// prevent.
319    fn still_at(&self, ptr: usize) -> bool {
320        match self {
321            ValueWeak::Str(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
322            ValueWeak::Array(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
323        }
324    }
325}
326
327/// A `Ptr`-keyed ledger row: the lineage plus the weak reference used to
328/// detect address reuse.
329struct PtrEntry {
330    /// Weak reference to the value this row describes.
331    weak: ValueWeak,
332    /// Lineage of the value.
333    node: ProvNode,
334}
335
336/// The ledger. One mutex over all three key spaces — every hook takes it
337/// only after [`active`] returned true, so an unarmed shell never
338/// contends on it.
339#[derive(Default)]
340struct Ledger {
341    /// In-flight VM values, keyed by `Arc` address.
342    ptr: HashMap<usize, PtrEntry>,
343    /// Tracked parameters, keyed by name.
344    name: HashMap<String, ProvNode>,
345    /// Names armed via `provenance -m`, including names that have no
346    /// lineage yet (they gain one on the next assignment).
347    tracked: HashSet<String>,
348    /// Tracked shell functions, keyed by name. Separate from `name` so a
349    /// function and a parameter may share a name without sharing a
350    /// chain — `path` and `path()` are different things.
351    func: HashMap<String, ProvNode>,
352    /// Functions armed via `provenance -m -f`, or by [`track_all`].
353    tracked_funcs: HashSet<String>,
354    /// Names [`track_all`] declined to arm because [`MAX_AUTO_NAMES`]
355    /// was already reached.
356    auto_dropped: usize,
357    /// Values that crossed a `String`-typed host boundary, keyed by a
358    /// hash of their exact bytes.
359    content: HashMap<u64, ProvNode>,
360    /// Insertion order of `content` keys, for the FIFO bound.
361    content_order: VecDeque<u64>,
362}
363
364fn ledger() -> &'static Mutex<Ledger> {
365    static LEDGER: OnceLock<Mutex<Ledger>> = OnceLock::new();
366    LEDGER.get_or_init(|| Mutex::new(Ledger::default()))
367}
368
369/// Take the ledger lock, recovering from a poisoned mutex. A panic in a
370/// hook must not turn every later provenance call into a silent no-op,
371/// and no invariant spans the lock — each row is self-contained.
372fn lock() -> MutexGuard<'static, Ledger> {
373    match ledger().lock() {
374        Ok(g) => g,
375        Err(poisoned) => poisoned.into_inner(),
376    }
377}
378
379// ── Gates ───────────────────────────────────────────────────────────
380
381/// Whether the engine may be armed at all: `[provenance] enabled` in
382/// `~/.zshrs/zshrs.toml` (default `true`), with `ZSHRS_PROVENANCE=0` as
383/// an environment kill switch that wins over the config. Read once per
384/// process — the config snapshot itself is already process-cached.
385pub fn enabled() -> bool {
386    static ENABLED: OnceLock<bool> = OnceLock::new();
387    *ENABLED.get_or_init(|| {
388        if std::env::var("ZSHRS_PROVENANCE").is_ok_and(|v| v == "0") {
389            return false;
390        }
391        crate::config::current().provenance.enabled
392    })
393}
394
395/// Whether `[provenance] track_all` (or `ZSHRS_PROVENANCE_ALL`) asked
396/// for track-everything mode. `ZSHRS_PROVENANCE_ALL` wins over the file
397/// in both directions: `=1` turns it on, `=0` off.
398fn track_all_configured() -> bool {
399    static CONFIGURED: OnceLock<bool> = OnceLock::new();
400    *CONFIGURED.get_or_init(|| match std::env::var("ZSHRS_PROVENANCE_ALL") {
401        Ok(v) if v == "1" => true,
402        Ok(v) if v == "0" => false,
403        _ => crate::config::current().provenance.track_all,
404    })
405}
406
407/// Arm track-everything mode when the config asks for it. Called once at
408/// shell startup — before that the engine is inert whatever the file
409/// says, because nothing has read it.
410pub fn init_from_config() {
411    if enabled() && track_all_configured() {
412        set_track_all(true);
413    }
414}
415
416/// Whether every parameter write and function arms itself.
417#[inline]
418pub fn track_all() -> bool {
419    TRACK_ALL.load(Ordering::Relaxed)
420}
421
422/// Turn track-everything mode on or off at runtime (`provenance -a`).
423/// Returns false when the engine is disabled by config/env, which is the
424/// one thing `-a` cannot override.
425pub fn set_track_all(on: bool) -> bool {
426    if on && !enabled() {
427        return false;
428    }
429    TRACK_ALL.store(on, Ordering::Relaxed);
430    if on {
431        PROV_ACTIVE.store(true, Ordering::Relaxed);
432    } else {
433        let l = lock();
434        let empty = l.tracked.is_empty()
435            && l.name.is_empty()
436            && l.tracked_funcs.is_empty()
437            && l.func.is_empty();
438        drop(l);
439        if empty {
440            PROV_ACTIVE.store(false, Ordering::Relaxed);
441        }
442    }
443    true
444}
445
446/// Whether [`track_all`] may arm `name` by itself: not one of the
447/// parameters the shell rewrites on its own, and not a positional.
448fn auto_armable(name: &str) -> bool {
449    !name.is_empty() && !VOLATILE.contains(&name) && !name.chars().all(|c| c.is_ascii_digit())
450}
451
452/// The hot gate. `false` until something is tracked, so every hook site
453/// costs one relaxed load in the universal case.
454#[inline]
455pub fn active() -> bool {
456    PROV_ACTIVE.load(Ordering::Relaxed)
457}
458
459/// Record the current source line. Called from the `BUILTIN_SET_LINENO`
460/// bytecode handler while armed; deliberately does not read `$LINENO`
461/// from the parameter table, because the param-write hooks run with that
462/// table locked.
463#[inline]
464pub fn note_line(line: usize) {
465    CURRENT_LINE.store(line, Ordering::Relaxed);
466}
467
468/// Line most recently reported by [`note_line`], or 0 when unknown.
469pub fn current_line() -> usize {
470    CURRENT_LINE.load(Ordering::Relaxed)
471}
472
473/// The shell function whose body is executing right now: its name, the
474/// file it was defined in, and the file line that definition starts on
475/// (`funcsourcetrace`'s pair — `Src/exec.c:1613` fills the frame's
476/// filename from `scriptfilename` at call time).
477///
478/// Only the topmost frame counts. A `source` or `eval` frame above a
479/// function makes `$LINENO` count in *that* text instead, so the
480/// function's definition line is the wrong thing to add to it.
481///
482/// Takes the stack with `try_lock`: a hook can fire from anywhere the VM
483/// runs, and a lineage row is worth less than a deadlock against the
484/// frame push/pop that a blocking lock would risk.
485fn current_function() -> Option<(String, Option<String>, i64)> {
486    let stack = crate::ported::modules::parameter::FUNCSTACK
487        .try_lock()
488        .ok()?;
489    let frame = stack.last()?;
490    (frame.tp == crate::ported::zsh_h::FS_FUNC)
491        .then(|| (frame.name.clone(), frame.filename.clone(), frame.flineno))
492}
493
494// ── Summaries ───────────────────────────────────────────────────────
495
496/// Short, single-line description of a string value for a ledger row.
497/// The full value stays reachable through the shell itself; the ledger
498/// only needs a human-readable handle.
499pub fn summarize_str(s: &str) -> String {
500    let mut out = String::with_capacity(SUMMARY_MAX + 12);
501    out.push('"');
502    for ch in s.chars().take(SUMMARY_MAX) {
503        match ch {
504            '\n' => out.push_str("\\n"),
505            '\t' => out.push_str("\\t"),
506            '"' => out.push_str("\\\""),
507            '\\' => out.push_str("\\\\"),
508            c => out.push(c),
509        }
510    }
511    if s.chars().count() > SUMMARY_MAX {
512        out.push('…');
513    }
514    out.push('"');
515    out
516}
517
518/// Short description of a `fusevm::Value` for a ledger row.
519pub fn summarize_value(v: &Value) -> String {
520    match v {
521        Value::Str(s) => summarize_str(s),
522        Value::Array(a) => format!("ARRAY len={}", a.len()),
523        Value::Hash(h) => format!("ASSOC entries={}", h.len()),
524        Value::Int(n) => n.to_string(),
525        Value::Float(f) => f.to_string(),
526        Value::Bool(b) => b.to_string(),
527        Value::Status(c) => format!("STATUS {}", c),
528        Value::Undef => "unset".to_string(),
529        other => format!("{:?}", other),
530    }
531}
532
533fn content_key(s: &str) -> u64 {
534    let mut h = DefaultHasher::new();
535    s.hash(&mut h);
536    h.finish()
537}
538
539/// `Arc` address of a value's heap payload, or `None` for immediates
540/// (ints, floats, bools, statuses, undef) which have no stable identity
541/// to key on.
542fn value_ptr(v: &Value) -> Option<usize> {
543    match v {
544        Value::Str(s) => Some(Arc::as_ptr(s) as usize),
545        Value::Array(a) => Some(Arc::as_ptr(a) as usize),
546        _ => None,
547    }
548}
549
550fn value_weak(v: &Value) -> Option<ValueWeak> {
551    match v {
552        Value::Str(s) => Some(ValueWeak::Str(Arc::downgrade(s))),
553        Value::Array(a) => Some(ValueWeak::Array(Arc::downgrade(a))),
554        _ => None,
555    }
556}
557
558// ── Ledger primitives ───────────────────────────────────────────────
559
560impl Ledger {
561    /// Look up a `Ptr` row, reaping it when the weak check says the
562    /// address was recycled.
563    fn ptr_node(&mut self, ptr: usize) -> Option<ProvNode> {
564        let live = match self.ptr.get(&ptr) {
565            Some(e) => e.weak.still_at(ptr),
566            None => return None,
567        };
568        if !live {
569            self.ptr.remove(&ptr);
570            return None;
571        }
572        self.ptr.get(&ptr).map(|e| e.node.clone())
573    }
574
575    fn put_ptr(&mut self, v: &Value, node: ProvNode) {
576        let (Some(ptr), Some(weak)) = (value_ptr(v), value_weak(v)) else {
577            return;
578        };
579        self.ptr.insert(ptr, PtrEntry { weak, node });
580    }
581
582    fn put_content(&mut self, s: &str, node: ProvNode) {
583        // Empty strings are the most common value in a shell and carry
584        // no useful identity — keying on them would attach a lineage to
585        // every unset expansion in the script.
586        if s.is_empty() {
587            return;
588        }
589        let key = content_key(s);
590        if self.content.insert(key, node).is_none() {
591            self.content_order.push_back(key);
592            while self.content_order.len() > CONTENT_CAP {
593                if let Some(old) = self.content_order.pop_front() {
594                    self.content.remove(&old);
595                }
596            }
597        }
598    }
599
600    fn content_node(&self, s: &str) -> Option<ProvNode> {
601        if s.is_empty() {
602            return None;
603        }
604        self.content.get(&content_key(s)).cloned()
605    }
606
607    /// Append `op` to the owning parameter's chain, when the node has an
608    /// owner that is still tracked. This is what makes
609    /// `provenance NAME` show what happened to the value *after* it left
610    /// the parameter.
611    fn extend_owner(&mut self, node: &ProvNode, op: &ProvOp) {
612        let Some(owner) = node.owner.as_deref() else {
613            return;
614        };
615        if let Some(target) = self.name.get_mut(owner) {
616            target.push_op(op.clone());
617        }
618    }
619}
620
621// ── Tracking control (the `provenance` builtin's surface) ───────────
622
623/// Arm tracking for parameter `name`. A parameter that already holds a
624/// value gets it as the chain's origin, so lineage starts at "what it
625/// holds now"; an unset one takes its origin from its first assignment.
626/// Returns false when the engine is disabled by config/env.
627pub fn track_name(name: &str, current_value: Option<&str>) -> bool {
628    if !enabled() {
629        return false;
630    }
631    let site = Site::now();
632    let mut l = lock();
633    l.tracked.insert(name.to_string());
634    // A parameter that already holds a value gets that value as its
635    // origin. An unset one gets no node at all: its first assignment
636    // supplies the origin, so `X=$(date)` reads as a cmdsubst origin
637    // rather than a placeholder the assignment has to argue with.
638    if let Some(v) = current_value {
639        let mut node = ProvNode::origin(format!("param {} = {}", name, summarize_str(v)), site);
640        node.owner = Some(name.to_string());
641        l.name.insert(name.to_string(), node);
642    }
643    drop(l);
644    PROV_ACTIVE.store(true, Ordering::Relaxed);
645    true
646}
647
648/// Drop tracking and lineage for `name`. Idempotent; returns true when
649/// something was actually removed.
650pub fn untrack_name(name: &str) -> bool {
651    let mut l = lock();
652    let had = l.tracked.remove(name) | l.name.remove(name).is_some();
653    let empty = l.tracked.is_empty()
654        && l.name.is_empty()
655        && l.tracked_funcs.is_empty()
656        && l.func.is_empty();
657    drop(l);
658    // Track-everything mode keeps the engine armed: the next write to
659    // any parameter arms it again, so disarming here would only cost the
660    // rows between now and then.
661    if empty && !track_all() {
662        PROV_ACTIVE.store(false, Ordering::Relaxed);
663    }
664    had
665}
666
667/// Drop every ledger entry and disarm the engine, track-everything mode
668/// included — `provenance -c` is the full stop.
669pub fn clear() {
670    let mut l = lock();
671    *l = Ledger::default();
672    drop(l);
673    TRACK_ALL.store(false, Ordering::Relaxed);
674    PROV_ACTIVE.store(false, Ordering::Relaxed);
675}
676
677/// Names currently armed, sorted.
678pub fn tracked_names() -> Vec<String> {
679    let l = lock();
680    let mut v: Vec<String> = l.tracked.iter().cloned().collect();
681    v.sort();
682    v
683}
684
685/// Lineage of a tracked parameter, or `None` when it is not tracked.
686pub fn lookup_name(name: &str) -> Option<ProvNode> {
687    lock().name.get(name).cloned()
688}
689
690/// Lineage attached to an in-flight VM value, or `None`. Stale rows are
691/// reaped on lookup.
692pub fn lookup_value(v: &Value) -> Option<ProvNode> {
693    let ptr = value_ptr(v)?;
694    lock().ptr_node(ptr)
695}
696
697/// Lineage attached to a raw string that crossed a host boundary.
698pub fn lookup_content(s: &str) -> Option<ProvNode> {
699    lock().content_node(s)
700}
701
702// ── Hook points ─────────────────────────────────────────────────────
703//
704// Every function below is called from a bytecode/host site and must be
705// guarded by `if provenance::active()` at the call site, so an unarmed
706// shell pays one relaxed load and nothing else.
707
708/// A command substitution produced `out`. Records a speculative origin
709/// so a later assignment of the same bytes can inherit it.
710pub fn on_cmd_subst(source: &str, out: &str) {
711    let site = Site::now();
712    let origin = if source.is_empty() {
713        "cmdsubst".to_string()
714    } else {
715        format!("cmdsubst {}", summarize_str(source))
716    };
717    lock().put_content(out, ProvNode::origin(origin, site));
718}
719
720/// A process substitution produced the path `out` for sub-chunk source
721/// `source`.
722pub fn on_process_subst(source: &str, out: &str) {
723    let site = Site::now();
724    let origin = if source.is_empty() {
725        "procsubst".to_string()
726    } else {
727        format!("procsubst {}", summarize_str(source))
728    };
729    lock().put_content(out, ProvNode::origin(origin, site));
730}
731
732/// A glob expanded to `results`. Skips high-fanout expansions: a
733/// thousand-file glob would evict the whole content FIFO for matches
734/// nobody is tracking.
735pub fn on_glob(pattern: &str, results: &[String]) {
736    if results.is_empty() || results.len() > 32 {
737        return;
738    }
739    let site = Site::now();
740    let mut l = lock();
741    for r in results {
742        l.put_content(
743            r,
744            ProvNode::origin(format!("glob {}", summarize_str(pattern)), site.clone()),
745        );
746    }
747}
748
749/// A heredoc / herestring body became the next command's stdin.
750pub fn on_heredoc(kind: &str, body: &str) {
751    let site = Site::now();
752    lock().put_content(body, ProvNode::origin(kind.to_string(), site));
753}
754
755/// Parameter `name` was read by an `ExpandParam` bytecode op, producing
756/// `value`. When the parameter is tracked, the value carries the
757/// parameter's chain forward — by `Arc` identity for the rest of the
758/// chunk, and by content for the host boundaries that only see `String`.
759pub fn on_param_read(name: &str, value: &Value) {
760    let site = Site::now();
761    let mut l = lock();
762    let Some(mut node) = l.name.get(name).cloned() else {
763        return;
764    };
765    let op = ProvOp {
766        op: "expand".to_string(),
767        args: vec![format!("${}", name), summarize_value(value)],
768        site,
769    };
770    if let Some(target) = l.name.get_mut(name) {
771        target.push_op(op.clone());
772    }
773    node.push_op(op);
774    node.owner = Some(name.to_string());
775    l.put_ptr(value, node.clone());
776    if let Value::Str(s) = value {
777        l.put_content(s, node);
778    }
779}
780
781/// Two word segments were concatenated by a bytecode concat op. When
782/// either operand carries a lineage — by `Arc` identity for a value the
783/// VM is still holding, or by content for one that crossed a host
784/// boundary — the produced value inherits the richer chain. This is the
785/// link that keeps derived values traceable: `G=${F}.bak` does not hold
786/// F's bytes, but it was built from them.
787pub fn on_concat(lhs: &Value, rhs: &Value, result: &Value) {
788    let site = Site::now();
789    let mut l = lock();
790    let mut chosen: Option<ProvNode> = None;
791    for operand in [lhs, rhs] {
792        let found = value_ptr(operand)
793            .and_then(|p| l.ptr_node(p))
794            .or_else(|| match operand {
795                Value::Str(s) => l.content_node(s),
796                _ => None,
797            });
798        if let Some(node) = found {
799            // Prefer the longer chain, matching stryke's `record_op`
800            // parent selection.
801            if chosen.as_ref().is_none_or(|c| node.ops.len() > c.ops.len()) {
802                chosen = Some(node);
803            }
804        }
805    }
806    let Some(mut node) = chosen else {
807        return;
808    };
809    // A segment concat where one side is empty adds no bytes — the word
810    // assembler emits one per leading/trailing segment. Propagate the
811    // lineage, but do not record an op that changed nothing.
812    let is_noop = matches!(lhs, Value::Str(s) if s.is_empty())
813        || matches!(rhs, Value::Str(s) if s.is_empty());
814    if !is_noop {
815        let op = ProvOp {
816            op: "concat".to_string(),
817            args: vec![summarize_value(lhs), summarize_value(rhs)],
818            site,
819        };
820        l.extend_owner(&node, &op);
821        node.push_op(op);
822    }
823    l.put_ptr(result, node.clone());
824    if let Value::Str(s) = result {
825        l.put_content(s, node);
826    }
827}
828
829/// Parameter `name` was assigned. `kind` names the write funnel
830/// (`assign`, `assign[]`, `append`, `array`, `assoc`) and `value` is the
831/// summary of what was stored. When the assigned bytes already carry a
832/// lineage — a command substitution's output, a glob match, another
833/// tracked parameter's value — that chain becomes this parameter's
834/// origin on the first write, and is spliced into the chain (as an
835/// `origin` op followed by its ops) on later ones. Reassignment never
836/// discards the parameter's history: the chain is the whole life of the
837/// parameter, one write op per assignment.
838pub fn on_param_write(name: &str, kind: &str, value: &str) {
839    let site = Site::now();
840    let mut l = lock();
841    if !l.tracked.contains(name) {
842        // Track-everything mode arms a parameter the first time the
843        // shell writes it, which is also the first moment its chain can
844        // say anything.
845        if !track_all() || !auto_armable(name) {
846            return;
847        }
848        if l.tracked.len() + l.tracked_funcs.len() >= MAX_AUTO_NAMES {
849            l.auto_dropped += 1;
850            return;
851        }
852        l.tracked.insert(name.to_string());
853    }
854    // A value whose bytes are this parameter's own current value carries
855    // this same chain; splicing it back in would duplicate the chain
856    // onto itself.
857    let inherited = l
858        .content_node(value)
859        .filter(|n| n.owner.as_deref() != Some(name));
860    let mut node = match l.name.remove(name) {
861        // The parameter already has a chain: the assignment extends it.
862        // A value that arrived with its own lineage contributes that
863        // lineage — recorded as an `origin` op, then its ops — instead
864        // of replacing the parameter's history.
865        Some(mut existing) => {
866            if let Some(inh) = inherited {
867                existing.push_op(ProvOp {
868                    op: "origin".to_string(),
869                    args: vec![inh.origin],
870                    site: inh.origin_site,
871                });
872                for op in inh.ops {
873                    existing.push_op(op);
874                }
875            }
876            existing
877        }
878        // First write to a parameter armed while unset: the value's own
879        // lineage is the origin, or the assignment itself is.
880        None => inherited.unwrap_or_else(|| {
881            ProvNode::origin(format!("{} {}", kind, summarize_str(value)), site.clone())
882        }),
883    };
884    node.owner = Some(name.to_string());
885    node.push_op(ProvOp {
886        op: kind.to_string(),
887        args: vec![name.to_string(), summarize_str(value)],
888        site,
889    });
890    l.name.insert(name.to_string(), node.clone());
891    l.put_content(value, node);
892}
893
894/// Parameter `name` was unset. Keeps the chain (that is the interesting
895/// part) and records the unset as its final op.
896pub fn on_param_unset(name: &str) {
897    let site = Site::now();
898    let mut l = lock();
899    if let Some(node) = l.name.get_mut(name) {
900        node.push_op(ProvOp {
901            op: "unset".to_string(),
902            args: vec![name.to_string()],
903            site,
904        });
905    }
906}
907
908/// A command is about to run with `args` (argv[0] first). Any argument
909/// carrying a lineage gets an `exec` op appended, and the op also
910/// extends the owning parameter's chain so `provenance NAME` shows where
911/// the value was consumed.
912pub fn on_exec(kind: &str, args: &[String]) {
913    if args.is_empty() {
914        return;
915    }
916    let site = Site::now();
917    let cmd = args[0].clone();
918    let mut l = lock();
919    for (i, a) in args.iter().enumerate().skip(1) {
920        let Some(mut node) = l.content_node(a) else {
921            continue;
922        };
923        let op = ProvOp {
924            op: kind.to_string(),
925            args: vec![cmd.clone(), format!("argv[{}]", i)],
926            site: site.clone(),
927        };
928        l.extend_owner(&node, &op);
929        node.push_op(op);
930        l.put_content(a, node);
931    }
932}
933
934// ── Shell functions ─────────────────────────────────────────────────
935//
936// A function has a lineage of its own: where it was defined, every
937// redefinition, every call, and the `unfunction` that ended it. The
938// value taps above answer "where did these bytes come from?"; these
939// answer the same question about the code that produced them.
940
941/// Site of a function's definition, as `shfunctab` recorded it —
942/// `Src/exec.c:5383-5388` stores the defining file and the line the
943/// definition starts on. Falls back to the current site when the
944/// function came from somewhere with neither (an `eval`, a `-c` line).
945fn def_site(file: Option<&str>, line: i64) -> Site {
946    let mut site = Site::now();
947    if file.is_some() || line > 0 {
948        site.file = file.map(str::to_string).filter(|f| f != "zsh");
949        site.line = line.max(0) as usize;
950        site.func = None;
951    }
952    site
953}
954
955/// Arm tracking for shell function `name`, seeding its origin from where
956/// the function is defined right now. Returns false when the engine is
957/// disabled by config/env.
958pub fn track_func(name: &str, body: Option<&str>, file: Option<&str>, line: i64) -> bool {
959    if !enabled() {
960        return false;
961    }
962    let mut l = lock();
963    l.tracked_funcs.insert(name.to_string());
964    // Same rule as `track_name`, which this used to break: a function that
965    // ALREADY EXISTS gets its definition site as the origin, and one that does
966    // not exist yet gets no node at all, so its first definition supplies the
967    // origin.
968    //
969    // Seeding a node unconditionally meant arming a function before defining it
970    // stamped an origin of `function NAME (line 0)` — a definition site that had
971    // never happened — and then `on_func_define` found a node already there and
972    // logged the REAL first definition as a `redefine` against it. Arming early
973    // is the normal way to watch a function get defined, so that is the common
974    // path, not an edge case.
975    //
976    // `(None, 0)` is `shfunc_def_site`'s documented "no such function"; a defined
977    // one always carries a 1-based line.
978    if file.is_some() || line > 0 {
979        let site = def_site(file, line);
980        // Seed the origin with the body the function has RIGHT NOW.
981        // Arming an already-defined function is the common case
982        // (`provenance -m greet` on something sourced earlier), and it
983        // never passes through `on_func_define`, so this is the only
984        // chance to record what the body was before any redefinition.
985        let summary = body_summary(body);
986        l.func.entry(name.to_string()).or_insert_with(|| {
987            ProvNode::origin(
988                match &summary {
989                    Some(b) => format!("function {} {{ {} }}", name, b),
990                    None => format!("function {}", name),
991                },
992                site,
993            )
994        });
995    }
996    drop(l);
997    PROV_ACTIVE.store(true, Ordering::Relaxed);
998    true
999}
1000
1001/// Drop tracking and lineage for function `name`.
1002pub fn untrack_func(name: &str) -> bool {
1003    let mut l = lock();
1004    let had = l.tracked_funcs.remove(name) | l.func.remove(name).is_some();
1005    let empty = l.tracked.is_empty()
1006        && l.name.is_empty()
1007        && l.tracked_funcs.is_empty()
1008        && l.func.is_empty();
1009    drop(l);
1010    if empty && !track_all() {
1011        PROV_ACTIVE.store(false, Ordering::Relaxed);
1012    }
1013    had
1014}
1015
1016/// Lineage of a tracked function, or `None` when it is not tracked.
1017pub fn lookup_func(name: &str) -> Option<ProvNode> {
1018    lock().func.get(name).cloned()
1019}
1020
1021/// Tracked function names, sorted.
1022pub fn tracked_func_names() -> Vec<String> {
1023    let l = lock();
1024    let mut v: Vec<String> = l.tracked_funcs.iter().cloned().collect();
1025    v.sort();
1026    v
1027}
1028
1029/// Names [`track_all`] declined to arm because [`MAX_AUTO_NAMES`] was
1030/// already reached.
1031pub fn auto_dropped() -> usize {
1032    lock().auto_dropped
1033}
1034
1035/// Arm `name` in the function namespace when track-everything mode is
1036/// on and the ledger has room. Caller holds the lock.
1037fn auto_arm_func(l: &mut Ledger, name: &str) -> bool {
1038    if l.tracked_funcs.contains(name) {
1039        return true;
1040    }
1041    if !track_all() || !auto_armable(name) {
1042        return false;
1043    }
1044    if l.tracked.len() + l.tracked_funcs.len() >= MAX_AUTO_NAMES {
1045        l.auto_dropped += 1;
1046        return false;
1047    }
1048    l.tracked_funcs.insert(name.to_string());
1049    true
1050}
1051
1052/// Collapse a function body to one line that fits the chain's argument
1053/// column. Bodies are multi-line and indented; the chain wants "what is
1054/// this, and what did it become", not a listing, so runs of whitespace
1055/// (newlines included) fold to a single space and the tail is cut with
1056/// `…`. Returns None when there is no body text to show, so a caller
1057/// with nothing to record renders exactly as it did before.
1058fn body_summary(body: Option<&str>) -> Option<String> {
1059    let collapsed = body?.split_whitespace().collect::<Vec<_>>().join(" ");
1060    if collapsed.is_empty() {
1061        return None;
1062    }
1063    // `render` lays the argument column out as `{:<40}`; leave room for
1064    // the function name and the ellipsis.
1065    const MAX_BODY_WIDTH: usize = 32;
1066    if collapsed.chars().count() > MAX_BODY_WIDTH {
1067        let keep: String = collapsed.chars().take(MAX_BODY_WIDTH).collect();
1068        Some(format!("{}…", keep))
1069    } else {
1070        Some(collapsed)
1071    }
1072}
1073
1074/// A function was defined, at `file`:`line`. The first definition is the
1075/// origin; a later one is a `redefine` op, so a chain shows every body
1076/// the name ever had and where each came from.
1077///
1078/// `body` is the function's source text. It is recorded on both the
1079/// origin and every `redefine`, because a redefinition op that names
1080/// only the function cannot answer what the body was changed TO — which
1081/// is the only reason to look at a redefine op at all.
1082pub fn on_func_define(name: &str, body: Option<&str>, file: Option<&str>, line: i64) {
1083    let site = def_site(file, line);
1084    let summary = body_summary(body);
1085    let mut l = lock();
1086    if !auto_arm_func(&mut l, name) {
1087        return;
1088    }
1089    match l.func.get_mut(name) {
1090        Some(node) => node.push_op(ProvOp {
1091            op: "redefine".to_string(),
1092            args: vec![match &summary {
1093                Some(b) => format!("{} {{ {} }}", name, b),
1094                None => name.to_string(),
1095            }],
1096            site,
1097        }),
1098        None => {
1099            l.func.insert(
1100                name.to_string(),
1101                ProvNode::origin(
1102                    match &summary {
1103                        Some(b) => format!("function {} {{ {} }}", name, b),
1104                        None => format!("function {}", name),
1105                    },
1106                    site,
1107                ),
1108            );
1109        }
1110    }
1111}
1112
1113/// Render a call's positionals the way the chain displays them:
1114/// `greet(alpha beta)`. An argument that is empty or carries whitespace
1115/// is single-quoted so `f 'a b'` and `f a b` stay distinguishable; the
1116/// whole list is capped to the width of `render`'s argument column so a
1117/// call with a hundred arguments cannot smear the table.
1118fn call_signature(name: &str, args: &[String]) -> String {
1119    // `render` lays the argument column out as `{:<40}`; leave room for
1120    // the name, the parentheses and the ellipsis.
1121    const MAX_ARGS_WIDTH: usize = 32;
1122    let mut rendered = String::new();
1123    for (i, a) in args.iter().enumerate() {
1124        if i > 0 {
1125            rendered.push(' ');
1126        }
1127        if a.is_empty() || a.chars().any(char::is_whitespace) {
1128            rendered.push('\'');
1129            rendered.push_str(a);
1130            rendered.push('\'');
1131        } else {
1132            rendered.push_str(a);
1133        }
1134        if rendered.chars().count() > MAX_ARGS_WIDTH {
1135            let keep: String = rendered.chars().take(MAX_ARGS_WIDTH).collect();
1136            rendered = format!("{}…", keep);
1137            break;
1138        }
1139    }
1140    format!("{}({})", name, rendered)
1141}
1142
1143/// A function is about to run. The op records the *call* site, which is
1144/// where the caller stands, not where the function was defined, and the
1145/// ARGUMENTS the call was made with — without them two calls to the same
1146/// function are indistinguishable on the chain, which defeats the point
1147/// of asking where a value came from.
1148///
1149/// `args` is the positional list only: `doshfunc`'s `doshargs[0]` is the
1150/// function name (`Src/exec.c:5986` sets `argzero` from it) and the
1151/// positionals are `doshargs[1..]` (c:5978-5998).
1152pub fn on_func_call(
1153    name: &str,
1154    args: &[String],
1155    body: Option<&str>,
1156    file: Option<&str>,
1157    line: i64,
1158) {
1159    let site = Site::now();
1160    // Arming a function that already exists (`provenance -m greet` on a
1161    // function defined earlier) never saw `on_func_define`, so the chain
1162    // is created HERE. Seed its origin with the body the function
1163    // currently has; without this the common arm-after-definition case
1164    // shows an origin with no body at all.
1165    let summary = body_summary(body);
1166    let mut l = lock();
1167    if !auto_arm_func(&mut l, name) {
1168        return;
1169    }
1170    let node = l.func.entry(name.to_string()).or_insert_with(|| {
1171        ProvNode::origin(
1172            match &summary {
1173                Some(b) => format!("function {} {{ {} }}", name, b),
1174                None => format!("function {}", name),
1175            },
1176            def_site(file, line),
1177        )
1178    });
1179    node.push_op(ProvOp {
1180        op: "call".to_string(),
1181        args: vec![call_signature(name, args)],
1182        site,
1183    });
1184}
1185
1186/// A function was removed (`unfunction`, `unset -f`). Kept on the chain
1187/// as its final op, the same way an unset parameter is.
1188pub fn on_func_unset(name: &str) {
1189    let site = Site::now();
1190    let mut l = lock();
1191    if let Some(node) = l.func.get_mut(name) {
1192        node.push_op(ProvOp {
1193            op: "unfunction".to_string(),
1194            args: vec![name.to_string()],
1195            site,
1196        });
1197    }
1198}
1199
1200// ── Rendering ───────────────────────────────────────────────────────
1201
1202/// Human-readable lineage report, as printed by `provenance NAME`.
1203pub fn render(label: &str, node: &ProvNode) -> String {
1204    let mut out = format!("{}\n", label);
1205    out.push_str(&format!(
1206        "  origin: {} ({}, {})\n",
1207        node.origin,
1208        node.origin_site.location(),
1209        node.origin_site.clock(true)
1210    ));
1211    if node.ops.is_empty() {
1212        out.push_str("  ops: (none)\n");
1213        return out;
1214    }
1215    // An op that ran on a different day than the origin spells its date
1216    // out; the rest carry the time alone, which is what a chain built
1217    // inside one session needs.
1218    let origin_day = node.origin_site.day();
1219    out.push_str("  ops:\n");
1220    for (i, op) in node.ops.iter().enumerate() {
1221        out.push_str(&format!(
1222            "    {:>2}. {:<10} {:<40} {:<24} {}\n",
1223            i + 1,
1224            op.op,
1225            op.args.join(" "),
1226            op.site.location(),
1227            op.site.clock(op.site.day() != origin_day)
1228        ));
1229    }
1230    if node.dropped_ops > 0 {
1231        out.push_str(&format!(
1232            "    … {} more ops (chain capped at {})\n",
1233            node.dropped_ops, MAX_OPS
1234        ));
1235    }
1236    out
1237}
1238
1239/// A JSON string, or `null` when there is no file to name.
1240fn json_str_or_null(s: Option<&str>) -> String {
1241    match s {
1242        Some(v) => format!("{:?}", v),
1243        None => "null".to_string(),
1244    }
1245}
1246
1247/// JSON lineage report, as printed by `provenance -j NAME`. Hand-rolled
1248/// so the engine pulls in no serializer of its own; the shapes here are
1249/// flat strings and integers.
1250pub fn render_json(label: &str, node: &ProvNode) -> String {
1251    let mut out = String::new();
1252    out.push_str(&format!(
1253        "{{\"name\":{:?},\"origin\":{:?},\"origin_line\":{},\"origin_file\":{},\"origin_function\":{},\"origin_time\":{:?},\"ops\":[",
1254        label,
1255        node.origin,
1256        node.origin_site.line,
1257        json_str_or_null(node.origin_site.file.as_deref()),
1258        json_str_or_null(node.origin_site.func.as_deref()),
1259        node.origin_site.rfc3339(),
1260    ));
1261    for (i, op) in node.ops.iter().enumerate() {
1262        if i > 0 {
1263            out.push(',');
1264        }
1265        out.push_str(&format!("{{\"op\":{:?},\"args\":[", op.op));
1266        for (j, a) in op.args.iter().enumerate() {
1267            if j > 0 {
1268                out.push(',');
1269            }
1270            out.push_str(&format!("{:?}", a));
1271        }
1272        out.push_str(&format!(
1273            "],\"line\":{},\"file\":{},\"function\":{},\"time\":{:?}}}",
1274            op.site.line,
1275            json_str_or_null(op.site.file.as_deref()),
1276            json_str_or_null(op.site.func.as_deref()),
1277            op.site.rfc3339(),
1278        ));
1279    }
1280    out.push_str(&format!("],\"dropped_ops\":{}}}", node.dropped_ops));
1281    out
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287
1288    /// Every test drives the same process-global ledger, so they take
1289    /// the shared state lock and start from a clean ledger.
1290    fn setup() -> std::sync::MutexGuard<'static, ()> {
1291        let g = crate::test_util::global_state_lock();
1292        clear();
1293        PROV_ACTIVE.store(true, Ordering::Relaxed);
1294        g
1295    }
1296
1297    #[test]
1298    fn tracked_name_seeds_an_origin_from_the_current_value() {
1299        let _g = setup();
1300        note_line(7);
1301        assert!(track_name("FOO", Some("bar")));
1302        let node = lookup_name("FOO").expect("tracked name has a node");
1303        assert_eq!(node.origin_site.line, 7);
1304        assert!(
1305            node.origin.contains("param FOO"),
1306            "origin = {}",
1307            node.origin
1308        );
1309        assert!(node.ops.is_empty(), "no ops at the origin");
1310        assert_eq!(tracked_names(), vec!["FOO".to_string()]);
1311    }
1312
1313    #[test]
1314    fn assignment_inherits_the_command_substitutions_lineage() {
1315        let _g = setup();
1316        note_line(3);
1317        track_name("OUT", None);
1318        on_cmd_subst("date +%s", "1750000000");
1319        on_param_write("OUT", "assign", "1750000000");
1320        let node = lookup_name("OUT").expect("assignment created a node");
1321        assert!(
1322            node.origin.starts_with("cmdsubst"),
1323            "origin must come from the substitution, got {}",
1324            node.origin
1325        );
1326        assert_eq!(node.ops.len(), 1);
1327        assert_eq!(node.ops[0].op, "assign");
1328    }
1329
1330    #[test]
1331    fn expansion_then_exec_extends_the_owning_parameters_chain() {
1332        let _g = setup();
1333        note_line(1);
1334        track_name("F", Some("report.txt"));
1335        on_param_write("F", "assign", "report.txt");
1336        // `$F` read by an ExpandParam op, then handed to `wc -l $F`.
1337        let v = Value::str("report.txt");
1338        on_param_read("F", &v);
1339        on_exec("exec", &["wc".into(), "-l".into(), "report.txt".into()]);
1340        let node = lookup_name("F").expect("F is tracked");
1341        let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1342        assert!(
1343            ops.contains(&"exec"),
1344            "consumption must land on the owner's chain, got {:?}",
1345            ops
1346        );
1347        let exec_op = node.ops.iter().find(|o| o.op == "exec").unwrap();
1348        assert_eq!(exec_op.args[0], "wc");
1349        assert_eq!(exec_op.args[1], "argv[2]");
1350    }
1351
1352    #[test]
1353    fn concat_carries_the_lineage_of_whichever_operand_has_one() {
1354        let _g = setup();
1355        note_line(4);
1356        track_name("F", Some("alpha"));
1357        on_param_write("F", "assign", "alpha");
1358        let read = Value::str("alpha");
1359        on_param_read("F", &read);
1360        let joined = Value::str("alpha.bak");
1361        on_concat(&read, &Value::str(".bak"), &joined);
1362        let node = lookup_value(&joined).expect("result inherited the chain");
1363        assert_eq!(node.owner.as_deref(), Some("F"));
1364        assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("concat"));
1365        // The owner's own chain records the concat too, so
1366        // `provenance F` shows what was built out of it.
1367        let owner = lookup_name("F").unwrap();
1368        assert!(owner.ops.iter().any(|o| o.op == "concat"), "{owner:?}");
1369    }
1370
1371    #[test]
1372    fn an_empty_segment_concat_propagates_without_recording_an_op() {
1373        let _g = setup();
1374        track_name("F", Some("alpha"));
1375        on_param_write("F", "assign", "alpha");
1376        let read = Value::str("alpha");
1377        on_param_read("F", &read);
1378        let before = lookup_name("F").unwrap().ops.len();
1379        // The word assembler emits `"" + $F` for a leading segment.
1380        let same = Value::str("alpha");
1381        on_concat(&Value::str(""), &read, &same);
1382        assert_eq!(
1383            lookup_name("F").unwrap().ops.len(),
1384            before,
1385            "a concat that adds no bytes must not appear in the chain"
1386        );
1387        assert!(
1388            lookup_value(&same).is_some(),
1389            "the lineage still has to reach the produced value"
1390        );
1391    }
1392
1393    #[test]
1394    fn concat_of_two_untracked_values_records_nothing() {
1395        let _g = setup();
1396        let out = Value::str("ab");
1397        on_concat(&Value::str("a"), &Value::str("b"), &out);
1398        assert!(lookup_value(&out).is_none());
1399    }
1400
1401    #[test]
1402    fn untracked_names_never_gain_a_lineage() {
1403        let _g = setup();
1404        on_cmd_subst("ls", "a\nb");
1405        on_param_write("UNTRACKED", "assign", "a\nb");
1406        assert!(
1407            lookup_name("UNTRACKED").is_none(),
1408            "writes to untracked names must not create rows"
1409        );
1410    }
1411
1412    #[test]
1413    fn value_lineage_survives_arc_clones_and_dies_with_the_value() {
1414        let _g = setup();
1415        track_name("V", Some("x"));
1416        let v = Value::str("payload");
1417        on_param_read("V", &v);
1418        let clone = v.clone();
1419        assert!(
1420            lookup_value(&clone).is_some(),
1421            "an Arc clone is the same value"
1422        );
1423        let ptr = value_ptr(&v).unwrap();
1424        drop(v);
1425        drop(clone);
1426        // The row is stale now: the Weak cannot upgrade, so a later
1427        // allocation reusing the address must not inherit the lineage.
1428        let mut l = lock();
1429        assert!(l.ptr_node(ptr).is_none(), "dropped value must reap its row");
1430        assert!(!l.ptr.contains_key(&ptr), "reaped row must be removed");
1431    }
1432
1433    #[test]
1434    fn content_entries_are_bounded_by_the_fifo_cap() {
1435        let _g = setup();
1436        for i in 0..(CONTENT_CAP + 100) {
1437            on_cmd_subst("gen", &format!("value-{}", i));
1438        }
1439        let l = lock();
1440        assert_eq!(l.content.len(), CONTENT_CAP);
1441        assert_eq!(l.content_order.len(), CONTENT_CAP);
1442        drop(l);
1443        assert!(
1444            lookup_content("value-0").is_none(),
1445            "oldest speculative entry must have been evicted"
1446        );
1447        assert!(
1448            lookup_content(&format!("value-{}", CONTENT_CAP + 99)).is_some(),
1449            "newest speculative entry must survive"
1450        );
1451    }
1452
1453    #[test]
1454    fn high_fanout_globs_are_not_recorded() {
1455        let _g = setup();
1456        let many: Vec<String> = (0..64).map(|i| format!("f{}", i)).collect();
1457        on_glob("*", &many);
1458        assert!(
1459            lookup_content("f0").is_none(),
1460            "a 64-match glob must not evict the ledger"
1461        );
1462        on_glob("*.rs", &["lib.rs".to_string()]);
1463        assert!(lookup_content("lib.rs").is_some(), "small globs record");
1464    }
1465
1466    #[test]
1467    fn untrack_and_clear_disarm_the_engine() {
1468        let _g = setup();
1469        track_name("A", Some("1"));
1470        track_name("B", Some("2"));
1471        assert!(untrack_name("A"));
1472        assert!(active(), "still armed while B is tracked");
1473        assert!(untrack_name("B"));
1474        assert!(!active(), "last untrack disarms the hot gate");
1475        assert!(!untrack_name("B"), "second untrack is a no-op");
1476        track_name("C", None);
1477        clear();
1478        assert!(!active());
1479        assert!(tracked_names().is_empty());
1480    }
1481
1482    #[test]
1483    fn reassignment_extends_the_chain_instead_of_replacing_it() {
1484        let _g = setup();
1485        note_line(1);
1486        track_name("X", None);
1487        note_line(2);
1488        on_param_write("X", "assign", "23");
1489        note_line(3);
1490        on_param_write("X", "assign", "1");
1491        note_line(4);
1492        on_param_write("X", "assign", "55");
1493        let node = lookup_name("X").expect("X has a chain");
1494        assert_eq!(node.origin_site.line, 2, "origin is the first write");
1495        let seen: Vec<(&str, &str, usize)> = node
1496            .ops
1497            .iter()
1498            .map(|o| (o.op.as_str(), o.args[1].as_str(), o.site.line))
1499            .collect();
1500        assert_eq!(
1501            seen,
1502            vec![
1503                ("assign", "\"23\"", 2),
1504                ("assign", "\"1\"", 3),
1505                ("assign", "\"55\"", 4),
1506            ],
1507            "every assignment stays on the chain: {:?}",
1508            node.ops
1509        );
1510    }
1511
1512    #[test]
1513    fn a_later_substitution_is_spliced_in_not_swapped_for_the_chain() {
1514        let _g = setup();
1515        note_line(1);
1516        track_name("X", Some("seed"));
1517        note_line(2);
1518        on_param_write("X", "assign", "23");
1519        note_line(3);
1520        on_cmd_subst("date +%s", "1750000000");
1521        on_param_write("X", "assign", "1750000000");
1522        let node = lookup_name("X").expect("X has a chain");
1523        assert!(
1524            node.origin.contains("param X = "),
1525            "the armed value stays the origin, got {}",
1526            node.origin
1527        );
1528        let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1529        assert_eq!(ops, vec!["assign", "origin", "assign"], "{:?}", node.ops);
1530        assert!(
1531            node.ops[1].args[0].starts_with("cmdsubst"),
1532            "the substitution is recorded where it happened: {:?}",
1533            node.ops[1]
1534        );
1535    }
1536
1537    #[test]
1538    fn rewriting_a_parameter_with_its_own_value_does_not_duplicate_the_chain() {
1539        let _g = setup();
1540        track_name("X", None);
1541        on_param_write("X", "assign", "same");
1542        on_param_write("X", "assign", "same");
1543        let node = lookup_name("X").unwrap();
1544        assert_eq!(
1545            node.ops.len(),
1546            1,
1547            "immediate repeat collapses: {:?}",
1548            node.ops
1549        );
1550        note_line(9);
1551        on_param_write("X", "assign", "same");
1552        let node = lookup_name("X").unwrap();
1553        assert_eq!(
1554            node.ops.iter().filter(|o| o.op == "origin").count(),
1555            0,
1556            "a parameter never inherits from itself: {:?}",
1557            node.ops
1558        );
1559    }
1560
1561    #[test]
1562    fn a_repeat_at_the_same_position_collapses_despite_a_later_clock() {
1563        let _g = setup();
1564        note_line(4);
1565        track_name("Q", None);
1566        on_param_write("Q", "assign", "v");
1567        let first = lookup_name("Q").unwrap().ops[0].site.time_ms;
1568        std::thread::sleep(std::time::Duration::from_millis(5));
1569        on_param_write("Q", "assign", "v");
1570        let node = lookup_name("Q").unwrap();
1571        assert_eq!(node.ops.len(), 1, "the clock must not defeat the collapse");
1572        assert_eq!(node.ops[0].site.time_ms, first, "the first stamp stands");
1573    }
1574
1575    #[test]
1576    fn a_site_carries_the_file_the_line_belongs_to() {
1577        let _g = setup();
1578        let saved = crate::ported::utils::scriptfilename_get();
1579        crate::ported::utils::set_scriptfilename(Some("/tmp/lineage.zsh".to_string()));
1580        note_line(11);
1581        track_name("S", None);
1582        on_param_write("S", "assign", "v");
1583        crate::ported::utils::set_scriptfilename(saved);
1584        let node = lookup_name("S").unwrap();
1585        assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lineage.zsh"));
1586        assert_eq!(node.origin_site.line, 11);
1587        assert_eq!(node.origin_site.func, None, "no function frame is active");
1588        assert!(node.origin_site.time_ms > 0, "the origin is stamped");
1589        assert_eq!(node.origin_site.location(), "/tmp/lineage.zsh:11");
1590        assert!(
1591            node.origin_site.clock(true).starts_with("20"),
1592            "clock = {}",
1593            node.origin_site.clock(true)
1594        );
1595        assert!(
1596            render("S", &node).contains("/tmp/lineage.zsh:11"),
1597            "the report names the file"
1598        );
1599    }
1600
1601    #[test]
1602    fn track_all_arms_a_parameter_on_its_first_write() {
1603        let _g = setup();
1604        assert!(set_track_all(true));
1605        note_line(3);
1606        on_param_write("NEVER_ARMED", "assign", "v");
1607        let node = lookup_name("NEVER_ARMED").expect("track_all armed it");
1608        assert_eq!(node.ops.len(), 1);
1609        assert_eq!(tracked_names(), vec!["NEVER_ARMED".to_string()]);
1610        set_track_all(false);
1611        on_param_write("STILL_UNARMED", "assign", "v");
1612        assert!(
1613            lookup_name("STILL_UNARMED").is_none(),
1614            "turning it off stops arming new names"
1615        );
1616    }
1617
1618    /// A `call` op must carry the arguments the call was made with.
1619    /// Without them two calls to one function are byte-identical on the
1620    /// chain, so "where did this value come from" cannot distinguish
1621    /// `deploy staging` from `deploy prod` — which is the question the
1622    /// engine exists to answer.
1623    #[test]
1624    fn call_ops_record_the_arguments_they_were_called_with() {
1625        let _g = setup();
1626        assert!(track_func("deploy", None, Some("/tmp/d.zsh"), 1));
1627        on_func_call(
1628            "deploy",
1629            &["staging".to_string()],
1630            None,
1631            Some("/tmp/d.zsh"),
1632            1,
1633        );
1634        on_func_call("deploy", &["prod".to_string()], None, Some("/tmp/d.zsh"), 2);
1635        on_func_call("deploy", &[], None, Some("/tmp/d.zsh"), 3);
1636        let node = lookup_func("deploy").expect("armed");
1637        let args: Vec<&str> = node.ops.iter().map(|o| o.args[0].as_str()).collect();
1638        assert_eq!(
1639            args,
1640            vec!["deploy(staging)", "deploy(prod)", "deploy()"],
1641            "each call must be distinguishable by its arguments"
1642        );
1643    }
1644
1645    /// An empty or whitespace-bearing argument is single-quoted, so
1646    /// `f 'a b'` (one argument) does not render the same as `f a b`
1647    /// (two), and a long list is capped rather than smearing the table.
1648    #[test]
1649    fn call_signature_quotes_ambiguous_args_and_caps_long_lists() {
1650        let one = call_signature("f", &["a b".to_string()]);
1651        let two = call_signature("f", &["a".to_string(), "b".to_string()]);
1652        assert_eq!(one, "f('a b')");
1653        assert_eq!(two, "f(a b)");
1654        assert_ne!(one, two, "quoting is what keeps these apart");
1655        assert_eq!(call_signature("f", &["".to_string()]), "f('')");
1656
1657        let many: Vec<String> = (0..40).map(|i| format!("arg{i}")).collect();
1658        let rendered = call_signature("f", &many);
1659        assert!(rendered.ends_with("…)"), "long list truncates: {rendered}");
1660        assert!(
1661            rendered.chars().count() <= 40,
1662            "must fit render's 40-wide arg column: {} chars",
1663            rendered.chars().count()
1664        );
1665    }
1666
1667    #[test]
1668    fn track_all_skips_the_parameters_the_shell_rewrites_itself() {
1669        let _g = setup();
1670        assert!(set_track_all(true));
1671        for volatile in ["LINENO", "RANDOM", "status", "_", "3"] {
1672            on_param_write(volatile, "assign", "v");
1673            assert!(
1674                lookup_name(volatile).is_none(),
1675                "{volatile} must not arm itself"
1676            );
1677        }
1678        on_param_write("REPLY", "assign", "v");
1679        assert!(lookup_name("REPLY").is_some(), "ordinary names still arm");
1680    }
1681
1682    #[test]
1683    fn track_all_stops_arming_at_the_ceiling_and_counts_the_rest() {
1684        let _g = setup();
1685        assert!(set_track_all(true));
1686        for i in 0..MAX_AUTO_NAMES + 8 {
1687            on_param_write(&format!("P{}", i), "assign", "v");
1688        }
1689        assert_eq!(tracked_names().len(), MAX_AUTO_NAMES);
1690        assert_eq!(auto_dropped(), 8, "the overflow is counted, not stored");
1691    }
1692
1693    #[test]
1694    fn a_function_records_its_definition_calls_and_removal() {
1695        let _g = setup();
1696        assert!(set_track_all(true));
1697        on_func_define("build", None, Some("/tmp/lib.zsh"), 12);
1698        note_line(40);
1699        on_func_call("build", &[], None, Some("/tmp/lib.zsh"), 12);
1700        on_func_define("build", None, Some("/tmp/lib.zsh"), 80);
1701        on_func_unset("build");
1702        let node = lookup_func("build").expect("the function has a chain");
1703        assert_eq!(node.origin, "function build");
1704        assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
1705        assert_eq!(node.origin_site.line, 12, "origin is the first definition");
1706        let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1707        assert_eq!(
1708            ops,
1709            vec!["call", "redefine", "unfunction"],
1710            "{:?}",
1711            node.ops
1712        );
1713        assert_eq!(
1714            node.ops[0].site.line, 40,
1715            "the call op is the caller's site"
1716        );
1717        assert_eq!(
1718            node.ops[1].site.line, 80,
1719            "the redefine op is the new body's"
1720        );
1721        assert_eq!(tracked_func_names(), vec!["build".to_string()]);
1722        assert!(
1723            lookup_name("build").is_none(),
1724            "the parameter namespace is separate"
1725        );
1726    }
1727
1728    // Arming a function BEFORE it is defined is the normal way to watch one get
1729    // defined, and it used to produce a lineage that was wrong twice over: an
1730    // origin of `function NAME` at line 0 -- a definition site that never
1731    // happened -- and the real first definition logged as a `redefine` against
1732    // it. `track_name` has always got this right for parameters (an unset one
1733    // gets no node, so its first assignment supplies the origin); this is the
1734    // same rule for functions.
1735    #[test]
1736    fn arming_a_function_before_it_exists_leaves_the_origin_to_its_definition() {
1737        let _g = setup();
1738        // `(None, 0)` is shfunc_def_site's "no such function".
1739        assert!(track_func("later", None, None, 0));
1740        assert!(
1741            lookup_func("later").is_none(),
1742            "no definition has happened yet, so there is nothing to attribute"
1743        );
1744        assert_eq!(
1745            tracked_func_names(),
1746            vec!["later".to_string()],
1747            "but it IS armed"
1748        );
1749
1750        on_func_define("later", None, Some("/tmp/lib.zsh"), 7);
1751        let node = lookup_func("later").expect("the definition creates the chain");
1752        assert_eq!(node.origin, "function later");
1753        assert_eq!(
1754            node.origin_site.line, 7,
1755            "the origin is where it was defined"
1756        );
1757        assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
1758        assert!(
1759            node.ops.is_empty(),
1760            "the first definition is the origin, not a redefine: {:?}",
1761            node.ops
1762        );
1763
1764        // A LATER definition is still a redefine, against the real origin.
1765        on_func_define("later", None, Some("/tmp/lib.zsh"), 20);
1766        let node = lookup_func("later").expect("still tracked");
1767        assert_eq!(node.origin_site.line, 7, "the origin does not move");
1768        let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1769        assert_eq!(ops, vec!["redefine"], "{:?}", node.ops);
1770        assert_eq!(node.ops[0].site.line, 20);
1771    }
1772
1773    // The other half of the same rule: a function that already exists when it is
1774    // armed keeps its definition site, so arming does not erase where it came
1775    // from.
1776    #[test]
1777    fn arming_an_existing_function_seeds_the_origin_from_its_definition_site() {
1778        let _g = setup();
1779        assert!(track_func("known", None, Some("/tmp/lib.zsh"), 3));
1780        let node = lookup_func("known").expect("armed with a known definition site");
1781        assert_eq!(node.origin_site.line, 3);
1782        assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
1783        assert!(node.ops.is_empty());
1784
1785        // Re-arming does not move the origin or duplicate the node.
1786        assert!(track_func("known", None, Some("/tmp/lib.zsh"), 3));
1787        let node = lookup_func("known").expect("still there");
1788        assert_eq!(node.origin_site.line, 3);
1789        assert_eq!(tracked_func_names(), vec!["known".to_string()]);
1790    }
1791
1792    #[test]
1793    fn an_unarmed_function_records_nothing_without_track_all() {
1794        let _g = setup();
1795        on_func_define("quiet", None, Some("/tmp/lib.zsh"), 1);
1796        on_func_call("quiet", &[], None, Some("/tmp/lib.zsh"), 1);
1797        assert!(lookup_func("quiet").is_none());
1798        assert!(track_func("quiet", None, Some("/tmp/lib.zsh"), 1));
1799        on_func_call("quiet", &[], None, Some("/tmp/lib.zsh"), 1);
1800        let node = lookup_func("quiet").expect("armed by name");
1801        assert_eq!(
1802            node.ops.len(),
1803            1,
1804            "only the call after arming: {:?}",
1805            node.ops
1806        );
1807        assert!(untrack_func("quiet"));
1808        assert!(!active(), "the last untrack disarms the hot gate");
1809    }
1810
1811    #[test]
1812    fn unset_is_recorded_as_the_final_op() {
1813        let _g = setup();
1814        track_name("Z", Some("v"));
1815        on_param_unset("Z");
1816        let node = lookup_name("Z").unwrap();
1817        assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("unset"));
1818    }
1819
1820    #[test]
1821    fn render_and_json_carry_the_whole_chain() {
1822        let _g = setup();
1823        note_line(12);
1824        track_name("R", None);
1825        on_cmd_subst("echo hi", "hi");
1826        on_param_write("R", "assign", "hi");
1827        let node = lookup_name("R").unwrap();
1828        let text = render("R", &node);
1829        assert!(text.contains("origin: cmdsubst"), "text = {}", text);
1830        assert!(text.contains("line 12"), "text = {}", text);
1831        let json = render_json("R", &node);
1832        assert!(json.starts_with("{\"name\":\"R\""), "json = {}", json);
1833        assert!(json.contains("\"op\":\"assign\""), "json = {}", json);
1834        assert!(json.ends_with("\"dropped_ops\":0}"), "json = {}", json);
1835    }
1836
1837    /// Eight threads hammering the ledger concurrently: no data race, no
1838    /// deadlock, and the tracked node stays coherent. Ports stryke's
1839    /// `concurrent_mark_lookup_threads_observe_consistent_state`.
1840    #[test]
1841    fn concurrent_hooks_keep_the_ledger_coherent() {
1842        let _g = setup();
1843        note_line(1);
1844        track_name("SHARED", Some("seed"));
1845        let handles: Vec<_> = (0..8)
1846            .map(|i| {
1847                std::thread::spawn(move || {
1848                    for n in 0..50 {
1849                        let out = format!("t{}-{}", i, n);
1850                        on_cmd_subst("worker", &out);
1851                        let v = Value::str(out.clone());
1852                        on_param_read("SHARED", &v);
1853                        on_exec("exec", &["cmd".into(), out]);
1854                        assert!(lookup_name("SHARED").is_some());
1855                    }
1856                })
1857            })
1858            .collect();
1859        for h in handles {
1860            h.join().expect("thread");
1861        }
1862        let node = lookup_name("SHARED").expect("origin still live");
1863        assert_eq!(node.origin_site.line, 1);
1864        assert_eq!(node.owner.as_deref(), Some("SHARED"));
1865    }
1866}