1use serde::{Deserialize, Serialize};
48
49use crate::anchor::{AnchorGrain, AnchorHashStability, prepared_content_hash};
50use crate::entity::Entity;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Touchpoint {
56 PreparedForm,
59 DeliveryUnits,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Preparation {
67 pub id: &'static str,
69 pub touchpoint: Touchpoint,
71 pub grains: &'static [AnchorGrain],
75 pub description: &'static str,
77}
78
79pub const ENTITY_LOAD_BEARING: &str = "entity-load-bearing";
84
85pub const DATED_ENTRIES: &str = "dated-entries";
101
102pub const WHOLE_FILE_UNIT: &str = "whole";
106
107pub const CODE_MAP: &str = "code-map";
126
127pub 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
159pub fn registry() -> &'static [Preparation] {
161 REGISTRY
162}
163
164pub fn lookup(id: &str) -> Option<&'static Preparation> {
166 REGISTRY.iter().find(|p| p.id == id)
167}
168
169pub fn is_registered(id: &str) -> bool {
171 lookup(id).is_some()
172}
173
174pub fn registered_identifiers() -> Vec<&'static str> {
177 REGISTRY.iter().map(|p| p.id).collect()
178}
179
180pub fn delivery_preparation(declared: Option<&str>) -> Option<&'static Preparation> {
184 lookup(declared?).filter(|p| p.touchpoint == Touchpoint::DeliveryUnits)
185}
186
187pub 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
200pub 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
217pub fn url_prepared_hash(content: &[u8]) -> String {
223 prepared_content_hash(content)
224}
225
226pub 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#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum PathPrepared {
248 Hash(String),
250 NoHash,
254 UnitAbsent,
257}
258
259pub fn path_prepared_hash(
271 preparation: Option<&str>,
272 artifact: &str,
273 grain: AnchorGrain,
274 bytes: &[u8],
275) -> PathPrepared {
276 let (path, locator) = split_unit_id(artifact);
277 match (preparation, grain) {
278 (_, AnchorGrain::Url | AnchorGrain::Entity | AnchorGrain::Tree) => PathPrepared::NoHash,
279 (Some(DATED_ENTRIES), AnchorGrain::Span) if locator.is_some() => {
280 let text = String::from_utf8_lossy(bytes);
281 match unitize(DATED_ENTRIES, &text)
282 .and_then(|units| units.into_iter().find(|u| Some(u.key.as_str()) == locator))
283 {
284 Some(unit) => PathPrepared::Hash(unit.hash),
285 None => PathPrepared::UnitAbsent,
286 }
287 }
288 (Some(CODE_MAP), AnchorGrain::File | AnchorGrain::Span) => {
289 let text = String::from_utf8_lossy(bytes);
290 PathPrepared::Hash(prepared_content_hash(
291 code_map_digest(path, &text).as_bytes(),
292 ))
293 }
294 (_, AnchorGrain::File | AnchorGrain::Span) => {
295 PathPrepared::Hash(prepared_content_hash(bytes))
296 }
297 }
298}
299
300pub fn code_map_tree_digest(files: &[(String, String)]) -> String {
305 let mut rows: Vec<(&str, &str)> = files
306 .iter()
307 .map(|(path, text)| (path.as_str(), text.as_str()))
308 .collect();
309 rows.sort();
310 rows.iter()
311 .map(|(path, text)| {
312 format!(
313 "{} {path}",
314 prepared_content_hash(code_map_digest(path, text).as_bytes())
315 )
316 })
317 .collect::<Vec<_>>()
318 .join("\n")
319}
320
321pub fn plain_tree_digest(files: &[(String, Vec<u8>)]) -> String {
330 let mut rows: Vec<(&str, &[u8])> = files
331 .iter()
332 .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
333 .collect();
334 rows.sort();
335 rows.iter()
336 .map(|(path, bytes)| format!("{} {path}", prepared_content_hash(bytes)))
337 .collect::<Vec<_>>()
338 .join("\n")
339}
340
341pub fn code_map_digest(path: &str, text: &str) -> String {
343 match family_of(path) {
344 Family::Text => text.to_string(),
345 Family::Json => serde_json::from_str::<serde_json::Value>(text)
346 .map(|v| v.to_string())
347 .unwrap_or_else(|_| text.to_string()),
348 Family::Vue => {
349 declaration_lines(&strip_c_comments(&vue_script_blocks(text)), Family::CLike)
350 }
351 Family::CLike => declaration_lines(&strip_c_comments(text), Family::CLike),
352 Family::Rust => declaration_lines(&strip_c_comments(text), Family::Rust),
353 Family::Python => declaration_lines(&strip_python_comments(text), Family::Python),
354 }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358enum Family {
359 CLike,
360 Rust,
363 Python,
364 Json,
365 Vue,
366 Text,
367}
368
369fn family_of(path: &str) -> Family {
370 let name = path.rsplit('/').next().unwrap_or(path);
371 let ext = match name.rsplit_once('.') {
372 Some((_, ext)) => ext.to_ascii_lowercase(),
373 None => return Family::Text,
374 };
375 match ext.as_str() {
376 "rs" => Family::Rust,
377 "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" | "go" | "java" | "kt"
378 | "kts" | "swift" | "cs" | "c" | "h" | "cc" | "cpp" | "hpp" | "m" | "mm" | "php"
379 | "dart" | "scala" => Family::CLike,
380 "py" | "pyi" => Family::Python,
381 "json" => Family::Json,
382 "vue" | "svelte" => Family::Vue,
383 _ => Family::Text,
384 }
385}
386
387fn vue_script_blocks(text: &str) -> String {
390 let lower = text.to_ascii_lowercase();
391 let mut out = String::new();
392 let mut from = 0;
393 while let Some(open) = lower[from..].find("<script") {
394 let open = from + open;
395 let Some(tag_end) = lower[open..].find('>') else {
396 break;
397 };
398 let body_start = open + tag_end + 1;
399 let Some(close) = lower[body_start..].find("</script") else {
400 out.push_str(&text[body_start..]);
401 break;
402 };
403 out.push_str(&text[body_start..body_start + close]);
404 out.push('\n');
405 from = body_start + close + 8;
406 }
407 out
408}
409
410fn strip_c_comments(text: &str) -> String {
414 let mut out = String::with_capacity(text.len());
415 let mut chars = text.chars().peekable();
416 let mut in_str: Option<char> = None;
417 let mut escape = false;
418 while let Some(c) = chars.next() {
419 if let Some(q) = in_str {
420 out.push(c);
421 if escape {
422 escape = false;
423 } else if c == '\\' {
424 escape = true;
425 } else if c == q || (c == '\n' && q != '`') {
426 in_str = None;
427 }
428 continue;
429 }
430 match c {
431 '"' | '\'' | '`' => {
432 in_str = Some(c);
433 out.push(c);
434 }
435 '/' => match chars.peek() {
436 Some('/') => {
437 for n in chars.by_ref() {
438 if n == '\n' {
439 out.push('\n');
440 break;
441 }
442 }
443 }
444 Some('*') => {
445 chars.next();
446 let mut prev = '\0';
447 for n in chars.by_ref() {
448 if n == '\n' {
449 out.push('\n');
450 }
451 if prev == '*' && n == '/' {
452 break;
453 }
454 prev = n;
455 }
456 }
457 _ => out.push(c),
458 },
459 _ => out.push(c),
460 }
461 }
462 out
463}
464
465fn strip_python_comments(text: &str) -> String {
468 let mut out = String::with_capacity(text.len());
469 let bytes: Vec<char> = text.chars().collect();
470 let mut i = 0;
471 let mut in_str: Option<char> = None;
472 let mut triple: Option<char> = None;
473 while i < bytes.len() {
474 let c = bytes[i];
475 if let Some(q) = triple {
476 if c == q && i + 2 < bytes.len() && bytes[i + 1] == q && bytes[i + 2] == q {
477 triple = None;
478 i += 3;
479 continue;
480 }
481 if c == '\n' {
482 out.push('\n');
483 }
484 i += 1;
485 continue;
486 }
487 if let Some(q) = in_str {
488 out.push(c);
489 if c == '\\' && i + 1 < bytes.len() {
490 out.push(bytes[i + 1]);
491 i += 2;
492 continue;
493 }
494 if c == q || c == '\n' {
495 in_str = None;
496 }
497 i += 1;
498 continue;
499 }
500 match c {
501 '"' | '\'' => {
502 if i + 2 < bytes.len() && bytes[i + 1] == c && bytes[i + 2] == c {
503 triple = Some(c);
504 i += 3;
505 continue;
506 }
507 in_str = Some(c);
508 out.push(c);
509 }
510 '#' => {
511 while i < bytes.len() && bytes[i] != '\n' {
512 i += 1;
513 }
514 continue;
515 }
516 _ => out.push(c),
517 }
518 i += 1;
519 }
520 out
521}
522
523const C_LIKE_TOP_LEVEL: &[&str] = &[
524 "import ",
525 "export ",
526 "module.exports",
527 "exports.",
528 "function ",
529 "async function ",
530 "class ",
531 "interface ",
532 "type ",
533 "enum ",
534 "declare ",
535 "const ",
536 "let ",
537 "var ",
538 "pub ",
539 "fn ",
540 "struct ",
541 "trait ",
542 "impl ",
543 "impl<",
544 "mod ",
545 "use ",
546 "static ",
547 "macro_rules!",
548 "package ",
549 "func ",
550 "namespace ",
551 "using ",
552 "#include",
553 "#[",
554 "@",
555 "public ",
556 "private ",
557 "protected ",
558 "abstract ",
559 "final ",
560 "override ",
561 "typedef ",
562 "extern ",
563 "template",
564 "def ",
565];
566
567const C_LIKE_MEMBER: &[&str] = &[
568 "pub ",
569 "fn ",
570 "public ",
571 "private ",
572 "protected ",
573 "static ",
574 "abstract ",
575 "override ",
576 "readonly ",
577 "async ",
578 "get ",
579 "set ",
580 "constructor",
581 "#[",
582 "@",
583];
584
585fn method_re() -> &'static regex::Regex {
588 static METHOD: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
589 METHOD.get_or_init(|| {
590 regex::Regex::new(r"^(?:(?:async|static|get|set|public|private|protected|override)\s+)*[A-Za-z_$][\w$]*\s*(?:<[^>]*>)?\s*\(").unwrap()
591 })
592}
593
594fn property_re() -> &'static regex::Regex {
598 static PROPERTY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
599 PROPERTY.get_or_init(|| {
600 regex::Regex::new(r#"^(?:readonly\s+)?(?:['"]?)[A-Za-z_$][\w$]*(?:['"]?)\??\s*:"#).unwrap()
601 })
602}
603
604fn typed_member_re() -> &'static regex::Regex {
609 static TYPED_MEMBER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
610 TYPED_MEMBER.get_or_init(|| {
611 regex::Regex::new(
612 r"^(?:readonly\s+)?[A-Za-z_$][\w$]*\??\s*(?:<[^>]*>)?\s*\(.*\)\s*:\s*[^{;]+[;,]?$",
613 )
614 .unwrap()
615 })
616}
617
618fn typed_member(line: &str) -> bool {
619 typed_member_re().is_match(line) && !line.contains("? ")
620}
621
622fn enum_member_re() -> &'static regex::Regex {
624 static ENUM_MEMBER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
625 ENUM_MEMBER
626 .get_or_init(|| regex::Regex::new(r"^[A-Z][A-Za-z0-9_]*(?:\s*=\s*[^,]+)?,?$").unwrap())
627}
628
629fn member_signature_only(line: &str, depth: i32) -> bool {
634 depth >= 1
635 && !C_LIKE_MEMBER.iter().any(|p| line.starts_with(p))
636 && !property_re().is_match(line)
637 && !typed_member(line)
638 && method_re().is_match(line)
639}
640
641fn c_like_keeps(line: &str, depth: i32, next_opens_body: bool, properties: bool) -> bool {
642 let method = method_re();
643 let property = property_re();
644 let signature_shaped = |line: &str| {
648 method.is_match(line)
649 && !line.starts_with("if ")
650 && !line.starts_with("for ")
651 && !line.starts_with("while ")
652 && !line.starts_with("switch ")
653 && !line.starts_with("return ")
654 && !line.starts_with("catch ")
655 && (line.ends_with('{')
656 || line.ends_with('(')
657 || line.ends_with(',')
658 || (line.ends_with(')') && next_opens_body))
659 };
660 match depth {
661 0 => C_LIKE_TOP_LEVEL.iter().any(|p| line.starts_with(p)),
662 1 => {
663 C_LIKE_MEMBER.iter().any(|p| line.starts_with(p))
664 || signature_shaped(line)
665 || (properties && (property.is_match(line) || typed_member(line)))
666 || enum_member_re().is_match(line)
667 }
668 2 => signature_shaped(line),
669 _ => false,
670 }
671}
672
673fn python_keeps(line: &str, indent: usize) -> bool {
674 static CONSTANT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
675 let constant = CONSTANT
676 .get_or_init(|| regex::Regex::new(r"^(?:[A-Z_][A-Z0-9_]*|__all__)\s*[:=]").unwrap());
677 let decl = line.starts_with("def ")
678 || line.starts_with("async def ")
679 || line.starts_with("class ")
680 || line.starts_with('@');
681 if indent == 0 {
682 decl || line.starts_with("import ") || line.starts_with("from ") || constant.is_match(line)
683 } else {
684 indent <= 4 && decl
685 }
686}
687
688fn python_constant_cut(line: &str) -> Option<usize> {
691 static CONSTANT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
692 let constant = CONSTANT.get_or_init(|| {
693 regex::Regex::new(r"^(?:[A-Z_][A-Z0-9_]*|__all__)(?:\s*:\s*[^=]+?)?\s*=").unwrap()
694 });
695 constant.find(line).map(|m| m.end())
696}
697
698fn paren_balance(s: &str) -> i32 {
699 s.chars()
700 .map(|c| match c {
701 '(' => 1,
702 ')' => -1,
703 _ => 0,
704 })
705 .sum()
706}
707
708fn brace_delta(s: &str) -> i32 {
709 s.chars()
710 .map(|c| match c {
711 '{' => 1,
712 '}' => -1,
713 _ => 0,
714 })
715 .sum()
716}
717
718fn cut_at_body(sig: &str, family: Family) -> String {
722 match family {
723 Family::Python => sig.trim_end_matches(':').trim_end().to_string(),
724 _ if sig.starts_with("import ")
725 || sig.starts_with("use ")
726 || sig.starts_with("export {")
727 || sig.starts_with("export type {")
728 || sig.starts_with("export * ") =>
729 {
730 sig.trim_end().to_string()
731 }
732 _ => {
733 let mut depth = 0i32;
737 let mut cut = sig.len();
738 let bytes = sig.as_bytes();
739 for (i, c) in sig.char_indices() {
740 match c {
741 '(' | '[' => depth += 1,
742 ')' | ']' => depth -= 1,
743 '{' if depth <= 0 => {
744 cut = i;
745 break;
746 }
747 '=' if depth <= 0 && bytes.get(i + 1) == Some(&b'>') => {
748 let rest = sig[i + 2..].trim_start();
749 if !rest.starts_with('{') {
750 cut = i + 2;
751 break;
752 }
753 }
754 _ => {}
755 }
756 }
757 sig[..cut].trim_end().to_string()
758 }
759 }
760}
761
762fn normalize_signature(sig: &str) -> String {
768 let collapsed: Vec<&str> = sig
769 .trim()
770 .trim_end_matches(';')
771 .split_whitespace()
772 .collect();
773 let joined = collapsed.join(" ").replace('"', "'");
774 let is_punct = |c: char| "()[]{},;:=<>|&?!-+*/.".contains(c);
775 let mut out = String::with_capacity(joined.len());
776 let chars: Vec<char> = joined.chars().collect();
777 for (i, &c) in chars.iter().enumerate() {
778 if c == ' ' {
779 let before = chars[..i].iter().rev().find(|x| **x != ' ').copied();
780 let after = chars[i + 1..].iter().find(|x| **x != ' ').copied();
781 if before.is_some_and(is_punct) || after.is_some_and(is_punct) {
782 continue;
783 }
784 }
785 out.push(c);
786 }
787 let out = out
790 .replace(",)", ")")
791 .replace(",]", "]")
792 .replace(",}", "}")
793 .replace(",>", ">");
794 let out = out.trim_end_matches(',').to_string();
795 let out = out.replace("=|", "=");
797 let out = out.trim_end_matches('{').trim_end().to_string();
799 static QUOTED_KEY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
803 static ARROW_PARENS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
804 let quoted_key =
805 QUOTED_KEY.get_or_init(|| regex::Regex::new(r"^'([A-Za-z_$][\w$]*)':").unwrap());
806 let arrow_parens =
807 ARROW_PARENS.get_or_init(|| regex::Regex::new(r"\(([A-Za-z_$][\w$]*)\)=>").unwrap());
808 let out = quoted_key.replace(&out, "$1:").into_owned();
809 let out = arrow_parens.replace_all(&out, "$1=>").into_owned();
810 static SCALAR_PROPERTY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
813 let scalar = SCALAR_PROPERTY
814 .get_or_init(|| regex::Regex::new(r#"^([A-Za-z_$][\w$]*:)(?:'|`|-?\d)"#).unwrap());
815 let out = match scalar.captures(&out) {
816 Some(caps) => caps[1].to_string(),
817 None => out,
818 };
819 if out.starts_with("from ") || out.starts_with("import ") {
820 return out
821 .replace('(', " ")
822 .replace(')', "")
823 .split_whitespace()
824 .collect::<Vec<_>>()
825 .join(" ");
826 }
827 out
828}
829
830fn cut_value_binding(line: &str) -> Option<String> {
836 static BINDING: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
837 static DEFAULT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
838 let binding = BINDING.get_or_init(|| {
839 regex::Regex::new(
840 r"^((?:(?:export\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:(?:static|readonly|private|public|protected|declare|override|const|let|var)\s+)*(?:[A-Za-z_$][\w$]*|\{[^}]*\}|\[[^\]]*\])(?:\s*:\s*[^=]+?)?|(?:module\.)?exports(?:\.[A-Za-z_$][\w$]*)?)\s*=)\s*(.*)$",
841 )
842 .unwrap()
843 });
844 let default =
845 DEFAULT.get_or_init(|| regex::Regex::new(r"^(export\s+default)\s+(.*)$").unwrap());
846 let function_like = |value: &str| {
847 static ARROW: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
848 let arrow = ARROW
849 .get_or_init(|| regex::Regex::new(r"^(?:async\s+)?[A-Za-z_$][\w$]*\s*=>").unwrap());
850 value.starts_with('(')
851 || value.starts_with("async ")
852 || value.starts_with("async(")
853 || value.starts_with("function")
854 || value.starts_with("class")
855 || arrow.is_match(value)
856 };
857 if let Some(caps) = binding.captures(line) {
858 let value = caps[2].trim();
859 if value.starts_with('>') {
862 return None;
863 }
864 let module_export = caps[1].starts_with("exports") || caps[1].starts_with("module.exports");
867 if function_like(value) || (module_export && value.starts_with('{')) {
870 return None;
871 }
872 return Some(caps[1].to_string());
873 }
874 if let Some(caps) = default.captures(line) {
875 let value = caps[2].trim();
876 if value.is_empty() || value.starts_with('{') || function_like(value) {
877 return None;
878 }
879 return Some(caps[1].to_string());
880 }
881 None
882}
883
884fn angle_balance(s: &str) -> i32 {
885 let s = s.replace("->", " ").replace("=>", " ");
887 s.chars()
888 .map(|c| match c {
889 '<' => 1,
890 '>' => -1,
891 _ => 0,
892 })
893 .sum::<i32>()
894 .max(0)
895}
896
897fn bracket_balance(s: &str) -> i32 {
898 s.chars()
899 .map(|c| match c {
900 '[' => 1,
901 ']' => -1,
902 _ => 0,
903 })
904 .sum()
905}
906
907fn opens_destructure(line: &str) -> bool {
910 static DESTRUCTURE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
911 let re = DESTRUCTURE
912 .get_or_init(|| regex::Regex::new(r"^(?:export\s+)?(?:const|let|var)\s+[\{\[]").unwrap());
913 re.is_match(line) && brace_delta(line) + bracket_balance(line) > 0
914}
915
916fn is_list_declaration(sig: &str) -> bool {
919 sig.starts_with("import ")
920 || sig.starts_with("use ")
921 || sig.starts_with("export {")
922 || sig.starts_with("export type {")
923}
924
925fn declaration_lines(stripped: &str, family: Family) -> String {
926 let lines: Vec<&str> = stripped.lines().collect();
927 let mut out: Vec<String> = Vec::new();
928 let mut depth: i32 = 0;
929 let mut body_skip: Option<(i32, i32, i32)> = None;
933 let mut i = 0;
934 while i < lines.len() {
935 let raw = lines[i];
936 let line = raw.trim();
937 if line.is_empty() {
938 i += 1;
939 continue;
940 }
941 if let Some((braces, brackets, parens)) = body_skip {
942 let braces = braces + brace_delta(raw);
943 let brackets = brackets + bracket_balance(raw);
944 let parens = parens + paren_balance(raw);
945 depth += brace_delta(raw);
946 body_skip = if braces <= 0 && brackets <= 0 && parens <= 0 {
947 None
948 } else {
949 Some((braces, brackets, parens))
950 };
951 i += 1;
952 continue;
953 }
954 let indent = raw.len() - raw.trim_start().len();
955 let next_opens_body = lines[i + 1..]
956 .iter()
957 .map(|l| l.trim())
958 .find(|l| !l.is_empty())
959 .is_some_and(|l| l.starts_with('{'));
960 let keep = match family {
961 Family::Python => python_keeps(line, indent),
962 _ => c_like_keeps(line, depth, next_opens_body, family != Family::Rust),
963 };
964 if keep
965 && family == Family::Python
966 && indent == 0
967 && let Some(eq) = python_constant_cut(line)
968 {
969 out.push(normalize_signature(&line[..eq]));
971 i += 1;
972 continue;
973 }
974 let mut binding_end = i;
978 let destructured = if keep && family != Family::Python && opens_destructure(line) {
979 let mut joined = line.to_string();
980 while brace_delta(&joined) + bracket_balance(&joined) > 0
981 && binding_end + 1 < lines.len()
982 && binding_end - i < 60
983 {
984 binding_end += 1;
985 if lines[binding_end].trim().is_empty() {
986 continue;
987 }
988 joined.push(' ');
989 joined.push_str(lines[binding_end].trim());
990 }
991 Some(joined)
992 } else {
993 None
994 };
995 let binding_line = destructured.as_deref().unwrap_or(line);
996 if keep
997 && family != Family::Python
998 && !(depth >= 1 && enum_member_re().is_match(line))
999 && let Some(cut) = cut_value_binding(binding_line)
1000 {
1001 out.push(normalize_signature(&cut));
1006 let span = &lines[i..=binding_end];
1007 let mut opened = (
1008 span.iter().map(|l| brace_delta(l)).sum::<i32>(),
1009 span.iter().map(|l| bracket_balance(l)).sum::<i32>(),
1010 span.iter().map(|l| paren_balance(l)).sum::<i32>(),
1011 );
1012 depth += opened.0;
1013 i = binding_end + 1;
1014 if binding_line.trim_end().ends_with('=') {
1015 while i < lines.len() && lines[i].trim().is_empty() {
1018 i += 1;
1019 }
1020 if i < lines.len() {
1021 let v = lines[i];
1022 opened = (
1023 opened.0 + brace_delta(v),
1024 opened.1 + bracket_balance(v),
1025 opened.2 + paren_balance(v),
1026 );
1027 depth += brace_delta(v);
1028 i += 1;
1029 }
1030 }
1031 if opened.0 > 0 || opened.1 > 0 || opened.2 > 0 {
1032 body_skip = Some(opened);
1033 }
1034 continue;
1035 }
1036 if keep {
1037 let mut sig = line.to_string();
1042 let mut j = i;
1043 let c_like = family != Family::Python;
1044 let next_is_operator_led = |k: usize| {
1048 c_like
1049 && lines[k + 1..]
1050 .iter()
1051 .map(|l| l.trim())
1052 .find(|l| !l.is_empty())
1053 .is_some_and(|l| {
1054 l.starts_with('|')
1055 || l.starts_with('&')
1056 || l.starts_with('?')
1057 || l.starts_with(':')
1058 || l.starts_with('.')
1059 || l.starts_with('+')
1060 })
1061 };
1062 let ends_open = |s: &str| {
1068 let t = s.trim_end();
1069 c_like && (t.ends_with('=') || t.ends_with(':'))
1070 };
1071 while (!sig.trim_end().ends_with('{') || is_list_declaration(&sig))
1072 && (paren_balance(&sig) > 0
1073 || bracket_balance(&sig) > 0
1074 || (c_like && angle_balance(&sig) > 0)
1075 || (is_list_declaration(&sig) && brace_delta(&sig) > 0)
1076 || ends_open(&sig)
1077 || next_is_operator_led(j))
1078 && j + 1 < lines.len()
1079 && j - i < 60
1080 {
1081 j += 1;
1082 if lines[j].trim().is_empty() {
1083 continue;
1084 }
1085 sig.push(' ');
1086 sig.push_str(lines[j].trim());
1087 }
1088 let opens_body = sig.trim_end().ends_with('{')
1094 || sig.contains("=>")
1095 || lines[j + 1..]
1096 .iter()
1097 .map(|l| l.trim())
1098 .find(|l| !l.is_empty())
1099 .is_some_and(|l| l.starts_with('{'));
1100 if c_like && member_signature_only(&sig, depth) && !opens_body {
1101 for l in &lines[i..=j] {
1102 depth += brace_delta(l);
1103 }
1104 i = j + 1;
1105 continue;
1106 }
1107 out.push(normalize_signature(&cut_at_body(&sig, family)));
1108 if c_like {
1109 for l in &lines[i..=j] {
1110 depth += brace_delta(l);
1111 }
1112 }
1113 i = j + 1;
1114 } else {
1115 if family != Family::Python {
1116 depth += brace_delta(raw);
1117 }
1118 i += 1;
1119 }
1120 }
1121 out.join("\n")
1122}
1123
1124pub fn load_bearing_sections(
1133 type_def: &memstead_schema::types::TypeDefinition,
1134) -> Vec<&memstead_schema::types::SectionDef> {
1135 let explicit: Vec<_> = type_def
1136 .sections
1137 .iter()
1138 .filter(|s| s.load_bearing == Some(true))
1139 .collect();
1140 if !explicit.is_empty() {
1141 return explicit;
1142 }
1143 let required: Vec<_> = type_def
1144 .sections
1145 .iter()
1146 .filter(|s| s.required && s.load_bearing != Some(false))
1147 .collect();
1148 if !required.is_empty() {
1149 return required;
1150 }
1151 type_def.sections.iter().collect()
1152}
1153
1154pub fn entity_load_bearing_form(
1165 entity: &Entity,
1166 type_def: Option<&memstead_schema::types::TypeDefinition>,
1167) -> String {
1168 fn push(out: &mut String, key: &str, content: &str) {
1169 out.push_str("## ");
1170 out.push_str(key);
1171 out.push_str("\n\n");
1172 out.push_str(content.trim());
1173 out.push_str("\n\n");
1174 }
1175 let mut out = String::new();
1176 match type_def {
1177 Some(td) => {
1178 for section in load_bearing_sections(td) {
1179 if let Some(content) = entity.sections.get(§ion.key) {
1180 push(&mut out, §ion.key, content);
1181 }
1182 }
1183 }
1184 None => {
1185 for (key, content) in &entity.sections {
1186 push(&mut out, key, content);
1187 }
1188 }
1189 }
1190 out
1191}
1192
1193pub fn entity_prepared_hash(
1202 entity: &Entity,
1203 type_def: Option<&memstead_schema::types::TypeDefinition>,
1204 preparation: Option<&str>,
1205) -> Option<String> {
1206 let form = match preparation {
1207 None => crate::render::render_entity_markdown(entity, None),
1208 Some(ENTITY_LOAD_BEARING) => entity_load_bearing_form(entity, type_def),
1209 Some(_) => return None,
1210 };
1211 Some(prepared_content_hash(form.as_bytes()))
1212}
1213
1214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220pub struct DeliveryUnit {
1221 pub key: String,
1224 pub order_key: String,
1227 pub start_line: usize,
1229 pub end_line: usize,
1231 pub hash: String,
1234}
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1238#[serde(rename_all = "lowercase")]
1239pub enum UnitChange {
1240 Added,
1242 Modified,
1244 Deleted,
1246}
1247
1248pub fn unit_id(path: &str, key: &str) -> String {
1250 format!("{path}#{key}")
1251}
1252
1253pub fn split_unit_id(id: &str) -> (&str, Option<&str>) {
1256 match id.find('#') {
1257 Some(cut) => (&id[..cut], Some(&id[cut + 1..])),
1258 None => (id, None),
1259 }
1260}
1261
1262pub fn unitize(preparation: &str, content: &str) -> Option<Vec<DeliveryUnit>> {
1266 match preparation {
1267 DATED_ENTRIES => Some(dated_entries(content)),
1268 _ => None,
1269 }
1270}
1271
1272pub fn unit_text(content: &str, unit: &DeliveryUnit) -> String {
1274 content
1275 .lines()
1276 .skip(unit.start_line.saturating_sub(1))
1277 .take(unit.end_line + 1 - unit.start_line.max(1))
1278 .collect::<Vec<_>>()
1279 .join("\n")
1280}
1281
1282pub fn diff_units(
1288 before: &[DeliveryUnit],
1289 after: &[DeliveryUnit],
1290) -> Vec<(DeliveryUnit, UnitChange)> {
1291 let old: std::collections::BTreeMap<&str, &DeliveryUnit> =
1292 before.iter().map(|u| (u.key.as_str(), u)).collect();
1293 let new: std::collections::BTreeMap<&str, &DeliveryUnit> =
1294 after.iter().map(|u| (u.key.as_str(), u)).collect();
1295 let mut out = Vec::new();
1296 for u in after {
1297 match old.get(u.key.as_str()) {
1298 None => out.push((u.clone(), UnitChange::Added)),
1299 Some(prev) if prev.hash != u.hash => out.push((u.clone(), UnitChange::Modified)),
1300 Some(_) => {}
1301 }
1302 }
1303 for u in before {
1304 if !new.contains_key(u.key.as_str()) {
1305 out.push((u.clone(), UnitChange::Deleted));
1306 }
1307 }
1308 out
1309}
1310
1311fn dated_entries(content: &str) -> Vec<DeliveryUnit> {
1312 let lines: Vec<&str> = content.lines().collect();
1313 let starts: Vec<(usize, String)> = lines
1314 .iter()
1315 .enumerate()
1316 .filter_map(|(i, line)| leading_stamp(line).map(|stamp| (i, stamp)))
1317 .collect();
1318 if starts.is_empty() {
1319 return vec![DeliveryUnit {
1320 key: WHOLE_FILE_UNIT.to_string(),
1321 order_key: String::new(),
1322 start_line: 1,
1323 end_line: lines.len().max(1),
1324 hash: prepared_content_hash(content.as_bytes()),
1325 }];
1326 }
1327 let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1328 let mut units = Vec::with_capacity(starts.len());
1329 for (n, (start, stamp)) in starts.iter().enumerate() {
1330 let from = if n == 0 { 0 } else { *start };
1333 let to = starts.get(n + 1).map_or(lines.len(), |(next, _)| *next);
1334 let text = lines[from..to].join("\n");
1335 let count = seen
1336 .entry(stamp.clone())
1337 .and_modify(|c| *c += 1)
1338 .or_insert(1);
1339 let key = if *count == 1 {
1340 stamp.clone()
1341 } else {
1342 format!("{stamp}.{count}")
1343 };
1344 units.push(DeliveryUnit {
1345 key,
1346 order_key: stamp.clone(),
1347 start_line: from + 1,
1348 end_line: to,
1349 hash: prepared_content_hash(text.as_bytes()),
1350 });
1351 }
1352 units
1353}
1354
1355fn leading_stamp(line: &str) -> Option<String> {
1359 static STAMP: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1360 let re = STAMP.get_or_init(|| {
1361 regex::Regex::new(
1362 r"^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b",
1363 )
1364 .expect("the stamp regex compiles")
1365 });
1366 let s = line.trim_start_matches(|c: char| {
1367 c.is_whitespace() || matches!(c, '#' | '-' | '*' | '>' | '[' | '(' | '|' | '`' | '+')
1368 });
1369 let caps = re.captures(s)?;
1370 let num = |i: usize| -> u32 {
1371 caps.get(i)
1372 .map(|m| m.as_str().parse().unwrap_or(0))
1373 .unwrap_or(0)
1374 };
1375 let (y, mo, d, h, mi, sec) = (num(1), num(2), num(3), num(4), num(5), num(6));
1376 let days_in_month = match mo {
1377 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1378 4 | 6 | 9 | 11 => 30,
1379 2 => 29,
1380 _ => return None,
1381 };
1382 if !(1..=days_in_month).contains(&d) || h > 23 || mi > 59 || sec > 59 {
1383 return None;
1384 }
1385 Some(format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{sec:02}"))
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use crate::entity::EntityId;
1392 use indexmap::IndexMap;
1393 use memstead_schema::types::{SectionDef, TypeDefinition};
1394
1395 fn section(key: &str, required: bool, load_bearing: Option<bool>) -> SectionDef {
1396 let mut v = serde_json::json!({
1397 "key": key, "heading": key, "required": required, "search_weight": 1.0
1398 });
1399 if let Some(lb) = load_bearing {
1400 v["load_bearing"] = serde_json::json!(lb);
1401 }
1402 serde_json::from_value(v).unwrap()
1403 }
1404
1405 fn type_with(sections: Vec<SectionDef>) -> TypeDefinition {
1408 let schemas = memstead_schema::builtins::load_builtin_schemas().unwrap();
1409 let base = schemas
1410 .iter()
1411 .find_map(|s| s.get_type("assertion"))
1412 .expect("a builtin schema declares `assertion`");
1413 let mut td = (*base).clone();
1414 td.sections = sections;
1415 td
1416 }
1417
1418 fn entity(sections: &[(&str, &str)]) -> Entity {
1419 let mut map = IndexMap::new();
1420 for (k, v) in sections {
1421 map.insert(k.to_string(), v.to_string());
1422 }
1423 Entity {
1424 id: EntityId::canonical("m--e"),
1425 title: "E".into(),
1426 entity_type: "t".into(),
1427 mem: "m".into(),
1428 file_path: "e.md".into(),
1429 metadata: IndexMap::new(),
1430 sections: map,
1431 relationships: Vec::new(),
1432 content_hash: "h".into(),
1433 stub: false,
1434 stub_kind: None,
1435 heading_spans: Default::default(),
1436 raw_section_headings: Vec::new(),
1437 }
1438 }
1439
1440 #[test]
1441 fn registry_knows_its_three_flavours_and_nothing_else() {
1442 assert!(is_registered(ENTITY_LOAD_BEARING));
1443 assert!(is_registered(DATED_ENTRIES));
1444 assert!(is_registered(CODE_MAP));
1445 assert!(!is_registered("pdf-to-markdown"));
1446 assert!(!is_registered(""));
1447 assert_eq!(
1448 registered_identifiers(),
1449 vec![ENTITY_LOAD_BEARING, DATED_ENTRIES, CODE_MAP]
1450 );
1451 let c = lookup(CODE_MAP).unwrap();
1452 assert_eq!(c.touchpoint, Touchpoint::PreparedForm);
1453 assert!(applies_to_namespace(c, "path"));
1454 assert!(applies_to_namespace(c, "path+commit"));
1455 assert!(!applies_to_namespace(c, "entity"));
1456 assert!(!applies_to_namespace(c, "url"));
1457 assert!(delivery_preparation(Some(CODE_MAP)).is_none());
1458 let p = lookup(ENTITY_LOAD_BEARING).unwrap();
1459 assert_eq!(p.touchpoint, Touchpoint::PreparedForm);
1460 assert!(applies_to_namespace(p, "entity"));
1461 assert!(!applies_to_namespace(p, "path"));
1462 assert!(!applies_to_namespace(p, "url"));
1463 let d = lookup(DATED_ENTRIES).unwrap();
1464 assert_eq!(d.touchpoint, Touchpoint::DeliveryUnits);
1465 assert!(applies_to_namespace(d, "path"));
1466 assert!(applies_to_namespace(d, "path+commit"));
1467 assert!(!applies_to_namespace(d, "entity"));
1468 assert!(!applies_to_namespace(d, "url"));
1469 assert_eq!(
1471 delivery_preparation(Some(DATED_ENTRIES)).map(|p| p.id),
1472 Some(DATED_ENTRIES)
1473 );
1474 assert!(delivery_preparation(Some(ENTITY_LOAD_BEARING)).is_none());
1475 assert!(delivery_preparation(Some("pdf-to-markdown")).is_none());
1476 assert!(delivery_preparation(None).is_none());
1477 assert!(unitize(ENTITY_LOAD_BEARING, "x").is_none());
1478 assert!(unitize("pdf-to-markdown", "x").is_none());
1479 }
1480
1481 const LOG: &str = "# Ops log\n\nPreamble text.\n\n## 2026-08-24 10:05 boot\nline a\n\n\
1482 - 2026-08-24T10:05:00Z boot again\nline b\n2026-08-25 shutdown\nline c\n";
1483
1484 #[test]
1488 fn dated_entries_unitize_deterministically() {
1489 let units = unitize(DATED_ENTRIES, LOG).unwrap();
1490 let keys: Vec<&str> = units.iter().map(|u| u.key.as_str()).collect();
1491 assert_eq!(
1492 keys,
1493 vec![
1494 "2026-08-24T10:05:00",
1495 "2026-08-24T10:05:00.2",
1496 "2026-08-25T00:00:00"
1497 ]
1498 );
1499 assert_eq!(
1500 units[0].start_line, 1,
1501 "the preamble folds into the first unit"
1502 );
1503 assert_eq!((units[0].end_line, units[1].start_line), (7, 8));
1504 assert_eq!(units[2].end_line, 11);
1505 assert_eq!(units[1].order_key, "2026-08-24T10:05:00");
1506 assert!(unit_text(LOG, &units[2]).starts_with("2026-08-25 shutdown"));
1507 assert_eq!(
1508 units[2].hash,
1509 prepared_content_hash(unit_text(LOG, &units[2]).as_bytes())
1510 );
1511
1512 let whole = unitize(DATED_ENTRIES, "no stamps here\njust prose\n").unwrap();
1513 assert_eq!(whole.len(), 1);
1514 assert_eq!(whole[0].key, WHOLE_FILE_UNIT);
1515 assert_eq!(whole[0].order_key, "");
1516
1517 assert_eq!(
1518 leading_stamp("[2026-02-30] bad day"),
1519 None,
1520 "day out of range"
1521 );
1522 assert_eq!(
1523 leading_stamp("2026-08-24T25:00 x"),
1524 None,
1525 "hour out of range"
1526 );
1527 assert_eq!(leading_stamp("v2026-08-24"), None, "not at the line start");
1528 assert_eq!(leading_stamp("2026-08-2400"), None, "digits run on");
1529 assert_eq!(
1530 leading_stamp("> **2026-08-24T10:05:00.250+02:00** note").as_deref(),
1531 Some("2026-08-24T10:05:00")
1532 );
1533 assert_eq!(
1534 unit_id("logs/ops.md", "2026-08-25T00:00:00"),
1535 "logs/ops.md#2026-08-25T00:00:00"
1536 );
1537 assert_eq!(
1538 split_unit_id("logs/ops.md#2026-08-25T00:00:00"),
1539 ("logs/ops.md", Some("2026-08-25T00:00:00"))
1540 );
1541 assert_eq!(split_unit_id("logs/ops.md"), ("logs/ops.md", None));
1542 }
1543
1544 const JS: &str = "// Auth module\nimport axios from 'axios'\nimport { t } from '@/i18n'\n\n/* block\n comment */\nconst RETRIES = 3\n\nexport default {\n name: 'Auth',\n props: ['user'],\n data() {\n return { token: null, busy: false }\n },\n methods: {\n async login(user, password) {\n // body\n const r = await axios.post('/login', { user, password })\n return r.data\n },\n logout() {\n this.token = null\n }\n }\n}\n\nexport function helper(a, b) {\n return a + b\n}\n\nexport const LIMIT = { max: 10 }\n";
1545
1546 #[test]
1551 fn code_map_digest_sees_interfaces_not_bodies() {
1552 let digest = code_map_digest("src/auth.js", JS);
1553 assert_eq!(
1554 digest,
1555 "import axios from 'axios'\nimport{t}from '@/i18n'\nconst RETRIES=\n\
1556 export default\nname:\nprops:['user']\ndata()\nmethods:\n\
1557 async login(user,password)\nlogout()\nexport function helper(a,b)\n\
1558 export const LIMIT="
1559 );
1560 let value_forms = [
1565 "export const base = cfg.API ? cfg.API : 'x'\n",
1566 "export const base = cfg.API\n ? cfg.API\n : 'x'\n",
1567 "export const base =\n 'a' +\n 'b'\n",
1568 "export const base = new Client({\n region: 'eu',\n retries: 3,\n})\n",
1569 "export const base = axios\n .create(cfg)\n .interceptors\n",
1570 ];
1571 let cut: Vec<String> = value_forms
1572 .iter()
1573 .map(|t| code_map_digest("cfg.js", t))
1574 .collect();
1575 assert!(cut.iter().all(|d| d == "export const base="), "{cut:?}");
1576 assert_eq!(
1577 code_map_digest(
1578 "s.js",
1579 "const store = new Vuex.Store({\n state: { n: 1 },\n mutations: {\n inc(s) { s.n += 1 }\n }\n})\n"
1580 ),
1581 code_map_digest(
1582 "s.js",
1583 "const store = new Vuex.Store({\n state: { n: 1 },\n mutations: {\n inc(s) { s.n += 2 }\n }\n})\n"
1584 )
1585 );
1586 assert_eq!(
1587 code_map_digest("d.js", "export default new Vuetify({\n theme: 'x',\n})\n"),
1588 "export default"
1589 );
1590 assert_eq!(
1591 code_map_digest("f.js", "const f = x => x.id\n"),
1592 code_map_digest("f.js", "const f = (x) => x.id\n")
1593 );
1594 assert_eq!(
1595 code_map_digest("f.js", "const f = (x) => x.id\n"),
1596 "const f=x=>"
1597 );
1598 assert_eq!(
1599 code_map_digest(
1600 "f.js",
1601 "export const g = async (a, b) => {\n return a\n}\n"
1602 ),
1603 "export const g=async(a,b)=>"
1604 );
1605 let knr = "class S {\n login(user, password) {\n return 1\n }\n logout() {\n }\n}\n";
1607 let allman = "class S\n{\n login(user, password)\n {\n return 1\n }\n logout()\n {\n }\n}\n";
1608 assert_eq!(
1609 code_map_digest("s.js", knr),
1610 "class S\nlogin(user,password)\nlogout()"
1611 );
1612 assert_eq!(
1613 code_map_digest("s.js", allman),
1614 code_map_digest("s.js", knr)
1615 );
1616 let same = |a: &str, b: &str, why: &str| {
1622 assert_eq!(
1623 code_map_digest("w.js", a),
1624 code_map_digest("w.js", b),
1625 "{why}"
1626 );
1627 };
1628 same(
1629 "export const pick = state => state.items.filter(i => i.active).map(i => i.id)\n",
1630 "export const pick = state =>\n state.items\n .filter(i => i.active)\n .map(i => i.id)\n",
1631 "arrow expression body wrapped",
1632 );
1633 assert_eq!(
1634 code_map_digest("w.js", "export const pick = (state) => state.items\n"),
1635 "export const pick=state=>"
1636 );
1637 same(
1638 "export default {\n select: state => state.items.filter(i => i.active),\n}\n",
1639 "export default {\n select: state =>\n state.items.filter(i => i.active),\n}\n",
1640 "property arrow body wrapped",
1641 );
1642 same(
1643 "module.exports = {\n validate: (v) => {\n return v\n },\n}\n",
1644 "module.exports = {\n validate: v => {\n return v\n },\n}\n",
1645 "arrowParens on a property arrow",
1646 );
1647 assert_eq!(
1648 code_map_digest(
1649 "w.js",
1650 "module.exports = {\n validate: v => {\n return v\n },\n}\n"
1651 ),
1652 "module.exports=\nvalidate:v=>"
1653 );
1654 same(
1655 "export function setup(app) {\n registerPlugin(app, options, extra)\n}\n",
1656 "export function setup(app) {\n registerPlugin(\n app,\n options,\n extra\n )\n}\n",
1657 "wrapped call statement in a function body",
1658 );
1659 assert_eq!(
1660 code_map_digest(
1661 "w.js",
1662 "export function setup(app) {\n registerPlugin(\n app,\n options,\n extra\n )\n}\n"
1663 ),
1664 "export function setup(app)"
1665 );
1666 same(
1667 "class S {\n run() {\n helper(a, b, c)\n }\n}\n",
1668 "class S {\n run() {\n helper(\n a,\n b,\n c\n )\n }\n}\n",
1669 "wrapped call in a class method body",
1670 );
1671 assert_eq!(
1672 code_map_digest(
1673 "s.rs",
1674 "impl S {\n pub fn run(&self) {\n helper(\n a,\n b,\n )\n }\n}\n"
1675 ),
1676 code_map_digest(
1677 "s.rs",
1678 "impl S {\n pub fn run(&self) {\n helper(a, b)\n }\n}\n"
1679 )
1680 );
1681 same(
1682 "exports.base = cfg.API ? cfg.API : 'http://localhost'\n",
1683 "exports.base = cfg.API\n ? cfg.API\n : 'http://localhost'\n",
1684 "exports ternary wrapped",
1685 );
1686 same(
1687 "module.exports = mongoose.model('User', schema).plugin(paginate)\n",
1688 "module.exports = mongoose\n .model('User', schema)\n .plugin(paginate)\n",
1689 "module.exports chain wrapped",
1690 );
1691 assert_eq!(
1692 code_map_digest(
1693 "w.js",
1694 "exports.TIMEOUT = compute(\n settings,\n defaults\n)\n"
1695 ),
1696 "exports.TIMEOUT="
1697 );
1698 assert_eq!(
1699 code_map_digest(
1700 "t.ts",
1701 "export type Mode = 'discovery' | 'sync' | 'verify'\n"
1702 ),
1703 code_map_digest(
1704 "t.ts",
1705 "export type Mode =\n | 'discovery'\n | 'sync'\n | 'verify'\n"
1706 )
1707 );
1708 assert_ne!(
1709 code_map_digest("t.ts", "export type Mode = 'discovery' | 'sync'\n"),
1710 code_map_digest(
1711 "t.ts",
1712 "export type Mode = 'discovery' | 'sync' | 'verify'\n"
1713 ),
1714 "a union member is interface"
1715 );
1716 assert_eq!(
1717 code_map_digest(
1718 "g.rs",
1719 "pub fn all(&self) -> Result<Vec<String>, Error> {\n todo!()\n}\n"
1720 ),
1721 code_map_digest(
1722 "g.rs",
1723 "pub fn all(\n &self,\n) -> Result<\n Vec<String>,\n Error,\n> {\n todo!()\n}\n"
1724 )
1725 );
1726 assert_eq!(
1731 code_map_digest(
1732 "c.rs",
1733 "pub const DESCRIPTION: &str =\n \"a long description\";\n"
1734 ),
1735 code_map_digest(
1736 "c.rs",
1737 "pub const DESCRIPTION: &str = \"a long description\";\n"
1738 )
1739 );
1740 assert_eq!(
1741 code_map_digest("c.rs", "pub const DESCRIPTION: &str = \"x\";\n"),
1742 "pub const DESCRIPTION:&str="
1743 );
1744 assert_eq!(
1745 code_map_digest(
1746 "f.rs",
1747 "pub struct H {\n pub handler:\n Box<dyn Fn(&str) -> Result<(), Error> + Send>,\n}\n"
1748 ),
1749 code_map_digest(
1750 "f.rs",
1751 "pub struct H {\n pub handler: Box<dyn Fn(&str) -> Result<(), Error> + Send>,\n}\n"
1752 )
1753 );
1754 assert_eq!(
1755 code_map_digest(
1756 "t.rs",
1757 "pub type Handler =\n Box<dyn Fn(&str) -> Result<(), Error>>;\n"
1758 ),
1759 code_map_digest(
1760 "t.rs",
1761 "pub type Handler = Box<dyn Fn(&str) -> Result<(), Error>>;\n"
1762 )
1763 );
1764 assert!(code_map_digest("t.rs", "pub type Handler =\n Box<X>;\n").contains("Box<X>"));
1765 same(
1766 "class Api {\n static url = 'a' + 'b';\n private readonly base = x || 'y';\n}\n",
1767 "class Api {\n static url =\n 'a' +\n 'b';\n private readonly base =\n x || 'y';\n}\n",
1768 "class fields wrapped after =",
1769 );
1770 assert_eq!(
1771 code_map_digest("w.js", "class Api {\n static url = 'a';\n}\n"),
1772 "class Api\nstatic url="
1773 );
1774 same(
1775 "export default {\n message: 'a' + 'b',\n data() {\n return {}\n },\n}\n",
1776 "export default {\n message:\n 'a' +\n 'b',\n data() {\n return {}\n },\n}\n",
1777 "bare key wrapped away from its value",
1778 );
1779 assert!(
1780 code_map_digest(
1781 "w.js",
1782 "export default {\n message:\n 'a' +\n 'b',\n}\n"
1783 )
1784 .contains("message:")
1785 );
1786 same(
1787 "it('logs in', async () => {\n const r = await login()\n expect(r).toBe(1)\n})\n",
1788 "it('logs in', async () => {\n const r = await login();\n expect(r).toBe(2);\n});\n",
1789 "a callback body is body",
1790 );
1791 same(
1792 "export function setup(app) {\n setTimeout(() => {\n app.start(1)\n }, 10)\n}\n",
1793 "export function setup(app) {\n setTimeout(() => {\n app.start(2)\n }, 10)\n}\n",
1794 "a callback body inside a function body",
1795 );
1796 same(
1797 "export default {\n created() {\n setTimeout(() => {\n this.a = 1\n }, 5)\n },\n}\n",
1798 "export default {\n created() {\n setTimeout(() => {\n this.a = 2\n }, 5)\n },\n}\n",
1799 "a callback body inside a member body",
1800 );
1801 assert_eq!(
1802 code_map_digest(
1803 "r.js",
1804 "export const routes = [\n { path: '/', meta: { auth: true } },\n { path: '/x' },\n]\n"
1805 ),
1806 "export const routes="
1807 );
1808 let api = "export interface Api {\n name: string\n load(id: string): Promise<void>\n}\n";
1810 assert_eq!(
1811 code_map_digest("a.ts", api),
1812 "export interface Api\nname:string\nload(id:string):Promise<void>"
1813 );
1814 assert_ne!(
1815 code_map_digest("a.ts", api),
1816 code_map_digest("a.ts", &api.replace("name: string", "name: number"))
1817 );
1818 assert_ne!(
1819 code_map_digest("a.ts", api),
1820 code_map_digest(
1821 "a.ts",
1822 &api.replace("load(id: string)", "load(id: string, force: boolean)")
1823 )
1824 );
1825 let color = "export enum Color {\n Red,\n Green = 2,\n}\n";
1826 assert_eq!(
1827 code_map_digest("e.ts", color),
1828 "export enum Color\nRed\nGreen=2"
1829 );
1830 assert_ne!(
1831 code_map_digest("e.ts", color),
1832 code_map_digest("e.ts", &color.replace("Green = 2,", "Green = 2,\n Blue,"))
1833 );
1834 assert_eq!(
1835 code_map_digest("q.js", "const { a, b } = require('./x')\n"),
1836 "const{a,b}="
1837 );
1838 assert_ne!(
1839 code_map_digest("q.js", "const { a, b } = require('./x')\n"),
1840 code_map_digest("q.js", "const { a, c } = require('./x')\n")
1841 );
1842 let wrapped_api = "export interface Api {\n name: string\n load(\n id: string,\n force: boolean,\n ): Promise<void>\n}\n";
1845 assert_eq!(
1846 code_map_digest("a.ts", wrapped_api),
1847 code_map_digest(
1848 "a.ts",
1849 "export interface Api {\n name: string\n load(id: string, force: boolean): Promise<void>\n}\n"
1850 )
1851 );
1852 assert_ne!(
1853 code_map_digest("a.ts", wrapped_api),
1854 code_map_digest(
1855 "a.ts",
1856 &wrapped_api.replace(
1857 "force: boolean,\n",
1858 "force: boolean,\n options: LoadOptions,\n"
1859 )
1860 )
1861 );
1862 let wrapped_require = "const {\n a,\n b,\n} = require('./x')\n";
1863 assert_eq!(code_map_digest("q.js", wrapped_require), "const{a,b}=");
1864 assert_ne!(
1865 code_map_digest("q.js", wrapped_require),
1866 code_map_digest("q.js", &wrapped_require.replace(" b,\n", " c,\n"))
1867 );
1868 assert_eq!(
1869 code_map_digest("q.js", "export const [\n first,\n second,\n] = pair()\n"),
1870 "export const[first,second]="
1871 );
1872 assert_eq!(
1873 code_map_digest(
1874 "o.js",
1875 "export default {\n 'name': 'X',\n props: ['a'],\n}\n"
1876 ),
1877 code_map_digest(
1878 "o.js",
1879 "export default {\n name: 'X',\n props: ['a'],\n}\n"
1880 )
1881 );
1882 let h = |text: &str| prepared_content_hash(code_map_digest("src/auth.js", text).as_bytes());
1883 let base = h(JS);
1884 assert_eq!(h(&JS.replace("// body", "// rewritten comment")), base);
1886 assert_eq!(h(&JS.replace(" return a + b", " return a+b")), base);
1887 assert_eq!(h(&JS.replace("/login", "/session")), base);
1888 assert_eq!(h(&JS.replace("return r.data", "return r.data.user")), base);
1889 assert_eq!(
1890 h(&JS.replace("max: 10", "max: 20")),
1891 base,
1892 "a value is body"
1893 );
1894 assert_eq!(
1898 h(&JS.replace("login(user, password)", "login(user,password)")),
1899 base
1900 );
1901 assert_eq!(
1902 h(&JS.replace(
1903 "login(user, password)",
1904 "login(\n user,\n password\n )"
1905 )),
1906 base
1907 );
1908 assert_eq!(
1909 h(&JS.replace("import axios from 'axios'", "import axios from \"axios\";")),
1910 base
1911 );
1912 assert_eq!(
1913 h(&JS.replace("helper(a, b)", "helper (a /* first */, b)")),
1914 base
1915 );
1916 assert_eq!(
1917 h(&JS.replace("export const LIMIT = {", "export const LIMIT={")),
1918 base
1919 );
1920 assert_eq!(
1924 h(&JS.replace(
1925 "import { t } from '@/i18n'",
1926 "import {\n t,\n} from '@/i18n'"
1927 )),
1928 base
1929 );
1930 assert_eq!(h(&JS.replace("props: ['user'],", "props: ['user']")), base);
1931 assert_eq!(
1932 h(&JS.replace("props: ['user'],", "props: [\n 'user',\n ],")),
1933 base
1934 );
1935 assert_eq!(
1936 h(&JS.replace(
1937 "login(user, password)",
1938 "login(\n user,\n password,\n )"
1939 )),
1940 base
1941 );
1942 assert_eq!(
1943 h(&JS.replace("name: 'Auth',", "name: 'Login',")),
1944 base,
1945 "a scalar value is body"
1946 );
1947 assert_ne!(
1949 h(&JS.replace(
1950 "import { t } from '@/i18n'",
1951 "import {\n t,\n n,\n} from '@/i18n'"
1952 )),
1953 base
1954 );
1955 assert_eq!(
1956 code_map_digest("x.js", "export {\n a,\n b,\n} from './x'\n"),
1957 code_map_digest("x.js", "export { a, b } from './x'\n")
1958 );
1959 assert_ne!(
1961 h(&JS.replace("login(user, password)", "login(user, password, remember)")),
1962 base
1963 );
1964 assert_ne!(
1965 h(&JS.replace("export function helper", "function helper")),
1966 base
1967 );
1968 assert_ne!(h(&JS.replace("import axios from 'axios'\n", "")), base);
1969 assert_ne!(
1970 h(&JS.replace("props: ['user']", "props: ['user', 'tenant']")),
1971 base
1972 );
1973 let vue = format!(
1976 "<template>\n <div @click=\"login\">{{{{ t('hi') }}}}</div>\n</template>\n\n<script>\n{JS}</script>\n\n<style scoped>\n.a {{ color: red }}\n</style>\n"
1977 );
1978 assert_eq!(code_map_digest("src/Auth.vue", &vue), digest);
1979 assert_eq!(
1980 code_map_digest("src/Auth.vue", &vue.replace("color: red", "color: blue")),
1981 digest
1982 );
1983 assert_eq!(
1985 code_map_digest("README.md", "# hi\n\ntext\n"),
1986 "# hi\n\ntext\n"
1987 );
1988 assert_eq!(
1989 code_map_digest(
1990 "package.json",
1991 "{\n \"name\": \"x\",\n \"version\": \"1\"\n}\n"
1992 ),
1993 code_map_digest("package.json", "{\"name\":\"x\",\"version\":\"1\"}")
1994 );
1995 }
1996
1997 const PY: &str = "# -*- coding: utf-8 -*-\nimport os\nfrom typing import List\n\nTIMEOUT = 30 # seconds\n\n\
1998 def load(path: str, *, strict: bool = False) -> List[str]:\n \"\"\"Docstring.\"\"\"\n with open(path) as f:\n return f.readlines()\n\n\
1999 class Loader:\n retries = 3\n\n @property\n def name(self):\n return 'x'\n\n def run(self,\n arg):\n def inner():\n pass\n return arg\n";
2000
2001 #[test]
2002 fn code_map_digest_python_and_rust() {
2003 assert_eq!(
2004 code_map_digest("pivot.py", PY),
2005 "import os\nfrom typing import List\nTIMEOUT=\n\
2006 def load(path:str,*,strict:bool=False)->List[str]\nclass Loader\n\
2007 @property\ndef name(self)\ndef run(self,arg)"
2008 );
2009 assert_eq!(
2010 code_map_digest(
2011 "pivot.py",
2012 &PY.replace(
2013 "def run(self,\n arg):",
2014 "def run(\n self,\n arg,\n ):"
2015 )
2016 ),
2017 code_map_digest("pivot.py", PY),
2018 "a formatter's trailing comma in a wrapped def is invisible"
2019 );
2020 assert_eq!(
2021 code_map_digest("i.py", "from typing import (\n Dict,\n List,\n)\n"),
2022 code_map_digest("i.py", "from typing import Dict, List\n"),
2023 "black's parenthesized import list is formatting"
2024 );
2025 let h = |t: &str| prepared_content_hash(code_map_digest("pivot.py", t).as_bytes());
2026 assert_eq!(
2027 h(PY),
2028 h(&PY.replace("return f.readlines()", "return list(f)"))
2029 );
2030 assert_eq!(h(PY), h(&PY.replace("Docstring.", "Another docstring.")));
2031 assert_ne!(
2032 h(PY),
2033 h(&PY.replace("def run(self,", "def run(self, extra,"))
2034 );
2035
2036 let rs = "//! Module docs\nuse std::fmt;\n\n/// A thing.\n#[derive(Debug)]\npub struct Thing {\n pub id: u32,\n secret: String,\n}\n\nimpl Thing {\n pub fn new(id: u32) -> Self {\n Self { id, secret: String::new() }\n }\n fn hidden(&self) {}\n}\n";
2037 assert_eq!(
2038 code_map_digest("src/thing.rs", rs),
2039 "use std::fmt\n#[derive(Debug)]\npub struct Thing\npub id:u32\nimpl Thing\n\
2040 pub fn new(id:u32)->Self\nfn hidden(&self)"
2041 );
2042 }
2043
2044 #[test]
2049 fn plain_tree_digest_is_sorted_and_content_sensitive() {
2050 let files = vec![
2051 ("src/b.rs".to_string(), b"fn b() {}\n".to_vec()),
2052 ("src/a.rs".to_string(), b"fn a() {}\n".to_vec()),
2053 ];
2054 let base = plain_tree_digest(&files);
2055 assert!(
2056 base.starts_with(&format!(
2057 "{} src/a.rs\n",
2058 prepared_content_hash(b"fn a() {}\n")
2059 )),
2060 "rows sort by path regardless of input order"
2061 );
2062 let reordered = vec![files[1].clone(), files[0].clone()];
2063 assert_eq!(plain_tree_digest(&reordered), base);
2064 let body_edit = vec![
2065 files[0].clone(),
2066 ("src/a.rs".to_string(), b"fn a() { /* edit */ }\n".to_vec()),
2067 ];
2068 assert_ne!(
2069 plain_tree_digest(&body_edit),
2070 base,
2071 "any byte change moves the plain digest (unlike the code map)"
2072 );
2073 let mut joined = files.clone();
2074 joined.push(("src/c.rs".to_string(), b"fn c() {}\n".to_vec()));
2075 assert_ne!(plain_tree_digest(&joined), base, "a joining file moves it");
2076 let left = vec![files[0].clone()];
2077 assert_ne!(plain_tree_digest(&left), base, "a leaving file moves it");
2078 }
2079
2080 #[test]
2084 fn code_map_tree_digest_and_path_rule() {
2085 let files = vec![
2086 ("src/b.js".to_string(), "export const B = 1\n".to_string()),
2087 ("src/a.js".to_string(), JS.to_string()),
2088 ];
2089 let base = code_map_tree_digest(&files);
2090 assert!(base.starts_with(&format!(
2091 "{} src/a.js\n",
2092 prepared_content_hash(code_map_digest("src/a.js", JS).as_bytes())
2093 )));
2094 let body_edit = vec![
2095 files[0].clone(),
2096 ("src/a.js".to_string(), JS.replace("/login", "/session")),
2097 ];
2098 assert_eq!(
2099 code_map_tree_digest(&body_edit),
2100 base,
2101 "a body edit leaves the tree map"
2102 );
2103 let sig_edit = vec![
2104 files[0].clone(),
2105 (
2106 "src/a.js".to_string(),
2107 JS.replace("logout()", "logout(everywhere)"),
2108 ),
2109 ];
2110 assert_ne!(code_map_tree_digest(&sig_edit), base);
2111 let mut joined = files.clone();
2112 joined.push(("src/c.js".to_string(), "export const C = 1\n".to_string()));
2113 assert_ne!(code_map_tree_digest(&joined), base);
2114
2115 let digest_hash = prepared_content_hash(code_map_digest("src/a.js", JS).as_bytes());
2116 assert_eq!(
2117 path_prepared_hash(Some(CODE_MAP), "src/a.js", AnchorGrain::File, JS.as_bytes()),
2118 PathPrepared::Hash(digest_hash.clone())
2119 );
2120 assert_eq!(
2121 path_prepared_hash(
2122 Some(CODE_MAP),
2123 "src/a.js#L1-L3",
2124 AnchorGrain::Span,
2125 JS.as_bytes()
2126 ),
2127 PathPrepared::Hash(digest_hash)
2128 );
2129 assert_eq!(
2130 path_prepared_hash(None, "src/a.js", AnchorGrain::File, JS.as_bytes()),
2131 PathPrepared::Hash(prepared_content_hash(JS.as_bytes())),
2132 "no preparation: the bytes, byte-for-byte as before"
2133 );
2134 assert_eq!(
2135 path_prepared_hash(Some(CODE_MAP), "src", AnchorGrain::Tree, b""),
2136 PathPrepared::NoHash,
2137 "a tree needs enumeration; the caller supplies it"
2138 );
2139 assert_eq!(
2140 path_prepared_hash(None, "src", AnchorGrain::Tree, b""),
2141 PathPrepared::NoHash
2142 );
2143 let log = "2026-08-24 one\nbody\n2026-08-25 two\nbody\n";
2144 assert!(matches!(
2145 path_prepared_hash(
2146 Some(DATED_ENTRIES),
2147 "log.md#2026-08-25T00:00:00",
2148 AnchorGrain::Span,
2149 log.as_bytes()
2150 ),
2151 PathPrepared::Hash(_)
2152 ));
2153 assert_eq!(
2154 path_prepared_hash(
2155 Some(DATED_ENTRIES),
2156 "log.md#2026-08-26T00:00:00",
2157 AnchorGrain::Span,
2158 log.as_bytes()
2159 ),
2160 PathPrepared::UnitAbsent
2161 );
2162 assert_eq!(
2163 path_prepared_hash(
2164 Some(DATED_ENTRIES),
2165 "log.md",
2166 AnchorGrain::File,
2167 log.as_bytes()
2168 ),
2169 PathPrepared::Hash(prepared_content_hash(log.as_bytes()))
2170 );
2171 }
2172
2173 #[test]
2181 #[ignore]
2182 fn measure_code_map_over_corpus() {
2183 use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
2184 let Ok(root) = std::env::var("MEMSTEAD_CODE_MAP_CORPUS") else {
2185 eprintln!("MEMSTEAD_CODE_MAP_CORPUS unset; nothing measured");
2186 return;
2187 };
2188 let root = std::path::PathBuf::from(root);
2189 let split = |v: &str| -> Vec<String> {
2190 v.split(',')
2191 .map(str::trim)
2192 .filter(|s| !s.is_empty())
2193 .map(String::from)
2194 .collect()
2195 };
2196 let allows = split(&std::env::var("MEMSTEAD_CODE_MAP_ALLOW").unwrap_or_default());
2197 let denies = split(&std::env::var("MEMSTEAD_CODE_MAP_DENY").unwrap_or_default());
2198 let commits: usize = std::env::var("MEMSTEAD_CODE_MAP_COMMITS")
2199 .ok()
2200 .and_then(|v| v.parse().ok())
2201 .unwrap_or(200);
2202 let mut scope: Vec<PatternEntry> = allows
2203 .iter()
2204 .map(|p| PatternEntry {
2205 path: p.clone(),
2206 mode: PatternMode::Allow,
2207 })
2208 .collect();
2209 scope.extend(denies.iter().map(|p| PatternEntry {
2210 path: p.clone(),
2211 mode: PatternMode::Deny,
2212 }));
2213 let source = Source {
2214 name: "corpus".into(),
2215 medium_type: MediumType::Codebase,
2216 pointer: String::new(),
2217 change_detection: Some("git".into()),
2218 scope,
2219 engagement: None,
2220 preparation: Some(CODE_MAP.into()),
2221 };
2222 let files = crate::ingest::cursor::enumerate_facet_files(&source, &[], &root);
2223 let mut by_family: std::collections::BTreeMap<&str, (usize, usize, usize, usize, usize)> =
2224 std::collections::BTreeMap::new();
2225 for f in &files {
2226 let Ok(bytes) = std::fs::read(root.join(f)) else {
2227 continue;
2228 };
2229 let text = String::from_utf8_lossy(&bytes);
2230 let digest = code_map_digest(f, &text);
2231 let ext = f.rsplit('.').next().unwrap_or("");
2232 let fam = match family_of(f) {
2233 Family::CLike => {
2234 if ext == "py" {
2235 "py"
2236 } else {
2237 "js"
2238 }
2239 }
2240 Family::Rust => "rust",
2241 Family::Vue => "vue",
2242 Family::Python => "py",
2243 Family::Json => "json",
2244 Family::Text => "other",
2245 };
2246 let e = by_family.entry(fam).or_default();
2247 e.0 += 1;
2248 e.1 += text.len();
2249 e.2 += digest.len();
2250 e.3 += crate::chunking::estimate_tokens(&text);
2251 e.4 += crate::chunking::estimate_tokens(&digest);
2252 }
2253 let (mut n, mut rb, mut db, mut rt, mut dt) = (0, 0, 0, 0, 0);
2254 eprintln!(
2255 "| family | files | raw bytes | digest bytes | raw tokens | digest tokens | digest/raw |"
2256 );
2257 eprintln!("| --- | --- | --- | --- | --- | --- | --- |");
2258 for (fam, (c, b1, b2, t1, t2)) in &by_family {
2259 eprintln!(
2260 "| {fam} | {c} | {b1} | {b2} | {t1} | {t2} | {:.1}% |",
2261 100.0 * *b2 as f64 / (*b1).max(1) as f64
2262 );
2263 n += c;
2264 rb += b1;
2265 db += b2;
2266 rt += t1;
2267 dt += t2;
2268 }
2269 eprintln!(
2270 "| total | {n} | {rb} | {db} | {rt} | {dt} | {:.1}% |",
2271 100.0 * db as f64 / rb.max(1) as f64
2272 );
2273
2274 let git = |args: &[&str]| -> String {
2277 let out = std::process::Command::new("git")
2278 .args(args)
2279 .current_dir(&root)
2280 .output()
2281 .expect("git");
2282 String::from_utf8_lossy(&out.stdout).into_owned()
2283 };
2284 let mut builder = globset::GlobSetBuilder::new();
2285 for a in &allows {
2286 builder.add(globset::Glob::new(a).unwrap());
2287 }
2288 let allow_set = builder.build().unwrap();
2289 let mut dbuilder = globset::GlobSetBuilder::new();
2290 for d in &denies {
2291 dbuilder.add(globset::Glob::new(d).unwrap());
2292 }
2293 let deny_set = dbuilder.build().unwrap();
2294 let shas: Vec<String> = git(&["log", "--format=%H", "-n", &commits.to_string(), "--", "."])
2295 .lines()
2296 .map(String::from)
2297 .collect();
2298 let (mut commits_seen, mut commits_touching, mut commits_interface) =
2299 (0usize, 0usize, 0usize);
2300 let (mut files_changed, mut files_interface, mut files_body_only) =
2301 (0usize, 0usize, 0usize);
2302 for sha in &shas {
2303 commits_seen += 1;
2304 let parent = format!("{sha}~1");
2305 let names = git(&["diff", "--name-only", &parent, sha]);
2306 let mut touched = false;
2307 let mut iface = false;
2308 for f in names.lines() {
2309 if !allow_set.is_match(f) || deny_set.is_match(f) {
2310 continue;
2311 }
2312 let old = git(&["show", &format!("{parent}:{f}")]);
2313 let new = git(&["show", &format!("{sha}:{f}")]);
2314 if old.is_empty() || new.is_empty() {
2315 continue;
2316 }
2317 if prepared_content_hash(old.as_bytes()) == prepared_content_hash(new.as_bytes()) {
2318 continue;
2319 }
2320 touched = true;
2321 files_changed += 1;
2322 if prepared_content_hash(code_map_digest(f, &old).as_bytes())
2323 != prepared_content_hash(code_map_digest(f, &new).as_bytes())
2324 {
2325 files_interface += 1;
2326 iface = true;
2327 } else {
2328 files_body_only += 1;
2329 }
2330 }
2331 if touched {
2332 commits_touching += 1;
2333 }
2334 if iface {
2335 commits_interface += 1;
2336 }
2337 }
2338 eprintln!();
2339 eprintln!(
2340 "history: {commits_seen} commits inspected, {commits_touching} touched a scoped file's content, {commits_interface} of those changed an interface"
2341 );
2342 eprintln!(
2343 "files: {files_changed} scoped file changes, {files_interface} interface changes, {files_body_only} body-only ({:.1}% of file changes would not drift a code-map anchor)",
2344 100.0 * files_body_only as f64 / files_changed.max(1) as f64
2345 );
2346 }
2347
2348 #[test]
2352 fn unit_keys_survive_growth_and_diff_delivers_only_what_changed() {
2353 let before = unitize(DATED_ENTRIES, LOG).unwrap();
2354 let grown = format!("{LOG}2026-08-26 09:00 restart\nline d\n");
2355 let after = unitize(DATED_ENTRIES, &grown).unwrap();
2356 assert_eq!(
2357 &after[..3],
2358 &before[..],
2359 "existing units are byte-identical"
2360 );
2361 let delta = diff_units(&before, &after);
2362 assert_eq!(delta.len(), 1);
2363 assert_eq!(delta[0].0.key, "2026-08-26T09:00:00");
2364 assert_eq!(delta[0].1, UnitChange::Added);
2365
2366 let edited = LOG.replace("line c", "line c, revised");
2367 let delta = diff_units(&before, &unitize(DATED_ENTRIES, &edited).unwrap());
2368 assert_eq!(
2369 delta
2370 .iter()
2371 .map(|(u, c)| (u.key.as_str(), *c))
2372 .collect::<Vec<_>>(),
2373 vec![("2026-08-25T00:00:00", UnitChange::Modified)]
2374 );
2375
2376 let shrunk = LOG.replace("2026-08-25 shutdown\nline c\n", "");
2377 let delta = diff_units(&before, &unitize(DATED_ENTRIES, &shrunk).unwrap());
2378 assert_eq!(
2379 delta
2380 .iter()
2381 .map(|(u, c)| (u.key.as_str(), *c))
2382 .collect::<Vec<_>>(),
2383 vec![("2026-08-25T00:00:00", UnitChange::Deleted)]
2384 );
2385 assert!(diff_units(&before, &before).is_empty());
2386 }
2387
2388 #[test]
2389 fn url_defaults_unstable_every_other_grain_stable() {
2390 assert_eq!(
2391 default_hash_stability(AnchorGrain::Url),
2392 AnchorHashStability::Unstable
2393 );
2394 for g in [
2395 AnchorGrain::Span,
2396 AnchorGrain::File,
2397 AnchorGrain::Tree,
2398 AnchorGrain::Entity,
2399 ] {
2400 assert_eq!(default_hash_stability(g), AnchorHashStability::Stable);
2401 }
2402 }
2403
2404 #[test]
2408 fn url_prepared_form_is_the_shared_canonicalization() {
2409 let a = url_prepared_hash(b"<p>hello</p>\n");
2410 assert_eq!(a, prepared_content_hash(b"<p>hello</p>\n"));
2411 assert_eq!(a, url_prepared_hash(b"\xEF\xBB\xBF<p>hello</p>\r\n\r\n"));
2412 assert_ne!(a, url_prepared_hash(b"<p>hello!</p>\n"));
2413 assert_eq!(
2414 supplied_content_hash(AnchorGrain::Url, b"<p>hello</p>").as_deref(),
2415 Some(a.as_str())
2416 );
2417 assert!(supplied_content_hash(AnchorGrain::File, b"x").is_some());
2418 assert!(supplied_content_hash(AnchorGrain::Span, b"x").is_some());
2419 assert!(supplied_content_hash(AnchorGrain::Tree, b"x").is_none());
2420 assert!(supplied_content_hash(AnchorGrain::Entity, b"x").is_none());
2421 }
2422
2423 #[test]
2424 fn load_bearing_resolves_explicit_then_required_then_all() {
2425 let explicit = type_with(vec![
2426 section("claim", true, Some(true)),
2427 section("evidence", true, Some(false)),
2428 section("notes", false, None),
2429 ]);
2430 let keys: Vec<_> = load_bearing_sections(&explicit)
2431 .iter()
2432 .map(|s| s.key.as_str())
2433 .collect();
2434 assert_eq!(keys, vec!["claim"]);
2435
2436 let required = type_with(vec![
2437 section("claim", true, None),
2438 section("evidence", true, Some(false)),
2439 section("notes", false, None),
2440 ]);
2441 let keys: Vec<_> = load_bearing_sections(&required)
2442 .iter()
2443 .map(|s| s.key.as_str())
2444 .collect();
2445 assert_eq!(
2446 keys,
2447 vec!["claim"],
2448 "a required section opted out is excluded"
2449 );
2450
2451 let none = type_with(vec![section("a", false, None), section("b", false, None)]);
2452 let keys: Vec<_> = load_bearing_sections(&none)
2453 .iter()
2454 .map(|s| s.key.as_str())
2455 .collect();
2456 assert_eq!(keys, vec!["a", "b"], "no declaration at all: every section");
2457 }
2458
2459 #[test]
2462 fn notes_edit_keeps_the_hash_load_bearing_edit_breaks_it() {
2463 let td = type_with(vec![
2464 section("decision", true, None),
2465 section("notes", false, None),
2466 ]);
2467 let base = entity(&[("decision", "We ship."), ("notes", "first draft")]);
2468 let notes_edit = entity(&[("decision", "We ship."), ("notes", "first draft, revised")]);
2469 let claim_edit = entity(&[("decision", "We do not ship."), ("notes", "first draft")]);
2470 let h = |e: &Entity| entity_prepared_hash(e, Some(&td), Some(ENTITY_LOAD_BEARING)).unwrap();
2471 assert_eq!(h(&base), h(¬es_edit));
2472 assert_ne!(h(&base), h(&claim_edit));
2473
2474 let d = |e: &Entity| entity_prepared_hash(e, Some(&td), None).unwrap();
2477 assert_ne!(d(&base), d(¬es_edit));
2478 assert_eq!(
2479 d(&base),
2480 prepared_content_hash(crate::render::render_entity_markdown(&base, None).as_bytes())
2481 );
2482
2483 assert!(entity_prepared_hash(&base, Some(&td), Some("pdf-to-markdown")).is_none());
2485 }
2486
2487 #[test]
2490 fn form_is_keyed_and_trimmed() {
2491 let td = type_with(vec![
2492 section("claim", true, None),
2493 section("evidence", true, None),
2494 ]);
2495 let a = entity(&[("claim", "x"), ("evidence", "y")]);
2496 let b = entity(&[("claim", "y"), ("evidence", "x")]);
2497 let c = entity(&[("claim", "x \n\n"), ("evidence", "\n y")]);
2498 let form = |e: &Entity| entity_load_bearing_form(e, Some(&td));
2499 assert_ne!(form(&a), form(&b));
2500 assert_eq!(form(&a), form(&c));
2501 assert_eq!(form(&a), "## claim\n\nx\n\n## evidence\n\ny\n\n");
2502 assert_eq!(
2504 entity_load_bearing_form(&entity(&[("z", "1"), ("a", "2")]), None),
2505 "## z\n\n1\n\n## a\n\n2\n\n"
2506 );
2507 }
2508}