1use std::collections::HashSet;
10use std::path::PathBuf;
11
12use rmcp::handler::server::tool::ToolRouter;
13use rmcp::handler::server::wrapper::Parameters;
14use rmcp::tool_router;
15use serde_json::json;
16
17use crate::graph::edges::EdgeKind;
18use crate::graph::Graph;
19use crate::store::record::{
20 Category, ContextPacket, FileRecord, GotchaRecord, Priority, QualityTier, Record,
21 RecordLifecycle, StaleReviewPayload, StalenessTier,
22};
23
24use super::protocol::{
25 self as proto, Command, DecisionUpsertInput, DevNoteUpsertInput, GotchaConfirmInput,
26 GotchaDraftInput, GotchaTombstoneInput,
27};
28use super::server::{proxy_daemon_result, proxy_daemon_v2, ProxyDaemonResult};
29use super::types::{MemBootstrapParams, MemGetParams, MemQueryParams, MemSetParams};
30
31pub(crate) const VECTOR_B: &str =
33 "\n\n[mati] Before reading any file: call mem_get(\"file:<path>\").\n\
34 confidence>=0.6 + confirmed=true \u{2192} use record, skip file read.\n\
35 confidence<0.3 \u{2192} read file, consider mem_set to improve.\n\
36 \"add gotcha\" \u{2192} mem_set(Gotcha) then mati gotcha confirm <key>.";
37
38const TOKEN_BUDGET: usize = 2_000;
40
41const VECTOR_B_TOKENS: usize = 77;
43
44fn estimate_tokens(text: &str) -> usize {
46 text.len() / 4
47}
48
49fn priority_weight(priority: &Priority) -> f32 {
51 match priority {
52 Priority::Low => 0.25,
53 Priority::Normal => 0.50,
54 Priority::High => 0.75,
55 Priority::Critical => 1.00,
56 }
57}
58
59pub(crate) fn record_to_agent_json(record: &Record) -> serde_json::Value {
63 let mut obj = serde_json::Map::new();
64 obj.insert("key".into(), serde_json::json!(record.key));
65 obj.insert("value".into(), serde_json::json!(record.value));
66 obj.insert("category".into(), serde_json::json!(record.category));
67 obj.insert("priority".into(), serde_json::json!(record.priority));
68 if !record.tags.is_empty() {
69 obj.insert("tags".into(), serde_json::json!(record.tags));
70 }
71 obj.insert(
72 "confidence".into(),
73 serde_json::json!(record.confidence.value),
74 );
75 obj.insert(
76 "confirmation_count".into(),
77 serde_json::json!(record.confidence.confirmation_count),
78 );
79 obj.insert("quality".into(), serde_json::json!(record.quality.value));
80 obj.insert(
81 "quality_tier".into(),
82 serde_json::json!(record.quality.tier),
83 );
84 if !record.quality.signals.is_empty() {
85 obj.insert(
86 "quality_signals".into(),
87 serde_json::json!(record.quality.signals),
88 );
89 }
90 obj.insert("source".into(), serde_json::json!(record.source));
91 obj.insert(
92 "staleness_tier".into(),
93 serde_json::json!(record.staleness.tier),
94 );
95 if let Some(ref url) = record.ref_url {
96 obj.insert("ref_url".into(), serde_json::json!(url));
97 }
98 if let Some(ref payload) = record.payload {
99 obj.insert("payload".into(), strip_payload(payload, &record.category));
100 }
101 serde_json::Value::Object(obj)
102}
103
104fn strip_payload(payload: &serde_json::Value, category: &Category) -> serde_json::Value {
106 let Some(obj) = payload.as_object() else {
107 return payload.clone();
108 };
109
110 let internal_fields: &[&str] = match category {
112 Category::File => &[
113 "token_cost_estimate",
114 "last_modified_session",
115 "content_hash",
116 ],
117 Category::Gotcha => &["discovered_session"],
118 _ => &[],
119 };
120
121 if internal_fields.is_empty() {
122 return payload.clone();
123 }
124
125 let mut stripped = obj.clone();
126 for field in internal_fields {
127 stripped.remove(*field);
128 }
129
130 if matches!(category, Category::File) {
132 stripped.retain(|_, v| !matches!(v, serde_json::Value::Array(a) if a.is_empty()));
133 }
134
135 serde_json::Value::Object(stripped)
136}
137
138#[derive(Clone)]
145pub struct MatiServer {
146 root: PathBuf,
147 pub(crate) tool_router: ToolRouter<Self>,
148}
149
150impl MatiServer {
151 pub fn with_socket_root(root: PathBuf) -> Self {
153 Self {
154 root,
155 tool_router: Self::tool_router(),
156 }
157 }
158
159 fn socket_error(op: &str, result: ProxyDaemonResult) -> String {
160 let message = match result {
161 ProxyDaemonResult::NotRunning => format!("{op}: daemon not running"),
162 ProxyDaemonResult::StaleSocket => format!("{op}: daemon socket stale"),
163 ProxyDaemonResult::Unresponsive => format!("{op}: daemon unresponsive"),
164 ProxyDaemonResult::Ok(v) => format!("{op}: malformed daemon response: {v}"),
165 };
166 json!({ "error": message }).to_string()
167 }
168
169 async fn socket_call(&self, op: &str, args: serde_json::Value) -> String {
170 match proxy_daemon_result(&self.root, op, args).await {
171 ProxyDaemonResult::Ok(v) => Self::format_envelope(op, v),
172 other => Self::socket_error(op, other),
173 }
174 }
175
176 async fn socket_call_typed(&self, cmd: Command) -> String {
184 let op = cmd.kind();
185 match proxy_daemon_v2(&self.root, cmd).await {
186 ProxyDaemonResult::Ok(v) => Self::format_envelope(op, v),
187 other => Self::socket_error(op, other),
188 }
189 }
190
191 fn format_envelope(op: &str, v: serde_json::Value) -> String {
194 if v.get("ok") != Some(&serde_json::Value::Bool(true)) {
195 let err = v
196 .get("error")
197 .and_then(|e| e.as_str())
198 .unwrap_or("daemon request failed");
199 let code = v.get("code").and_then(|c| c.as_str()).unwrap_or("");
202 if code.is_empty() {
203 return json!({ "error": err, "op": op }).to_string();
204 }
205 return json!({ "error": err, "op": op, "code": code }).to_string();
206 }
207 match v.get("data") {
208 Some(serde_json::Value::String(s)) => s.clone(),
209 Some(other) => other.to_string(),
210 None => json!({ "error": "daemon response missing data" }).to_string(),
211 }
212 }
213}
214
215#[tool_router]
216impl MatiServer {
217 #[rmcp::tool(
227 name = "mem_get",
228 description = "Look up one mati knowledge record by key. Before reading a file directly, call this with \"file:<path>\" and use the record instead when it is confirmed and high-confidence.",
229 annotations(read_only_hint = true)
230 )]
231 pub(crate) async fn mem_get(&self, Parameters(params): Parameters<MemGetParams>) -> String {
232 self.socket_call("mem_get", json!({ "key": params.key }))
233 .await
234 }
235
236 #[rmcp::tool(
241 name = "mem_query",
242 description = "Search the mati knowledge store. Use mode \"text\" for BM25 full-text search, mode \"tag\" to filter by tag, or mode \"graph\" for a 1-hop traversal from a seed key.",
243 annotations(read_only_hint = true)
244 )]
245 pub(crate) async fn mem_query(&self, Parameters(params): Parameters<MemQueryParams>) -> String {
246 self.socket_call(
247 "mem_query",
248 json!({ "query": params.query, "mode": params.mode, "limit": params.limit }),
249 )
250 .await
251 }
252
253 #[rmcp::tool(
258 name = "mem_bootstrap",
259 description = "Assemble a compact context packet for the current coding session from relevant gotchas, file records, and decisions. Call this at session start.",
260 annotations(read_only_hint = true)
261 )]
262 pub(crate) async fn mem_bootstrap(
263 &self,
264 Parameters(params): Parameters<MemBootstrapParams>,
265 ) -> String {
266 self.socket_call(
267 "mem_bootstrap",
268 json!({ "context_files": params.context_files }),
269 )
270 .await
271 }
272
273 #[rmcp::tool(
279 name = "mem_set",
280 description = "Write, confirm, or delete a knowledge record. Actions: \"write\" (default) creates/updates a record, \"confirm\" activates a gotcha for hook enforcement, \"delete\" tombstones a gotcha.",
281 annotations(
282 read_only_hint = false,
283 destructive_hint = true,
284 idempotent_hint = false
285 )
286 )]
287 pub(crate) async fn mem_set(&self, Parameters(params): Parameters<MemSetParams>) -> String {
288 match build_mem_set_command(¶ms) {
294 Ok(cmd) => self.socket_call_typed(cmd).await,
295 Err(error) => json!({ "error": error }).to_string(),
296 }
297 }
298}
299
300fn build_mem_set_command(params: &MemSetParams) -> Result<Command, String> {
319 match params.action.as_str() {
320 "confirm" => {
321 if !params.key.starts_with("gotcha:") {
322 return Err("confirm action only applies to gotcha: keys".into());
323 }
324 Ok(Command::GotchaConfirm(GotchaConfirmInput {
325 key: params.key.clone(),
326 }))
327 }
328 "delete" => {
329 if !params.key.starts_with("gotcha:") {
330 return Err("delete action only applies to gotcha: keys".into());
331 }
332 Ok(Command::GotchaTombstone(GotchaTombstoneInput {
333 key: params.key.clone(),
334 }))
335 }
336 "write" | "" => build_mem_set_write_command(params),
337 other => Err(format!(
338 "unknown action: {other}. Valid: write, confirm, delete"
339 )),
340 }
341}
342
343fn build_mem_set_write_command(params: &MemSetParams) -> Result<Command, String> {
344 let payload = match ¶ms.payload {
347 serde_json::Value::String(s) => {
348 serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|_| params.payload.clone())
349 }
350 other => other.clone(),
351 };
352
353 let priority = parse_protocol_priority(¶ms.priority);
354
355 if let Some(stripped) = params.key.strip_prefix("gotcha:") {
356 if stripped.is_empty() {
357 return Err("gotcha key must not be just the prefix".into());
358 }
359 let rule = field_string(&payload, "rule")
360 .ok_or_else(|| "gotcha payload requires non-empty 'rule'".to_string())?;
361 let reason = field_string(&payload, "reason")
362 .ok_or_else(|| "gotcha payload requires non-empty 'reason'".to_string())?;
363 let severity = parse_protocol_severity(payload.get("severity").and_then(|v| v.as_str()));
364 let affected_files = field_string_list(&payload, "affected_files");
365 let ref_url = payload
366 .get("ref_url")
367 .and_then(|v| v.as_str())
368 .map(|s| s.to_string());
369
370 return Ok(Command::GotchaUpsert(GotchaDraftInput {
371 key: params.key.clone(),
372 rule,
373 reason,
374 severity,
375 affected_files,
376 ref_url,
377 tags: params.tags.clone(),
378 priority,
379 source: None,
380 }));
381 }
382
383 if let Some(slug) = params.key.strip_prefix("decision:") {
384 if slug.is_empty() {
385 return Err("decision key must not be just the prefix".into());
386 }
387 let summary = field_string(&payload, "summary")
388 .ok_or_else(|| "decision payload requires non-empty 'summary'".to_string())?;
389 let rationale = field_string(&payload, "rationale")
390 .ok_or_else(|| "decision payload requires non-empty 'rationale'".to_string())?;
391 return Ok(Command::DecisionUpsert(DecisionUpsertInput {
392 slug: slug.to_string(),
393 value: params.value.clone(),
394 summary,
395 rationale,
396 tags: params.tags.clone(),
397 priority,
398 }));
399 }
400
401 if let Some(stripped) = params.key.strip_prefix("dev_note:") {
402 if stripped.is_empty() {
403 return Err("dev_note key must not be just the prefix".into());
404 }
405 if params.value.is_empty() {
406 return Err("dev_note requires non-empty value".into());
407 }
408 return Ok(Command::DevNoteUpsert(DevNoteUpsertInput {
409 key: Some(params.key.clone()),
410 text: params.value.clone(),
411 tags: params.tags.clone(),
412 priority,
413 }));
414 }
415
416 Err("mem_set write requires key with gotcha:/decision:/dev_note: prefix".into())
417}
418
419fn field_string(payload: &serde_json::Value, field: &str) -> Option<String> {
420 payload
421 .get(field)
422 .and_then(|v| v.as_str())
423 .map(|s| s.to_string())
424 .filter(|s| !s.is_empty())
425}
426
427fn field_string_list(payload: &serde_json::Value, field: &str) -> Vec<String> {
428 payload
429 .get(field)
430 .and_then(|v| v.as_array())
431 .map(|arr| {
432 arr.iter()
433 .filter_map(|v| v.as_str().map(|s| s.to_string()))
434 .collect()
435 })
436 .unwrap_or_default()
437}
438
439fn parse_protocol_priority(s: &str) -> proto::Priority {
440 match s {
441 "Critical" | "critical" => proto::Priority::Critical,
442 "High" | "high" => proto::Priority::High,
443 "Low" | "low" => proto::Priority::Low,
444 _ => proto::Priority::Normal,
445 }
446}
447
448fn parse_protocol_severity(s: Option<&str>) -> proto::Severity {
449 match s.map(|s| s.to_ascii_lowercase()).as_deref() {
450 Some("critical") => proto::Severity::Critical,
451 Some("high") => proto::Severity::High,
452 Some("low") => proto::Severity::Low,
453 _ => proto::Severity::Normal,
454 }
455}
456
457fn is_injectable_gotcha(r: &Record) -> bool {
482 if !matches!(r.lifecycle, RecordLifecycle::Active) {
483 return false;
484 }
485 if r.staleness.tier == StalenessTier::Tombstone {
486 return false;
487 }
488 if r.quality.value < 0.4 {
489 return false;
490 }
491 if let Some(gotcha) = r.payload_as::<GotchaRecord>() {
492 if gotcha.confirmed {
493 return true;
494 }
495 }
496 r.key.starts_with("gotcha:cochange:")
498 || r.key.starts_with("gotcha:revert:")
499 || r.key.starts_with("gotcha:ownership:")
500}
501
502pub(crate) fn resolve_existing_for_write(
509 store_result: anyhow::Result<Option<Record>>,
510) -> Result<Option<Record>, String> {
511 match store_result {
512 Ok(record) => Ok(record),
513 Err(e) => Err(serde_json::json!({
514 "error": format!("store read failed \u{2014} refusing to write: {e}")
515 })
516 .to_string()),
517 }
518}
519
520pub async fn assemble_context_packet(
534 store: &crate::store::Store,
535 graph: &Graph,
536 context_files: &[String],
537) -> anyhow::Result<ContextPacket> {
538 let stage = store.get("stage:current").await?;
540
541 let mut file_records = Vec::new();
546 let mut context_gotcha_keys = HashSet::new();
547 let mut decision_keys = HashSet::new();
548 let mut unconfirmed_candidates = Vec::new();
550 let mut stale_warnings: Vec<String> = Vec::new();
552 let mut seen_stale_keys: HashSet<String> = HashSet::new();
553
554 for file_path in context_files {
555 let file_key = if file_path.starts_with("file:") {
556 file_path.clone()
557 } else {
558 format!("file:{file_path}")
559 };
560
561 if let Ok(Some(record)) = store.get(&file_key).await {
563 if record.staleness.tier == StalenessTier::Tombstone
565 || !matches!(record.lifecycle, RecordLifecycle::Active)
566 {
567 continue;
568 }
569
570 match record.staleness.tier {
572 StalenessTier::Stale => {
573 let path = file_key.strip_prefix("file:").unwrap_or(&file_key);
574 if seen_stale_keys.insert(file_key.clone()) {
575 stale_warnings.push(format!(
576 "`{path}` record is stale (staleness {:.2}) — verify before trusting",
577 record.staleness.value
578 ));
579 }
580 }
581 StalenessTier::Liability => {
582 let path = file_key.strip_prefix("file:").unwrap_or(&file_key);
583 if seen_stale_keys.insert(file_key.clone()) {
584 stale_warnings.push(format!(
585 "`{path}` record is a liability (staleness {:.2}) — do not trust, read the file",
586 record.staleness.value
587 ));
588 }
589 }
590 _ => {}
591 }
592
593 if let Some(fr) = record.payload_as::<FileRecord>() {
594 for key in &fr.gotcha_keys {
601 context_gotcha_keys.insert(key.clone());
602 }
603 let is_nudge_candidate = record.access_count >= 3 && fr.gotcha_keys.is_empty();
605 file_records.push(fr);
606 if is_nudge_candidate {
607 unconfirmed_candidates.push(file_key.clone());
608 }
609 }
610 }
611
612 for key in graph.neighbors(&file_key, &EdgeKind::HasGotcha) {
614 context_gotcha_keys.insert(key);
615 }
616
617 for imported in graph.neighbors(&file_key, &EdgeKind::Imports) {
619 for key in graph.neighbors(&imported, &EdgeKind::HasGotcha) {
620 context_gotcha_keys.insert(key);
621 }
622 }
623
624 for key in graph.neighbors(&file_key, &EdgeKind::AffectedBy) {
626 decision_keys.insert(key);
627 }
628 }
629
630 let mut confirmed_gotchas: Vec<Record> = if context_files.is_empty() {
632 let all_gotchas = store.scan_prefix("gotcha:").await?;
634 all_gotchas
635 .into_iter()
636 .filter(is_injectable_gotcha)
637 .collect()
638 } else {
639 let mut gotchas = Vec::with_capacity(context_gotcha_keys.len());
641 for key in &context_gotcha_keys {
642 if let Ok(Some(record)) = store.get(key).await {
643 if is_injectable_gotcha(&record) {
644 gotchas.push(record);
645 }
646 }
647 }
648 gotchas
649 };
650
651 {
653 let now = chrono::Utc::now();
654 for days_ago in 0..2 {
655 let date = (now - chrono::Duration::days(days_ago)).format("%Y-%m-%d");
656 let review_key = format!("analytics:stale_review_{date}");
657 if let Ok(Some(record)) = store.get(&review_key).await {
658 if let Some(payload) = record.payload_as::<StaleReviewPayload>() {
659 for entry in &payload.entries {
660 if seen_stale_keys.insert(entry.key.clone()) {
661 let path = entry.key.strip_prefix("file:").unwrap_or(&entry.key);
662 stale_warnings.push(format!(
663 "`{path}` staleness {:.2} ({:?}) — review recommended",
664 entry.staleness_value, entry.tier
665 ));
666 }
667 }
668 }
669 }
670 }
671 }
672
673 let mut related_decisions = Vec::new();
675 for key in &decision_keys {
676 if let Ok(Some(record)) = store.get(key).await {
677 related_decisions.push(record);
678 }
679 }
680 if related_decisions.is_empty() {
683 if let Ok(mut all_decisions) = store.scan_prefix("decision:").await {
684 all_decisions.retain(|r| matches!(r.lifecycle, RecordLifecycle::Active));
685 all_decisions.sort_by(|a, b| {
686 b.confidence
687 .value
688 .partial_cmp(&a.confidence.value)
689 .unwrap_or(std::cmp::Ordering::Equal)
690 });
691 const DECISION_FALLBACK_LIMIT: usize = 5;
692 related_decisions = all_decisions
693 .into_iter()
694 .take(DECISION_FALLBACK_LIMIT)
695 .collect();
696 }
697 }
698
699 confirmed_gotchas.sort_by(|a, b| {
701 let score_a = a.confidence.value * priority_weight(&a.priority);
702 let score_b = b.confidence.value * priority_weight(&b.priority);
703 score_b
704 .partial_cmp(&score_a)
705 .unwrap_or(std::cmp::Ordering::Equal)
706 });
707
708 let critical_gotchas: Vec<Record> = confirmed_gotchas
712 .into_iter()
713 .filter(|r| r.quality.tier != QualityTier::Suppressed)
714 .collect();
715
716 let available_tokens = TOKEN_BUDGET - VECTOR_B_TOKENS;
718 let mut sections = Vec::new();
719 let mut used_tokens = 0;
720
721 if let Some(ref stage_record) = stage {
723 let section = format!("## Current Stage\n{}\n", stage_record.value);
724 let tokens = estimate_tokens(§ion);
725 if used_tokens + tokens <= available_tokens {
726 sections.push(section);
727 used_tokens += tokens;
728 }
729 }
730
731 if !critical_gotchas.is_empty() {
733 let mut gotcha_section = String::from("## Gotchas\n");
734
735 for record in &critical_gotchas {
737 if record.key.starts_with("gotcha:cochange:") {
738 continue;
739 }
740 let caveat = if record.staleness.tier == StalenessTier::Liability {
741 " [STALE — verify]"
742 } else if record.quality.tier == QualityTier::Poor {
743 " [LOW QUALITY — verify]"
744 } else {
745 ""
746 };
747 let line = format!("- **{}**{}: {}\n", record.key, caveat, record.value);
748 let tokens = estimate_tokens(&line);
749 if used_tokens + tokens > available_tokens {
750 break;
751 }
752 gotcha_section.push_str(&line);
753 used_tokens += tokens;
754 }
755
756 let mut cochange_map: std::collections::BTreeMap<String, Vec<(String, String)>> =
758 std::collections::BTreeMap::new();
759 for record in &critical_gotchas {
760 if !record.key.starts_with("gotcha:cochange:") {
761 continue;
762 }
763 if let Some(pair) = record.key.strip_prefix("gotcha:cochange:") {
765 if let Some((src, tgt)) = pair.split_once('|') {
766 let pct = record
769 .value
770 .rfind('(')
771 .and_then(|i| {
772 record.value[i + 1..]
773 .find(')')
774 .map(|j| &record.value[i + 1..i + 1 + j])
775 })
776 .unwrap_or("?");
777 cochange_map
778 .entry(src.to_string())
779 .or_default()
780 .push((tgt.to_string(), pct.to_string()));
781 }
782 }
783 }
784 if !cochange_map.is_empty() {
785 let all_pairs: Vec<String> = cochange_map
786 .iter()
787 .flat_map(|(src, targets)| {
788 targets
789 .iter()
790 .map(move |(tgt, pct)| format!("{src}\u{2194}{tgt} ({pct})"))
791 })
792 .collect();
793 let total = all_pairs.len();
794 let display: Vec<&str> = all_pairs.iter().take(10).map(|s| s.as_str()).collect();
796 let suffix = if total > 10 {
797 format!(", +{} more", total - 10)
798 } else {
799 String::new()
800 };
801 let line = format!("- **Co-change partners**: {}{suffix}\n", display.join(", "));
802 let tokens = estimate_tokens(&line);
803 if used_tokens + tokens <= available_tokens {
804 gotcha_section.push_str(&line);
805 used_tokens += tokens;
806 }
807 }
808
809 if gotcha_section.len() > "## Gotchas\n".len() {
810 sections.push(gotcha_section);
811 }
812 }
813
814 if !file_records.is_empty() {
816 let mut file_section = String::from("## Context Files\n");
817 for fr in &file_records {
818 if fr.purpose.is_empty() {
819 continue;
820 }
821 let line = format!("- **{}**: {}\n", fr.path, fr.purpose);
822 let tokens = estimate_tokens(&line);
823 if used_tokens + tokens > available_tokens {
824 break;
825 }
826 file_section.push_str(&line);
827 used_tokens += tokens;
828 }
829 if file_section.len() > "## Context Files\n".len() {
830 sections.push(file_section);
831 }
832 }
833
834 {
837 use crate::analysis::blast_radius::BlastTier;
838 let mut impact_files: Vec<(&FileRecord, f32)> = file_records
839 .iter()
840 .filter_map(|fr| {
841 fr.blast_radius.as_ref().and_then(|br| {
842 if br.tier == BlastTier::Isolated {
843 None
844 } else {
845 Some((fr, br.score))
846 }
847 })
848 })
849 .collect();
850 impact_files.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
851
852 if !impact_files.is_empty() {
853 let mut impact_section = String::from("## Highest Impact Files\n");
854 for (fr, _score) in impact_files.iter().take(3) {
855 let br = fr
856 .blast_radius
857 .as_ref()
858 .expect("filter_map above kept only files with Some(blast_radius)");
859 let line = format!(
860 "- `{}`: {} direct importers ({})\n",
861 fr.path,
862 br.direct,
863 br.tier.label(),
864 );
865 let tokens = estimate_tokens(&line);
866 if used_tokens + tokens > available_tokens {
867 break;
868 }
869 impact_section.push_str(&line);
870 used_tokens += tokens;
871 }
872 if impact_section.len() > "## Highest Impact Files\n".len() {
873 sections.push(impact_section);
874 }
875 }
876 }
877
878 if !stale_warnings.is_empty() {
880 let mut stale_section = String::from("## Stale Warnings\n");
881 for warning in &stale_warnings {
882 let line = format!("- {warning}\n");
883 let tokens = estimate_tokens(&line);
884 if used_tokens + tokens > available_tokens {
885 break;
886 }
887 stale_section.push_str(&line);
888 used_tokens += tokens;
889 }
890 if stale_section.len() > "## Stale Warnings\n".len() {
891 sections.push(stale_section);
892 }
893 }
894
895 if !related_decisions.is_empty() {
897 let mut dec_section = String::from("## Decisions\n");
898 for record in &related_decisions {
899 let line = format!("- **{}**: {}\n", record.key, record.value);
900 let tokens = estimate_tokens(&line);
901 if used_tokens + tokens > available_tokens {
902 break;
903 }
904 dec_section.push_str(&line);
905 used_tokens += tokens;
906 }
907 if dec_section.len() > "## Decisions\n".len() {
908 sections.push(dec_section);
909 }
910 }
911
912 if !unconfirmed_candidates.is_empty() {
919 let mut nudge_section = String::from("## Suggested Actions\n");
920 for key in &unconfirmed_candidates {
921 let path = key.strip_prefix("file:").unwrap_or(key);
922 let line = format!(
923 "- `{path}` is read frequently but has no recorded gotchas. The developer may want to run `mati gotcha add {path}`.\n"
924 );
925 let tokens = estimate_tokens(&line);
926 if used_tokens + tokens > available_tokens {
927 break;
928 }
929 nudge_section.push_str(&line);
930 used_tokens += tokens;
931 }
932 if nudge_section.len() > "## Suggested Actions\n".len() {
933 sections.push(nudge_section);
934 }
935 }
936
937 let mut injection_string = sections.join("\n");
938 injection_string.push_str(VECTOR_B);
939
940 let token_estimate = estimate_tokens(&injection_string) as u32;
941
942 Ok(ContextPacket {
943 stage,
944 critical_gotchas,
945 file_records,
946 related_decisions,
947 recent_session: None,
948 token_estimate,
949 stale_warnings,
950 unconfirmed_candidates,
951 knowledge_gaps: vec![],
952 compliance_rate: None,
953 injection_string,
954 })
955}
956
957#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::store::record::*;
963 use crate::store::Store;
964 use tempfile::TempDir;
965
966 fn device_id() -> uuid::Uuid {
967 uuid::Uuid::nil()
968 }
969
970 fn now() -> u64 {
971 1_700_000_000
972 }
973
974 fn make_record(key: &str, value: &str, category: Category, quality_value: f32) -> Record {
975 Record {
976 key: key.to_string(),
977 value: value.to_string(),
978 category,
979 priority: Priority::Normal,
980 tags: vec![],
981 created_at: now(),
982 updated_at: now(),
983 ref_url: None,
984 staleness: StalenessScore::fresh(),
985 lifecycle: RecordLifecycle::Active,
986 version: RecordVersion {
987 device_id: device_id(),
988 logical_clock: 1,
989 wall_clock: now(),
990 },
991 quality: QualityScore {
992 value: quality_value,
993 tier: QualityScore::tier_from_value(quality_value),
994 signals: vec![],
995 computed_at: now(),
996 },
997 access_count: 0,
998 last_accessed: 0,
999 source: RecordSource::DeveloperManual,
1000 confidence: ConfidenceScore {
1001 value: 0.8,
1002 confirmation_count: 1,
1003 contributor_count: 1,
1004 last_challenged: None,
1005 challenge_count: 0,
1006 },
1007 gap_analysis_score: 0.0,
1008 payload: Some(serde_json::json!({})),
1009 }
1010 }
1011
1012 fn make_gotcha_record(key: &str, rule: &str, confirmed: bool, quality_value: f32) -> Record {
1013 let gotcha = GotchaRecord {
1014 rule: rule.to_string(),
1015 reason: "test reason".to_string(),
1016 severity: Priority::High,
1017 affected_files: vec![],
1018 ref_url: None,
1019 discovered_session: now(),
1020 confirmed,
1021 };
1022 let mut record = make_record(key, rule, Category::Gotcha, quality_value);
1023 record.payload = serde_json::to_value(&gotcha).ok();
1024 record
1025 }
1026
1027 fn handler_test_ctx() -> crate::mcp::dispatch_v2::RequestContext {
1036 crate::mcp::dispatch_v2::RequestContext {
1037 peer: crate::mcp::metadata::PeerContext {
1038 uid: 501,
1039 pid: Some(99999),
1040 },
1041 daemon_session: uuid::Uuid::nil(),
1042 repo_root: std::path::PathBuf::new(),
1043 }
1044 }
1045
1046 async fn call_mem_get(
1047 graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1048 key: &str,
1049 ) -> String {
1050 let ctx = handler_test_ctx();
1051 let input = crate::mcp::protocol::MemGetInput {
1052 key: key.to_string(),
1053 };
1054 let g = graph_arc.read().await;
1055 match crate::mcp::handlers::handle_mem_get(
1056 g.store(),
1057 graph_arc,
1058 &ctx,
1059 uuid::Uuid::new_v4(),
1060 &input,
1061 )
1062 .await
1063 {
1064 Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()),
1065 Err((_code, msg)) => format!("{{\"error\": \"{}\"}}", msg.replace('"', "\\\"")),
1066 }
1067 }
1068
1069 async fn call_mem_query(
1070 graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1071 query: &str,
1072 mode: crate::mcp::protocol::QueryMode,
1073 limit: u32,
1074 ) -> String {
1075 let input = crate::mcp::protocol::MemQueryInput {
1076 query: query.to_string(),
1077 mode,
1078 limit,
1079 };
1080 let g = graph_arc.read().await;
1081 match crate::mcp::handlers::handle_mem_query(g.store(), &g, &input).await {
1082 Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()),
1083 Err((_code, msg)) => format!("{{\"error\": \"{}\"}}", msg.replace('"', "\\\"")),
1084 }
1085 }
1086
1087 async fn call_mem_bootstrap(
1088 graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1089 context_files: Vec<String>,
1090 ) -> String {
1091 let ctx = handler_test_ctx();
1092 let input = crate::mcp::protocol::MemBootstrapInput { context_files };
1093 let g = graph_arc.read().await;
1094 match crate::mcp::handlers::handle_mem_bootstrap(
1095 g.store(),
1096 &g,
1097 graph_arc,
1098 &ctx,
1099 uuid::Uuid::new_v4(),
1100 &input,
1101 )
1102 .await
1103 {
1104 Ok(s) => s,
1105 Err((_code, msg)) => format!("[mati] bootstrap error: {msg}"),
1106 }
1107 }
1108
1109 async fn call_mem_set(
1110 graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1111 params: crate::mcp::types::MemSetParams,
1112 ) -> String {
1113 let ctx = handler_test_ctx();
1114 crate::mcp::handlers::handle_mem_set(graph_arc, &ctx, uuid::Uuid::new_v4(), ¶ms).await
1115 }
1116
1117 #[tokio::test]
1120 async fn mem_get_returns_null_for_nonexistent_key() {
1121 let dir = TempDir::new().unwrap();
1122 let store = Store::open(dir.path()).await.unwrap();
1123 let graph = Graph::load(store).await.unwrap();
1124 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1125
1126 let result = call_mem_get(&graph_arc, "file:nonexistent.rs").await;
1127 assert_eq!(result, "null");
1128 }
1129
1130 #[tokio::test]
1131 async fn mem_get_returns_record_for_existing_key() {
1132 let dir = TempDir::new().unwrap();
1133 let store = Store::open(dir.path()).await.unwrap();
1134 let record = make_record("gotcha:test", "test value", Category::Gotcha, 0.8);
1135 store.put("gotcha:test", &record).await.unwrap();
1136
1137 let graph = Graph::load(store).await.unwrap();
1138 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1139
1140 let result = call_mem_get(&graph_arc, "gotcha:test").await;
1141 assert!(result.contains("gotcha:test"));
1142 assert!(result.contains("test value"));
1143 }
1144
1145 #[tokio::test]
1146 async fn mem_get_blast_radius_warning_for_critical_file() {
1147 let dir = TempDir::new().unwrap();
1148 let store = Store::open(dir.path()).await.unwrap();
1149
1150 let fr = FileRecord {
1151 path: "src/core.rs".to_string(),
1152 purpose: "Core module".to_string(),
1153 entry_points: vec![],
1154 imports: vec![],
1155 gotcha_keys: vec![],
1156 decision_keys: vec![],
1157 todos: vec![],
1158 unsafe_count: 0,
1159 unwrap_count: 0,
1160 change_frequency: 0,
1161 last_author: None,
1162 is_hotspot: false,
1163 token_cost_estimate: 100,
1164 last_modified_session: 0,
1165 content_hash: None,
1166 line_count: 0,
1167 blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1168 direct: 45,
1169 transitive: 10,
1170 score: 48.0,
1171 tier: crate::analysis::blast_radius::BlastTier::Critical,
1172 }),
1173 propagated_staleness: None,
1174 };
1175 let mut record = make_record("file:src/core.rs", "Core module", Category::File, 0.5);
1176 record.payload = serde_json::to_value(&fr).ok();
1177 store.put("file:src/core.rs", &record).await.unwrap();
1178
1179 let graph = Graph::load(store).await.unwrap();
1180 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1181
1182 let result = call_mem_get(&graph_arc, "file:src/core.rs").await;
1183
1184 assert!(
1185 result.contains("HIGH IMPACT FILE"),
1186 "response must contain blast radius warning for critical file, got: {result}"
1187 );
1188 assert!(result.contains("45"), "warning must include direct count");
1189 }
1190
1191 #[tokio::test]
1192 async fn mem_get_no_blast_warning_for_low_file() {
1193 let dir = TempDir::new().unwrap();
1194 let store = Store::open(dir.path()).await.unwrap();
1195
1196 let fr = FileRecord {
1197 path: "src/leaf.rs".to_string(),
1198 purpose: "Leaf module".to_string(),
1199 entry_points: vec![],
1200 imports: vec![],
1201 gotcha_keys: vec![],
1202 decision_keys: vec![],
1203 todos: vec![],
1204 unsafe_count: 0,
1205 unwrap_count: 0,
1206 change_frequency: 0,
1207 last_author: None,
1208 is_hotspot: false,
1209 token_cost_estimate: 100,
1210 last_modified_session: 0,
1211 content_hash: None,
1212 line_count: 0,
1213 blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1214 direct: 2,
1215 transitive: 0,
1216 score: 2.0,
1217 tier: crate::analysis::blast_radius::BlastTier::Low,
1218 }),
1219 propagated_staleness: None,
1220 };
1221 let mut record = make_record("file:src/leaf.rs", "Leaf module", Category::File, 0.5);
1222 record.payload = serde_json::to_value(&fr).ok();
1223 store.put("file:src/leaf.rs", &record).await.unwrap();
1224
1225 let graph = Graph::load(store).await.unwrap();
1226 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1227
1228 let result = call_mem_get(&graph_arc, "file:src/leaf.rs").await;
1229
1230 assert!(
1231 !result.contains("HIGH IMPACT FILE"),
1232 "low blast radius file should NOT have warning"
1233 );
1234 }
1235
1236 #[tokio::test]
1239 async fn mem_get_includes_depth_hint_for_file_records() {
1240 let dir = TempDir::new().unwrap();
1241 let store = Store::open(dir.path()).await.unwrap();
1242
1243 let fr = FileRecord {
1245 path: "src/tiny.rs".to_string(),
1246 purpose: "Tiny leaf module".to_string(),
1247 entry_points: vec![],
1248 imports: vec![],
1249 gotcha_keys: vec![],
1250 decision_keys: vec![],
1251 todos: vec![],
1252 unsafe_count: 0,
1253 unwrap_count: 0,
1254 change_frequency: 0,
1255 last_author: None,
1256 is_hotspot: false,
1257 token_cost_estimate: 100,
1258 last_modified_session: 0,
1259 content_hash: None,
1260 line_count: 50,
1261 blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1262 direct: 0,
1263 transitive: 0,
1264 score: 0.0,
1265 tier: crate::analysis::blast_radius::BlastTier::Isolated,
1266 }),
1267 propagated_staleness: None,
1268 };
1269 let mut record = make_record("file:src/tiny.rs", "Tiny leaf", Category::File, 0.5);
1270 record.payload = serde_json::to_value(&fr).ok();
1271 store.put("file:src/tiny.rs", &record).await.unwrap();
1272
1273 let graph = Graph::load(store).await.unwrap();
1274 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1275
1276 let result = call_mem_get(&graph_arc, "file:src/tiny.rs").await;
1277 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1278 assert_eq!(
1279 parsed.get("enrichment_depth_hint").and_then(|v| v.as_str()),
1280 Some("fast"),
1281 "tiny isolated file should hint Fast tier; got: {result}"
1282 );
1283 }
1284
1285 #[tokio::test]
1286 async fn mem_get_depth_hint_for_hotspot_is_deep() {
1287 let dir = TempDir::new().unwrap();
1288 let store = Store::open(dir.path()).await.unwrap();
1289
1290 let fr = FileRecord {
1292 path: "src/core.rs".to_string(),
1293 purpose: "Core hotspot".to_string(),
1294 entry_points: vec![],
1295 imports: vec![],
1296 gotcha_keys: vec![],
1297 decision_keys: vec![],
1298 todos: vec![],
1299 unsafe_count: 0,
1300 unwrap_count: 0,
1301 change_frequency: 0,
1302 last_author: None,
1303 is_hotspot: true,
1304 token_cost_estimate: 5000,
1305 last_modified_session: 0,
1306 content_hash: None,
1307 line_count: 500,
1308 blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1309 direct: 20,
1310 transitive: 30,
1311 score: 35.0,
1312 tier: crate::analysis::blast_radius::BlastTier::High,
1313 }),
1314 propagated_staleness: None,
1315 };
1316 let mut record = make_record("file:src/core.rs", "Core hotspot", Category::File, 0.5);
1317 record.payload = serde_json::to_value(&fr).ok();
1318 store.put("file:src/core.rs", &record).await.unwrap();
1319
1320 let graph = Graph::load(store).await.unwrap();
1321 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1322
1323 let result = call_mem_get(&graph_arc, "file:src/core.rs").await;
1324 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1325 assert_eq!(
1326 parsed.get("enrichment_depth_hint").and_then(|v| v.as_str()),
1327 Some("deep"),
1328 "large hotspot file should hint Deep tier; got: {result}"
1329 );
1330 }
1331
1332 #[tokio::test]
1333 async fn mem_get_omits_depth_hint_for_non_file_records() {
1334 let dir = TempDir::new().unwrap();
1335 let store = Store::open(dir.path()).await.unwrap();
1336
1337 let record = make_record("gotcha:foo", "rule", Category::Gotcha, 0.6);
1338 store.put("gotcha:foo", &record).await.unwrap();
1339
1340 let graph = Graph::load(store).await.unwrap();
1341 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1342
1343 let result = call_mem_get(&graph_arc, "gotcha:foo").await;
1344 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1345 assert!(
1347 parsed.get("enrichment_depth_hint").is_none(),
1348 "non-file records should not carry enrichment_depth_hint; got: {result}"
1349 );
1350 }
1351
1352 #[tokio::test]
1355 async fn mem_query_text_mode_returns_results() {
1356 let dir = TempDir::new().unwrap();
1357 let store = Store::open(dir.path()).await.unwrap();
1358 let record = make_record(
1359 "gotcha:async-race",
1360 "never use inference in async context",
1361 Category::Gotcha,
1362 0.8,
1363 );
1364 store.put("gotcha:async-race", &record).await.unwrap();
1365
1366 let graph = Graph::load(store).await.unwrap();
1367 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1368
1369 let result = call_mem_query(
1370 &graph_arc,
1371 "inference",
1372 crate::mcp::protocol::QueryMode::Text,
1373 10,
1374 )
1375 .await;
1376 assert!(result.contains("gotcha:async-race"));
1377 }
1378
1379 #[tokio::test]
1387 async fn mem_query_semantic_returns_feature_gate_error() {
1388 let dir = TempDir::new().unwrap();
1389 let store = Store::open(dir.path()).await.unwrap();
1390 let graph = Graph::load(store).await.unwrap();
1391 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1392
1393 let result = call_mem_query(
1394 &graph_arc,
1395 "test",
1396 crate::mcp::protocol::QueryMode::Semantic,
1397 20,
1398 )
1399 .await;
1400 assert!(
1401 result.contains("--features semantic"),
1402 "semantic mode must surface feature-gate error, got: {result}"
1403 );
1404 }
1405
1406 #[tokio::test]
1409 async fn mem_bootstrap_empty_store_returns_vector_b() {
1410 let dir = TempDir::new().unwrap();
1411 let store = Store::open(dir.path()).await.unwrap();
1412 let graph = Graph::load(store).await.unwrap();
1413 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1414
1415 let result = call_mem_bootstrap(&graph_arc, vec![]).await;
1416 assert!(result.contains("[mati] Before reading any file"));
1417 assert!(result.contains("mem_get"));
1418 }
1419
1420 #[tokio::test]
1421 async fn mem_bootstrap_token_budget_never_exceeds_2000() {
1422 let dir = TempDir::new().unwrap();
1423 let store = Store::open(dir.path()).await.unwrap();
1424
1425 for i in 0..100 {
1427 let record = make_gotcha_record(
1428 &format!("gotcha:test-{i:03}"),
1429 &format!("This is a very long gotcha rule number {i} with lots of text to fill up the token budget and ensure we test the truncation logic properly"),
1430 true,
1431 0.8,
1432 );
1433 store.put(&record.key, &record).await.unwrap();
1434 }
1435
1436 let graph = Graph::load(store).await.unwrap();
1437
1438 let packet = assemble_context_packet(graph.store(), &graph, &[])
1439 .await
1440 .unwrap();
1441 let tokens = estimate_tokens(&packet.injection_string);
1442 assert!(
1443 tokens <= TOKEN_BUDGET,
1444 "token estimate {tokens} exceeds budget {TOKEN_BUDGET}"
1445 );
1446 }
1447
1448 #[tokio::test]
1449 async fn quality_filter_suppressed_excluded() {
1450 let dir = TempDir::new().unwrap();
1451 let store = Store::open(dir.path()).await.unwrap();
1452
1453 let suppressed = make_gotcha_record("gotcha:suppressed", "bad rule", true, 0.10);
1455 store.put("gotcha:suppressed", &suppressed).await.unwrap();
1456
1457 let good = make_gotcha_record("gotcha:good", "good rule", true, 0.80);
1459 store.put("gotcha:good", &good).await.unwrap();
1460
1461 let graph = Graph::load(store).await.unwrap();
1462 let packet = assemble_context_packet(graph.store(), &graph, &[])
1463 .await
1464 .unwrap();
1465
1466 assert!(
1467 !packet.injection_string.contains("gotcha:suppressed"),
1468 "suppressed gotcha must not appear in injection"
1469 );
1470 }
1471
1472 #[tokio::test]
1473 async fn quality_filter_poor_caveated() {
1474 let dir = TempDir::new().unwrap();
1475 let store = Store::open(dir.path()).await.unwrap();
1476
1477 let poor = make_gotcha_record("gotcha:poor", "poor rule", true, 0.30);
1479 store.put("gotcha:poor", &poor).await.unwrap();
1480
1481 let graph = Graph::load(store).await.unwrap();
1482 let packet = assemble_context_packet(graph.store(), &graph, &[])
1483 .await
1484 .unwrap();
1485
1486 if packet.injection_string.contains("gotcha:poor") {
1488 assert!(
1489 packet.injection_string.contains("LOW QUALITY"),
1490 "poor quality gotcha must be caveated"
1491 );
1492 }
1493 }
1494
1495 #[tokio::test]
1496 async fn assemble_context_packet_with_context_files_does_graph_traversal() {
1497 let dir = TempDir::new().unwrap();
1498 let store = Store::open(dir.path()).await.unwrap();
1499
1500 let gotcha = make_gotcha_record("gotcha:important", "do not use unwrap", true, 0.80);
1502 store.put("gotcha:important", &gotcha).await.unwrap();
1503
1504 let file_record = make_record("file:src/main.rs", "{}", Category::File, 0.5);
1506 store.put("file:src/main.rs", &file_record).await.unwrap();
1507
1508 let mut graph = Graph::load(store).await.unwrap();
1510 graph
1511 .add_edge("file:src/main.rs", EdgeKind::HasGotcha, "gotcha:important")
1512 .await
1513 .unwrap();
1514
1515 let packet = assemble_context_packet(graph.store(), &graph, &["src/main.rs".to_string()])
1516 .await
1517 .unwrap();
1518
1519 assert!(
1521 packet.injection_string.contains("gotcha:important")
1522 || packet
1523 .critical_gotchas
1524 .iter()
1525 .any(|g| g.key == "gotcha:important"),
1526 "graph-connected gotcha must appear in context packet"
1527 );
1528 }
1529
1530 #[tokio::test]
1531 async fn assemble_context_packet_excludes_unrelated_gotchas_for_context_files() {
1532 let dir = TempDir::new().unwrap();
1533 let store = Store::open(dir.path()).await.unwrap();
1534
1535 let relevant = make_gotcha_record("gotcha:relevant", "do not use unwrap", true, 0.80);
1536 let unrelated = make_gotcha_record("gotcha:unrelated", "keep retries bounded", true, 0.80);
1537 store.put("gotcha:relevant", &relevant).await.unwrap();
1538 store.put("gotcha:unrelated", &unrelated).await.unwrap();
1539
1540 let file_record = make_record("file:src/main.rs", "{}", Category::File, 0.5);
1541 store.put("file:src/main.rs", &file_record).await.unwrap();
1542
1543 let mut graph = Graph::load(store).await.unwrap();
1544 graph
1545 .add_edge("file:src/main.rs", EdgeKind::HasGotcha, "gotcha:relevant")
1546 .await
1547 .unwrap();
1548
1549 let packet = assemble_context_packet(graph.store(), &graph, &["src/main.rs".to_string()])
1550 .await
1551 .unwrap();
1552
1553 assert!(
1554 packet
1555 .critical_gotchas
1556 .iter()
1557 .any(|g| g.key == "gotcha:relevant"),
1558 "graph-connected gotcha must remain in context packet"
1559 );
1560 assert!(
1561 !packet
1562 .critical_gotchas
1563 .iter()
1564 .any(|g| g.key == "gotcha:unrelated"),
1565 "unrelated gotcha must not be injected for scoped bootstrap"
1566 );
1567 assert!(
1568 !packet.injection_string.contains("gotcha:unrelated"),
1569 "injection string must not mention unrelated gotchas"
1570 );
1571 }
1572
1573 #[tokio::test]
1583 async fn bootstrap_surfaces_confirmed_gotcha_when_graph_edge_missing() {
1584 let dir = TempDir::new().unwrap();
1585 let store = Store::open(dir.path()).await.unwrap();
1586
1587 let gotcha = make_gotcha_record(
1589 "gotcha:never-remove-rate-limit",
1590 "Never remove the rate limit check on incoming pipeline events because \
1591 removing it caused a cascade failure in staging",
1592 true,
1593 0.80,
1594 );
1595 store
1596 .put("gotcha:never-remove-rate-limit", &gotcha)
1597 .await
1598 .unwrap();
1599
1600 let file_record = {
1602 let fr = FileRecord {
1603 path: "src/pipeline/prefilter.rs".to_string(),
1604 purpose: String::new(), entry_points: vec![],
1606 imports: vec![],
1607 gotcha_keys: vec!["gotcha:never-remove-rate-limit".to_string()],
1608 decision_keys: vec![],
1609 todos: vec![],
1610 unsafe_count: 0,
1611 unwrap_count: 0,
1612 change_frequency: 18,
1613 last_author: Some("dev".to_string()),
1614 is_hotspot: true,
1615 token_cost_estimate: 0,
1616 last_modified_session: now(),
1617 content_hash: None,
1618 line_count: 0,
1619 blast_radius: None,
1620 propagated_staleness: None,
1621 };
1622 let mut r = make_record(
1623 "file:src/pipeline/prefilter.rs",
1624 "",
1625 Category::File,
1626 0.10, );
1628 r.payload = serde_json::to_value(&fr).ok();
1629 r
1630 };
1631 store
1632 .put("file:src/pipeline/prefilter.rs", &file_record)
1633 .await
1634 .unwrap();
1635
1636 let graph = Graph::load(store).await.unwrap();
1640 assert_eq!(
1641 graph.neighbors("file:src/pipeline/prefilter.rs", &EdgeKind::HasGotcha),
1642 Vec::<String>::new(),
1643 "test setup: graph must have no HasGotcha edge"
1644 );
1645
1646 let packet = assemble_context_packet(
1647 graph.store(),
1648 &graph,
1649 &["src/pipeline/prefilter.rs".to_string()],
1650 )
1651 .await
1652 .unwrap();
1653
1654 assert!(
1655 packet
1656 .critical_gotchas
1657 .iter()
1658 .any(|g| g.key == "gotcha:never-remove-rate-limit"),
1659 "bootstrap must surface confirmed gotcha even when graph edge is missing"
1660 );
1661 assert!(
1662 packet
1663 .injection_string
1664 .contains("gotcha:never-remove-rate-limit"),
1665 "injection string must include the gotcha"
1666 );
1667 }
1668
1669 #[tokio::test]
1672 async fn bootstrap_low_confidence_file_with_no_gotchas_returns_minimal_packet() {
1673 let dir = TempDir::new().unwrap();
1674 let store = Store::open(dir.path()).await.unwrap();
1675
1676 let file_record = {
1677 let fr = FileRecord {
1678 path: "src/empty.rs".to_string(),
1679 purpose: String::new(),
1680 entry_points: vec![],
1681 imports: vec![],
1682 gotcha_keys: vec![],
1683 decision_keys: vec![],
1684 todos: vec![],
1685 unsafe_count: 0,
1686 unwrap_count: 0,
1687 change_frequency: 1,
1688 last_author: None,
1689 is_hotspot: false,
1690 token_cost_estimate: 0,
1691 last_modified_session: now(),
1692 content_hash: None,
1693 line_count: 0,
1694 blast_radius: None,
1695 propagated_staleness: None,
1696 };
1697 let mut r = make_record("file:src/empty.rs", "", Category::File, 0.10);
1698 r.payload = serde_json::to_value(&fr).ok();
1699 r
1700 };
1701 store.put("file:src/empty.rs", &file_record).await.unwrap();
1702
1703 let graph = Graph::load(store).await.unwrap();
1704 let packet = assemble_context_packet(graph.store(), &graph, &["src/empty.rs".to_string()])
1705 .await
1706 .unwrap();
1707
1708 assert!(
1709 packet.critical_gotchas.is_empty(),
1710 "no gotchas should be surfaced for a file with no linked gotchas"
1711 );
1712 assert!(
1713 !packet.injection_string.contains("gotcha:"),
1714 "injection string must not mention any gotcha keys"
1715 );
1716 }
1717
1718 #[tokio::test]
1721 async fn nudge_shown_for_hot_file_with_no_gotchas() {
1722 let dir = TempDir::new().unwrap();
1723 let store = Store::open(dir.path()).await.unwrap();
1724
1725 let fr = FileRecord {
1726 path: "src/hot.rs".to_string(),
1727 purpose: "Hot module".to_string(),
1728 entry_points: vec!["run".to_string()],
1729 imports: vec![],
1730 gotcha_keys: vec![], decision_keys: vec![],
1732 todos: vec![],
1733 unsafe_count: 0,
1734 unwrap_count: 0,
1735 change_frequency: 10,
1736 last_author: None,
1737 is_hotspot: true,
1738 token_cost_estimate: 100,
1739 last_modified_session: now(),
1740 content_hash: None,
1741 line_count: 0,
1742 blast_radius: None,
1743 propagated_staleness: None,
1744 };
1745 let mut file_record = make_record("file:src/hot.rs", &fr.purpose, Category::File, 0.5);
1746 file_record.payload = serde_json::to_value(&fr).ok();
1747 file_record.access_count = 5; store.put("file:src/hot.rs", &file_record).await.unwrap();
1749
1750 let graph = Graph::load(store).await.unwrap();
1751 let packet = assemble_context_packet(graph.store(), &graph, &["src/hot.rs".to_string()])
1752 .await
1753 .unwrap();
1754
1755 assert!(
1756 packet
1757 .unconfirmed_candidates
1758 .contains(&"file:src/hot.rs".to_string()),
1759 "hot file with no gotchas should be in unconfirmed_candidates"
1760 );
1761 assert!(
1762 packet.injection_string.contains("Suggested Actions"),
1763 "nudge section should appear in injection string"
1764 );
1765 assert!(
1766 packet.injection_string.contains("mati gotcha add"),
1767 "nudge should suggest gotcha add command"
1768 );
1769 }
1770
1771 #[tokio::test]
1772 async fn no_nudge_for_file_with_low_access_count() {
1773 let dir = TempDir::new().unwrap();
1774 let store = Store::open(dir.path()).await.unwrap();
1775
1776 let fr = FileRecord {
1777 path: "src/cold.rs".to_string(),
1778 purpose: "Cold module".to_string(),
1779 entry_points: vec![],
1780 imports: vec![],
1781 gotcha_keys: vec![],
1782 decision_keys: vec![],
1783 todos: vec![],
1784 unsafe_count: 0,
1785 unwrap_count: 0,
1786 change_frequency: 0,
1787 last_author: None,
1788 is_hotspot: false,
1789 token_cost_estimate: 50,
1790 last_modified_session: now(),
1791 content_hash: None,
1792 line_count: 0,
1793 blast_radius: None,
1794 propagated_staleness: None,
1795 };
1796 let mut file_record = make_record("file:src/cold.rs", &fr.purpose, Category::File, 0.5);
1797 file_record.payload = serde_json::to_value(&fr).ok();
1798 file_record.access_count = 1; store.put("file:src/cold.rs", &file_record).await.unwrap();
1800
1801 let graph = Graph::load(store).await.unwrap();
1802 let packet = assemble_context_packet(graph.store(), &graph, &["src/cold.rs".to_string()])
1803 .await
1804 .unwrap();
1805
1806 assert!(
1807 packet.unconfirmed_candidates.is_empty(),
1808 "low-access file should not trigger nudge"
1809 );
1810 }
1811
1812 #[tokio::test]
1813 async fn no_nudge_for_file_with_gotchas() {
1814 let dir = TempDir::new().unwrap();
1815 let store = Store::open(dir.path()).await.unwrap();
1816
1817 let fr = FileRecord {
1818 path: "src/covered.rs".to_string(),
1819 purpose: "Covered module".to_string(),
1820 entry_points: vec![],
1821 imports: vec![],
1822 gotcha_keys: vec!["gotcha:existing".to_string()],
1823 decision_keys: vec![],
1824 todos: vec![],
1825 unsafe_count: 0,
1826 unwrap_count: 0,
1827 change_frequency: 10,
1828 last_author: None,
1829 is_hotspot: true,
1830 token_cost_estimate: 100,
1831 last_modified_session: now(),
1832 content_hash: None,
1833 line_count: 0,
1834 blast_radius: None,
1835 propagated_staleness: None,
1836 };
1837 let mut file_record = make_record(
1838 "file:src/covered.rs",
1839 &serde_json::to_string(&fr).unwrap(),
1840 Category::File,
1841 0.5,
1842 );
1843 file_record.access_count = 10;
1844 store
1845 .put("file:src/covered.rs", &file_record)
1846 .await
1847 .unwrap();
1848
1849 let graph = Graph::load(store).await.unwrap();
1850 let packet =
1851 assemble_context_packet(graph.store(), &graph, &["src/covered.rs".to_string()])
1852 .await
1853 .unwrap();
1854
1855 assert!(
1856 packet.unconfirmed_candidates.is_empty(),
1857 "file with gotchas should not trigger nudge"
1858 );
1859 }
1860
1861 #[tokio::test]
1864 async fn tombstone_gotcha_excluded_from_bootstrap() {
1865 let dir = TempDir::new().unwrap();
1866 let store = Store::open(dir.path()).await.unwrap();
1867
1868 let mut gotcha = make_gotcha_record("gotcha:tombstone", "tombstone rule", true, 0.80);
1870 gotcha.staleness = StalenessScore {
1871 value: 0.95,
1872 tier: StalenessTier::Tombstone,
1873 signals: vec![],
1874 computed_at: now(),
1875 last_record_sha: String::new(),
1876 };
1877 store.put("gotcha:tombstone", &gotcha).await.unwrap();
1878
1879 let good = make_gotcha_record("gotcha:good", "good rule", true, 0.80);
1881 store.put("gotcha:good", &good).await.unwrap();
1882
1883 let graph = Graph::load(store).await.unwrap();
1884 let packet = assemble_context_packet(graph.store(), &graph, &[])
1885 .await
1886 .unwrap();
1887
1888 assert!(
1889 !packet.injection_string.contains("gotcha:tombstone"),
1890 "tombstone gotcha must not appear in injection"
1891 );
1892 assert!(
1893 packet.injection_string.contains("gotcha:good"),
1894 "normal gotcha should appear"
1895 );
1896 }
1897
1898 #[tokio::test]
1899 async fn liability_gotcha_gets_stale_caveat() {
1900 let dir = TempDir::new().unwrap();
1901 let store = Store::open(dir.path()).await.unwrap();
1902
1903 let mut gotcha = make_gotcha_record("gotcha:liability", "liability rule", true, 0.80);
1904 gotcha.staleness = StalenessScore {
1905 value: 0.75,
1906 tier: StalenessTier::Liability,
1907 signals: vec![],
1908 computed_at: now(),
1909 last_record_sha: String::new(),
1910 };
1911 store.put("gotcha:liability", &gotcha).await.unwrap();
1912
1913 let graph = Graph::load(store).await.unwrap();
1914 let packet = assemble_context_packet(graph.store(), &graph, &[])
1915 .await
1916 .unwrap();
1917
1918 if packet.injection_string.contains("gotcha:liability") {
1919 assert!(
1920 packet.injection_string.contains("STALE"),
1921 "liability gotcha must have STALE caveat"
1922 );
1923 }
1924 }
1925
1926 #[tokio::test]
1927 async fn stale_file_generates_warning() {
1928 let dir = TempDir::new().unwrap();
1929 let store = Store::open(dir.path()).await.unwrap();
1930
1931 let fr = FileRecord {
1932 path: "src/stale.rs".to_string(),
1933 purpose: "Stale module".to_string(),
1934 entry_points: vec![],
1935 imports: vec![],
1936 gotcha_keys: vec![],
1937 decision_keys: vec![],
1938 todos: vec![],
1939 unsafe_count: 0,
1940 unwrap_count: 0,
1941 change_frequency: 0,
1942 last_author: None,
1943 is_hotspot: false,
1944 token_cost_estimate: 50,
1945 last_modified_session: now(),
1946 content_hash: None,
1947 line_count: 0,
1948 blast_radius: None,
1949 propagated_staleness: None,
1950 };
1951 let mut file_record = make_record(
1952 "file:src/stale.rs",
1953 &serde_json::to_string(&fr).unwrap(),
1954 Category::File,
1955 0.5,
1956 );
1957 file_record.staleness = StalenessScore {
1958 value: 0.55,
1959 tier: StalenessTier::Stale,
1960 signals: vec![],
1961 computed_at: now(),
1962 last_record_sha: String::new(),
1963 };
1964 store.put("file:src/stale.rs", &file_record).await.unwrap();
1965
1966 let graph = Graph::load(store).await.unwrap();
1967 let packet = assemble_context_packet(graph.store(), &graph, &["src/stale.rs".to_string()])
1968 .await
1969 .unwrap();
1970
1971 assert!(
1972 !packet.stale_warnings.is_empty(),
1973 "stale file should generate a warning"
1974 );
1975 assert!(
1976 packet.stale_warnings.iter().any(|w| w.contains("stale.rs")),
1977 "warning should mention the stale file"
1978 );
1979 }
1980
1981 #[tokio::test]
1982 async fn tombstone_file_excluded_from_traversal() {
1983 let dir = TempDir::new().unwrap();
1984 let store = Store::open(dir.path()).await.unwrap();
1985
1986 let fr = FileRecord {
1987 path: "src/dead.rs".to_string(),
1988 purpose: "Dead module".to_string(),
1989 entry_points: vec![],
1990 imports: vec![],
1991 gotcha_keys: vec![],
1992 decision_keys: vec![],
1993 todos: vec![],
1994 unsafe_count: 0,
1995 unwrap_count: 0,
1996 change_frequency: 0,
1997 last_author: None,
1998 is_hotspot: false,
1999 token_cost_estimate: 50,
2000 last_modified_session: now(),
2001 content_hash: None,
2002 line_count: 0,
2003 blast_radius: None,
2004 propagated_staleness: None,
2005 };
2006 let mut file_record = make_record(
2007 "file:src/dead.rs",
2008 &serde_json::to_string(&fr).unwrap(),
2009 Category::File,
2010 0.5,
2011 );
2012 file_record.staleness = StalenessScore {
2013 value: 0.95,
2014 tier: StalenessTier::Tombstone,
2015 signals: vec![],
2016 computed_at: now(),
2017 last_record_sha: String::new(),
2018 };
2019 store.put("file:src/dead.rs", &file_record).await.unwrap();
2020
2021 let graph = Graph::load(store).await.unwrap();
2022 let packet = assemble_context_packet(graph.store(), &graph, &["src/dead.rs".to_string()])
2023 .await
2024 .unwrap();
2025
2026 assert!(
2027 packet.file_records.is_empty(),
2028 "tombstone file should not appear in file_records"
2029 );
2030 }
2031
2032 #[tokio::test]
2033 async fn stale_warnings_deduplicated() {
2034 let dir = TempDir::new().unwrap();
2035 let store = Store::open(dir.path()).await.unwrap();
2036
2037 let fr = FileRecord {
2038 path: "src/dup.rs".to_string(),
2039 purpose: "Dup module".to_string(),
2040 entry_points: vec![],
2041 imports: vec![],
2042 gotcha_keys: vec![],
2043 decision_keys: vec![],
2044 todos: vec![],
2045 unsafe_count: 0,
2046 unwrap_count: 0,
2047 change_frequency: 0,
2048 last_author: None,
2049 is_hotspot: false,
2050 token_cost_estimate: 50,
2051 last_modified_session: now(),
2052 content_hash: None,
2053 line_count: 0,
2054 blast_radius: None,
2055 propagated_staleness: None,
2056 };
2057 let mut file_record = make_record(
2058 "file:src/dup.rs",
2059 &serde_json::to_string(&fr).unwrap(),
2060 Category::File,
2061 0.5,
2062 );
2063 file_record.staleness = StalenessScore {
2064 value: 0.55,
2065 tier: StalenessTier::Stale,
2066 signals: vec![],
2067 computed_at: now(),
2068 last_record_sha: String::new(),
2069 };
2070 store.put("file:src/dup.rs", &file_record).await.unwrap();
2071
2072 let review_payload = StaleReviewPayload {
2074 session_timestamp: now(),
2075 entries: vec![StaleReviewEntry {
2076 key: "file:src/dup.rs".to_string(),
2077 staleness_value: 0.55,
2078 tier: StalenessTier::Stale,
2079 last_updated: now(),
2080 signals: vec!["stale".to_string()],
2081 }],
2082 };
2083 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
2084 let review_key = format!("analytics:stale_review_{today}");
2085 let review_record = make_record(
2086 &review_key,
2087 &serde_json::to_string(&review_payload).unwrap(),
2088 Category::Analytics,
2089 0.5,
2090 );
2091 store.put(&review_key, &review_record).await.unwrap();
2092
2093 let graph = Graph::load(store).await.unwrap();
2094 let packet = assemble_context_packet(graph.store(), &graph, &["src/dup.rs".to_string()])
2095 .await
2096 .unwrap();
2097
2098 let dup_count = packet
2100 .stale_warnings
2101 .iter()
2102 .filter(|w| w.contains("dup.rs"))
2103 .count();
2104 assert_eq!(
2105 dup_count, 1,
2106 "same key should not produce duplicate warnings"
2107 );
2108 }
2109
2110 #[tokio::test]
2111 async fn stale_warnings_section_before_decisions() {
2112 let dir = TempDir::new().unwrap();
2113 let store = Store::open(dir.path()).await.unwrap();
2114
2115 let fr = FileRecord {
2117 path: "src/stale.rs".to_string(),
2118 purpose: "Stale".to_string(),
2119 entry_points: vec![],
2120 imports: vec![],
2121 gotcha_keys: vec![],
2122 decision_keys: vec![],
2123 todos: vec![],
2124 unsafe_count: 0,
2125 unwrap_count: 0,
2126 change_frequency: 0,
2127 last_author: None,
2128 is_hotspot: false,
2129 token_cost_estimate: 50,
2130 last_modified_session: now(),
2131 content_hash: None,
2132 line_count: 0,
2133 blast_radius: None,
2134 propagated_staleness: None,
2135 };
2136 let mut file_record = make_record(
2137 "file:src/stale.rs",
2138 &serde_json::to_string(&fr).unwrap(),
2139 Category::File,
2140 0.5,
2141 );
2142 file_record.staleness = StalenessScore {
2143 value: 0.55,
2144 tier: StalenessTier::Stale,
2145 signals: vec![],
2146 computed_at: now(),
2147 last_record_sha: String::new(),
2148 };
2149 store.put("file:src/stale.rs", &file_record).await.unwrap();
2150
2151 let decision = make_record("decision:arch", "Use SurrealKV", Category::Decision, 0.8);
2153 store.put("decision:arch", &decision).await.unwrap();
2154
2155 let mut graph = Graph::load(store).await.unwrap();
2156 graph
2157 .add_edge("file:src/stale.rs", EdgeKind::AffectedBy, "decision:arch")
2158 .await
2159 .unwrap();
2160
2161 let packet = assemble_context_packet(graph.store(), &graph, &["src/stale.rs".to_string()])
2162 .await
2163 .unwrap();
2164
2165 let stale_pos = packet.injection_string.find("## Stale Warnings");
2166 let dec_pos = packet.injection_string.find("## Decisions");
2167
2168 if let (Some(s), Some(d)) = (stale_pos, dec_pos) {
2169 assert!(s < d, "Stale Warnings section must appear before Decisions");
2170 }
2171 }
2172
2173 #[tokio::test]
2174 async fn unconfirmed_gotcha_never_injected() {
2175 let dir = TempDir::new().unwrap();
2176 let store = Store::open(dir.path()).await.unwrap();
2177
2178 let unconfirmed = make_gotcha_record("gotcha:unconfirmed", "unconfirmed rule", false, 0.80);
2180 store.put("gotcha:unconfirmed", &unconfirmed).await.unwrap();
2181
2182 let graph = Graph::load(store).await.unwrap();
2183 let packet = assemble_context_packet(graph.store(), &graph, &[])
2184 .await
2185 .unwrap();
2186
2187 assert!(
2188 !packet.injection_string.contains("gotcha:unconfirmed"),
2189 "unconfirmed gotcha must never be injected"
2190 );
2191 }
2192
2193 #[tokio::test]
2194 async fn empty_store_returns_only_vector_b() {
2195 let dir = TempDir::new().unwrap();
2196 let store = Store::open(dir.path()).await.unwrap();
2197 let graph = Graph::load(store).await.unwrap();
2198
2199 let packet = assemble_context_packet(graph.store(), &graph, &[])
2200 .await
2201 .unwrap();
2202
2203 assert!(packet.injection_string.contains("[mati] Before reading"));
2204 assert!(packet.critical_gotchas.is_empty());
2205 assert!(packet.file_records.is_empty());
2206 assert!(packet.stale_warnings.is_empty());
2207 assert!(packet.related_decisions.is_empty());
2208 }
2209
2210 #[tokio::test]
2218 async fn mem_set_rejects_file_writes_on_direct_path() {
2219 let dir = TempDir::new().unwrap();
2220 let store = Store::open(dir.path()).await.unwrap();
2221 let graph = Graph::load(store).await.unwrap();
2222 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2223
2224 let result = call_mem_set(
2225 &graph_arc,
2226 MemSetParams {
2227 action: "write".to_string(),
2228 key: "file:src/main.rs".to_string(),
2229 value: "Handles CLI dispatch and binary entry point".to_string(),
2230 category: "File".to_string(),
2231 payload: serde_json::json!({
2232 "path": "src/main.rs",
2233 "purpose": "Handles CLI dispatch and binary entry point"
2234 }),
2235 tags: vec!["entry-point".to_string()],
2236 priority: "Normal".to_string(),
2237 },
2238 )
2239 .await;
2240
2241 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2242 let error = parsed["error"]
2243 .as_str()
2244 .unwrap_or_else(|| panic!("expected an error envelope, got: {result}"));
2245 assert!(
2246 error.contains("must start with"),
2247 "file: write must be rejected by the prefix gate, got: {error}"
2248 );
2249 assert_eq!(parsed.get("ok"), None, "no record should be written");
2250
2251 let graph = graph_arc.read().await;
2253 assert!(
2254 graph
2255 .store()
2256 .get("file:src/main.rs")
2257 .await
2258 .unwrap()
2259 .is_none(),
2260 "file: write must not create a record"
2261 );
2262 }
2263
2264 #[tokio::test]
2265 async fn mem_set_writes_gotcha_with_quality_score() {
2266 let dir = TempDir::new().unwrap();
2267 let store = Store::open(dir.path()).await.unwrap();
2268 let graph = Graph::load(store).await.unwrap();
2269 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2270
2271 let result = call_mem_set(
2272 &graph_arc,
2273 MemSetParams { action: "write".to_string(),
2274 key: "gotcha:always-use-idempotency-keys".to_string(),
2275 value: "Always pass idempotency_key to Stripe charge creation because duplicate charges cause customer refund disputes".to_string(),
2276 category: "Gotcha".to_string(),
2277 payload: serde_json::json!({
2278 "rule": "Always pass idempotency_key to Stripe charge creation",
2279 "reason": "duplicate charges cause customer refund disputes",
2280 "severity": "Critical",
2281 "affected_files": ["src/payments/stripe.go"],
2282 "ref_url": null,
2283 "discovered_session": 0,
2284 "confirmed": false
2285 }),
2286 tags: vec![],
2287 priority: "Critical".to_string(),
2288 },
2289 )
2290 .await;
2291
2292 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2293 assert_eq!(parsed["ok"], true);
2294 assert!(parsed["quality"].as_f64().unwrap() > 0.2);
2296
2297 let graph = graph_arc.read().await;
2299 let record = graph
2300 .store()
2301 .get("gotcha:always-use-idempotency-keys")
2302 .await
2303 .unwrap()
2304 .unwrap();
2305 assert_eq!(record.priority, Priority::Critical);
2306 assert_eq!(record.source, RecordSource::ClaudeEnrich);
2307 }
2308
2309 #[tokio::test]
2315 async fn mem_set_rejects_invalid_key_prefix() {
2316 let dir = TempDir::new().unwrap();
2317 let store = Store::open(dir.path()).await.unwrap();
2318 let graph = Graph::load(store).await.unwrap();
2319 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2320
2321 let result = call_mem_set(
2322 &graph_arc,
2323 MemSetParams {
2324 action: "write".to_string(),
2325 key: "session:12345".to_string(),
2326 value: "test".to_string(),
2327 category: "Gotcha".to_string(),
2328 payload: serde_json::json!({}),
2329 tags: vec![],
2330 priority: "Normal".to_string(),
2331 },
2332 )
2333 .await;
2334
2335 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2336 let error = parsed["error"].as_str().unwrap();
2337 assert!(error.contains("must start with"), "got: {error}");
2338 assert!(!error.contains("file:"), "got: {error}");
2340 }
2341
2342 #[tokio::test]
2343 async fn mem_set_rejects_invalid_category() {
2344 let dir = TempDir::new().unwrap();
2345 let store = Store::open(dir.path()).await.unwrap();
2346 let graph = Graph::load(store).await.unwrap();
2347 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2348
2349 let result = call_mem_set(
2350 &graph_arc,
2351 MemSetParams {
2352 action: "write".to_string(),
2353 key: "gotcha:test".to_string(),
2354 value: "test".to_string(),
2355 category: "Unknown".to_string(),
2356 payload: serde_json::json!({}),
2357 tags: vec![],
2358 priority: "Normal".to_string(),
2359 },
2360 )
2361 .await;
2362
2363 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2364 assert!(parsed["error"]
2365 .as_str()
2366 .unwrap()
2367 .contains("unknown category"));
2368 }
2369
2370 #[tokio::test]
2371 async fn mem_set_rejects_key_category_mismatch() {
2372 let dir = TempDir::new().unwrap();
2373 let store = Store::open(dir.path()).await.unwrap();
2374 let graph = Graph::load(store).await.unwrap();
2375 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2376
2377 let result = call_mem_set(
2383 &graph_arc,
2384 MemSetParams {
2385 action: "write".to_string(),
2386 key: "gotcha:should-fail".to_string(),
2387 value: "test".to_string(),
2388 category: "Decision".to_string(),
2389 payload: serde_json::json!({"summary": "x", "rationale": "y"}),
2390 tags: vec![],
2391 priority: "Normal".to_string(),
2392 },
2393 )
2394 .await;
2395
2396 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2397 assert!(
2398 parsed["error"]
2399 .as_str()
2400 .unwrap()
2401 .contains("requires category"),
2402 "key-category mismatch must be rejected: {result}"
2403 );
2404 }
2405
2406 #[tokio::test]
2407 async fn mem_set_rejects_new_gotcha_without_rule_and_reason() {
2408 let dir = TempDir::new().unwrap();
2409 let store = Store::open(dir.path()).await.unwrap();
2410 let graph = Graph::load(store).await.unwrap();
2411 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2412
2413 let result = call_mem_set(
2414 &graph_arc,
2415 MemSetParams {
2416 action: "write".to_string(),
2417 key: "gotcha:missing-fields".to_string(),
2418 value: "test".to_string(),
2419 category: "Gotcha".to_string(),
2420 payload: serde_json::json!({"severity": "Normal"}),
2421 tags: vec![],
2422 priority: "Normal".to_string(),
2423 },
2424 )
2425 .await;
2426
2427 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2428 assert!(
2429 parsed["error"]
2430 .as_str()
2431 .unwrap()
2432 .contains("'rule' and 'reason'"),
2433 "new gotcha without rule/reason must be rejected: {result}"
2434 );
2435 }
2436
2437 #[tokio::test]
2438 async fn mem_set_rejects_new_decision_without_summary_rationale() {
2439 let dir = TempDir::new().unwrap();
2440 let store = Store::open(dir.path()).await.unwrap();
2441 let graph = Graph::load(store).await.unwrap();
2442 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2443
2444 let result = call_mem_set(
2445 &graph_arc,
2446 MemSetParams {
2447 action: "write".to_string(),
2448 key: "decision:incomplete".to_string(),
2449 value: "test".to_string(),
2450 category: "Decision".to_string(),
2451 payload: serde_json::json!({}),
2452 tags: vec![],
2453 priority: "Normal".to_string(),
2454 },
2455 )
2456 .await;
2457
2458 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2459 assert!(
2460 parsed["error"]
2461 .as_str()
2462 .unwrap()
2463 .contains("'summary' and 'rationale'"),
2464 "new decision without summary/rationale must be rejected: {result}"
2465 );
2466 }
2467
2468 #[tokio::test]
2469 async fn mem_set_rejects_new_dev_note_with_empty_value() {
2470 let dir = TempDir::new().unwrap();
2471 let store = Store::open(dir.path()).await.unwrap();
2472 let graph = Graph::load(store).await.unwrap();
2473 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2474
2475 let result = call_mem_set(
2476 &graph_arc,
2477 MemSetParams {
2478 action: "write".to_string(),
2479 key: "dev_note:empty".to_string(),
2480 value: "".to_string(),
2481 category: "DevNote".to_string(),
2482 payload: serde_json::json!({}),
2483 tags: vec![],
2484 priority: "Normal".to_string(),
2485 },
2486 )
2487 .await;
2488
2489 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2490 assert!(
2491 parsed["error"]
2492 .as_str()
2493 .unwrap()
2494 .contains("non-empty value"),
2495 "new dev_note with empty value must be rejected: {result}"
2496 );
2497 }
2498
2499 #[tokio::test]
2500 async fn mem_set_allows_partial_payload_on_update() {
2501 let dir = TempDir::new().unwrap();
2502 let store = Store::open(dir.path()).await.unwrap();
2503 let graph = Graph::load(store).await.unwrap();
2504 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2505
2506 let result = call_mem_set(
2508 &graph_arc,
2509 MemSetParams {
2510 action: "write".to_string(),
2511 key: "gotcha:partial-update".to_string(),
2512 value: "test rule because test reason".to_string(),
2513 category: "Gotcha".to_string(),
2514 payload: serde_json::json!({
2515 "rule": "test rule",
2516 "reason": "test reason",
2517 "severity": "Normal",
2518 "affected_files": [],
2519 }),
2520 tags: vec![],
2521 priority: "Normal".to_string(),
2522 },
2523 )
2524 .await;
2525 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2526 assert_eq!(parsed["ok"], true, "initial write must succeed");
2527
2528 let result = call_mem_set(
2531 &graph_arc,
2532 MemSetParams {
2533 action: "write".to_string(),
2534 key: "gotcha:partial-update".to_string(),
2535 value: "updated rule because updated reason".to_string(),
2536 category: "Gotcha".to_string(),
2537 payload: serde_json::json!({"reason": "updated reason"}),
2538 tags: vec![],
2539 priority: "Normal".to_string(),
2540 },
2541 )
2542 .await;
2543 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2544 assert_eq!(
2545 parsed["ok"], true,
2546 "partial-payload update must succeed: {result}"
2547 );
2548 }
2549
2550 #[tokio::test]
2551 async fn mem_set_preserves_confirmation_state_on_update() {
2552 let dir = TempDir::new().unwrap();
2553 let store = Store::open(dir.path()).await.unwrap();
2554
2555 let mut record = make_gotcha_record(
2557 "gotcha:confirmed-edit-test",
2558 "Always test first",
2559 true,
2560 0.70,
2561 );
2562 record.source = RecordSource::DeveloperManual;
2563 record.confidence = ConfidenceScore {
2564 value: 0.80,
2565 confirmation_count: 1,
2566 contributor_count: 1,
2567 last_challenged: None,
2568 challenge_count: 0,
2569 };
2570 record.tags = vec!["important".to_string()];
2571 store
2572 .put("gotcha:confirmed-edit-test", &record)
2573 .await
2574 .unwrap();
2575
2576 let graph = Graph::load(store).await.unwrap();
2577 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2578
2579 let result = call_mem_set(
2581 &graph_arc,
2582 MemSetParams {
2583 action: "write".to_string(),
2584 key: "gotcha:confirmed-edit-test".to_string(),
2585 value: "Always test first because untested changes cause regressions".to_string(),
2586 category: "Gotcha".to_string(),
2587 payload: serde_json::json!({
2588 "rule": "Always test first",
2589 "reason": "untested changes cause regressions",
2590 "severity": "High",
2591 "affected_files": ["src/main.rs"],
2592 "ref_url": null,
2593 "discovered_session": 0,
2594 "confirmed": true
2595 }),
2596 tags: vec![], priority: "High".to_string(),
2598 },
2599 )
2600 .await;
2601
2602 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2603 assert_eq!(parsed["ok"], true);
2604
2605 let graph = graph_arc.read().await;
2607 let updated = graph
2608 .store()
2609 .get("gotcha:confirmed-edit-test")
2610 .await
2611 .unwrap()
2612 .unwrap();
2613 assert_eq!(updated.source, RecordSource::DeveloperManual);
2614 assert!(
2615 (updated.confidence.value - 0.80).abs() < 0.01,
2616 "confidence should stay 0.80, got {}",
2617 updated.confidence.value
2618 );
2619 assert_eq!(updated.confidence.confirmation_count, 1);
2620 assert_eq!(
2621 updated.tags,
2622 vec!["important".to_string()],
2623 "tags should be preserved when caller sends empty"
2624 );
2625 }
2626
2627 #[tokio::test]
2628 async fn mem_set_moves_gotcha_links_and_edges_on_edit() {
2629 let dir = TempDir::new().unwrap();
2630 let store = Store::open(dir.path()).await.unwrap();
2631
2632 let old_file = Record::layer0_file_stub("file:src/old.rs", device_id(), 1, now());
2634 let new_file = Record::layer0_file_stub("file:src/new.rs", device_id(), 1, now());
2635 store.put("file:src/old.rs", &old_file).await.unwrap();
2636 store.put("file:src/new.rs", &new_file).await.unwrap();
2637
2638 let graph = Graph::load(store).await.unwrap();
2639 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2640
2641 call_mem_set(
2642 &graph_arc,
2643 MemSetParams {
2644 action: "write".to_string(),
2645 key: "gotcha:test-move".to_string(),
2646 value: "Always update the paired file because drift breaks the feature".to_string(),
2647 category: "Gotcha".to_string(),
2648 payload: serde_json::json!({
2649 "rule": "Always update the paired file",
2650 "reason": "drift breaks the feature",
2651 "severity": "High",
2652 "affected_files": ["src/old.rs"],
2653 "ref_url": null,
2654 "discovered_session": 0,
2655 "confirmed": false
2656 }),
2657 tags: vec![],
2658 priority: "High".to_string(),
2659 },
2660 )
2661 .await;
2662
2663 call_mem_set(
2664 &graph_arc,
2665 MemSetParams {
2666 action: "write".to_string(),
2667 key: "gotcha:test-move".to_string(),
2668 value: "Always update the paired file because drift breaks the feature".to_string(),
2669 category: "Gotcha".to_string(),
2670 payload: serde_json::json!({
2671 "rule": "Always update the paired file",
2672 "reason": "drift breaks the feature",
2673 "severity": "High",
2674 "affected_files": ["src/new.rs"],
2675 "ref_url": null,
2676 "discovered_session": 0,
2677 "confirmed": false
2678 }),
2679 tags: vec![],
2680 priority: "High".to_string(),
2681 },
2682 )
2683 .await;
2684
2685 let graph = graph_arc.read().await;
2686
2687 let old_file = graph.store().get("file:src/old.rs").await.unwrap().unwrap();
2688 let new_file = graph.store().get("file:src/new.rs").await.unwrap().unwrap();
2689 let old_payload = old_file.payload.unwrap();
2690 let new_payload = new_file.payload.unwrap();
2691
2692 assert!(
2693 old_payload["gotcha_keys"]
2694 .as_array()
2695 .map(|arr| arr.is_empty())
2696 .unwrap_or(true),
2697 "old file should no longer reference moved gotcha"
2698 );
2699 assert_eq!(new_payload["gotcha_keys"][0], "gotcha:test-move");
2700
2701 assert!(
2702 !graph
2703 .neighbors("file:src/old.rs", &EdgeKind::HasGotcha)
2704 .contains(&"gotcha:test-move".to_string()),
2705 "old file should not keep stale HasGotcha edge"
2706 );
2707 assert!(
2708 graph
2709 .neighbors("file:src/new.rs", &EdgeKind::HasGotcha)
2710 .contains(&"gotcha:test-move".to_string()),
2711 "new file should gain HasGotcha edge"
2712 );
2713 }
2714
2715 #[tokio::test]
2721 async fn test_query_limit_clamped_to_max() {
2722 let dir = TempDir::new().unwrap();
2723 let store = Store::open(dir.path()).await.unwrap();
2724
2725 for i in 0..60 {
2727 let record = make_record(
2728 &format!("gotcha:clamp-test-{i:03}"),
2729 &format!("clamp test rule number {i}"),
2730 Category::Gotcha,
2731 0.8,
2732 );
2733 store
2734 .put(&format!("gotcha:clamp-test-{i:03}"), &record)
2735 .await
2736 .unwrap();
2737 }
2738
2739 let graph = Graph::load(store).await.unwrap();
2740 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2741
2742 let result = call_mem_query(
2743 &graph_arc,
2744 "clamp test rule",
2745 crate::mcp::protocol::QueryMode::Text,
2746 100, )
2748 .await;
2749
2750 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2752 assert!(
2753 parsed.get("error").is_none(),
2754 "query with limit > 50 must not error"
2755 );
2756
2757 let results = parsed.as_array().expect("result should be a JSON array");
2759 assert!(
2760 results.len() <= 50,
2761 "result count {} exceeds MAX_QUERY_LIMIT (50)",
2762 results.len()
2763 );
2764 }
2765
2766 #[tokio::test]
2775 async fn test_mem_set_write_new_key_succeeds() {
2776 let dir = TempDir::new().unwrap();
2777 let store = Store::open(dir.path()).await.unwrap();
2778 let graph = Graph::load(store).await.unwrap();
2779 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2780
2781 let result = call_mem_set(
2782 &graph_arc,
2783 MemSetParams {
2784 action: "write".to_string(),
2785 key: "gotcha:store-read-ok-none".to_string(),
2786 value: "Regression rule because regression reason".to_string(),
2787 category: "Gotcha".to_string(),
2788 payload: serde_json::json!({
2789 "rule": "Regression rule",
2790 "reason": "regression reason",
2791 "severity": "Normal"
2792 }),
2793 tags: vec![],
2794 priority: "Normal".to_string(),
2795 },
2796 )
2797 .await;
2798
2799 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2800 assert_eq!(
2801 parsed["ok"], true,
2802 "writing a new key must succeed (Ok(None) arm)"
2803 );
2804 assert_eq!(parsed["key"], "gotcha:store-read-ok-none");
2805
2806 let graph = graph_arc.read().await;
2808 let record = graph
2809 .store()
2810 .get("gotcha:store-read-ok-none")
2811 .await
2812 .unwrap()
2813 .expect("record must exist after write");
2814 assert_eq!(record.value, "Regression rule because regression reason");
2815
2816 }
2822
2823 #[tokio::test]
2830 async fn test_tombstone_removes_in_memory_graph_edges() {
2831 let dir = TempDir::new().unwrap();
2832 let store = Store::open(dir.path()).await.unwrap();
2833
2834 let file_record = Record::layer0_file_stub("file:src/target.rs", device_id(), 1, now());
2836 store.put("file:src/target.rs", &file_record).await.unwrap();
2837
2838 let graph = Graph::load(store).await.unwrap();
2839 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2840
2841 let write_result = call_mem_set(
2843 &graph_arc,
2844 MemSetParams {
2845 action: "write".to_string(),
2846 key: "gotcha:graph-cleanup-test".to_string(),
2847 value: "Never skip validation because it causes silent data corruption".to_string(),
2848 category: "Gotcha".to_string(),
2849 payload: serde_json::json!({
2850 "rule": "Never skip validation",
2851 "reason": "causes silent data corruption",
2852 "severity": "High",
2853 "affected_files": ["src/target.rs"],
2854 "ref_url": null,
2855 "discovered_session": 0,
2856 "confirmed": false
2857 }),
2858 tags: vec![],
2859 priority: "High".to_string(),
2860 },
2861 )
2862 .await;
2863 let parsed: serde_json::Value = serde_json::from_str(&write_result).unwrap();
2864 assert_eq!(parsed["ok"], true, "gotcha write must succeed");
2865
2866 {
2868 let graph = graph_arc.read().await;
2869 let neighbors = graph.neighbors("file:src/target.rs", &EdgeKind::HasGotcha);
2870 assert!(
2871 neighbors.contains(&"gotcha:graph-cleanup-test".to_string()),
2872 "HasGotcha edge must exist after write; neighbors: {neighbors:?}"
2873 );
2874 }
2875
2876 let delete_result = call_mem_set(
2878 &graph_arc,
2879 MemSetParams {
2880 action: "delete".to_string(),
2881 key: "gotcha:graph-cleanup-test".to_string(),
2882 value: String::new(),
2883 category: String::new(),
2884 payload: serde_json::json!({}),
2885 tags: vec![],
2886 priority: "Normal".to_string(),
2887 },
2888 )
2889 .await;
2890 let parsed: serde_json::Value = serde_json::from_str(&delete_result).unwrap();
2891 assert_eq!(parsed["ok"], true, "gotcha delete must succeed");
2892 assert_eq!(parsed["tombstoned"], true);
2893
2894 {
2896 let graph = graph_arc.read().await;
2897 let neighbors = graph.neighbors("file:src/target.rs", &EdgeKind::HasGotcha);
2898 assert!(
2899 !neighbors.contains(&"gotcha:graph-cleanup-test".to_string()),
2900 "HasGotcha edge must be removed after delete; neighbors: {neighbors:?}"
2901 );
2902 }
2903
2904 let graph_query_result = call_mem_query(
2906 &graph_arc,
2907 "file:src/target.rs",
2908 crate::mcp::protocol::QueryMode::Graph,
2909 20,
2910 )
2911 .await;
2912 let parsed: serde_json::Value = serde_json::from_str(&graph_query_result).unwrap();
2913 let gotchas = parsed["gotchas"]
2914 .as_array()
2915 .expect("gotchas group must be an array");
2916 assert!(
2917 !gotchas
2918 .iter()
2919 .any(|g| g["key"] == "gotcha:graph-cleanup-test"),
2920 "deleted gotcha must not appear in graph query results"
2921 );
2922 }
2923
2924 #[tokio::test]
2930 async fn test_graph_mode_respects_global_limit() {
2931 let dir = TempDir::new().unwrap();
2932 let store = Store::open(dir.path()).await.unwrap();
2933
2934 let file_record =
2936 Record::layer0_file_stub("file:src/graph_limit.rs", device_id(), 1, now());
2937 store
2938 .put("file:src/graph_limit.rs", &file_record)
2939 .await
2940 .unwrap();
2941
2942 let graph = Graph::load(store).await.unwrap();
2943 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2944
2945 for i in 0..5 {
2947 let result = call_mem_set(
2948 &graph_arc,
2949 MemSetParams {
2950 action: "write".to_string(),
2951 key: format!("gotcha:limit-test-{i}"),
2952 value: format!("Limit test gotcha {i}"),
2953 category: "Gotcha".to_string(),
2954 payload: serde_json::json!({
2955 "rule": format!("Limit rule {i}"),
2956 "reason": "testing",
2957 "severity": "Normal",
2958 "affected_files": ["src/graph_limit.rs"],
2959 "ref_url": null,
2960 "discovered_session": 0,
2961 "confirmed": false
2962 }),
2963 tags: vec![],
2964 priority: "Normal".to_string(),
2965 },
2966 )
2967 .await;
2968 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2969 assert_eq!(parsed["ok"], true, "gotcha write {i} must succeed");
2970 }
2971
2972 let result = call_mem_query(
2974 &graph_arc,
2975 "file:src/graph_limit.rs",
2976 crate::mcp::protocol::QueryMode::Graph,
2977 3,
2978 )
2979 .await;
2980 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2981 assert!(parsed.get("error").is_none(), "graph query must not error");
2982
2983 let mut total = 0;
2985 for group in &["gotchas", "co_changes", "imports", "decisions", "notes"] {
2986 if let Some(arr) = parsed[group].as_array() {
2987 total += arr.len();
2988 }
2989 }
2990 assert!(
2991 total <= 3,
2992 "graph mode with limit=3 must return at most 3 total records, got {total}"
2993 );
2994 }
2995
2996 #[tokio::test]
2998 async fn test_graph_mode_limit_zero_returns_empty() {
2999 let dir = TempDir::new().unwrap();
3000 let store = Store::open(dir.path()).await.unwrap();
3001
3002 let file_record = Record::layer0_file_stub("file:src/zero.rs", device_id(), 1, now());
3003 store.put("file:src/zero.rs", &file_record).await.unwrap();
3004
3005 let graph = Graph::load(store).await.unwrap();
3006 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3007
3008 let _ = call_mem_set(
3010 &graph_arc,
3011 MemSetParams {
3012 action: "write".to_string(),
3013 key: "gotcha:zero-limit-test".to_string(),
3014 value: "Should not appear".to_string(),
3015 category: "Gotcha".to_string(),
3016 payload: serde_json::json!({
3017 "rule": "Zero limit test",
3018 "reason": "testing",
3019 "severity": "Normal",
3020 "affected_files": ["src/zero.rs"],
3021 "ref_url": null,
3022 "discovered_session": 0,
3023 "confirmed": false
3024 }),
3025 tags: vec![],
3026 priority: "Normal".to_string(),
3027 },
3028 )
3029 .await;
3030
3031 let result = call_mem_query(
3032 &graph_arc,
3033 "file:src/zero.rs",
3034 crate::mcp::protocol::QueryMode::Graph,
3035 0,
3036 )
3037 .await;
3038 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3039
3040 let mut total = 0;
3041 for group in &["gotchas", "co_changes", "imports", "decisions", "notes"] {
3042 if let Some(arr) = parsed[group].as_array() {
3043 total += arr.len();
3044 }
3045 }
3046 assert_eq!(total, 0, "limit=0 must return zero records, got {total}");
3047 }
3048
3049 #[tokio::test]
3056 async fn test_mem_set_overwrite_preserves_existing_data() {
3057 let dir = TempDir::new().unwrap();
3058 let store = Store::open(dir.path()).await.unwrap();
3059
3060 let mut original =
3064 make_gotcha_record("gotcha:preserve-confirmation", "Original rule", true, 0.7);
3065 original.source = RecordSource::DeveloperManual;
3066 original.confidence.value = 0.85;
3067 original.confidence.confirmation_count = 3;
3068 store
3069 .put("gotcha:preserve-confirmation", &original)
3070 .await
3071 .unwrap();
3072
3073 let graph = Graph::load(store).await.unwrap();
3074 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3075
3076 let result = call_mem_set(
3078 &graph_arc,
3079 MemSetParams {
3080 action: "write".to_string(),
3081 key: "gotcha:preserve-confirmation".to_string(),
3082 value: "Updated rule from enrichment".to_string(),
3083 category: "Gotcha".to_string(),
3084 payload: serde_json::json!({"reason": "updated reason"}),
3085 tags: vec![],
3086 priority: "Normal".to_string(),
3087 },
3088 )
3089 .await;
3090 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3091 assert_eq!(parsed["ok"], true, "overwrite must succeed");
3092
3093 let graph = graph_arc.read().await;
3095 let record = graph
3096 .store()
3097 .get("gotcha:preserve-confirmation")
3098 .await
3099 .unwrap()
3100 .expect("record must exist");
3101 assert_eq!(
3102 record.value, "Updated rule from enrichment",
3103 "value must be updated"
3104 );
3105 assert!(
3108 record.confidence.value >= 0.80,
3109 "confirmed record confidence must be preserved, got {}",
3110 record.confidence.value
3111 );
3112 }
3113
3114 #[tokio::test]
3119 async fn test_tombstone_multi_file_removes_all_edges() {
3120 let dir = TempDir::new().unwrap();
3121 let store = Store::open(dir.path()).await.unwrap();
3122
3123 let f1 = Record::layer0_file_stub("file:src/a.rs", device_id(), 1, now());
3125 let f2 = Record::layer0_file_stub("file:src/b.rs", device_id(), 1, now());
3126 store.put("file:src/a.rs", &f1).await.unwrap();
3127 store.put("file:src/b.rs", &f2).await.unwrap();
3128
3129 let graph = Graph::load(store).await.unwrap();
3130 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3131
3132 let result = call_mem_set(
3134 &graph_arc,
3135 MemSetParams {
3136 action: "write".to_string(),
3137 key: "gotcha:multi-file-tombstone".to_string(),
3138 value: "Cross-file gotcha".to_string(),
3139 category: "Gotcha".to_string(),
3140 payload: serde_json::json!({
3141 "rule": "Cross-file rule",
3142 "reason": "testing multi-file cleanup",
3143 "severity": "Normal",
3144 "affected_files": ["src/a.rs", "src/b.rs"],
3145 "ref_url": null,
3146 "discovered_session": 0,
3147 "confirmed": false
3148 }),
3149 tags: vec![],
3150 priority: "Normal".to_string(),
3151 },
3152 )
3153 .await;
3154 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3155 assert_eq!(parsed["ok"], true);
3156
3157 {
3159 let graph = graph_arc.read().await;
3160 assert!(
3161 graph
3162 .neighbors("file:src/a.rs", &EdgeKind::HasGotcha)
3163 .contains(&"gotcha:multi-file-tombstone".to_string()),
3164 "file:src/a.rs must have HasGotcha edge before delete"
3165 );
3166 assert!(
3167 graph
3168 .neighbors("file:src/b.rs", &EdgeKind::HasGotcha)
3169 .contains(&"gotcha:multi-file-tombstone".to_string()),
3170 "file:src/b.rs must have HasGotcha edge before delete"
3171 );
3172 }
3173
3174 let result = call_mem_set(
3176 &graph_arc,
3177 MemSetParams {
3178 action: "delete".to_string(),
3179 key: "gotcha:multi-file-tombstone".to_string(),
3180 value: String::new(),
3181 category: String::new(),
3182 payload: serde_json::json!({}),
3183 tags: vec![],
3184 priority: "Normal".to_string(),
3185 },
3186 )
3187 .await;
3188 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3189 assert_eq!(parsed["ok"], true);
3190
3191 {
3193 let graph = graph_arc.read().await;
3194 assert!(
3195 !graph
3196 .neighbors("file:src/a.rs", &EdgeKind::HasGotcha)
3197 .contains(&"gotcha:multi-file-tombstone".to_string()),
3198 "file:src/a.rs must NOT have HasGotcha edge after delete"
3199 );
3200 assert!(
3201 !graph
3202 .neighbors("file:src/b.rs", &EdgeKind::HasGotcha)
3203 .contains(&"gotcha:multi-file-tombstone".to_string()),
3204 "file:src/b.rs must NOT have HasGotcha edge after delete"
3205 );
3206 }
3207 }
3208
3209 #[tokio::test]
3214 async fn test_confirm_is_non_idempotent() {
3215 let dir = TempDir::new().unwrap();
3216 let store = Store::open(dir.path()).await.unwrap();
3217
3218 let file_record = Record::layer0_file_stub("file:src/idem.rs", device_id(), 1, now());
3219 store.put("file:src/idem.rs", &file_record).await.unwrap();
3220
3221 let graph = Graph::load(store).await.unwrap();
3222 let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3223
3224 let _ = call_mem_set(
3226 &graph_arc,
3227 MemSetParams {
3228 action: "write".to_string(),
3229 key: "gotcha:idem-test".to_string(),
3230 value: "Idempotency test rule".to_string(),
3231 category: "Gotcha".to_string(),
3232 payload: serde_json::json!({
3233 "rule": "Idempotency test",
3234 "reason": "testing",
3235 "severity": "Normal",
3236 "affected_files": ["src/idem.rs"],
3237 "ref_url": null,
3238 "discovered_session": 0,
3239 "confirmed": false
3240 }),
3241 tags: vec![],
3242 priority: "Normal".to_string(),
3243 },
3244 )
3245 .await;
3246
3247 let r1 = call_mem_set(
3249 &graph_arc,
3250 MemSetParams {
3251 action: "confirm".to_string(),
3252 key: "gotcha:idem-test".to_string(),
3253 value: String::new(),
3254 category: String::new(),
3255 payload: serde_json::json!({}),
3256 tags: vec![],
3257 priority: "Normal".to_string(),
3258 },
3259 )
3260 .await;
3261 let p1: serde_json::Value = serde_json::from_str(&r1).unwrap();
3262 assert_eq!(p1["ok"], true, "first confirm must succeed");
3263 assert_eq!(p1["confirmed"], true);
3264
3265 let count_after_first = {
3267 let graph = graph_arc.read().await;
3268 let record = graph
3269 .store()
3270 .get("gotcha:idem-test")
3271 .await
3272 .unwrap()
3273 .unwrap();
3274 record.confidence.confirmation_count
3275 };
3276
3277 let r2 = call_mem_set(
3279 &graph_arc,
3280 MemSetParams {
3281 action: "confirm".to_string(),
3282 key: "gotcha:idem-test".to_string(),
3283 value: String::new(),
3284 category: String::new(),
3285 payload: serde_json::json!({}),
3286 tags: vec![],
3287 priority: "Normal".to_string(),
3288 },
3289 )
3290 .await;
3291 let p2: serde_json::Value = serde_json::from_str(&r2).unwrap();
3292 assert_eq!(p2["ok"], true, "second confirm must succeed");
3293
3294 let count_after_second = {
3296 let graph = graph_arc.read().await;
3297 let record = graph
3298 .store()
3299 .get("gotcha:idem-test")
3300 .await
3301 .unwrap()
3302 .unwrap();
3303 record.confidence.confirmation_count
3304 };
3305
3306 assert!(
3307 count_after_second > count_after_first,
3308 "confirmation_count must increase on each confirm: first={count_after_first}, second={count_after_second}"
3309 );
3310 }
3311
3312 #[test]
3317 fn test_store_read_error_refuses_write() {
3318 let result = resolve_existing_for_write(Err(anyhow::anyhow!("simulated disk I/O timeout")));
3319 assert!(result.is_err(), "store error must refuse write");
3320 let err = result.unwrap_err();
3321 let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
3322 assert!(
3323 parsed["error"]
3324 .as_str()
3325 .unwrap()
3326 .contains("store read failed"),
3327 "error must mention store read failure"
3328 );
3329 assert!(
3330 parsed["error"]
3331 .as_str()
3332 .unwrap()
3333 .contains("simulated disk I/O timeout"),
3334 "error must include the underlying cause"
3335 );
3336 }
3337
3338 #[test]
3340 fn test_store_read_ok_none_passes_through() {
3341 let result = resolve_existing_for_write(Ok(None));
3342 assert!(result.is_ok());
3343 assert!(result.unwrap().is_none());
3344 }
3345
3346 #[test]
3348 fn test_store_read_ok_some_passes_through() {
3349 let record = make_record("file:test.rs", "test", Category::File, 0.5);
3350 let result = resolve_existing_for_write(Ok(Some(record.clone())));
3351 assert!(result.is_ok());
3352 let resolved = result.unwrap();
3353 assert!(resolved.is_some());
3354 assert_eq!(resolved.unwrap().key, "file:test.rs");
3355 }
3356
3357 #[tokio::test]
3358 async fn bootstrap_highest_impact_section_appears() {
3359 let dir = TempDir::new().unwrap();
3360 let store = Store::open(dir.path()).await.unwrap();
3361
3362 let fr_critical = FileRecord {
3364 path: "src/core.rs".to_string(),
3365 purpose: "Core module".to_string(),
3366 entry_points: vec![],
3367 imports: vec![],
3368 gotcha_keys: vec![],
3369 decision_keys: vec![],
3370 todos: vec![],
3371 unsafe_count: 0,
3372 unwrap_count: 0,
3373 change_frequency: 0,
3374 last_author: None,
3375 is_hotspot: false,
3376 token_cost_estimate: 100,
3377 last_modified_session: 0,
3378 content_hash: None,
3379 line_count: 0,
3380 blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
3381 direct: 45,
3382 transitive: 10,
3383 score: 48.0,
3384 tier: crate::analysis::blast_radius::BlastTier::Critical,
3385 }),
3386 propagated_staleness: None,
3387 };
3388 let mut rec = make_record("file:src/core.rs", "Core module", Category::File, 0.5);
3389 rec.payload = serde_json::to_value(&fr_critical).ok();
3390 store.put("file:src/core.rs", &rec).await.unwrap();
3391
3392 let fr_low = FileRecord {
3394 path: "src/leaf.rs".to_string(),
3395 purpose: "Leaf module".to_string(),
3396 entry_points: vec![],
3397 imports: vec![],
3398 gotcha_keys: vec![],
3399 decision_keys: vec![],
3400 todos: vec![],
3401 unsafe_count: 0,
3402 unwrap_count: 0,
3403 change_frequency: 0,
3404 last_author: None,
3405 is_hotspot: false,
3406 token_cost_estimate: 100,
3407 last_modified_session: 0,
3408 content_hash: None,
3409 line_count: 0,
3410 blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
3411 direct: 3,
3412 transitive: 0,
3413 score: 3.0,
3414 tier: crate::analysis::blast_radius::BlastTier::Low,
3415 }),
3416 propagated_staleness: None,
3417 };
3418 let mut rec2 = make_record("file:src/leaf.rs", "Leaf module", Category::File, 0.5);
3419 rec2.payload = serde_json::to_value(&fr_low).ok();
3420 store.put("file:src/leaf.rs", &rec2).await.unwrap();
3421
3422 let graph = Graph::load(store).await.unwrap();
3423 let packet = assemble_context_packet(
3424 graph.store(),
3425 &graph,
3426 &["src/core.rs".to_string(), "src/leaf.rs".to_string()],
3427 )
3428 .await
3429 .unwrap();
3430
3431 assert!(
3432 packet.injection_string.contains("Highest Impact"),
3433 "bootstrap must include highest impact section, got: {}",
3434 packet.injection_string
3435 );
3436 assert!(
3437 packet.injection_string.contains("src/core.rs"),
3438 "critical file must appear in impact section"
3439 );
3440 let core_pos = packet.injection_string.find("src/core.rs").unwrap();
3442 let leaf_pos = packet
3443 .injection_string
3444 .find("src/leaf.rs")
3445 .unwrap_or(usize::MAX);
3446 assert!(
3447 core_pos < leaf_pos,
3448 "core.rs should appear before leaf.rs in impact section"
3449 );
3450 }
3451
3452 fn make_params(action: &str, key: &str) -> MemSetParams {
3464 MemSetParams {
3465 action: action.to_string(),
3466 key: key.to_string(),
3467 value: String::new(),
3468 category: String::new(),
3469 payload: serde_json::Value::Object(serde_json::Map::new()),
3470 tags: vec![],
3471 priority: "Normal".to_string(),
3472 }
3473 }
3474
3475 #[test]
3476 fn mem_set_socket_routes_gotcha_confirm() {
3477 let p = make_params("confirm", "gotcha:foo");
3478 let cmd = build_mem_set_command(&p).expect("must build");
3479 assert_eq!(cmd.kind(), "gotcha_confirm");
3480 assert_eq!(cmd.target_key(), "gotcha:foo");
3481 }
3482
3483 #[test]
3484 fn mem_set_socket_rejects_confirm_on_non_gotcha_key() {
3485 let p = make_params("confirm", "decision:not-allowed");
3486 let err = build_mem_set_command(&p).expect_err("must reject");
3487 assert!(
3488 err.contains("gotcha:"),
3489 "error must mention gotcha: prefix, got: {err}"
3490 );
3491 }
3492
3493 #[test]
3494 fn mem_set_socket_routes_gotcha_tombstone() {
3495 let p = make_params("delete", "gotcha:foo");
3496 let cmd = build_mem_set_command(&p).expect("must build");
3497 assert_eq!(cmd.kind(), "gotcha_tombstone");
3498 assert_eq!(cmd.target_key(), "gotcha:foo");
3499 }
3500
3501 #[test]
3502 fn mem_set_socket_routes_gotcha_upsert_by_key_prefix() {
3503 let mut p = make_params("write", "gotcha:stripe-idempotency");
3504 p.payload = serde_json::json!({
3505 "rule": "Always include an idempotency key",
3506 "reason": "Stripe retries cause double charges without it",
3507 "severity": "High",
3508 "affected_files": ["src/payments/stripe.rs"],
3509 });
3510 p.tags = vec!["payments".into()];
3511 p.priority = "High".into();
3512 let cmd = build_mem_set_command(&p).expect("must build");
3513 assert_eq!(cmd.kind(), "gotcha_upsert");
3514 match cmd {
3515 Command::GotchaUpsert(input) => {
3516 assert_eq!(input.key, "gotcha:stripe-idempotency");
3517 assert_eq!(input.rule, "Always include an idempotency key");
3518 assert_eq!(input.severity, proto::Severity::High);
3519 assert_eq!(input.priority, proto::Priority::High);
3520 assert_eq!(input.affected_files, vec!["src/payments/stripe.rs"]);
3521 assert_eq!(input.tags, vec!["payments".to_string()]);
3522 }
3523 _ => panic!("expected GotchaUpsert"),
3524 }
3525 }
3526
3527 #[test]
3528 fn mem_set_socket_routes_decision_upsert_by_key_prefix() {
3529 let mut p = make_params("write", "decision:retry-strategy");
3530 p.value = "We use exponential backoff because linear overloads downstream".into();
3531 p.payload = serde_json::json!({
3532 "summary": "Exponential backoff for all retries",
3533 "rationale": "Linear retry caused cascading failures in prod 2024-01",
3534 });
3535 let cmd = build_mem_set_command(&p).expect("must build");
3536 assert_eq!(cmd.kind(), "decision_upsert");
3537 match cmd {
3538 Command::DecisionUpsert(input) => {
3539 assert_eq!(input.slug, "retry-strategy");
3540 assert_eq!(input.summary, "Exponential backoff for all retries");
3541 assert!(input.rationale.contains("cascading"));
3542 }
3543 _ => panic!("expected DecisionUpsert"),
3544 }
3545 }
3546
3547 #[test]
3548 fn mem_set_socket_routes_dev_note_upsert_by_key_prefix() {
3549 let mut p = make_params("write", "dev_note:remember-changelog");
3550 p.value = "Remember to update the changelog before release".into();
3551 let cmd = build_mem_set_command(&p).expect("must build");
3552 assert_eq!(cmd.kind(), "dev_note_upsert");
3553 match cmd {
3554 Command::DevNoteUpsert(input) => {
3555 assert_eq!(input.key.as_deref(), Some("dev_note:remember-changelog"));
3556 assert!(input.text.contains("changelog"));
3557 }
3558 _ => panic!("expected DevNoteUpsert"),
3559 }
3560 }
3561
3562 #[test]
3563 fn mem_set_socket_rejects_write_with_unknown_prefix() {
3564 let p = make_params("write", "file:src/main.rs");
3567 let err = build_mem_set_command(&p).expect_err("must reject");
3568 assert!(
3569 err.contains("gotcha:") && err.contains("decision:") && err.contains("dev_note:"),
3570 "error must list valid prefixes, got: {err}"
3571 );
3572 }
3573
3574 #[test]
3575 fn mem_set_socket_rejects_unknown_action() {
3576 let p = make_params("smuggle", "gotcha:foo");
3577 let err = build_mem_set_command(&p).expect_err("must reject");
3578 assert!(
3579 err.contains("smuggle"),
3580 "error must echo the bad action, got: {err}"
3581 );
3582 }
3583
3584 #[test]
3585 fn mem_set_socket_rejects_gotcha_write_missing_payload_fields() {
3586 let p = make_params("write", "gotcha:incomplete");
3587 let err = build_mem_set_command(&p).expect_err("must reject");
3588 assert!(
3589 err.contains("rule"),
3590 "error must mention 'rule', got: {err}"
3591 );
3592 }
3593
3594 #[test]
3595 fn mem_set_socket_handles_codex_string_payload() {
3596 let mut p = make_params("write", "gotcha:codex-style");
3599 p.payload = serde_json::Value::String(
3600 r#"{"rule":"do X","reason":"because Y","severity":"low"}"#.to_string(),
3601 );
3602 let cmd = build_mem_set_command(&p).expect("must build from stringified payload");
3603 match cmd {
3604 Command::GotchaUpsert(input) => {
3605 assert_eq!(input.rule, "do X");
3606 assert_eq!(input.severity, proto::Severity::Low);
3607 }
3608 _ => panic!("expected GotchaUpsert"),
3609 }
3610 }
3611}