edda_postmortem/
sign_check.rs1use crate::scars::normalize_prefix;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28pub const DOCTRINE_FILES: &[&str] = &[
30 "failure-memory.md",
31 "layer-1-ideology.md",
32 "layer-6-heart-methods.md",
33];
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DoctrineEntry {
38 pub file: String,
39 pub heading: String,
40 pub first_line: String,
41 pub family_key: String,
42}
43
44pub fn parse_entries(body: &str, file_label: &str) -> Vec<DoctrineEntry> {
51 let mut out = Vec::new();
52 let lines: Vec<&str> = body.lines().collect();
53 let mut i = 0;
54 while i < lines.len() {
55 let line = lines[i];
56 if let Some(heading) = line.strip_prefix("### ") {
57 let mut j = i + 1;
59 let mut first_line = String::new();
60 while j < lines.len() {
61 let candidate = lines[j].trim();
62 if candidate.is_empty() {
63 j += 1;
64 continue;
65 }
66 if candidate.starts_with('#') {
67 break;
68 }
69 let cleaned = candidate
70 .trim_start_matches(['-', '*', '>', ' '])
71 .to_string();
72 first_line = cleaned;
73 break;
74 }
75 let heading_trimmed = heading.trim().to_string();
76 let family_key = normalize_prefix(&strip_entry_number(&heading_trimmed));
77 if !family_key.is_empty() {
78 out.push(DoctrineEntry {
79 file: file_label.to_string(),
80 heading: heading_trimmed,
81 first_line,
82 family_key,
83 });
84 }
85 }
86 i += 1;
87 }
88 out
89}
90
91fn strip_entry_number(heading: &str) -> String {
94 let colon = match heading.find(':') {
95 Some(i) => i,
96 None => return heading.to_string(),
97 };
98 let prefix = &heading[..colon];
99 if prefix.len() <= 8
102 && !prefix.is_empty()
103 && prefix
104 .chars()
105 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
106 {
107 heading[colon + 1..].trim().to_string()
108 } else {
109 heading.to_string()
110 }
111}
112
113pub fn load_doctrine_entries(doctrine_dir: &Path) -> Vec<DoctrineEntry> {
116 let root = resolve_doctrine_root(doctrine_dir);
117 let mut out = Vec::new();
118 for name in DOCTRINE_FILES {
119 let path = root.join(name);
120 let Ok(body) = fs::read_to_string(&path) else {
121 continue;
122 };
123 out.extend(parse_entries(&body, name));
124 }
125 out
126}
127
128fn resolve_doctrine_root(doctrine_dir: &Path) -> PathBuf {
131 let refs = doctrine_dir.join("references");
132 if refs.join("state-snapshot.md").exists() {
133 refs
134 } else {
135 doctrine_dir.to_path_buf()
136 }
137}
138
139const MIN_FAMILY_OVERLAP: usize = 12;
142
143pub fn related_entries<'a>(
149 candidate_maxim: &str,
150 entries: &'a [DoctrineEntry],
151) -> Vec<&'a DoctrineEntry> {
152 let key = normalize_prefix(candidate_maxim);
153 if key.is_empty() {
154 return Vec::new();
155 }
156 entries
157 .iter()
158 .filter(|e| family_keys_related(&key, &e.family_key))
159 .collect()
160}
161
162fn family_keys_related(a: &str, b: &str) -> bool {
163 if a.is_empty() || b.is_empty() {
164 return false;
165 }
166 if a == b {
167 return true;
168 }
169 let (shorter, longer) = if a.len() < b.len() { (a, b) } else { (b, a) };
170 shorter.len() >= MIN_FAMILY_OVERLAP && longer.starts_with(shorter)
172}
173
174pub fn render_related_hint(candidate_maxim: &str, entries: &[DoctrineEntry]) -> String {
177 let related = related_entries(candidate_maxim, entries);
178 if related.is_empty() {
179 return String::new();
180 }
181 let mut out = String::from(
182 "\n- **Related in doctrine (machine hint, not judgment)**: same family key as—\n",
183 );
184 for entry in related {
185 out.push_str(&format!(" - `{}` → {}\n", entry.file, entry.heading));
186 }
187 out.push_str(" (machine surfaces same-family entries; the operator judges duplicate/contradict/refine at sign time.)\n");
188 out
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use tempfile::tempdir;
195
196 #[test]
197 fn parse_entries_extracts_heading_and_first_line() {
198 let body = "\
199# Failure Memory\n\
200\n\
201### FM-1: watch out for silent walls\n\
202- **Temptation:** retrying without new signal.\n\
203- **How it failed:** three silent failures in a row.\n\
204\n\
205### FM-2: unrelated ticket\n\
206- Just a note.\n\
207";
208 let entries = parse_entries(body, "failure-memory.md");
209 assert_eq!(entries.len(), 2);
210 assert_eq!(entries[0].heading, "FM-1: watch out for silent walls");
211 assert!(entries[0]
212 .first_line
213 .contains("retrying without new signal"));
214 }
215
216 #[test]
217 fn related_entries_matches_family_key_of_candidate() {
218 let entries = parse_entries(
221 "### FM-5: Retrying into a silent wall\n**Temptation:** ...\n",
222 "failure-memory.md",
223 );
224 let related = related_entries("Retrying into a silent wall (machine-surfaced)", &entries);
225 assert_eq!(related.len(), 1);
226 assert!(related[0].heading.contains("Retrying"));
227 }
228
229 #[test]
230 fn unrelated_candidate_returns_empty_hint() {
231 let entries = parse_entries(
232 "### FM-5: Retrying into a silent wall\n**Temptation:** ...\n",
233 "failure-memory.md",
234 );
235 let hint = render_related_hint("A completely different maxim about caching", &entries);
236 assert_eq!(hint, "");
237 }
238
239 #[test]
240 fn related_candidate_renders_hint_block_naming_files() {
241 let entries = parse_entries(
242 "### FM-5: Retrying into a silent wall\n**Temptation:** ...\n",
243 "failure-memory.md",
244 );
245 let hint = render_related_hint("Retrying into a silent wall", &entries);
246 assert!(hint.contains("Related in doctrine"));
247 assert!(hint.contains("failure-memory.md"));
248 assert!(hint.contains("Retrying"));
249 assert!(hint.contains("not judgment"));
251 assert!(hint.contains("operator judges"));
252 }
253
254 #[test]
255 fn strip_entry_number_handles_common_forms() {
256 assert_eq!(strip_entry_number("FM-5: Retrying"), "Retrying");
257 assert_eq!(strip_entry_number("6.3: Paid lesson"), "Paid lesson");
258 assert_eq!(strip_entry_number("L1.2: The belief"), "The belief");
259 assert_eq!(
261 strip_entry_number("Something: with colon"),
262 "Something: with colon"
263 );
264 assert_eq!(strip_entry_number("No colon here"), "No colon here");
266 }
267
268 #[test]
269 fn load_doctrine_entries_handles_missing_dir_gracefully() {
270 let dir = tempdir().unwrap();
271 let entries = load_doctrine_entries(dir.path());
273 assert!(entries.is_empty());
274 }
275
276 #[test]
277 fn load_doctrine_entries_reads_files_from_references_subdir() {
278 let dir = tempdir().unwrap();
279 let refs = dir.path().join("references");
280 fs::create_dir_all(&refs).unwrap();
281 fs::write(refs.join("state-snapshot.md"), "# State").unwrap();
282 fs::write(
283 refs.join("failure-memory.md"),
284 "### FM-1: sample\n- **Temptation:** the fix looked easy.\n",
285 )
286 .unwrap();
287 let entries = load_doctrine_entries(dir.path());
288 assert_eq!(entries.len(), 1);
289 assert_eq!(entries[0].file, "failure-memory.md");
290 }
291
292 #[test]
293 fn vocabulary_alignment_family_key_uses_normalize_prefix() {
294 let a = "Retrying Into a Silent WALL";
298 let b = "retrying into a silent wall";
299 assert_eq!(
300 normalize_prefix(a),
301 normalize_prefix(b),
302 "scars.rs normalize_prefix is the single family key vocabulary"
303 );
304 let entries = parse_entries(&format!("### FM-x: {a}\nbody\n"), "failure-memory.md");
305 let related = related_entries(b, &entries);
306 assert_eq!(
307 related.len(),
308 1,
309 "case/whitespace-normalized keys collide as scars.rs guarantees"
310 );
311 }
312}