Skip to main content

memstead_base/
preparation.rs

1//! The engine-owned **preparation registry** — the one place that says which
2//! preparations exist, on which anchor grains, at which engine touchpoint,
3//! and what each grain's PREPARED FORM is.
4//!
5//! A source declares at most one preparation ([`crate::pipeline::Source::preparation`],
6//! a string identifier). The engine refuses any identifier this registry
7//! does not know ([`crate::binding::CapabilityError::PreparationUnsupported`],
8//! raised by [`crate::binding::validate_binding`] and mirrored on the
9//! brief-render path for a record that acquired one by hand) and consults the
10//! registry at exactly two touchpoints:
11//!
12//! - **Touchpoint A — prepared form.** Anchor observation asks the registry
13//!   for an artifact's prepared form before hashing it (the engine's one
14//!   per-anchor observation site). The standalone `verify-anchors` operation
15//!   and the binding-backed verify share that site, so both inherit every
16//!   registered preparation without redesign.
17//! - **Touchpoint B — delivery units.** The ingest delivery path asks the
18//!   registry for a source's unit sequence ([`unitize`]): one file can carry
19//!   many delivery units, addressed `<path>#<key>`, and the units of a whole
20//!   source form one deterministic total order derived from the units' own
21//!   keys ([`Touchpoint::DeliveryUnits`], first entry [`DATED_ENTRIES`]).
22//!   A source declaring no delivery preparation keeps file-granularity
23//!   delivery unchanged.
24//!
25//! **Identity.** [`crate::binding::PREPARATION_IMPL_VERSION`] is hashed into
26//! every binding's `hash(D)` next to the declared identifier. Landing or
27//! changing an implementation bumps the constant, which invalidates every
28//! finding keyed on the old hash by construction (`ingest::findings` keys on
29//! `hash(D)` alone).
30//!
31//! **Prepared forms per grain.** The path grains (`span` / `file`) hash their
32//! bytes through [`crate::anchor::prepared_content_hash`] (the minimal
33//! canonicalization: BOM, line endings, final newline). The `url` grain uses
34//! the **same canonicalization over observation-supplied content** — the
35//! engine never fetches, so whoever observed the URL supplies the bytes at
36//! write time (`AnchorInput::content`) — and defaults to `hash_stability:
37//! unstable`, a served page being a moving target. The `entity` grain's
38//! prepared form is computed from the live graph, never from supplied bytes:
39//! the canonical rendered markdown by default, or — under
40//! [`ENTITY_LOAD_BEARING`] — the stable serialization of the type's
41//! load-bearing sections.
42//!
43//! **Non-goal, by standing decision:** PDF / DOCX / audio conversion. An
44//! agent with a capable read tool extracts; the raw-byte fallback of the
45//! prepared-content hash already drift-detects a binary artifact.
46
47use serde::{Deserialize, Serialize};
48
49use crate::anchor::{AnchorGrain, AnchorHashStability, prepared_content_hash};
50use crate::entity::Entity;
51
52/// The engine touchpoint a registered preparation plugs into.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Touchpoint {
56    /// Touchpoint A: anchor observation asks the registry for the prepared
57    /// form an artifact hashes as (content and code-map flavours).
58    PreparedForm,
59    /// Touchpoint B: the ingest delivery path asks the registry for a
60    /// source's unit sequence (the delivery flavour).
61    DeliveryUnits,
62}
63
64/// One registered preparation.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Preparation {
67    /// The identifier a source declares (`Source::preparation`).
68    pub id: &'static str,
69    /// Which engine touchpoint consults it.
70    pub touchpoint: Touchpoint,
71    /// The anchor grains it produces a prepared form for. A binding may
72    /// declare it only over a medium whose anchor namespace admits at least
73    /// one of these grains (checked at binding validation).
74    pub grains: &'static [AnchorGrain],
75    /// One sentence for the operator and the refusal payloads.
76    pub description: &'static str,
77}
78
79/// Content preparation on the `entity` grain: the prepared form is the stable
80/// serialization of the entity type's **load-bearing sections** (see
81/// [`load_bearing_sections`]), so a dependent's prepared hash breaks when a
82/// load-bearing sentence changes and holds when a comma lands in the notes.
83pub const ENTITY_LOAD_BEARING: &str = "entity-load-bearing";
84
85/// Delivery preparation on path-shaped sources: a file is a sequence of
86/// **dated entries**. A unit begins at every line that opens with an ISO
87/// date or date-time (`2026-08-24`, `2026-08-24 10:05`, `2026-08-24T10:05:00Z`,
88/// after any leading markdown markers such as `## `, `- `, `> `, `[`); it
89/// runs to the next such line. Text before the first entry folds into the
90/// first unit; a file with no dated line is one unit keyed
91/// [`WHOLE_FILE_UNIT`]. The unit key is the stamp normalized to
92/// `YYYY-MM-DDTHH:MM:SS` (missing time parts read `00`), with `.2`, `.3`, …
93/// appended to the second, third, … entry carrying the same stamp in one
94/// file, in file order — so appending entries never renames an existing
95/// unit. The order key is the normalized stamp; across a whole source, units
96/// sort by stamp, then path, then key, which is what makes a chronological
97/// corpus deliver in its own order regardless of how files were discovered.
98/// Undated files (order key empty) come first, in path order. Fractional
99/// seconds and zone designators are accepted and ignored for ordering.
100pub const DATED_ENTRIES: &str = "dated-entries";
101
102/// The key of the single unit a file yields when a delivery preparation finds
103/// no unit boundary in it — the whole file, still addressable as
104/// `<path>#whole`.
105pub const WHOLE_FILE_UNIT: &str = "whole";
106
107/// Code-map preparation on path-shaped sources (touchpoint A): a scoped
108/// file's prepared form is its **interface digest** — imports, exports and
109/// declarations with their signatures; comments, formatting and bodies are
110/// invisible. The digest is heuristic and language-family aware by file
111/// extension: C-like families (JS/TS, Rust, Go, Java, Kotlin, Swift, C#, C,
112/// C++, PHP, Dart, Scala) keep top-level declaration lines and class or
113/// object member signatures, cut at the body's opening brace; Python keeps
114/// imports, `def`/`class` lines (top level and one level in), decorators and
115/// upper-case module constants; a Vue single-file component is its script
116/// block under the C-like rule; JSON is its canonical compact form; every
117/// other file is taken whole. A `tree`-grain anchor under this preparation
118/// hashes the digest of every scoped file under the tree (path and file
119/// hash, path order), which is what closes the tree grain's
120/// recorded-but-unhashed residue for code sources. Values inside
121/// declarations (a config object's members, an array literal's contents
122/// beyond its first line, a scalar property value) are body, not
123/// interface: the digest sees names and signatures. A literal object
124/// restructured between one line and many reads as a shape change.
125pub const CODE_MAP: &str = "code-map";
126
127/// The registry — every preparation this engine implements. The refusal in
128/// [`crate::binding::validate_binding`] is exactly "not in this list".
129pub const REGISTRY: &[Preparation] = &[
130    Preparation {
131        id: ENTITY_LOAD_BEARING,
132        touchpoint: Touchpoint::PreparedForm,
133        grains: &[AnchorGrain::Entity],
134        description: "an entity's prepared form is the stable serialization of its type's \
135                      load-bearing sections (explicitly declared, else the required sections, \
136                      else every section) — notes-only edits keep dependents' anchors resolving",
137    },
138    Preparation {
139        id: DATED_ENTRIES,
140        touchpoint: Touchpoint::DeliveryUnits,
141        grains: &[AnchorGrain::Span],
142        description: "a file is a sequence of entries opening with an ISO date or date-time; \
143                      each entry is one delivery unit `<path>#<stamp>`, and a source's units \
144                      deliver in stamp order, identical on every pass — a chronological corpus \
145                      (logs, transcripts, journals, mail threads) is never shuffled",
146    },
147    Preparation {
148        id: CODE_MAP,
149        touchpoint: Touchpoint::PreparedForm,
150        grains: &[AnchorGrain::File, AnchorGrain::Span, AnchorGrain::Tree],
151        description: "a scoped code file's prepared form is its interface digest (imports, \
152                      exports, declarations and their signatures; comments, formatting and \
153                      bodies invisible), and a tree's is the digest of every scoped file under \
154                      it — an anchor drifts when an interface changes and stays quiet when \
155                      only an implementation does",
156    },
157];
158
159/// Every registered preparation.
160pub fn registry() -> &'static [Preparation] {
161    REGISTRY
162}
163
164/// Look a declared identifier up.
165pub fn lookup(id: &str) -> Option<&'static Preparation> {
166    REGISTRY.iter().find(|p| p.id == id)
167}
168
169/// Whether `id` names a registered preparation.
170pub fn is_registered(id: &str) -> bool {
171    lookup(id).is_some()
172}
173
174/// The registered identifiers, in registry order — the recovery payload of
175/// the unknown-identifier refusal.
176pub fn registered_identifiers() -> Vec<&'static str> {
177    REGISTRY.iter().map(|p| p.id).collect()
178}
179
180/// The delivery preparation a source declares, if its declared identifier
181/// is a registered touchpoint-B entry — `None` for no declaration, an
182/// unregistered identifier, or a prepared-form (touchpoint A) flavour.
183pub fn delivery_preparation(declared: Option<&str>) -> Option<&'static Preparation> {
184    lookup(declared?).filter(|p| p.touchpoint == Touchpoint::DeliveryUnits)
185}
186
187/// Whether a registered preparation can apply over a medium whose anchor
188/// namespace is `anchor_namespace` (see
189/// [`crate::binding::medium_capabilities`]): at least one of the
190/// preparation's grains must be expressible there. `entity-load-bearing`
191/// over a `codebase` source would never meet an entity-grain anchor, so it
192/// is refused at declaration rather than silently never applying.
193pub fn applies_to_namespace(preparation: &Preparation, anchor_namespace: &str) -> bool {
194    preparation
195        .grains
196        .iter()
197        .any(|g| g.supported_by_namespace(anchor_namespace))
198}
199
200// ---------------------------------------------------------------------------
201// Per-grain prepared forms
202// ---------------------------------------------------------------------------
203
204/// The medium-declared default hash stability per grain. A `url` anchor
205/// defaults to `unstable` — a served page is a moving target, so a hash
206/// break resolves `recheck`, never `drifted`, unless the author asserts
207/// `stable` explicitly. Every other grain keeps its `stable` default.
208pub fn default_hash_stability(grain: AnchorGrain) -> AnchorHashStability {
209    match grain {
210        AnchorGrain::Url => AnchorHashStability::Unstable,
211        AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree | AnchorGrain::Entity => {
212            AnchorHashStability::Stable
213        }
214    }
215}
216
217/// The `url` grain's canonicalization entry: the prepared form of a URL
218/// artifact is the observation-supplied content under the same minimal
219/// canonicalization the path grains use, so a `url` anchor's recorded hash
220/// means the same thing a `file` anchor's does. The engine never fetches;
221/// the observer supplies the bytes.
222pub fn url_prepared_hash(content: &[u8]) -> String {
223    prepared_content_hash(content)
224}
225
226/// The prepared-content hash of **supplied** content for a grain — the
227/// write-time observation an agent performs when it hands the engine what it
228/// read (`AnchorInput::content`). `None` for a grain whose prepared form
229/// is never computed from supplied bytes: `entity` (computed from the live
230/// graph, so a supplied rendering could disagree with the store) and `tree`
231/// (its prepared form is a digest over the enumerated scoped files —
232/// observation-side work no single supplied byte-string can represent).
233pub fn supplied_content_hash(grain: AnchorGrain, content: &[u8]) -> Option<String> {
234    match grain {
235        AnchorGrain::Span | AnchorGrain::File => Some(prepared_content_hash(content)),
236        AnchorGrain::Url => Some(url_prepared_hash(content)),
237        AnchorGrain::Tree | AnchorGrain::Entity => None,
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Touchpoint A for path grains: the prepared form of a file or tree
243// ---------------------------------------------------------------------------
244
245/// What a path-grain observation yields under a source's preparation.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum PathPrepared {
248    /// The prepared-content hash to record or compare.
249    Hash(String),
250    /// The grain has no prepared form at this single-artifact touchpoint (a
251    /// `tree`, whose digest needs its files enumerated — the caller's job):
252    /// observe no hash here, resolve `recheck` where no digest arrives.
253    NoHash,
254    /// The artifact addresses a sub-file unit the file no longer yields (a
255    /// `<path>#<key>` span under a delivery preparation): an absent artifact.
256    UnitAbsent,
257}
258
259/// The prepared-content hash of one path-grain artifact's bytes under
260/// `preparation` — the one rule anchor observation and the write-time
261/// `content` path share, so a hash recorded at write time is the hash a
262/// later observation computes. No preparation (or one that does not prepare
263/// path grains): the file's bytes under the minimal canonicalization for
264/// `file`/`span`. [`DATED_ENTRIES`]: a `<path>#<key>`
265/// span hashes its unit ([`PathPrepared::UnitAbsent`] when the key is gone),
266/// a bare file its bytes. [`CODE_MAP`]: `file`/`span` hash the interface
267/// digest. A `tree` needs its files enumerated, which is the caller's job
268/// under every preparation ([`code_map_tree_digest`] /
269/// [`plain_tree_digest`]), so it answers `NoHash` here.
270pub fn path_prepared_hash(
271    preparation: Option<&str>,
272    artifact: &str,
273    grain: AnchorGrain,
274    bytes: &[u8],
275) -> PathPrepared {
276    let (path, locator) = split_unit_id(artifact);
277    match (preparation, grain) {
278        (_, AnchorGrain::Url | AnchorGrain::Entity | AnchorGrain::Tree) => PathPrepared::NoHash,
279        (Some(DATED_ENTRIES), AnchorGrain::Span) if locator.is_some() => {
280            let text = String::from_utf8_lossy(bytes);
281            match unitize(DATED_ENTRIES, &text)
282                .and_then(|units| units.into_iter().find(|u| Some(u.key.as_str()) == locator))
283            {
284                Some(unit) => PathPrepared::Hash(unit.hash),
285                None => PathPrepared::UnitAbsent,
286            }
287        }
288        (Some(CODE_MAP), AnchorGrain::File | AnchorGrain::Span) => {
289            let text = String::from_utf8_lossy(bytes);
290            PathPrepared::Hash(prepared_content_hash(
291                code_map_digest(path, &text).as_bytes(),
292            ))
293        }
294        (_, AnchorGrain::File | AnchorGrain::Span) => {
295            PathPrepared::Hash(prepared_content_hash(bytes))
296        }
297    }
298}
299
300/// The code map of a tree: one line per scoped file under it, `<file digest
301/// hash>  <path>`, in path order — hashed by the caller through
302/// [`prepared_content_hash`]. A file joining, leaving, or changing its
303/// interface changes the tree's map; an implementation edit does not.
304pub fn code_map_tree_digest(files: &[(String, String)]) -> String {
305    let mut rows: Vec<(&str, &str)> = files
306        .iter()
307        .map(|(path, text)| (path.as_str(), text.as_str()))
308        .collect();
309    rows.sort();
310    rows.iter()
311        .map(|(path, text)| {
312            format!(
313                "{}  {path}",
314                prepared_content_hash(code_map_digest(path, text).as_bytes())
315            )
316        })
317        .collect::<Vec<_>>()
318        .join("\n")
319}
320
321/// The plain digest of a tree: one line per scoped file under it,
322/// `<prepared-content hash>  <path>`, in path order — hashed by the caller
323/// through [`prepared_content_hash`]. Any byte change in any scoped file,
324/// and any file joining or leaving the tree, changes the digest — the same
325/// whole-content posture a `file` anchor has, lifted to the directory. This
326/// is the no-preparation counterpart of [`code_map_tree_digest`]; it is what
327/// lets a plain `tree` anchor adjudicate deterministically instead of
328/// resting in `recheck` forever.
329pub fn plain_tree_digest(files: &[(String, Vec<u8>)]) -> String {
330    let mut rows: Vec<(&str, &[u8])> = files
331        .iter()
332        .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
333        .collect();
334    rows.sort();
335    rows.iter()
336        .map(|(path, bytes)| format!("{}  {path}", prepared_content_hash(bytes)))
337        .collect::<Vec<_>>()
338        .join("\n")
339}
340
341/// The interface digest of one file's text under [`CODE_MAP`].
342pub fn code_map_digest(path: &str, text: &str) -> String {
343    match family_of(path) {
344        Family::Text => text.to_string(),
345        Family::Json => serde_json::from_str::<serde_json::Value>(text)
346            .map(|v| v.to_string())
347            .unwrap_or_else(|_| text.to_string()),
348        Family::Vue => {
349            declaration_lines(&strip_c_comments(&vue_script_blocks(text)), Family::CLike)
350        }
351        Family::CLike => declaration_lines(&strip_c_comments(text), Family::CLike),
352        Family::Rust => declaration_lines(&strip_c_comments(text), Family::Rust),
353        Family::Python => declaration_lines(&strip_python_comments(text), Family::Python),
354    }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358enum Family {
359    CLike,
360    /// C-like braces, but a struct field is interface only with `pub`
361    /// (a `key: Type` line without it is body), and enum variants are.
362    Rust,
363    Python,
364    Json,
365    Vue,
366    Text,
367}
368
369fn family_of(path: &str) -> Family {
370    let name = path.rsplit('/').next().unwrap_or(path);
371    let ext = match name.rsplit_once('.') {
372        Some((_, ext)) => ext.to_ascii_lowercase(),
373        None => return Family::Text,
374    };
375    match ext.as_str() {
376        "rs" => Family::Rust,
377        "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" | "go" | "java" | "kt"
378        | "kts" | "swift" | "cs" | "c" | "h" | "cc" | "cpp" | "hpp" | "m" | "mm" | "php"
379        | "dart" | "scala" => Family::CLike,
380        "py" | "pyi" => Family::Python,
381        "json" => Family::Json,
382        "vue" | "svelte" => Family::Vue,
383        _ => Family::Text,
384    }
385}
386
387/// The concatenated `<script>` blocks of a single-file component (the
388/// template and styles are not interface).
389fn vue_script_blocks(text: &str) -> String {
390    let lower = text.to_ascii_lowercase();
391    let mut out = String::new();
392    let mut from = 0;
393    while let Some(open) = lower[from..].find("<script") {
394        let open = from + open;
395        let Some(tag_end) = lower[open..].find('>') else {
396            break;
397        };
398        let body_start = open + tag_end + 1;
399        let Some(close) = lower[body_start..].find("</script") else {
400            out.push_str(&text[body_start..]);
401            break;
402        };
403        out.push_str(&text[body_start..body_start + close]);
404        out.push('\n');
405        from = body_start + close + 8;
406    }
407    out
408}
409
410/// Strip `//` line comments and `/* */` block comments, outside string
411/// literals (a `'`/`"` string ends at its line; a template literal may span
412/// lines). Newlines are kept so line structure survives.
413fn strip_c_comments(text: &str) -> String {
414    let mut out = String::with_capacity(text.len());
415    let mut chars = text.chars().peekable();
416    let mut in_str: Option<char> = None;
417    let mut escape = false;
418    while let Some(c) = chars.next() {
419        if let Some(q) = in_str {
420            out.push(c);
421            if escape {
422                escape = false;
423            } else if c == '\\' {
424                escape = true;
425            } else if c == q || (c == '\n' && q != '`') {
426                in_str = None;
427            }
428            continue;
429        }
430        match c {
431            '"' | '\'' | '`' => {
432                in_str = Some(c);
433                out.push(c);
434            }
435            '/' => match chars.peek() {
436                Some('/') => {
437                    for n in chars.by_ref() {
438                        if n == '\n' {
439                            out.push('\n');
440                            break;
441                        }
442                    }
443                }
444                Some('*') => {
445                    chars.next();
446                    let mut prev = '\0';
447                    for n in chars.by_ref() {
448                        if n == '\n' {
449                            out.push('\n');
450                        }
451                        if prev == '*' && n == '/' {
452                            break;
453                        }
454                        prev = n;
455                    }
456                }
457                _ => out.push(c),
458            },
459            _ => out.push(c),
460        }
461    }
462    out
463}
464
465/// Strip `#` comments outside strings and drop triple-quoted strings whole
466/// (docstrings and block literals are never interface).
467fn strip_python_comments(text: &str) -> String {
468    let mut out = String::with_capacity(text.len());
469    let bytes: Vec<char> = text.chars().collect();
470    let mut i = 0;
471    let mut in_str: Option<char> = None;
472    let mut triple: Option<char> = None;
473    while i < bytes.len() {
474        let c = bytes[i];
475        if let Some(q) = triple {
476            if c == q && i + 2 < bytes.len() && bytes[i + 1] == q && bytes[i + 2] == q {
477                triple = None;
478                i += 3;
479                continue;
480            }
481            if c == '\n' {
482                out.push('\n');
483            }
484            i += 1;
485            continue;
486        }
487        if let Some(q) = in_str {
488            out.push(c);
489            if c == '\\' && i + 1 < bytes.len() {
490                out.push(bytes[i + 1]);
491                i += 2;
492                continue;
493            }
494            if c == q || c == '\n' {
495                in_str = None;
496            }
497            i += 1;
498            continue;
499        }
500        match c {
501            '"' | '\'' => {
502                if i + 2 < bytes.len() && bytes[i + 1] == c && bytes[i + 2] == c {
503                    triple = Some(c);
504                    i += 3;
505                    continue;
506                }
507                in_str = Some(c);
508                out.push(c);
509            }
510            '#' => {
511                while i < bytes.len() && bytes[i] != '\n' {
512                    i += 1;
513                }
514                continue;
515            }
516            _ => out.push(c),
517        }
518        i += 1;
519    }
520    out
521}
522
523const C_LIKE_TOP_LEVEL: &[&str] = &[
524    "import ",
525    "export ",
526    "module.exports",
527    "exports.",
528    "function ",
529    "async function ",
530    "class ",
531    "interface ",
532    "type ",
533    "enum ",
534    "declare ",
535    "const ",
536    "let ",
537    "var ",
538    "pub ",
539    "fn ",
540    "struct ",
541    "trait ",
542    "impl ",
543    "impl<",
544    "mod ",
545    "use ",
546    "static ",
547    "macro_rules!",
548    "package ",
549    "func ",
550    "namespace ",
551    "using ",
552    "#include",
553    "#[",
554    "@",
555    "public ",
556    "private ",
557    "protected ",
558    "abstract ",
559    "final ",
560    "override ",
561    "typedef ",
562    "extern ",
563    "template",
564    "def ",
565];
566
567const C_LIKE_MEMBER: &[&str] = &[
568    "pub ",
569    "fn ",
570    "public ",
571    "private ",
572    "protected ",
573    "static ",
574    "abstract ",
575    "override ",
576    "readonly ",
577    "async ",
578    "get ",
579    "set ",
580    "constructor",
581    "#[",
582    "@",
583];
584
585/// A member signature: an optionally qualified identifier followed by a
586/// parameter list.
587fn method_re() -> &'static regex::Regex {
588    static METHOD: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
589    METHOD.get_or_init(|| {
590        regex::Regex::new(r"^(?:(?:async|static|get|set|public|private|protected|override)\s+)*[A-Za-z_$][\w$]*\s*(?:<[^>]*>)?\s*\(").unwrap()
591    })
592}
593
594/// A property member (`key: …`, an optional `readonly`/`?`), whatever its
595/// value: an object literal's member, an interface member's type, a bare
596/// key a formatter broke away from its value. Scalar values are cut later.
597fn property_re() -> &'static regex::Regex {
598    static PROPERTY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
599    PROPERTY.get_or_init(|| {
600        regex::Regex::new(r#"^(?:readonly\s+)?(?:['"]?)[A-Za-z_$][\w$]*(?:['"]?)\??\s*:"#).unwrap()
601    })
602}
603
604/// An interface's method member: a signature carrying a return type and no
605/// body (`load(id: string): Promise<void>`, an optional `?` after the name,
606/// a trailing `;`). A ternary statement (`f(a) ? b : c`) is not one: its
607/// `?` is followed by a space, a member's never is.
608fn typed_member_re() -> &'static regex::Regex {
609    static TYPED_MEMBER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
610    TYPED_MEMBER.get_or_init(|| {
611        regex::Regex::new(
612            r"^(?:readonly\s+)?[A-Za-z_$][\w$]*\??\s*(?:<[^>]*>)?\s*\(.*\)\s*:\s*[^{;]+[;,]?$",
613        )
614        .unwrap()
615    })
616}
617
618fn typed_member(line: &str) -> bool {
619    typed_member_re().is_match(line) && !line.contains("? ")
620}
621
622/// An enum member (`Blue`, `Blue = 2,`): a capitalized bare identifier line.
623fn enum_member_re() -> &'static regex::Regex {
624    static ENUM_MEMBER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
625    ENUM_MEMBER
626        .get_or_init(|| regex::Regex::new(r"^[A-Z][A-Za-z0-9_]*(?:\s*=\s*[^,]+)?,?$").unwrap())
627}
628
629/// Whether a kept depth-1 or depth-2 line was kept ONLY as a member
630/// signature (not by a keyword prefix, not as a property): such a line must
631/// open a body once joined, or it was a wrapped call statement, not a
632/// signature.
633fn member_signature_only(line: &str, depth: i32) -> bool {
634    depth >= 1
635        && !C_LIKE_MEMBER.iter().any(|p| line.starts_with(p))
636        && !property_re().is_match(line)
637        && !typed_member(line)
638        && method_re().is_match(line)
639}
640
641fn c_like_keeps(line: &str, depth: i32, next_opens_body: bool, properties: bool) -> bool {
642    let method = method_re();
643    let property = property_re();
644    // A member signature opens a body (`{`, on this line or, Allman-style,
645    // on the next), or is wrapped across lines (ends with `(` or `,`); a
646    // finished call statement ends with `)` and is followed by anything else.
647    let signature_shaped = |line: &str| {
648        method.is_match(line)
649            && !line.starts_with("if ")
650            && !line.starts_with("for ")
651            && !line.starts_with("while ")
652            && !line.starts_with("switch ")
653            && !line.starts_with("return ")
654            && !line.starts_with("catch ")
655            && (line.ends_with('{')
656                || line.ends_with('(')
657                || line.ends_with(',')
658                || (line.ends_with(')') && next_opens_body))
659    };
660    match depth {
661        0 => C_LIKE_TOP_LEVEL.iter().any(|p| line.starts_with(p)),
662        1 => {
663            C_LIKE_MEMBER.iter().any(|p| line.starts_with(p))
664                || signature_shaped(line)
665                || (properties && (property.is_match(line) || typed_member(line)))
666                || enum_member_re().is_match(line)
667        }
668        2 => signature_shaped(line),
669        _ => false,
670    }
671}
672
673fn python_keeps(line: &str, indent: usize) -> bool {
674    static CONSTANT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
675    let constant = CONSTANT
676        .get_or_init(|| regex::Regex::new(r"^(?:[A-Z_][A-Z0-9_]*|__all__)\s*[:=]").unwrap());
677    let decl = line.starts_with("def ")
678        || line.starts_with("async def ")
679        || line.starts_with("class ")
680        || line.starts_with('@');
681    if indent == 0 {
682        decl || line.starts_with("import ") || line.starts_with("from ") || constant.is_match(line)
683    } else {
684        indent <= 4 && decl
685    }
686}
687
688/// For a Python module constant (`NAME = value`, `NAME: type = value`), the
689/// byte index just past the `=`; `None` for any other line.
690fn python_constant_cut(line: &str) -> Option<usize> {
691    static CONSTANT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
692    let constant = CONSTANT.get_or_init(|| {
693        regex::Regex::new(r"^(?:[A-Z_][A-Z0-9_]*|__all__)(?:\s*:\s*[^=]+?)?\s*=").unwrap()
694    });
695    constant.find(line).map(|m| m.end())
696}
697
698fn paren_balance(s: &str) -> i32 {
699    s.chars()
700        .map(|c| match c {
701            '(' => 1,
702            ')' => -1,
703            _ => 0,
704        })
705        .sum()
706}
707
708fn brace_delta(s: &str) -> i32 {
709    s.chars()
710        .map(|c| match c {
711            '{' => 1,
712            '}' => -1,
713            _ => 0,
714        })
715        .sum()
716}
717
718/// Cut a declaration at its body: the first `{` outside parentheses for a
719/// C-like line (an import, `use`, or re-export list is not a body and is
720/// kept whole), the trailing `:` for Python.
721fn cut_at_body(sig: &str, family: Family) -> String {
722    match family {
723        Family::Python => sig.trim_end_matches(':').trim_end().to_string(),
724        _ if sig.starts_with("import ")
725            || sig.starts_with("use ")
726            || sig.starts_with("export {")
727            || sig.starts_with("export type {")
728            || sig.starts_with("export * ") =>
729        {
730            sig.trim_end().to_string()
731        }
732        _ => {
733            // The body opens at the first `{` outside parentheses, or, for
734            // an arrow whose body is an expression, right after the `=>`:
735            // an expression body is body whatever line it wraps onto.
736            let mut depth = 0i32;
737            let mut cut = sig.len();
738            let bytes = sig.as_bytes();
739            for (i, c) in sig.char_indices() {
740                match c {
741                    '(' | '[' => depth += 1,
742                    ')' | ']' => depth -= 1,
743                    '{' if depth <= 0 => {
744                        cut = i;
745                        break;
746                    }
747                    '=' if depth <= 0 && bytes.get(i + 1) == Some(&b'>') => {
748                        let rest = sig[i + 2..].trim_start();
749                        if !rest.starts_with('{') {
750                            cut = i + 2;
751                            break;
752                        }
753                    }
754                    _ => {}
755                }
756            }
757            sig[..cut].trim_end().to_string()
758        }
759    }
760}
761
762/// Normalize a kept declaration so that formatting inside it is invisible:
763/// trailing semicolons dropped, double quotes read as single quotes, runs of
764/// whitespace collapsed, and no whitespace next to punctuation — so
765/// `f(a,b)`, `f(a, b)` and a signature wrapped across lines digest alike,
766/// while every token that carries meaning survives.
767fn normalize_signature(sig: &str) -> String {
768    let collapsed: Vec<&str> = sig
769        .trim()
770        .trim_end_matches(';')
771        .split_whitespace()
772        .collect();
773    let joined = collapsed.join(" ").replace('"', "'");
774    let is_punct = |c: char| "()[]{},;:=<>|&?!-+*/.".contains(c);
775    let mut out = String::with_capacity(joined.len());
776    let chars: Vec<char> = joined.chars().collect();
777    for (i, &c) in chars.iter().enumerate() {
778        if c == ' ' {
779            let before = chars[..i].iter().rev().find(|x| **x != ' ').copied();
780            let after = chars[i + 1..].iter().find(|x| **x != ' ').copied();
781            if before.is_some_and(is_punct) || after.is_some_and(is_punct) {
782                continue;
783            }
784        }
785        out.push(c);
786    }
787    // Trailing commas are a formatter's choice, never a signature's: drop
788    // one before a closing bracket and at the end of the line.
789    let out = out
790        .replace(",)", ")")
791        .replace(",]", "]")
792        .replace(",}", "}")
793        .replace(",>", ">");
794    let out = out.trim_end_matches(',').to_string();
795    // A union type wrapped by a formatter leads its first member with `|`.
796    let out = out.replace("=|", "=");
797    // A kept line that opens a body keeps nothing of the brace itself.
798    let out = out.trim_end_matches('{').trim_end().to_string();
799    // Formatter defaults that are not signatures: a quoted property key
800    // reads as the bare key; `(x)=>` reads as `x=>`; a Python import list's
801    // parentheses (black's wrapped form) vanish.
802    static QUOTED_KEY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
803    static ARROW_PARENS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
804    let quoted_key =
805        QUOTED_KEY.get_or_init(|| regex::Regex::new(r"^'([A-Za-z_$][\w$]*)':").unwrap());
806    let arrow_parens =
807        ARROW_PARENS.get_or_init(|| regex::Regex::new(r"\(([A-Za-z_$][\w$]*)\)=>").unwrap());
808    let out = quoted_key.replace(&out, "$1:").into_owned();
809    let out = arrow_parens.replace_all(&out, "$1=>").into_owned();
810    // A scalar property value (`name: 'Auth'`, `port: 3000`) is body, exactly
811    // as a one-line literal's members are: keep the key alone.
812    static SCALAR_PROPERTY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
813    let scalar = SCALAR_PROPERTY
814        .get_or_init(|| regex::Regex::new(r#"^([A-Za-z_$][\w$]*:)(?:'|`|-?\d)"#).unwrap());
815    let out = match scalar.captures(&out) {
816        Some(caps) => caps[1].to_string(),
817        None => out,
818    };
819    if out.starts_with("from ") || out.starts_with("import ") {
820        return out
821            .replace('(', " ")
822            .replace(')', "")
823            .split_whitespace()
824            .collect::<Vec<_>>()
825            .join(" ");
826    }
827    out
828}
829
830/// A top-level binding whose value is not a function is cut right after its
831/// `=` (and a default export that is not a function or object literal right
832/// after `default`): the value is body, so its wrapping, its operators and
833/// its literal shape never reach the digest. A function-valued binding keeps
834/// its signature. `None` when the line is not such a binding.
835fn cut_value_binding(line: &str) -> Option<String> {
836    static BINDING: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
837    static DEFAULT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
838    let binding = BINDING.get_or_init(|| {
839        regex::Regex::new(
840            r"^((?:(?:export\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:(?:static|readonly|private|public|protected|declare|override|const|let|var)\s+)*(?:[A-Za-z_$][\w$]*|\{[^}]*\}|\[[^\]]*\])(?:\s*:\s*[^=]+?)?|(?:module\.)?exports(?:\.[A-Za-z_$][\w$]*)?)\s*=)\s*(.*)$",
841        )
842        .unwrap()
843    });
844    let default =
845        DEFAULT.get_or_init(|| regex::Regex::new(r"^(export\s+default)\s+(.*)$").unwrap());
846    let function_like = |value: &str| {
847        static ARROW: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
848        let arrow = ARROW
849            .get_or_init(|| regex::Regex::new(r"^(?:async\s+)?[A-Za-z_$][\w$]*\s*=>").unwrap());
850        value.starts_with('(')
851            || value.starts_with("async ")
852            || value.starts_with("async(")
853            || value.starts_with("function")
854            || value.starts_with("class")
855            || arrow.is_match(value)
856    };
857    if let Some(caps) = binding.captures(line) {
858        let value = caps[2].trim();
859        // The `=` of an arrow `=>` (a property whose type annotation the
860        // regex read as `key: (params)`) is not an assignment.
861        if value.starts_with('>') {
862            return None;
863        }
864        // A module's object-literal export (`module.exports = {`) is an
865        // interface container like `export default {`: its members stay.
866        let module_export = caps[1].starts_with("exports") || caps[1].starts_with("module.exports");
867        // An empty value means a formatter broke it onto the next line:
868        // still a value, still body (the caller skips that line).
869        if function_like(value) || (module_export && value.starts_with('{')) {
870            return None;
871        }
872        return Some(caps[1].to_string());
873    }
874    if let Some(caps) = default.captures(line) {
875        let value = caps[2].trim();
876        if value.is_empty() || value.starts_with('{') || function_like(value) {
877            return None;
878        }
879        return Some(caps[1].to_string());
880    }
881    None
882}
883
884fn angle_balance(s: &str) -> i32 {
885    // Generics only: `->` and `=>` carry a `>` that is not a bracket.
886    let s = s.replace("->", " ").replace("=>", " ");
887    s.chars()
888        .map(|c| match c {
889            '<' => 1,
890            '>' => -1,
891            _ => 0,
892        })
893        .sum::<i32>()
894        .max(0)
895}
896
897fn bracket_balance(s: &str) -> i32 {
898    s.chars()
899        .map(|c| match c {
900            '[' => 1,
901            ']' => -1,
902            _ => 0,
903        })
904        .sum()
905}
906
907/// A binding whose destructuring pattern a formatter wrapped: the pattern
908/// opens on the binding line (`const {`, `let [`) and closes on a later one.
909fn opens_destructure(line: &str) -> bool {
910    static DESTRUCTURE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
911    let re = DESTRUCTURE
912        .get_or_init(|| regex::Regex::new(r"^(?:export\s+)?(?:const|let|var)\s+[\{\[]").unwrap());
913    re.is_match(line) && brace_delta(line) + bracket_balance(line) > 0
914}
915
916/// An import, `use`, or re-export list wraps across lines on its braces;
917/// nothing else joins on a brace (a brace elsewhere opens a body).
918fn is_list_declaration(sig: &str) -> bool {
919    sig.starts_with("import ")
920        || sig.starts_with("use ")
921        || sig.starts_with("export {")
922        || sig.starts_with("export type {")
923}
924
925fn declaration_lines(stripped: &str, family: Family) -> String {
926    let lines: Vec<&str> = stripped.lines().collect();
927    let mut out: Vec<String> = Vec::new();
928    let mut depth: i32 = 0;
929    // After a binding whose value was cut as body, the value's own lines
930    // are skipped whole until every bracket the value opened has closed:
931    // (brace, bracket, paren) balances accumulated from the binding line.
932    let mut body_skip: Option<(i32, i32, i32)> = None;
933    let mut i = 0;
934    while i < lines.len() {
935        let raw = lines[i];
936        let line = raw.trim();
937        if line.is_empty() {
938            i += 1;
939            continue;
940        }
941        if let Some((braces, brackets, parens)) = body_skip {
942            let braces = braces + brace_delta(raw);
943            let brackets = brackets + bracket_balance(raw);
944            let parens = parens + paren_balance(raw);
945            depth += brace_delta(raw);
946            body_skip = if braces <= 0 && brackets <= 0 && parens <= 0 {
947                None
948            } else {
949                Some((braces, brackets, parens))
950            };
951            i += 1;
952            continue;
953        }
954        let indent = raw.len() - raw.trim_start().len();
955        let next_opens_body = lines[i + 1..]
956            .iter()
957            .map(|l| l.trim())
958            .find(|l| !l.is_empty())
959            .is_some_and(|l| l.starts_with('{'));
960        let keep = match family {
961            Family::Python => python_keeps(line, indent),
962            _ => c_like_keeps(line, depth, next_opens_body, family != Family::Rust),
963        };
964        if keep
965            && family == Family::Python
966            && indent == 0
967            && let Some(eq) = python_constant_cut(line)
968        {
969            // A module constant's value is body, as a JS binding's is.
970            out.push(normalize_signature(&line[..eq]));
971            i += 1;
972            continue;
973        }
974        // A destructuring pattern a formatter wrapped (`const {\n  a,\n  b,\n} = …`)
975        // joins across its brackets before the binding rule sees it, so the
976        // names inside are interface in the wrapped form as in the one-line form.
977        let mut binding_end = i;
978        let destructured = if keep && family != Family::Python && opens_destructure(line) {
979            let mut joined = line.to_string();
980            while brace_delta(&joined) + bracket_balance(&joined) > 0
981                && binding_end + 1 < lines.len()
982                && binding_end - i < 60
983            {
984                binding_end += 1;
985                if lines[binding_end].trim().is_empty() {
986                    continue;
987                }
988                joined.push(' ');
989                joined.push_str(lines[binding_end].trim());
990            }
991            Some(joined)
992        } else {
993            None
994        };
995        let binding_line = destructured.as_deref().unwrap_or(line);
996        if keep
997            && family != Family::Python
998            && !(depth >= 1 && enum_member_re().is_match(line))
999            && let Some(cut) = cut_value_binding(binding_line)
1000        {
1001            // (An enum member's explicit value is interface, not a binding's
1002            // body: `Green = 2,` keeps its value.)
1003            // The value is body: keep the binding's name, count its braces,
1004            // and skip the value's lines until every bracket it opened closes.
1005            out.push(normalize_signature(&cut));
1006            let span = &lines[i..=binding_end];
1007            let mut opened = (
1008                span.iter().map(|l| brace_delta(l)).sum::<i32>(),
1009                span.iter().map(|l| bracket_balance(l)).sum::<i32>(),
1010                span.iter().map(|l| paren_balance(l)).sum::<i32>(),
1011            );
1012            depth += opened.0;
1013            i = binding_end + 1;
1014            if binding_line.trim_end().ends_with('=') {
1015                // The value starts on the next non-empty line: consume it
1016                // and whatever it opens.
1017                while i < lines.len() && lines[i].trim().is_empty() {
1018                    i += 1;
1019                }
1020                if i < lines.len() {
1021                    let v = lines[i];
1022                    opened = (
1023                        opened.0 + brace_delta(v),
1024                        opened.1 + bracket_balance(v),
1025                        opened.2 + paren_balance(v),
1026                    );
1027                    depth += brace_delta(v);
1028                    i += 1;
1029                }
1030            }
1031            if opened.0 > 0 || opened.1 > 0 || opened.2 > 0 {
1032                body_skip = Some(opened);
1033            }
1034            continue;
1035        }
1036        if keep {
1037            // A signature wrapped across lines joins on its parentheses, a
1038            // wrapped array literal on its brackets, a wrapped import or
1039            // export list on its braces — so a formatter's line width is
1040            // invisible and a member added inside a wrapped list is not.
1041            let mut sig = line.to_string();
1042            let mut j = i;
1043            let c_like = family != Family::Python;
1044            // Continuation: an open bracket of any kind, or (C-like) a next
1045            // line a formatter led with an operator (`|` in a union type,
1046            // `.` in a chain, `?`/`:` in a ternary, `&&`/`||`/`+`).
1047            let next_is_operator_led = |k: usize| {
1048                c_like
1049                    && lines[k + 1..]
1050                        .iter()
1051                        .map(|l| l.trim())
1052                        .find(|l| !l.is_empty())
1053                        .is_some_and(|l| {
1054                            l.starts_with('|')
1055                                || l.starts_with('&')
1056                                || l.starts_with('?')
1057                                || l.starts_with(':')
1058                                || l.starts_with('.')
1059                                || l.starts_with('+')
1060                        })
1061            };
1062            // A line that ends by opening a body (`{`) joins nothing: what
1063            // follows is body, even inside a callback argument's parentheses.
1064            // An import or export list's brace opens a list, not a body.
1065            // A line that ends in `=` or `:` (a formatter broke the value or
1066            // the type onto the next line) joins its continuation.
1067            let ends_open = |s: &str| {
1068                let t = s.trim_end();
1069                c_like && (t.ends_with('=') || t.ends_with(':'))
1070            };
1071            while (!sig.trim_end().ends_with('{') || is_list_declaration(&sig))
1072                && (paren_balance(&sig) > 0
1073                    || bracket_balance(&sig) > 0
1074                    || (c_like && angle_balance(&sig) > 0)
1075                    || (is_list_declaration(&sig) && brace_delta(&sig) > 0)
1076                    || ends_open(&sig)
1077                    || next_is_operator_led(j))
1078                && j + 1 < lines.len()
1079                && j - i < 60
1080            {
1081                j += 1;
1082                if lines[j].trim().is_empty() {
1083                    continue;
1084                }
1085                sig.push(' ');
1086                sig.push_str(lines[j].trim());
1087            }
1088            // A line kept only as a member signature must open a body once
1089            // joined; a wrapped call statement joins to `name(args)` with no
1090            // body after it and is dropped, as its one-line form would be.
1091            // Judged on the joined signature: a typed member a formatter
1092            // wrapped (`load(\n  id: string,\n): Promise<void>`) is typed.
1093            let opens_body = sig.trim_end().ends_with('{')
1094                || sig.contains("=>")
1095                || lines[j + 1..]
1096                    .iter()
1097                    .map(|l| l.trim())
1098                    .find(|l| !l.is_empty())
1099                    .is_some_and(|l| l.starts_with('{'));
1100            if c_like && member_signature_only(&sig, depth) && !opens_body {
1101                for l in &lines[i..=j] {
1102                    depth += brace_delta(l);
1103                }
1104                i = j + 1;
1105                continue;
1106            }
1107            out.push(normalize_signature(&cut_at_body(&sig, family)));
1108            if c_like {
1109                for l in &lines[i..=j] {
1110                    depth += brace_delta(l);
1111                }
1112            }
1113            i = j + 1;
1114        } else {
1115            if family != Family::Python {
1116                depth += brace_delta(raw);
1117            }
1118            i += 1;
1119        }
1120    }
1121    out.join("\n")
1122}
1123
1124/// The load-bearing sections of a type, in the type's declared order:
1125///
1126/// 1. the sections declaring `load_bearing: true`, when any does;
1127/// 2. otherwise the required sections, minus any declaring
1128///    `load_bearing: false`, when that leaves at least one;
1129/// 3. otherwise every section — a type with no required sections and no
1130///    declaration has no notes/claim split the engine can honour, and an
1131///    empty set would hash to a constant that never drifts.
1132pub fn load_bearing_sections(
1133    type_def: &memstead_schema::types::TypeDefinition,
1134) -> Vec<&memstead_schema::types::SectionDef> {
1135    let explicit: Vec<_> = type_def
1136        .sections
1137        .iter()
1138        .filter(|s| s.load_bearing == Some(true))
1139        .collect();
1140    if !explicit.is_empty() {
1141        return explicit;
1142    }
1143    let required: Vec<_> = type_def
1144        .sections
1145        .iter()
1146        .filter(|s| s.required && s.load_bearing != Some(false))
1147        .collect();
1148    if !required.is_empty() {
1149        return required;
1150    }
1151    type_def.sections.iter().collect()
1152}
1153
1154/// The `entity-load-bearing` prepared form: the entity's load-bearing
1155/// sections serialized stably — each as `## <key>`, a blank line, the
1156/// trimmed content, a blank line — in the type's declared section order.
1157/// Keyed by section KEY (not heading) so a heading rename in the schema
1158/// does not read as a content change; a section the entity does not carry
1159/// is skipped. Title, metadata, and relationships are outside the form:
1160/// the anchor's artifact is the entity id, and a rename orphans the anchor
1161/// on its own. Without a type definition (a type the mem's schema does not
1162/// declare) every section the entity carries is load-bearing, in the
1163/// entity's own order.
1164pub fn entity_load_bearing_form(
1165    entity: &Entity,
1166    type_def: Option<&memstead_schema::types::TypeDefinition>,
1167) -> String {
1168    fn push(out: &mut String, key: &str, content: &str) {
1169        out.push_str("## ");
1170        out.push_str(key);
1171        out.push_str("\n\n");
1172        out.push_str(content.trim());
1173        out.push_str("\n\n");
1174    }
1175    let mut out = String::new();
1176    match type_def {
1177        Some(td) => {
1178            for section in load_bearing_sections(td) {
1179                if let Some(content) = entity.sections.get(&section.key) {
1180                    push(&mut out, &section.key, content);
1181                }
1182            }
1183        }
1184        None => {
1185            for (key, content) in &entity.sections {
1186                push(&mut out, key, content);
1187            }
1188        }
1189    }
1190    out
1191}
1192
1193/// Touchpoint A for the `entity` grain: the prepared-content hash of an
1194/// entity under the source's declared preparation. `None` declares
1195/// nothing — the canonical rendered markdown, byte-for-byte today's form.
1196/// [`ENTITY_LOAD_BEARING`] hashes [`entity_load_bearing_form`]. An
1197/// identifier the registry does not know yields `None`: the form cannot be
1198/// computed, and observation reports the anchor unobserved rather than
1199/// hashing a fabricated form (validation refuses such a record at every
1200/// edit path; only a hand-edited file reaches here).
1201pub fn entity_prepared_hash(
1202    entity: &Entity,
1203    type_def: Option<&memstead_schema::types::TypeDefinition>,
1204    preparation: Option<&str>,
1205) -> Option<String> {
1206    let form = match preparation {
1207        None => crate::render::render_entity_markdown(entity, None),
1208        Some(ENTITY_LOAD_BEARING) => entity_load_bearing_form(entity, type_def),
1209        Some(_) => return None,
1210    };
1211    Some(prepared_content_hash(form.as_bytes()))
1212}
1213
1214// ---------------------------------------------------------------------------
1215// Touchpoint B: delivery units
1216// ---------------------------------------------------------------------------
1217
1218/// One delivery unit of a file under a delivery preparation.
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220pub struct DeliveryUnit {
1221    /// The unit's key, unique within its file; the addressed form is
1222    /// `<path>#<key>` ([`unit_id`]).
1223    pub key: String,
1224    /// The intrinsic key the source's units sort by (a normalized stamp for
1225    /// [`DATED_ENTRIES`]); empty for a [`WHOLE_FILE_UNIT`].
1226    pub order_key: String,
1227    /// First line of the unit, 1-based.
1228    pub start_line: usize,
1229    /// Last line of the unit, 1-based, inclusive.
1230    pub end_line: usize,
1231    /// The prepared-content hash of the unit's text — what a span anchor
1232    /// over the unit records, and what a change run compares.
1233    pub hash: String,
1234}
1235
1236/// How a unit changed between two states of its file.
1237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1238#[serde(rename_all = "lowercase")]
1239pub enum UnitChange {
1240    /// The key is new.
1241    Added,
1242    /// The key existed; the unit's text changed.
1243    Modified,
1244    /// The key is gone.
1245    Deleted,
1246}
1247
1248/// The addressed form of a unit: `<path>#<key>`.
1249pub fn unit_id(path: &str, key: &str) -> String {
1250    format!("{path}#{key}")
1251}
1252
1253/// Split an artifact id into its path and, when it addresses a unit, the
1254/// unit key after the first `#`.
1255pub fn split_unit_id(id: &str) -> (&str, Option<&str>) {
1256    match id.find('#') {
1257        Some(cut) => (&id[..cut], Some(&id[cut + 1..])),
1258        None => (id, None),
1259    }
1260}
1261
1262/// Touchpoint B: the delivery units of one file's content under a delivery
1263/// preparation, in file order. `None` when `preparation` is not a registered
1264/// delivery preparation (the caller keeps file-granularity delivery).
1265pub fn unitize(preparation: &str, content: &str) -> Option<Vec<DeliveryUnit>> {
1266    match preparation {
1267        DATED_ENTRIES => Some(dated_entries(content)),
1268        _ => None,
1269    }
1270}
1271
1272/// The text of one unit, lines `start_line..=end_line` of `content`.
1273pub fn unit_text(content: &str, unit: &DeliveryUnit) -> String {
1274    content
1275        .lines()
1276        .skip(unit.start_line.saturating_sub(1))
1277        .take(unit.end_line + 1 - unit.start_line.max(1))
1278        .collect::<Vec<_>>()
1279        .join("\n")
1280}
1281
1282/// The units that differ between two states of one file, keyed by unit key:
1283/// a key only in `after` is [`UnitChange::Added`], a key in both whose hash
1284/// differs is [`UnitChange::Modified`] (the `after` unit), a key only in
1285/// `before` is [`UnitChange::Deleted`] (the `before` unit, so its order key
1286/// still places it). Unchanged units are not delivered again.
1287pub fn diff_units(
1288    before: &[DeliveryUnit],
1289    after: &[DeliveryUnit],
1290) -> Vec<(DeliveryUnit, UnitChange)> {
1291    let old: std::collections::BTreeMap<&str, &DeliveryUnit> =
1292        before.iter().map(|u| (u.key.as_str(), u)).collect();
1293    let new: std::collections::BTreeMap<&str, &DeliveryUnit> =
1294        after.iter().map(|u| (u.key.as_str(), u)).collect();
1295    let mut out = Vec::new();
1296    for u in after {
1297        match old.get(u.key.as_str()) {
1298            None => out.push((u.clone(), UnitChange::Added)),
1299            Some(prev) if prev.hash != u.hash => out.push((u.clone(), UnitChange::Modified)),
1300            Some(_) => {}
1301        }
1302    }
1303    for u in before {
1304        if !new.contains_key(u.key.as_str()) {
1305            out.push((u.clone(), UnitChange::Deleted));
1306        }
1307    }
1308    out
1309}
1310
1311fn dated_entries(content: &str) -> Vec<DeliveryUnit> {
1312    let lines: Vec<&str> = content.lines().collect();
1313    let starts: Vec<(usize, String)> = lines
1314        .iter()
1315        .enumerate()
1316        .filter_map(|(i, line)| leading_stamp(line).map(|stamp| (i, stamp)))
1317        .collect();
1318    if starts.is_empty() {
1319        return vec![DeliveryUnit {
1320            key: WHOLE_FILE_UNIT.to_string(),
1321            order_key: String::new(),
1322            start_line: 1,
1323            end_line: lines.len().max(1),
1324            hash: prepared_content_hash(content.as_bytes()),
1325        }];
1326    }
1327    let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1328    let mut units = Vec::with_capacity(starts.len());
1329    for (n, (start, stamp)) in starts.iter().enumerate() {
1330        // The preamble (anything before the first stamp) folds into the
1331        // first unit: it is context for the entries, never a unit of its own.
1332        let from = if n == 0 { 0 } else { *start };
1333        let to = starts.get(n + 1).map_or(lines.len(), |(next, _)| *next);
1334        let text = lines[from..to].join("\n");
1335        let count = seen
1336            .entry(stamp.clone())
1337            .and_modify(|c| *c += 1)
1338            .or_insert(1);
1339        let key = if *count == 1 {
1340            stamp.clone()
1341        } else {
1342            format!("{stamp}.{count}")
1343        };
1344        units.push(DeliveryUnit {
1345            key,
1346            order_key: stamp.clone(),
1347            start_line: from + 1,
1348            end_line: to,
1349            hash: prepared_content_hash(text.as_bytes()),
1350        });
1351    }
1352    units
1353}
1354
1355/// The ISO stamp a line opens with (after leading markdown markers),
1356/// normalized to `YYYY-MM-DDTHH:MM:SS`; `None` when the line opens with
1357/// anything else or the stamp is out of range.
1358fn leading_stamp(line: &str) -> Option<String> {
1359    static STAMP: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1360    let re = STAMP.get_or_init(|| {
1361        regex::Regex::new(
1362            r"^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b",
1363        )
1364        .expect("the stamp regex compiles")
1365    });
1366    let s = line.trim_start_matches(|c: char| {
1367        c.is_whitespace() || matches!(c, '#' | '-' | '*' | '>' | '[' | '(' | '|' | '`' | '+')
1368    });
1369    let caps = re.captures(s)?;
1370    let num = |i: usize| -> u32 {
1371        caps.get(i)
1372            .map(|m| m.as_str().parse().unwrap_or(0))
1373            .unwrap_or(0)
1374    };
1375    let (y, mo, d, h, mi, sec) = (num(1), num(2), num(3), num(4), num(5), num(6));
1376    let days_in_month = match mo {
1377        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1378        4 | 6 | 9 | 11 => 30,
1379        2 => 29,
1380        _ => return None,
1381    };
1382    if !(1..=days_in_month).contains(&d) || h > 23 || mi > 59 || sec > 59 {
1383        return None;
1384    }
1385    Some(format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{sec:02}"))
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use crate::entity::EntityId;
1392    use indexmap::IndexMap;
1393    use memstead_schema::types::{SectionDef, TypeDefinition};
1394
1395    fn section(key: &str, required: bool, load_bearing: Option<bool>) -> SectionDef {
1396        let mut v = serde_json::json!({
1397            "key": key, "heading": key, "required": required, "search_weight": 1.0
1398        });
1399        if let Some(lb) = load_bearing {
1400            v["load_bearing"] = serde_json::json!(lb);
1401        }
1402        serde_json::from_value(v).unwrap()
1403    }
1404
1405    /// A real builtin type with its sections replaced — the fixture never
1406    /// has to track `TypeDefinition`'s required-field roster.
1407    fn type_with(sections: Vec<SectionDef>) -> TypeDefinition {
1408        let schemas = memstead_schema::builtins::load_builtin_schemas().unwrap();
1409        let base = schemas
1410            .iter()
1411            .find_map(|s| s.get_type("assertion"))
1412            .expect("a builtin schema declares `assertion`");
1413        let mut td = (*base).clone();
1414        td.sections = sections;
1415        td
1416    }
1417
1418    fn entity(sections: &[(&str, &str)]) -> Entity {
1419        let mut map = IndexMap::new();
1420        for (k, v) in sections {
1421            map.insert(k.to_string(), v.to_string());
1422        }
1423        Entity {
1424            id: EntityId::canonical("m--e"),
1425            title: "E".into(),
1426            entity_type: "t".into(),
1427            mem: "m".into(),
1428            file_path: "e.md".into(),
1429            metadata: IndexMap::new(),
1430            sections: map,
1431            relationships: Vec::new(),
1432            content_hash: "h".into(),
1433            stub: false,
1434            stub_kind: None,
1435            heading_spans: Default::default(),
1436            raw_section_headings: Vec::new(),
1437        }
1438    }
1439
1440    #[test]
1441    fn registry_knows_its_three_flavours_and_nothing_else() {
1442        assert!(is_registered(ENTITY_LOAD_BEARING));
1443        assert!(is_registered(DATED_ENTRIES));
1444        assert!(is_registered(CODE_MAP));
1445        assert!(!is_registered("pdf-to-markdown"));
1446        assert!(!is_registered(""));
1447        assert_eq!(
1448            registered_identifiers(),
1449            vec![ENTITY_LOAD_BEARING, DATED_ENTRIES, CODE_MAP]
1450        );
1451        let c = lookup(CODE_MAP).unwrap();
1452        assert_eq!(c.touchpoint, Touchpoint::PreparedForm);
1453        assert!(applies_to_namespace(c, "path"));
1454        assert!(applies_to_namespace(c, "path+commit"));
1455        assert!(!applies_to_namespace(c, "entity"));
1456        assert!(!applies_to_namespace(c, "url"));
1457        assert!(delivery_preparation(Some(CODE_MAP)).is_none());
1458        let p = lookup(ENTITY_LOAD_BEARING).unwrap();
1459        assert_eq!(p.touchpoint, Touchpoint::PreparedForm);
1460        assert!(applies_to_namespace(p, "entity"));
1461        assert!(!applies_to_namespace(p, "path"));
1462        assert!(!applies_to_namespace(p, "url"));
1463        let d = lookup(DATED_ENTRIES).unwrap();
1464        assert_eq!(d.touchpoint, Touchpoint::DeliveryUnits);
1465        assert!(applies_to_namespace(d, "path"));
1466        assert!(applies_to_namespace(d, "path+commit"));
1467        assert!(!applies_to_namespace(d, "entity"));
1468        assert!(!applies_to_namespace(d, "url"));
1469        // Touchpoint B lookup: only a delivery flavour answers.
1470        assert_eq!(
1471            delivery_preparation(Some(DATED_ENTRIES)).map(|p| p.id),
1472            Some(DATED_ENTRIES)
1473        );
1474        assert!(delivery_preparation(Some(ENTITY_LOAD_BEARING)).is_none());
1475        assert!(delivery_preparation(Some("pdf-to-markdown")).is_none());
1476        assert!(delivery_preparation(None).is_none());
1477        assert!(unitize(ENTITY_LOAD_BEARING, "x").is_none());
1478        assert!(unitize("pdf-to-markdown", "x").is_none());
1479    }
1480
1481    const LOG: &str = "# Ops log\n\nPreamble text.\n\n## 2026-08-24 10:05 boot\nline a\n\n\
1482                       - 2026-08-24T10:05:00Z boot again\nline b\n2026-08-25 shutdown\nline c\n";
1483
1484    /// Unitization: entries open at dated lines, the preamble folds into the
1485    /// first unit, same-stamp entries get an ordinal, an undated file is one
1486    /// `whole` unit, and the stamp normalizes across the accepted spellings.
1487    #[test]
1488    fn dated_entries_unitize_deterministically() {
1489        let units = unitize(DATED_ENTRIES, LOG).unwrap();
1490        let keys: Vec<&str> = units.iter().map(|u| u.key.as_str()).collect();
1491        assert_eq!(
1492            keys,
1493            vec![
1494                "2026-08-24T10:05:00",
1495                "2026-08-24T10:05:00.2",
1496                "2026-08-25T00:00:00"
1497            ]
1498        );
1499        assert_eq!(
1500            units[0].start_line, 1,
1501            "the preamble folds into the first unit"
1502        );
1503        assert_eq!((units[0].end_line, units[1].start_line), (7, 8));
1504        assert_eq!(units[2].end_line, 11);
1505        assert_eq!(units[1].order_key, "2026-08-24T10:05:00");
1506        assert!(unit_text(LOG, &units[2]).starts_with("2026-08-25 shutdown"));
1507        assert_eq!(
1508            units[2].hash,
1509            prepared_content_hash(unit_text(LOG, &units[2]).as_bytes())
1510        );
1511
1512        let whole = unitize(DATED_ENTRIES, "no stamps here\njust prose\n").unwrap();
1513        assert_eq!(whole.len(), 1);
1514        assert_eq!(whole[0].key, WHOLE_FILE_UNIT);
1515        assert_eq!(whole[0].order_key, "");
1516
1517        assert_eq!(
1518            leading_stamp("[2026-02-30] bad day"),
1519            None,
1520            "day out of range"
1521        );
1522        assert_eq!(
1523            leading_stamp("2026-08-24T25:00 x"),
1524            None,
1525            "hour out of range"
1526        );
1527        assert_eq!(leading_stamp("v2026-08-24"), None, "not at the line start");
1528        assert_eq!(leading_stamp("2026-08-2400"), None, "digits run on");
1529        assert_eq!(
1530            leading_stamp("> **2026-08-24T10:05:00.250+02:00** note").as_deref(),
1531            Some("2026-08-24T10:05:00")
1532        );
1533        assert_eq!(
1534            unit_id("logs/ops.md", "2026-08-25T00:00:00"),
1535            "logs/ops.md#2026-08-25T00:00:00"
1536        );
1537        assert_eq!(
1538            split_unit_id("logs/ops.md#2026-08-25T00:00:00"),
1539            ("logs/ops.md", Some("2026-08-25T00:00:00"))
1540        );
1541        assert_eq!(split_unit_id("logs/ops.md"), ("logs/ops.md", None));
1542    }
1543
1544    const JS: &str = "// Auth module\nimport axios from 'axios'\nimport { t } from '@/i18n'\n\n/* block\n   comment */\nconst RETRIES = 3\n\nexport default {\n  name: 'Auth',\n  props: ['user'],\n  data() {\n    return { token: null, busy: false }\n  },\n  methods: {\n    async login(user, password) {\n      // body\n      const r = await axios.post('/login', { user, password })\n      return r.data\n    },\n    logout() {\n      this.token = null\n    }\n  }\n}\n\nexport function helper(a, b) {\n  return a + b\n}\n\nexport const LIMIT = { max: 10 }\n";
1545
1546    /// The code map keeps the interface and nothing else: comments,
1547    /// formatting and implementation bodies are invisible; a signature or
1548    /// export change is visible; the digest is the same for JS and for the
1549    /// script block of a Vue component.
1550    #[test]
1551    fn code_map_digest_sees_interfaces_not_bodies() {
1552        let digest = code_map_digest("src/auth.js", JS);
1553        assert_eq!(
1554            digest,
1555            "import axios from 'axios'\nimport{t}from '@/i18n'\nconst RETRIES=\n\
1556             export default\nname:\nprops:['user']\ndata()\nmethods:\n\
1557             async login(user,password)\nlogout()\nexport function helper(a,b)\n\
1558             export const LIMIT="
1559        );
1560        // A top-level value is body whatever its shape: a ternary or a
1561        // binary expression wrapped by a formatter, a member chain, an
1562        // object opened with `({`; a function-valued binding keeps its
1563        // signature, with or without parentheses around a lone parameter.
1564        let value_forms = [
1565            "export const base = cfg.API ? cfg.API : 'x'\n",
1566            "export const base = cfg.API\n  ? cfg.API\n  : 'x'\n",
1567            "export const base =\n  'a' +\n  'b'\n",
1568            "export const base = new Client({\n  region: 'eu',\n  retries: 3,\n})\n",
1569            "export const base = axios\n  .create(cfg)\n  .interceptors\n",
1570        ];
1571        let cut: Vec<String> = value_forms
1572            .iter()
1573            .map(|t| code_map_digest("cfg.js", t))
1574            .collect();
1575        assert!(cut.iter().all(|d| d == "export const base="), "{cut:?}");
1576        assert_eq!(
1577            code_map_digest(
1578                "s.js",
1579                "const store = new Vuex.Store({\n  state: { n: 1 },\n  mutations: {\n    inc(s) { s.n += 1 }\n  }\n})\n"
1580            ),
1581            code_map_digest(
1582                "s.js",
1583                "const store = new Vuex.Store({\n  state: { n: 1 },\n  mutations: {\n    inc(s) { s.n += 2 }\n  }\n})\n"
1584            )
1585        );
1586        assert_eq!(
1587            code_map_digest("d.js", "export default new Vuetify({\n  theme: 'x',\n})\n"),
1588            "export default"
1589        );
1590        assert_eq!(
1591            code_map_digest("f.js", "const f = x => x.id\n"),
1592            code_map_digest("f.js", "const f = (x) => x.id\n")
1593        );
1594        assert_eq!(
1595            code_map_digest("f.js", "const f = (x) => x.id\n"),
1596            "const f=x=>"
1597        );
1598        assert_eq!(
1599            code_map_digest(
1600                "f.js",
1601                "export const g = async (a, b) => {\n  return a\n}\n"
1602            ),
1603            "export const g=async(a,b)=>"
1604        );
1605        // Brace style and quoted keys are formatting.
1606        let knr = "class S {\n  login(user, password) {\n    return 1\n  }\n  logout() {\n  }\n}\n";
1607        let allman = "class S\n{\n  login(user, password)\n  {\n    return 1\n  }\n  logout()\n  {\n  }\n}\n";
1608        assert_eq!(
1609            code_map_digest("s.js", knr),
1610            "class S\nlogin(user,password)\nlogout()"
1611        );
1612        assert_eq!(
1613            code_map_digest("s.js", allman),
1614            code_map_digest("s.js", knr)
1615        );
1616        // Formatter line wrapping in every shape a formatter produces: an
1617        // arrow's expression body after `=>`, a property arrow with or
1618        // without parentheses, a call statement wrapped inside a body (which
1619        // never enters the digest), CommonJS `exports` values, a union type
1620        // led by `|`, rustfmt-wrapped generics.
1621        let same = |a: &str, b: &str, why: &str| {
1622            assert_eq!(
1623                code_map_digest("w.js", a),
1624                code_map_digest("w.js", b),
1625                "{why}"
1626            );
1627        };
1628        same(
1629            "export const pick = state => state.items.filter(i => i.active).map(i => i.id)\n",
1630            "export const pick = state =>\n  state.items\n    .filter(i => i.active)\n    .map(i => i.id)\n",
1631            "arrow expression body wrapped",
1632        );
1633        assert_eq!(
1634            code_map_digest("w.js", "export const pick = (state) => state.items\n"),
1635            "export const pick=state=>"
1636        );
1637        same(
1638            "export default {\n  select: state => state.items.filter(i => i.active),\n}\n",
1639            "export default {\n  select: state =>\n    state.items.filter(i => i.active),\n}\n",
1640            "property arrow body wrapped",
1641        );
1642        same(
1643            "module.exports = {\n  validate: (v) => {\n    return v\n  },\n}\n",
1644            "module.exports = {\n  validate: v => {\n    return v\n  },\n}\n",
1645            "arrowParens on a property arrow",
1646        );
1647        assert_eq!(
1648            code_map_digest(
1649                "w.js",
1650                "module.exports = {\n  validate: v => {\n    return v\n  },\n}\n"
1651            ),
1652            "module.exports=\nvalidate:v=>"
1653        );
1654        same(
1655            "export function setup(app) {\n  registerPlugin(app, options, extra)\n}\n",
1656            "export function setup(app) {\n  registerPlugin(\n    app,\n    options,\n    extra\n  )\n}\n",
1657            "wrapped call statement in a function body",
1658        );
1659        assert_eq!(
1660            code_map_digest(
1661                "w.js",
1662                "export function setup(app) {\n  registerPlugin(\n    app,\n    options,\n    extra\n  )\n}\n"
1663            ),
1664            "export function setup(app)"
1665        );
1666        same(
1667            "class S {\n  run() {\n    helper(a, b, c)\n  }\n}\n",
1668            "class S {\n  run() {\n    helper(\n      a,\n      b,\n      c\n    )\n  }\n}\n",
1669            "wrapped call in a class method body",
1670        );
1671        assert_eq!(
1672            code_map_digest(
1673                "s.rs",
1674                "impl S {\n    pub fn run(&self) {\n        helper(\n            a,\n            b,\n        )\n    }\n}\n"
1675            ),
1676            code_map_digest(
1677                "s.rs",
1678                "impl S {\n    pub fn run(&self) {\n        helper(a, b)\n    }\n}\n"
1679            )
1680        );
1681        same(
1682            "exports.base = cfg.API ? cfg.API : 'http://localhost'\n",
1683            "exports.base = cfg.API\n  ? cfg.API\n  : 'http://localhost'\n",
1684            "exports ternary wrapped",
1685        );
1686        same(
1687            "module.exports = mongoose.model('User', schema).plugin(paginate)\n",
1688            "module.exports = mongoose\n  .model('User', schema)\n  .plugin(paginate)\n",
1689            "module.exports chain wrapped",
1690        );
1691        assert_eq!(
1692            code_map_digest(
1693                "w.js",
1694                "exports.TIMEOUT = compute(\n  settings,\n  defaults\n)\n"
1695            ),
1696            "exports.TIMEOUT="
1697        );
1698        assert_eq!(
1699            code_map_digest(
1700                "t.ts",
1701                "export type Mode = 'discovery' | 'sync' | 'verify'\n"
1702            ),
1703            code_map_digest(
1704                "t.ts",
1705                "export type Mode =\n  | 'discovery'\n  | 'sync'\n  | 'verify'\n"
1706            )
1707        );
1708        assert_ne!(
1709            code_map_digest("t.ts", "export type Mode = 'discovery' | 'sync'\n"),
1710            code_map_digest(
1711                "t.ts",
1712                "export type Mode = 'discovery' | 'sync' | 'verify'\n"
1713            ),
1714            "a union member is interface"
1715        );
1716        assert_eq!(
1717            code_map_digest(
1718                "g.rs",
1719                "pub fn all(&self) -> Result<Vec<String>, Error> {\n    todo!()\n}\n"
1720            ),
1721            code_map_digest(
1722                "g.rs",
1723                "pub fn all(\n    &self,\n) -> Result<\n    Vec<String>,\n    Error,\n> {\n    todo!()\n}\n"
1724            )
1725        );
1726        // rustfmt and prettier breaking a value or a type onto the next line
1727        // (`pub const`, a struct field's type, a type alias, a class field, a
1728        // bare object key); callback bodies never enter the digest; a
1729        // bracket-opened value is skipped whole.
1730        assert_eq!(
1731            code_map_digest(
1732                "c.rs",
1733                "pub const DESCRIPTION: &str =\n    \"a long description\";\n"
1734            ),
1735            code_map_digest(
1736                "c.rs",
1737                "pub const DESCRIPTION: &str = \"a long description\";\n"
1738            )
1739        );
1740        assert_eq!(
1741            code_map_digest("c.rs", "pub const DESCRIPTION: &str = \"x\";\n"),
1742            "pub const DESCRIPTION:&str="
1743        );
1744        assert_eq!(
1745            code_map_digest(
1746                "f.rs",
1747                "pub struct H {\n    pub handler:\n        Box<dyn Fn(&str) -> Result<(), Error> + Send>,\n}\n"
1748            ),
1749            code_map_digest(
1750                "f.rs",
1751                "pub struct H {\n    pub handler: Box<dyn Fn(&str) -> Result<(), Error> + Send>,\n}\n"
1752            )
1753        );
1754        assert_eq!(
1755            code_map_digest(
1756                "t.rs",
1757                "pub type Handler =\n    Box<dyn Fn(&str) -> Result<(), Error>>;\n"
1758            ),
1759            code_map_digest(
1760                "t.rs",
1761                "pub type Handler = Box<dyn Fn(&str) -> Result<(), Error>>;\n"
1762            )
1763        );
1764        assert!(code_map_digest("t.rs", "pub type Handler =\n    Box<X>;\n").contains("Box<X>"));
1765        same(
1766            "class Api {\n  static url = 'a' + 'b';\n  private readonly base = x || 'y';\n}\n",
1767            "class Api {\n  static url =\n    'a' +\n    'b';\n  private readonly base =\n    x || 'y';\n}\n",
1768            "class fields wrapped after =",
1769        );
1770        assert_eq!(
1771            code_map_digest("w.js", "class Api {\n  static url = 'a';\n}\n"),
1772            "class Api\nstatic url="
1773        );
1774        same(
1775            "export default {\n  message: 'a' + 'b',\n  data() {\n    return {}\n  },\n}\n",
1776            "export default {\n  message:\n    'a' +\n    'b',\n  data() {\n    return {}\n  },\n}\n",
1777            "bare key wrapped away from its value",
1778        );
1779        assert!(
1780            code_map_digest(
1781                "w.js",
1782                "export default {\n  message:\n    'a' +\n    'b',\n}\n"
1783            )
1784            .contains("message:")
1785        );
1786        same(
1787            "it('logs in', async () => {\n  const r = await login()\n  expect(r).toBe(1)\n})\n",
1788            "it('logs in', async () => {\n  const r = await login();\n  expect(r).toBe(2);\n});\n",
1789            "a callback body is body",
1790        );
1791        same(
1792            "export function setup(app) {\n  setTimeout(() => {\n    app.start(1)\n  }, 10)\n}\n",
1793            "export function setup(app) {\n  setTimeout(() => {\n    app.start(2)\n  }, 10)\n}\n",
1794            "a callback body inside a function body",
1795        );
1796        same(
1797            "export default {\n  created() {\n    setTimeout(() => {\n      this.a = 1\n    }, 5)\n  },\n}\n",
1798            "export default {\n  created() {\n    setTimeout(() => {\n      this.a = 2\n    }, 5)\n  },\n}\n",
1799            "a callback body inside a member body",
1800        );
1801        assert_eq!(
1802            code_map_digest(
1803                "r.js",
1804                "export const routes = [\n  { path: '/', meta: { auth: true } },\n  { path: '/x' },\n]\n"
1805            ),
1806            "export const routes="
1807        );
1808        // Interface and enum members and destructured names are interface.
1809        let api = "export interface Api {\n  name: string\n  load(id: string): Promise<void>\n}\n";
1810        assert_eq!(
1811            code_map_digest("a.ts", api),
1812            "export interface Api\nname:string\nload(id:string):Promise<void>"
1813        );
1814        assert_ne!(
1815            code_map_digest("a.ts", api),
1816            code_map_digest("a.ts", &api.replace("name: string", "name: number"))
1817        );
1818        assert_ne!(
1819            code_map_digest("a.ts", api),
1820            code_map_digest(
1821                "a.ts",
1822                &api.replace("load(id: string)", "load(id: string, force: boolean)")
1823            )
1824        );
1825        let color = "export enum Color {\n  Red,\n  Green = 2,\n}\n";
1826        assert_eq!(
1827            code_map_digest("e.ts", color),
1828            "export enum Color\nRed\nGreen=2"
1829        );
1830        assert_ne!(
1831            code_map_digest("e.ts", color),
1832            code_map_digest("e.ts", &color.replace("Green = 2,", "Green = 2,\n  Blue,"))
1833        );
1834        assert_eq!(
1835            code_map_digest("q.js", "const { a, b } = require('./x')\n"),
1836            "const{a,b}="
1837        );
1838        assert_ne!(
1839            code_map_digest("q.js", "const { a, b } = require('./x')\n"),
1840            code_map_digest("q.js", "const { a, c } = require('./x')\n")
1841        );
1842        // A formatter's wrap of a typed member and of a destructuring pattern
1843        // digests as the one-line form, and an edit inside the wrap is seen.
1844        let wrapped_api = "export interface Api {\n  name: string\n  load(\n    id: string,\n    force: boolean,\n  ): Promise<void>\n}\n";
1845        assert_eq!(
1846            code_map_digest("a.ts", wrapped_api),
1847            code_map_digest(
1848                "a.ts",
1849                "export interface Api {\n  name: string\n  load(id: string, force: boolean): Promise<void>\n}\n"
1850            )
1851        );
1852        assert_ne!(
1853            code_map_digest("a.ts", wrapped_api),
1854            code_map_digest(
1855                "a.ts",
1856                &wrapped_api.replace(
1857                    "force: boolean,\n",
1858                    "force: boolean,\n    options: LoadOptions,\n"
1859                )
1860            )
1861        );
1862        let wrapped_require = "const {\n  a,\n  b,\n} = require('./x')\n";
1863        assert_eq!(code_map_digest("q.js", wrapped_require), "const{a,b}=");
1864        assert_ne!(
1865            code_map_digest("q.js", wrapped_require),
1866            code_map_digest("q.js", &wrapped_require.replace("  b,\n", "  c,\n"))
1867        );
1868        assert_eq!(
1869            code_map_digest("q.js", "export const [\n  first,\n  second,\n] = pair()\n"),
1870            "export const[first,second]="
1871        );
1872        assert_eq!(
1873            code_map_digest(
1874                "o.js",
1875                "export default {\n  'name': 'X',\n  props: ['a'],\n}\n"
1876            ),
1877            code_map_digest(
1878                "o.js",
1879                "export default {\n  name: 'X',\n  props: ['a'],\n}\n"
1880            )
1881        );
1882        let h = |text: &str| prepared_content_hash(code_map_digest("src/auth.js", text).as_bytes());
1883        let base = h(JS);
1884        // Comment, formatting, body: invisible.
1885        assert_eq!(h(&JS.replace("// body", "// rewritten comment")), base);
1886        assert_eq!(h(&JS.replace("  return a + b", "    return   a+b")), base);
1887        assert_eq!(h(&JS.replace("/login", "/session")), base);
1888        assert_eq!(h(&JS.replace("return r.data", "return r.data.user")), base);
1889        assert_eq!(
1890            h(&JS.replace("max: 10", "max: 20")),
1891            base,
1892            "a value is body"
1893        );
1894        // Formatting inside a declaration: invisible (comma spacing, a
1895        // wrapped signature, semicolons, quote style, a comment in the
1896        // parameter list).
1897        assert_eq!(
1898            h(&JS.replace("login(user, password)", "login(user,password)")),
1899            base
1900        );
1901        assert_eq!(
1902            h(&JS.replace(
1903                "login(user, password)",
1904                "login(\n      user,\n      password\n    )"
1905            )),
1906            base
1907        );
1908        assert_eq!(
1909            h(&JS.replace("import axios from 'axios'", "import axios from \"axios\";")),
1910            base
1911        );
1912        assert_eq!(
1913            h(&JS.replace("helper(a, b)", "helper (a /* first */, b)")),
1914            base
1915        );
1916        assert_eq!(
1917            h(&JS.replace("export const LIMIT = {", "export const LIMIT={")),
1918            base
1919        );
1920        // Formatter-class rewrites: a brace-wrapped import list, a trailing
1921        // comma on the last member, a wrapped array, a wrapped parameter
1922        // list with a trailing comma; and a scalar value is body in every form.
1923        assert_eq!(
1924            h(&JS.replace(
1925                "import { t } from '@/i18n'",
1926                "import {\n  t,\n} from '@/i18n'"
1927            )),
1928            base
1929        );
1930        assert_eq!(h(&JS.replace("props: ['user'],", "props: ['user']")), base);
1931        assert_eq!(
1932            h(&JS.replace("props: ['user'],", "props: [\n    'user',\n  ],")),
1933            base
1934        );
1935        assert_eq!(
1936            h(&JS.replace(
1937                "login(user, password)",
1938                "login(\n      user,\n      password,\n    )"
1939            )),
1940            base
1941        );
1942        assert_eq!(
1943            h(&JS.replace("name: 'Auth',", "name: 'Login',")),
1944            base,
1945            "a scalar value is body"
1946        );
1947        // A member added inside a wrapped import list is visible.
1948        assert_ne!(
1949            h(&JS.replace(
1950                "import { t } from '@/i18n'",
1951                "import {\n  t,\n  n,\n} from '@/i18n'"
1952            )),
1953            base
1954        );
1955        assert_eq!(
1956            code_map_digest("x.js", "export {\n  a,\n  b,\n} from './x'\n"),
1957            code_map_digest("x.js", "export { a, b } from './x'\n")
1958        );
1959        // Signature, export, import: visible.
1960        assert_ne!(
1961            h(&JS.replace("login(user, password)", "login(user, password, remember)")),
1962            base
1963        );
1964        assert_ne!(
1965            h(&JS.replace("export function helper", "function helper")),
1966            base
1967        );
1968        assert_ne!(h(&JS.replace("import axios from 'axios'\n", "")), base);
1969        assert_ne!(
1970            h(&JS.replace("props: ['user']", "props: ['user', 'tenant']")),
1971            base
1972        );
1973        // The same script inside a Vue component digests identically; the
1974        // template and style are not interface.
1975        let vue = format!(
1976            "<template>\n  <div @click=\"login\">{{{{ t('hi') }}}}</div>\n</template>\n\n<script>\n{JS}</script>\n\n<style scoped>\n.a {{ color: red }}\n</style>\n"
1977        );
1978        assert_eq!(code_map_digest("src/Auth.vue", &vue), digest);
1979        assert_eq!(
1980            code_map_digest("src/Auth.vue", &vue.replace("color: red", "color: blue")),
1981            digest
1982        );
1983        // Non-code files are taken whole; JSON canonicalizes formatting away.
1984        assert_eq!(
1985            code_map_digest("README.md", "# hi\n\ntext\n"),
1986            "# hi\n\ntext\n"
1987        );
1988        assert_eq!(
1989            code_map_digest(
1990                "package.json",
1991                "{\n  \"name\": \"x\",\n  \"version\": \"1\"\n}\n"
1992            ),
1993            code_map_digest("package.json", "{\"name\":\"x\",\"version\":\"1\"}")
1994        );
1995    }
1996
1997    const PY: &str = "# -*- coding: utf-8 -*-\nimport os\nfrom typing import List\n\nTIMEOUT = 30  # seconds\n\n\
1998                      def load(path: str, *, strict: bool = False) -> List[str]:\n    \"\"\"Docstring.\"\"\"\n    with open(path) as f:\n        return f.readlines()\n\n\
1999                      class Loader:\n    retries = 3\n\n    @property\n    def name(self):\n        return 'x'\n\n    def run(self,\n            arg):\n        def inner():\n            pass\n        return arg\n";
2000
2001    #[test]
2002    fn code_map_digest_python_and_rust() {
2003        assert_eq!(
2004            code_map_digest("pivot.py", PY),
2005            "import os\nfrom typing import List\nTIMEOUT=\n\
2006             def load(path:str,*,strict:bool=False)->List[str]\nclass Loader\n\
2007             @property\ndef name(self)\ndef run(self,arg)"
2008        );
2009        assert_eq!(
2010            code_map_digest(
2011                "pivot.py",
2012                &PY.replace(
2013                    "def run(self,\n            arg):",
2014                    "def run(\n        self,\n        arg,\n    ):"
2015                )
2016            ),
2017            code_map_digest("pivot.py", PY),
2018            "a formatter's trailing comma in a wrapped def is invisible"
2019        );
2020        assert_eq!(
2021            code_map_digest("i.py", "from typing import (\n    Dict,\n    List,\n)\n"),
2022            code_map_digest("i.py", "from typing import Dict, List\n"),
2023            "black's parenthesized import list is formatting"
2024        );
2025        let h = |t: &str| prepared_content_hash(code_map_digest("pivot.py", t).as_bytes());
2026        assert_eq!(
2027            h(PY),
2028            h(&PY.replace("return f.readlines()", "return list(f)"))
2029        );
2030        assert_eq!(h(PY), h(&PY.replace("Docstring.", "Another docstring.")));
2031        assert_ne!(
2032            h(PY),
2033            h(&PY.replace("def run(self,", "def run(self, extra,"))
2034        );
2035
2036        let rs = "//! Module docs\nuse std::fmt;\n\n/// A thing.\n#[derive(Debug)]\npub struct Thing {\n    pub id: u32,\n    secret: String,\n}\n\nimpl Thing {\n    pub fn new(id: u32) -> Self {\n        Self { id, secret: String::new() }\n    }\n    fn hidden(&self) {}\n}\n";
2037        assert_eq!(
2038            code_map_digest("src/thing.rs", rs),
2039            "use std::fmt\n#[derive(Debug)]\npub struct Thing\npub id:u32\nimpl Thing\n\
2040             pub fn new(id:u32)->Self\nfn hidden(&self)"
2041        );
2042    }
2043
2044    /// The plain tree digest is order-insensitive on input, sorted on
2045    /// output, and moves on any byte change and on any file joining or
2046    /// leaving — the whole-content posture of a `file` anchor lifted to the
2047    /// directory, so a plain `tree` anchor adjudicates deterministically.
2048    #[test]
2049    fn plain_tree_digest_is_sorted_and_content_sensitive() {
2050        let files = vec![
2051            ("src/b.rs".to_string(), b"fn b() {}\n".to_vec()),
2052            ("src/a.rs".to_string(), b"fn a() {}\n".to_vec()),
2053        ];
2054        let base = plain_tree_digest(&files);
2055        assert!(
2056            base.starts_with(&format!(
2057                "{}  src/a.rs\n",
2058                prepared_content_hash(b"fn a() {}\n")
2059            )),
2060            "rows sort by path regardless of input order"
2061        );
2062        let reordered = vec![files[1].clone(), files[0].clone()];
2063        assert_eq!(plain_tree_digest(&reordered), base);
2064        let body_edit = vec![
2065            files[0].clone(),
2066            ("src/a.rs".to_string(), b"fn a() { /* edit */ }\n".to_vec()),
2067        ];
2068        assert_ne!(
2069            plain_tree_digest(&body_edit),
2070            base,
2071            "any byte change moves the plain digest (unlike the code map)"
2072        );
2073        let mut joined = files.clone();
2074        joined.push(("src/c.rs".to_string(), b"fn c() {}\n".to_vec()));
2075        assert_ne!(plain_tree_digest(&joined), base, "a joining file moves it");
2076        let left = vec![files[0].clone()];
2077        assert_ne!(plain_tree_digest(&left), base, "a leaving file moves it");
2078    }
2079
2080    /// A tree's map changes when a file joins, leaves, or changes its
2081    /// interface, and holds when only a body changes; the observation rule
2082    /// routes each grain to its prepared form.
2083    #[test]
2084    fn code_map_tree_digest_and_path_rule() {
2085        let files = vec![
2086            ("src/b.js".to_string(), "export const B = 1\n".to_string()),
2087            ("src/a.js".to_string(), JS.to_string()),
2088        ];
2089        let base = code_map_tree_digest(&files);
2090        assert!(base.starts_with(&format!(
2091            "{}  src/a.js\n",
2092            prepared_content_hash(code_map_digest("src/a.js", JS).as_bytes())
2093        )));
2094        let body_edit = vec![
2095            files[0].clone(),
2096            ("src/a.js".to_string(), JS.replace("/login", "/session")),
2097        ];
2098        assert_eq!(
2099            code_map_tree_digest(&body_edit),
2100            base,
2101            "a body edit leaves the tree map"
2102        );
2103        let sig_edit = vec![
2104            files[0].clone(),
2105            (
2106                "src/a.js".to_string(),
2107                JS.replace("logout()", "logout(everywhere)"),
2108            ),
2109        ];
2110        assert_ne!(code_map_tree_digest(&sig_edit), base);
2111        let mut joined = files.clone();
2112        joined.push(("src/c.js".to_string(), "export const C = 1\n".to_string()));
2113        assert_ne!(code_map_tree_digest(&joined), base);
2114
2115        let digest_hash = prepared_content_hash(code_map_digest("src/a.js", JS).as_bytes());
2116        assert_eq!(
2117            path_prepared_hash(Some(CODE_MAP), "src/a.js", AnchorGrain::File, JS.as_bytes()),
2118            PathPrepared::Hash(digest_hash.clone())
2119        );
2120        assert_eq!(
2121            path_prepared_hash(
2122                Some(CODE_MAP),
2123                "src/a.js#L1-L3",
2124                AnchorGrain::Span,
2125                JS.as_bytes()
2126            ),
2127            PathPrepared::Hash(digest_hash)
2128        );
2129        assert_eq!(
2130            path_prepared_hash(None, "src/a.js", AnchorGrain::File, JS.as_bytes()),
2131            PathPrepared::Hash(prepared_content_hash(JS.as_bytes())),
2132            "no preparation: the bytes, byte-for-byte as before"
2133        );
2134        assert_eq!(
2135            path_prepared_hash(Some(CODE_MAP), "src", AnchorGrain::Tree, b""),
2136            PathPrepared::NoHash,
2137            "a tree needs enumeration; the caller supplies it"
2138        );
2139        assert_eq!(
2140            path_prepared_hash(None, "src", AnchorGrain::Tree, b""),
2141            PathPrepared::NoHash
2142        );
2143        let log = "2026-08-24 one\nbody\n2026-08-25 two\nbody\n";
2144        assert!(matches!(
2145            path_prepared_hash(
2146                Some(DATED_ENTRIES),
2147                "log.md#2026-08-25T00:00:00",
2148                AnchorGrain::Span,
2149                log.as_bytes()
2150            ),
2151            PathPrepared::Hash(_)
2152        ));
2153        assert_eq!(
2154            path_prepared_hash(
2155                Some(DATED_ENTRIES),
2156                "log.md#2026-08-26T00:00:00",
2157                AnchorGrain::Span,
2158                log.as_bytes()
2159            ),
2160            PathPrepared::UnitAbsent
2161        );
2162        assert_eq!(
2163            path_prepared_hash(
2164                Some(DATED_ENTRIES),
2165                "log.md",
2166                AnchorGrain::File,
2167                log.as_bytes()
2168            ),
2169            PathPrepared::Hash(prepared_content_hash(log.as_bytes()))
2170        );
2171    }
2172
2173    /// Measurement harness for a real corpus (the WOENENN acceptance case):
2174    /// `MEMSTEAD_CODE_MAP_CORPUS=<repo root>` plus `MEMSTEAD_CODE_MAP_ALLOW`
2175    /// and `MEMSTEAD_CODE_MAP_DENY` (comma-separated globs) and optionally
2176    /// `MEMSTEAD_CODE_MAP_COMMITS=<n>` (history depth, default 200). Prints
2177    /// the raw-versus-digest size of the scoped corpus and, over the last n
2178    /// commits, how many changed scoped files changed their interface. Run
2179    /// with `--ignored --nocapture`; it is not a pass/fail test.
2180    #[test]
2181    #[ignore]
2182    fn measure_code_map_over_corpus() {
2183        use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
2184        let Ok(root) = std::env::var("MEMSTEAD_CODE_MAP_CORPUS") else {
2185            eprintln!("MEMSTEAD_CODE_MAP_CORPUS unset; nothing measured");
2186            return;
2187        };
2188        let root = std::path::PathBuf::from(root);
2189        let split = |v: &str| -> Vec<String> {
2190            v.split(',')
2191                .map(str::trim)
2192                .filter(|s| !s.is_empty())
2193                .map(String::from)
2194                .collect()
2195        };
2196        let allows = split(&std::env::var("MEMSTEAD_CODE_MAP_ALLOW").unwrap_or_default());
2197        let denies = split(&std::env::var("MEMSTEAD_CODE_MAP_DENY").unwrap_or_default());
2198        let commits: usize = std::env::var("MEMSTEAD_CODE_MAP_COMMITS")
2199            .ok()
2200            .and_then(|v| v.parse().ok())
2201            .unwrap_or(200);
2202        let mut scope: Vec<PatternEntry> = allows
2203            .iter()
2204            .map(|p| PatternEntry {
2205                path: p.clone(),
2206                mode: PatternMode::Allow,
2207            })
2208            .collect();
2209        scope.extend(denies.iter().map(|p| PatternEntry {
2210            path: p.clone(),
2211            mode: PatternMode::Deny,
2212        }));
2213        let source = Source {
2214            name: "corpus".into(),
2215            medium_type: MediumType::Codebase,
2216            pointer: String::new(),
2217            change_detection: Some("git".into()),
2218            scope,
2219            engagement: None,
2220            preparation: Some(CODE_MAP.into()),
2221        };
2222        let files = crate::ingest::cursor::enumerate_facet_files(&source, &[], &root);
2223        let mut by_family: std::collections::BTreeMap<&str, (usize, usize, usize, usize, usize)> =
2224            std::collections::BTreeMap::new();
2225        for f in &files {
2226            let Ok(bytes) = std::fs::read(root.join(f)) else {
2227                continue;
2228            };
2229            let text = String::from_utf8_lossy(&bytes);
2230            let digest = code_map_digest(f, &text);
2231            let ext = f.rsplit('.').next().unwrap_or("");
2232            let fam = match family_of(f) {
2233                Family::CLike => {
2234                    if ext == "py" {
2235                        "py"
2236                    } else {
2237                        "js"
2238                    }
2239                }
2240                Family::Rust => "rust",
2241                Family::Vue => "vue",
2242                Family::Python => "py",
2243                Family::Json => "json",
2244                Family::Text => "other",
2245            };
2246            let e = by_family.entry(fam).or_default();
2247            e.0 += 1;
2248            e.1 += text.len();
2249            e.2 += digest.len();
2250            e.3 += crate::chunking::estimate_tokens(&text);
2251            e.4 += crate::chunking::estimate_tokens(&digest);
2252        }
2253        let (mut n, mut rb, mut db, mut rt, mut dt) = (0, 0, 0, 0, 0);
2254        eprintln!(
2255            "| family | files | raw bytes | digest bytes | raw tokens | digest tokens | digest/raw |"
2256        );
2257        eprintln!("| --- | --- | --- | --- | --- | --- | --- |");
2258        for (fam, (c, b1, b2, t1, t2)) in &by_family {
2259            eprintln!(
2260                "| {fam} | {c} | {b1} | {b2} | {t1} | {t2} | {:.1}% |",
2261                100.0 * *b2 as f64 / (*b1).max(1) as f64
2262            );
2263            n += c;
2264            rb += b1;
2265            db += b2;
2266            rt += t1;
2267            dt += t2;
2268        }
2269        eprintln!(
2270            "| total | {n} | {rb} | {db} | {rt} | {dt} | {:.1}% |",
2271            100.0 * db as f64 / rb.max(1) as f64
2272        );
2273
2274        // History: of the scoped files changed by each of the last N commits,
2275        // how many changed their interface digest?
2276        let git = |args: &[&str]| -> String {
2277            let out = std::process::Command::new("git")
2278                .args(args)
2279                .current_dir(&root)
2280                .output()
2281                .expect("git");
2282            String::from_utf8_lossy(&out.stdout).into_owned()
2283        };
2284        let mut builder = globset::GlobSetBuilder::new();
2285        for a in &allows {
2286            builder.add(globset::Glob::new(a).unwrap());
2287        }
2288        let allow_set = builder.build().unwrap();
2289        let mut dbuilder = globset::GlobSetBuilder::new();
2290        for d in &denies {
2291            dbuilder.add(globset::Glob::new(d).unwrap());
2292        }
2293        let deny_set = dbuilder.build().unwrap();
2294        let shas: Vec<String> = git(&["log", "--format=%H", "-n", &commits.to_string(), "--", "."])
2295            .lines()
2296            .map(String::from)
2297            .collect();
2298        let (mut commits_seen, mut commits_touching, mut commits_interface) =
2299            (0usize, 0usize, 0usize);
2300        let (mut files_changed, mut files_interface, mut files_body_only) =
2301            (0usize, 0usize, 0usize);
2302        for sha in &shas {
2303            commits_seen += 1;
2304            let parent = format!("{sha}~1");
2305            let names = git(&["diff", "--name-only", &parent, sha]);
2306            let mut touched = false;
2307            let mut iface = false;
2308            for f in names.lines() {
2309                if !allow_set.is_match(f) || deny_set.is_match(f) {
2310                    continue;
2311                }
2312                let old = git(&["show", &format!("{parent}:{f}")]);
2313                let new = git(&["show", &format!("{sha}:{f}")]);
2314                if old.is_empty() || new.is_empty() {
2315                    continue;
2316                }
2317                if prepared_content_hash(old.as_bytes()) == prepared_content_hash(new.as_bytes()) {
2318                    continue;
2319                }
2320                touched = true;
2321                files_changed += 1;
2322                if prepared_content_hash(code_map_digest(f, &old).as_bytes())
2323                    != prepared_content_hash(code_map_digest(f, &new).as_bytes())
2324                {
2325                    files_interface += 1;
2326                    iface = true;
2327                } else {
2328                    files_body_only += 1;
2329                }
2330            }
2331            if touched {
2332                commits_touching += 1;
2333            }
2334            if iface {
2335                commits_interface += 1;
2336            }
2337        }
2338        eprintln!();
2339        eprintln!(
2340            "history: {commits_seen} commits inspected, {commits_touching} touched a scoped file's content, {commits_interface} of those changed an interface"
2341        );
2342        eprintln!(
2343            "files: {files_changed} scoped file changes, {files_interface} interface changes, {files_body_only} body-only ({:.1}% of file changes would not drift a code-map anchor)",
2344            100.0 * files_body_only as f64 / files_changed.max(1) as f64
2345        );
2346    }
2347
2348    /// Keys are stable under growth: appending entries leaves every existing
2349    /// unit's key and hash untouched, so a change run delivers only the new
2350    /// unit; an edited entry delivers as modified, a removed one as deleted.
2351    #[test]
2352    fn unit_keys_survive_growth_and_diff_delivers_only_what_changed() {
2353        let before = unitize(DATED_ENTRIES, LOG).unwrap();
2354        let grown = format!("{LOG}2026-08-26 09:00 restart\nline d\n");
2355        let after = unitize(DATED_ENTRIES, &grown).unwrap();
2356        assert_eq!(
2357            &after[..3],
2358            &before[..],
2359            "existing units are byte-identical"
2360        );
2361        let delta = diff_units(&before, &after);
2362        assert_eq!(delta.len(), 1);
2363        assert_eq!(delta[0].0.key, "2026-08-26T09:00:00");
2364        assert_eq!(delta[0].1, UnitChange::Added);
2365
2366        let edited = LOG.replace("line c", "line c, revised");
2367        let delta = diff_units(&before, &unitize(DATED_ENTRIES, &edited).unwrap());
2368        assert_eq!(
2369            delta
2370                .iter()
2371                .map(|(u, c)| (u.key.as_str(), *c))
2372                .collect::<Vec<_>>(),
2373            vec![("2026-08-25T00:00:00", UnitChange::Modified)]
2374        );
2375
2376        let shrunk = LOG.replace("2026-08-25 shutdown\nline c\n", "");
2377        let delta = diff_units(&before, &unitize(DATED_ENTRIES, &shrunk).unwrap());
2378        assert_eq!(
2379            delta
2380                .iter()
2381                .map(|(u, c)| (u.key.as_str(), *c))
2382                .collect::<Vec<_>>(),
2383            vec![("2026-08-25T00:00:00", UnitChange::Deleted)]
2384        );
2385        assert!(diff_units(&before, &before).is_empty());
2386    }
2387
2388    #[test]
2389    fn url_defaults_unstable_every_other_grain_stable() {
2390        assert_eq!(
2391            default_hash_stability(AnchorGrain::Url),
2392            AnchorHashStability::Unstable
2393        );
2394        for g in [
2395            AnchorGrain::Span,
2396            AnchorGrain::File,
2397            AnchorGrain::Tree,
2398            AnchorGrain::Entity,
2399        ] {
2400            assert_eq!(default_hash_stability(g), AnchorHashStability::Stable);
2401        }
2402    }
2403
2404    /// The url grain's prepared form IS the path grains' canonicalization:
2405    /// same bytes, same hash, and the same noise (CRLF, BOM, final newline)
2406    /// is invisible.
2407    #[test]
2408    fn url_prepared_form_is_the_shared_canonicalization() {
2409        let a = url_prepared_hash(b"<p>hello</p>\n");
2410        assert_eq!(a, prepared_content_hash(b"<p>hello</p>\n"));
2411        assert_eq!(a, url_prepared_hash(b"\xEF\xBB\xBF<p>hello</p>\r\n\r\n"));
2412        assert_ne!(a, url_prepared_hash(b"<p>hello!</p>\n"));
2413        assert_eq!(
2414            supplied_content_hash(AnchorGrain::Url, b"<p>hello</p>").as_deref(),
2415            Some(a.as_str())
2416        );
2417        assert!(supplied_content_hash(AnchorGrain::File, b"x").is_some());
2418        assert!(supplied_content_hash(AnchorGrain::Span, b"x").is_some());
2419        assert!(supplied_content_hash(AnchorGrain::Tree, b"x").is_none());
2420        assert!(supplied_content_hash(AnchorGrain::Entity, b"x").is_none());
2421    }
2422
2423    #[test]
2424    fn load_bearing_resolves_explicit_then_required_then_all() {
2425        let explicit = type_with(vec![
2426            section("claim", true, Some(true)),
2427            section("evidence", true, Some(false)),
2428            section("notes", false, None),
2429        ]);
2430        let keys: Vec<_> = load_bearing_sections(&explicit)
2431            .iter()
2432            .map(|s| s.key.as_str())
2433            .collect();
2434        assert_eq!(keys, vec!["claim"]);
2435
2436        let required = type_with(vec![
2437            section("claim", true, None),
2438            section("evidence", true, Some(false)),
2439            section("notes", false, None),
2440        ]);
2441        let keys: Vec<_> = load_bearing_sections(&required)
2442            .iter()
2443            .map(|s| s.key.as_str())
2444            .collect();
2445        assert_eq!(
2446            keys,
2447            vec!["claim"],
2448            "a required section opted out is excluded"
2449        );
2450
2451        let none = type_with(vec![section("a", false, None), section("b", false, None)]);
2452        let keys: Vec<_> = load_bearing_sections(&none)
2453            .iter()
2454            .map(|s| s.key.as_str())
2455            .collect();
2456        assert_eq!(keys, vec!["a", "b"], "no declaration at all: every section");
2457    }
2458
2459    /// The anker metric, mechanised: a notes-only edit leaves the prepared
2460    /// hash intact; a load-bearing edit breaks it.
2461    #[test]
2462    fn notes_edit_keeps_the_hash_load_bearing_edit_breaks_it() {
2463        let td = type_with(vec![
2464            section("decision", true, None),
2465            section("notes", false, None),
2466        ]);
2467        let base = entity(&[("decision", "We ship."), ("notes", "first draft")]);
2468        let notes_edit = entity(&[("decision", "We ship."), ("notes", "first draft, revised")]);
2469        let claim_edit = entity(&[("decision", "We do not ship."), ("notes", "first draft")]);
2470        let h = |e: &Entity| entity_prepared_hash(e, Some(&td), Some(ENTITY_LOAD_BEARING)).unwrap();
2471        assert_eq!(h(&base), h(&notes_edit));
2472        assert_ne!(h(&base), h(&claim_edit));
2473
2474        // The default form (no preparation) sees BOTH edits — today's
2475        // behaviour, byte-for-byte the canonical rendered markdown.
2476        let d = |e: &Entity| entity_prepared_hash(e, Some(&td), None).unwrap();
2477        assert_ne!(d(&base), d(&notes_edit));
2478        assert_eq!(
2479            d(&base),
2480            prepared_content_hash(crate::render::render_entity_markdown(&base, None).as_bytes())
2481        );
2482
2483        // An unregistered identifier computes nothing.
2484        assert!(entity_prepared_hash(&base, Some(&td), Some("pdf-to-markdown")).is_none());
2485    }
2486
2487    /// Content moving between two load-bearing sections changes the form
2488    /// (keys are part of it); trailing whitespace inside a section does not.
2489    #[test]
2490    fn form_is_keyed_and_trimmed() {
2491        let td = type_with(vec![
2492            section("claim", true, None),
2493            section("evidence", true, None),
2494        ]);
2495        let a = entity(&[("claim", "x"), ("evidence", "y")]);
2496        let b = entity(&[("claim", "y"), ("evidence", "x")]);
2497        let c = entity(&[("claim", "x  \n\n"), ("evidence", "\n y")]);
2498        let form = |e: &Entity| entity_load_bearing_form(e, Some(&td));
2499        assert_ne!(form(&a), form(&b));
2500        assert_eq!(form(&a), form(&c));
2501        assert_eq!(form(&a), "## claim\n\nx\n\n## evidence\n\ny\n\n");
2502        // No type definition: every section the entity carries, its order.
2503        assert_eq!(
2504            entity_load_bearing_form(&entity(&[("z", "1"), ("a", "2")]), None),
2505            "## z\n\n1\n\n## a\n\n2\n\n"
2506        );
2507    }
2508}