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