use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context, Result};
use clap::Args;
use uuid::Uuid;
use mati_core::analysis::{
build_edges, build_file_records, hash_and_parse_parallel, import_claude_md, mine_git_history,
parse_dependencies, Walker,
};
use mati_core::graph::edges::EdgeKind;
use mati_core::graph::Graph;
use mati_core::scaffold::codex::CodexInstallResult;
use mati_core::scaffold::settings::InstallResult;
use mati_core::scaffold::{install_codex, install_hooks, write_claude_md_stub};
use mati_core::store::{
derive_slug, Category, ConfidenceScore, FileRecord, GotchaRecord, Priority, QualityScore,
Record, RecordLifecycle, RecordSource, RecordVersion, StalenessScore, StalenessSignal, Store,
};
#[derive(Args)]
pub struct InitArgs {
#[arg(short, long)]
pub path: Option<PathBuf>,
#[arg(long)]
pub no_hooks: bool,
#[arg(long)]
pub codex: bool,
#[arg(long)]
pub claude: bool,
}
pub async fn run(args: InitArgs) -> Result<()> {
let root = match &args.path {
Some(p) => p.clone(),
None => std::env::current_dir()?,
};
let root = std::fs::canonicalize(&root)?;
let slug = derive_slug(&root);
let project_name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string());
println!();
println!("◈ mati — project: {} (slug: {})", project_name, slug);
println!();
let (claude_installed, codex_installed) = install_scaffold(&root, &args)?;
{
use crate::cli::daemon::{daemon_result, mati_root_for, DaemonResult};
let mati_root = mati_root_for(&root)?;
match daemon_result(&mati_root, "ping", serde_json::json!({})).await {
DaemonResult::Ok(_) => {
let owner = crate::cli::daemon::read_pid_file(&mati_root)
.map(|(_, o)| o)
.unwrap_or_else(|| "unknown".to_string());
if owner == "mcp" {
if claude_installed || codex_installed {
println!(" Hook scaffold updated successfully.");
println!();
}
anyhow::bail!(
"mati daemon is running and holds the store lock.\n\
The socket is owned by the active MCP server (mati serve).\n\
Hook scaffold was updated. To run a full re-init, close your\n\
Claude Code / Codex session first, then re-run:\n\n \
mati init\n"
);
} else {
if claude_installed || codex_installed {
println!(" Hook scaffold updated successfully.");
println!();
}
anyhow::bail!(
"mati daemon is running and holds the store lock.\n\
Hook scaffold was updated. To run a full re-init, stop the daemon first:\n\n \
mati daemon stop && mati init\n"
);
}
}
DaemonResult::Unresponsive => {
if claude_installed || codex_installed {
println!(" Hook scaffold updated successfully.");
println!();
}
anyhow::bail!(
"mati daemon socket exists but is not responding (may hold the store lock).\n\
Hook scaffold was updated. Stop the daemon first:\n\n mati daemon stop\n"
);
}
DaemonResult::NotRunning | DaemonResult::StaleSocket => {
let starting = mati_root.join("mati.starting");
if let Ok(content) = std::fs::read_to_string(&starting) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let active =
if let Some((_ts, pid)) = crate::cli::daemon::parse_sentinel(&content) {
mati_core::mcp::metadata::is_pid_alive(pid)
} else if let Ok(ts) = content.trim().parse::<u64>() {
now.saturating_sub(ts) < crate::cli::daemon::STARTING_STALE_SECS
} else {
false
};
if active {
if claude_installed || codex_installed {
println!(" Hook scaffold updated successfully.");
println!();
}
anyhow::bail!(
"a mati daemon is starting and may hold the store lock.\n\
Hook scaffold was updated. Wait a few seconds, then re-run:\n\n mati init\n"
);
}
}
}
}
}
let total_start = Instant::now();
let device_id = mati_core::store::stable_device_id();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let store_task = {
let root = root.clone();
tokio::spawn(async move { Store::open(&root).await })
};
let t = Instant::now();
let walker = Walker::new(&root);
let files = walker.walk()?;
let total_file_count = files.len();
println!(
" Scanning with ignore... {:>4} files {:>4}ms",
total_file_count,
t.elapsed().as_millis()
);
let walked_paths: HashSet<String> = files.iter().map(|f| f.rel_path.clone()).collect();
let store = store_task.await.context("Store::open task panicked")??;
let mtime_index_path = store.root.join("mtime_index.json");
let stored_mtimes: HashMap<String, u64> = std::fs::read(&mtime_index_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let (((hp, parse_ms), (git_result, git_ms)), (dep_result, dep_ms)) = rayon::join(
|| {
rayon::join(
|| {
let t = Instant::now();
(
hash_and_parse_parallel(&files, &stored_mtimes),
t.elapsed().as_millis(),
)
},
|| {
let t = Instant::now();
(
mine_git_history(&root, &walked_paths),
t.elapsed().as_millis(),
)
},
)
},
|| {
let t = Instant::now();
(parse_dependencies(&root, &files), t.elapsed().as_millis())
},
);
let files_to_parse = hp.parsed_files;
let analyses = hp.analyses;
let parse_count = hp.parse_count;
let skipped_count = hp.skipped_count;
if skipped_count > 0 {
println!(
" Mtime+parse (incremental)... {:>4} changed {:>4} skipped {:>3}ms",
parse_count, skipped_count, parse_ms
);
} else {
println!(
" Mtime+parse (first run)... {:>4} files {:>3}ms",
parse_count, parse_ms
);
}
let git_signals = match git_result {
Ok(g) => {
println!(
" Mining git history... {:>4}ms",
git_ms
);
Some(g)
}
Err(e) => {
tracing::warn!("git history mining failed: {e}");
println!(" Mining git history... skipped — {e:#}");
None
}
};
let dep_signals = match dep_result {
Ok(d) => {
println!(
" Parsing dependencies... {:>4} deps {:>4}ms",
d.deps.len(),
dep_ms
);
d
}
Err(e) => {
tracing::warn!("dependency parsing failed: {e}");
println!(" Parsing dependencies... skipped — {e:#}");
mati_core::analysis::DepSignals::empty()
}
};
let t = Instant::now();
let claude_md_path = root.join("CLAUDE.md");
let claude_import = match import_claude_md(&claude_md_path, device_id, 0) {
Ok(imp) => {
let section_count = imp.records.len();
println!(
" Importing CLAUDE.md... {:>4} sections {:>3}ms",
section_count,
t.elapsed().as_millis()
);
imp
}
Err(e) => {
tracing::warn!("CLAUDE.md import failed: {e}");
println!(" Importing CLAUDE.md... skipped — {e:#}");
mati_core::analysis::ClaudeMdImport { records: vec![] }
}
};
let mut file_records =
build_file_records(&files_to_parse, &analyses, git_signals.as_ref(), now);
let t = Instant::now();
let co_change_pairs: Vec<(String, String, u32)> = git_signals
.as_ref()
.map(|g| g.co_change_pairs.clone())
.unwrap_or_default();
let mut layer0_edges = build_edges(&files_to_parse, &analyses, &co_change_pairs);
let edge_count = layer0_edges.edges.len();
println!(
" Building graph edges... {:>4} edges {:>4}ms",
edge_count,
t.elapsed().as_millis()
);
let mut logical_clock: u64 = claude_import.records.len() as u64;
let cochange_gotchas: Vec<CoChangeGotcha> = match &git_signals {
Some(signals) => build_cochange_gotchas(signals, device_id, logical_clock, now),
None => vec![],
};
let cochange_count = cochange_gotchas.len();
logical_clock += cochange_count as u64;
let revert_gotchas: Vec<RevertGotcha> = match &git_signals {
Some(signals) => build_revert_gotchas(
signals,
&signals.change_frequency,
device_id,
logical_clock,
now,
),
None => vec![],
};
let revert_count = revert_gotchas.len();
logical_clock += revert_count as u64;
let ownership_gotchas: Vec<OwnershipGotcha> = match &git_signals {
Some(signals) => build_ownership_gotchas(signals, device_id, logical_clock, now),
None => vec![],
};
let ownership_count = ownership_gotchas.len();
logical_clock += ownership_count as u64;
{
let mut path_to_cochange_keys: HashMap<String, Vec<String>> = HashMap::new();
for cg in &cochange_gotchas {
path_to_cochange_keys
.entry(cg.source_path.clone())
.or_default()
.push(cg.key.clone());
}
for fr in file_records.iter_mut() {
fr.gotcha_keys
.retain(|k| !k.starts_with("gotcha:cochange:"));
}
for fr in file_records.iter_mut() {
if let Some(keys) = path_to_cochange_keys.get(&fr.path) {
fr.gotcha_keys.extend(keys.iter().cloned());
}
}
let mut path_to_revert_keys: HashMap<String, Vec<String>> = HashMap::new();
for rg in &revert_gotchas {
path_to_revert_keys
.entry(rg.source_path.clone())
.or_default()
.push(rg.key.clone());
}
for fr in file_records.iter_mut() {
fr.gotcha_keys.retain(|k| !k.starts_with("gotcha:revert:"));
}
for fr in file_records.iter_mut() {
if let Some(keys) = path_to_revert_keys.get(&fr.path) {
fr.gotcha_keys.extend(keys.iter().cloned());
}
}
let mut path_to_ownership_keys: HashMap<String, Vec<String>> = HashMap::new();
for og in &ownership_gotchas {
path_to_ownership_keys
.entry(og.source_path.clone())
.or_default()
.push(og.key.clone());
}
for fr in file_records.iter_mut() {
fr.gotcha_keys
.retain(|k| !k.starts_with("gotcha:ownership:"));
}
for fr in file_records.iter_mut() {
if let Some(keys) = path_to_ownership_keys.get(&fr.path) {
fr.gotcha_keys.extend(keys.iter().cloned());
}
}
}
{
let all_gotchas = store.scan_prefix("gotcha:").await.unwrap_or_default();
let mut path_to_manual_keys: HashMap<String, Vec<String>> = HashMap::new();
for rec in &all_gotchas {
if !matches!(rec.lifecycle, RecordLifecycle::Active) {
continue;
}
let is_auto = rec.key.starts_with("gotcha:cochange:")
|| rec.key.starts_with("gotcha:revert:")
|| rec.key.starts_with("gotcha:ownership:");
if is_auto {
continue;
}
if let Some(g) = rec.payload_as::<GotchaRecord>() {
for file_path in &g.affected_files {
path_to_manual_keys
.entry(file_path.clone())
.or_default()
.push(rec.key.clone());
}
}
}
for fr in file_records.iter_mut() {
if let Some(keys) = path_to_manual_keys.get(&fr.path) {
for k in keys {
if !fr.gotcha_keys.contains(k) {
fr.gotcha_keys.push(k.clone());
}
}
}
}
}
let mut lines_changed: HashMap<String, f32> = HashMap::new(); if skipped_count > 0 {
for fr in &file_records {
let (Some(new_hash), true) = (&fr.content_hash, fr.line_count > 0) else {
continue; };
let key = format!("file:{}", fr.path);
if let Ok(Some(existing)) = store.get(&key).await {
if let Some(old_fr) = existing.payload_as::<FileRecord>() {
if let Some(old_hash) = &old_fr.content_hash {
if old_hash != new_hash && old_fr.line_count > 0 {
let delta = fr.line_count.abs_diff(old_fr.line_count);
let ratio = delta as f32 / old_fr.line_count as f32;
lines_changed.insert(fr.path.clone(), ratio);
}
}
}
}
}
}
let file_record_structs: Vec<Record> = file_records
.iter()
.enumerate()
.map(|(i, fr)| {
let key = format!("file:{}", fr.path);
let mut rec = Record::layer0_file_stub(&key, device_id, logical_clock + i as u64, now);
rec.payload = serde_json::to_value(fr).ok();
if !fr.purpose.is_empty() {
rec.value = fr.purpose.clone();
rec.quality = QualityScore::doc_comment_default();
rec.confidence.value = 0.45;
}
if rec.quality.value < 0.40
&& fr
.gotcha_keys
.iter()
.any(|k| k.starts_with("gotcha:cochange:"))
{
rec.quality = QualityScore::doc_comment_default();
if rec.confidence.value < 0.45 {
rec.confidence.value = 0.45;
}
}
if let Some(&ratio) = lines_changed.get(&fr.path) {
rec.staleness
.signals
.push(StalenessSignal::LinesChangedPct(ratio));
}
rec
})
.collect();
logical_clock += file_record_structs.len() as u64;
let dep_record_structs: Vec<Record> = dep_signals
.deps
.iter()
.enumerate()
.map(|(i, dep)| {
let key = mati_core::analysis::dep_record_key(dep);
let mut rec = Record::layer0_file_stub(&key, device_id, logical_clock + i as u64, now);
rec.category = Category::Dependency;
rec.source = RecordSource::StaticAnalysis;
rec.value = match &dep.version {
mati_core::analysis::DepVersion::Declared(v) => {
format!("{} = \"{}\"", dep.name, v)
}
mati_core::analysis::DepVersion::Workspace => {
format!("{} (workspace)", dep.name)
}
};
let manifest_tag = match dep.manifest {
mati_core::analysis::ManifestKind::CargoToml => "manifest:cargo-toml",
mati_core::analysis::ManifestKind::PackageJson => "manifest:package-json",
mati_core::analysis::ManifestKind::GoMod => "manifest:go-mod",
};
rec.tags = vec![
format!("ecosystem:{}", dep.ecosystem.as_str()),
manifest_tag.to_string(),
if dep.dev {
"dev-dep".to_string()
} else {
"dep".to_string()
},
];
rec
})
.collect();
{
let mut merged = stored_mtimes;
merged.extend(hp.new_mtimes);
if let Ok(blob) = serde_json::to_string(&merged) {
let _ = std::fs::write(&mtime_index_path, blob);
}
}
let hash_record_structs: Vec<Record> = vec![];
let cochange_record_structs: Vec<Record> =
cochange_gotchas.into_iter().map(|cg| cg.record).collect();
let revert_record_structs: Vec<Record> =
revert_gotchas.into_iter().map(|rg| rg.record).collect();
let ownership_record_structs: Vec<Record> =
ownership_gotchas.into_iter().map(|og| og.record).collect();
if skipped_count == 0 {
let new_keys: HashSet<&str> = cochange_record_structs
.iter()
.map(|r| r.key.as_str())
.collect();
match store.scan_prefix("gotcha:cochange:").await {
Ok(existing) => {
for rec in existing {
if !new_keys.contains(rec.key.as_str()) {
if let Err(e) = store.delete(&rec.key).await {
tracing::warn!(
"failed to delete stale co-change gotcha {}: {e}",
rec.key
);
}
}
}
}
Err(e) => tracing::warn!("co-change tombstone scan failed (non-fatal): {e}"),
}
let new_revert_keys: HashSet<&str> = revert_record_structs
.iter()
.map(|r| r.key.as_str())
.collect();
match store.scan_prefix("gotcha:revert:").await {
Ok(existing) => {
for rec in existing {
if !new_revert_keys.contains(rec.key.as_str()) {
if let Err(e) = store.delete(&rec.key).await {
tracing::warn!("failed to delete stale revert gotcha {}: {e}", rec.key);
}
}
}
}
Err(e) => tracing::warn!("revert tombstone scan failed (non-fatal): {e}"),
}
let new_ownership_keys: HashSet<&str> = ownership_record_structs
.iter()
.map(|r| r.key.as_str())
.collect();
match store.scan_prefix("gotcha:ownership:").await {
Ok(existing) => {
for rec in existing {
if !new_ownership_keys.contains(rec.key.as_str()) {
if let Err(e) = store.delete(&rec.key).await {
tracing::warn!(
"failed to delete stale ownership gotcha {}: {e}",
rec.key
);
}
}
}
}
Err(e) => tracing::warn!("ownership tombstone scan failed (non-fatal): {e}"),
}
let new_dep_keys: HashSet<&str> =
dep_record_structs.iter().map(|r| r.key.as_str()).collect();
match store.scan_prefix("dep:").await {
Ok(existing) => {
for key in stale_dependency_keys(&existing, &new_dep_keys) {
if let Err(e) = store.delete(&key).await {
tracing::warn!("failed to delete stale dependency record {}: {e}", key);
}
}
}
Err(e) => tracing::warn!("dependency cleanup scan failed (non-fatal): {e}"),
}
}
let codeowners_record_structs =
build_codeowners_candidates(&root, &store, device_id, logical_clock, now).await;
let all_records: Vec<Record> = claude_import
.records
.iter()
.chain(file_record_structs.iter())
.chain(dep_record_structs.iter())
.chain(hash_record_structs.iter())
.chain(cochange_record_structs.iter())
.chain(revert_record_structs.iter())
.chain(ownership_record_structs.iter())
.chain(codeowners_record_structs.iter())
.cloned()
.collect();
let all_pairs: Vec<(&str, &Record)> = all_records.iter().map(|r| (r.key.as_str(), r)).collect();
let t = Instant::now();
store.put_batch_kv_only(&all_pairs).await?;
println!(
" Writing store (KV)... {:>4} recs {:>4}ms",
all_records.len(),
t.elapsed().as_millis(),
);
if skipped_count > 0 {
let in_memory_paths: HashSet<String> =
file_records.iter().map(|fr| fr.path.clone()).collect();
let mut path_to_all_keys: HashMap<String, Vec<String>> = HashMap::new();
for rec_vec in [
&cochange_record_structs,
&revert_record_structs,
&ownership_record_structs,
] {
for rec in rec_vec.iter() {
if let Some(g) = rec.payload_as::<GotchaRecord>() {
for file_path in &g.affected_files {
path_to_all_keys
.entry(file_path.clone())
.or_default()
.push(rec.key.clone());
}
}
}
}
for prefix in &["gotcha:cochange:", "gotcha:revert:", "gotcha:ownership:"] {
if let Ok(stored) = store.scan_prefix(prefix).await {
for rec in &stored {
if !matches!(rec.lifecycle, RecordLifecycle::Active) {
continue;
}
if let Some(g) = rec.payload_as::<GotchaRecord>() {
for file_path in &g.affected_files {
let keys = path_to_all_keys.entry(file_path.clone()).or_default();
if !keys.contains(&rec.key) {
keys.push(rec.key.clone());
}
}
}
}
}
}
let mut patched: Vec<(String, Record)> = Vec::new();
for (path, gotcha_keys) in &path_to_all_keys {
if in_memory_paths.contains(path) {
continue; }
let file_key = format!("file:{path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
if let Some(ref mut payload) = record.payload {
if let Some(obj) = payload.as_object_mut() {
let existing: Vec<String> = obj
.get("gotcha_keys")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut merged: Vec<String> = existing
.into_iter()
.filter(|k| {
!k.starts_with("gotcha:cochange:")
&& !k.starts_with("gotcha:revert:")
&& !k.starts_with("gotcha:ownership:")
})
.collect();
for k in gotcha_keys {
if !merged.contains(k) {
merged.push(k.clone());
}
}
obj.insert("gotcha_keys".to_string(), serde_json::json!(merged));
}
}
patched.push((file_key, record));
}
}
if !patched.is_empty() {
let pairs: Vec<(&str, &Record)> =
patched.iter().map(|(k, r)| (k.as_str(), r)).collect();
if let Err(e) = store.put_batch_kv_only(&pairs).await {
tracing::warn!("init: skipped-file gotcha_keys patch failed: {e}");
}
}
}
{
let significantly_changed: Vec<&str> = lines_changed
.iter()
.filter(|(_, &ratio)| ratio >= 0.10)
.map(|(path, _)| path.as_str())
.collect();
if !significantly_changed.is_empty() {
let changed_set: HashSet<&str> = significantly_changed.iter().copied().collect();
let mut to_flag: HashMap<String, Vec<String>> = HashMap::new();
for (a, b, _) in &co_change_pairs {
if changed_set.contains(a.as_str()) && !changed_set.contains(b.as_str()) {
to_flag.entry(b.clone()).or_default().push(a.clone());
}
if changed_set.contains(b.as_str()) && !changed_set.contains(a.as_str()) {
to_flag.entry(a.clone()).or_default().push(b.clone());
}
}
for (partner_path, changed_paths) in to_flag {
let key = format!("file:{}", partner_path);
if let Ok(Some(mut rec)) = store.get(&key).await {
for changed_path in changed_paths {
let signal = StalenessSignal::LinkedFileChanged { path: changed_path };
if !rec.staleness.signals.contains(&signal) {
rec.staleness.signals.push(signal);
}
}
let _ = store.put(&key, &rec).await;
}
}
}
}
if skipped_count == 0 {
let gotcha_recs: Vec<Record> = claude_import
.records
.iter()
.filter(|r| r.key.starts_with("gotcha:"))
.cloned()
.chain(cochange_record_structs.iter().cloned())
.chain(revert_record_structs.iter().cloned())
.chain(ownership_record_structs.iter().cloned())
.collect();
let decision_recs: Vec<Record> = claude_import
.records
.iter()
.filter(|r| r.key.starts_with("decision:"))
.cloned()
.collect();
if let Err(e) = super::stats::seed_snapshot(
&store,
&file_record_structs,
&gotcha_recs,
&decision_recs,
&dep_record_structs,
now,
)
.await
{
tracing::warn!("stats snapshot seed failed (non-fatal): {e}");
}
if let Err(e) = super::stale::seed_stale_cache(&store, &all_records).await {
tracing::warn!("stale cache seed failed (non-fatal): {e}");
}
}
{
let all_gotcha_recs = claude_import
.records
.iter()
.filter(|r| r.key.starts_with("gotcha:"))
.chain(cochange_record_structs.iter())
.chain(revert_record_structs.iter())
.chain(ownership_record_structs.iter());
for rec in all_gotcha_recs {
if !matches!(rec.lifecycle, RecordLifecycle::Active) {
continue;
}
let affected = rec
.payload_as::<GotchaRecord>()
.map(|g| g.affected_files)
.unwrap_or_default();
for file_path in &affected {
let file_key = format!("file:{file_path}");
layer0_edges
.edges
.push((file_key, EdgeKind::HasGotcha, rec.key.clone()));
}
}
}
let t = Instant::now();
let graph = if layer0_edges.edges.is_empty() && skipped_count > 0 {
Graph::empty(store)
} else {
let mut g = Graph::load(store).await?;
g.add_edges_batch(&layer0_edges.edges).await?;
g
};
println!(
" Graph load+edges... {:>4}ms",
t.elapsed().as_millis()
);
{
let store_ref = graph.store();
let t = Instant::now();
let all_keys: Vec<String> = file_records
.iter()
.map(|fr| format!("file:{}", fr.path))
.collect();
let blast_map =
mati_core::analysis::blast_radius::BlastRadius::compute_all(&graph, &all_keys);
let mut all_file_recs = store_ref.scan_prefix("file:").await.unwrap_or_default();
let mut blast_count = 0u32;
for record in all_file_recs.iter_mut() {
if let Some(br) = blast_map.get(&record.key) {
if let Some(mut fr) = record.payload_as::<FileRecord>() {
fr.blast_radius = Some(br.clone());
record.payload = serde_json::to_value(&fr).ok();
blast_count += 1;
}
}
}
let pairs: Vec<(&str, &Record)> =
all_file_recs.iter().map(|r| (r.key.as_str(), r)).collect();
let _ = store_ref.put_batch_kv_only(&pairs).await;
println!(
" Blast radius... {:>4} files {:>4}ms",
blast_count,
t.elapsed().as_millis()
);
}
{
let t = Instant::now();
let cluster_index = mati_core::analysis::clusters::ClusterIndex::compute(
&co_change_pairs,
file_records.len() + skipped_count,
);
let cluster_count = cluster_index.total;
let store_ref = graph.store();
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cluster_record = Record {
key: "cluster:index".to_string(),
value: format!(
"{} clusters, {} clustered files",
cluster_index.total, cluster_index.clustered_files
),
payload: serde_json::to_value(&cluster_index).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now_ts,
updated_at: now_ts,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id,
logical_clock: 1,
wall_clock: now_ts,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
};
let _ = store_ref.put("cluster:index", &cluster_record).await;
let pairs_payload = serde_json::json!({
"pairs": &co_change_pairs,
"total_files_at_init": file_records.len() + skipped_count,
});
let pairs_record = Record {
key: "analytics:co_change_pairs".to_string(),
value: format!(
"{} pairs (source of truth for clustering)",
co_change_pairs.len()
),
payload: Some(pairs_payload),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now_ts,
updated_at: now_ts,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id,
logical_clock: 1,
wall_clock: now_ts,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
};
let _ = store_ref
.put("analytics:co_change_pairs", &pairs_record)
.await;
println!(
" Clusters... {:>4} found {:>4}ms",
cluster_count,
t.elapsed().as_millis()
);
}
{
let t = Instant::now();
let store_ref = graph.store();
let mut all_file_recs = store_ref.scan_prefix("file:").await.unwrap_or_default();
let propagation =
mati_core::analysis::propagation::compute_propagation(&all_file_recs, &graph);
let mut prop_count = 0u32;
for record in all_file_recs.iter_mut() {
if let Some(prop) = propagation.get(&record.key) {
if let Some(mut fr) = record.payload_as::<FileRecord>() {
fr.propagated_staleness = Some(prop.clone());
record.payload = serde_json::to_value(&fr).ok();
prop_count += 1;
}
}
}
if prop_count > 0 {
let pairs: Vec<(&str, &Record)> =
all_file_recs.iter().map(|r| (r.key.as_str(), r)).collect();
let _ = store_ref.put_batch_kv_only(&pairs).await;
println!(
" Staleness propagation... {:>4} files {:>4}ms",
prop_count,
t.elapsed().as_millis()
);
}
}
if skipped_count == 0 {
graph.store().mark_search_stale();
println!(" Search index... (deferred to first MCP server startup)");
}
graph.close().await?;
let gotcha_candidates: usize = analyses
.iter()
.map(|a| a.todos.len() + a.unsafe_count as usize + a.unwrap_count as usize)
.sum();
let hotspot_count = git_signals
.as_ref()
.map(|g| g.hotspot_files.len())
.unwrap_or(0);
println!();
println!(" ─────────────────────────────────────────────");
println!(
" file records: {:>4} ({} parsed, {} skipped)",
total_file_count, parse_count, skipped_count
);
println!(
" gotcha candidates: {:>4} (TODOs, unsafe, unwrap — parsed files only)",
gotcha_candidates
);
println!(
" co-change gotchas: {:>4} (auto-generated from git history)",
cochange_count
);
println!(
" revert stubs: {:>4} (confirmed=false, surface in mati review)",
revert_count
);
println!(
" ownership stubs: {:>4} (confirmed=false, surface in mati review)",
ownership_count
);
println!(" dep records: {:>4}", dep_signals.deps.len());
println!(
" graph edges: {:>4} (import + co-change)",
edge_count
);
println!(
" imported from CLAUDE.md: {:>2}",
claude_import.records.len()
);
println!(" hotspot files: {:>4}", hotspot_count);
println!(" ─────────────────────────────────────────────");
println!();
println!(
" Total: {}ms · 0 tokens · 0 Claude calls",
total_start.elapsed().as_millis()
);
println!();
let integration_label = match (claude_installed, codex_installed, args.no_hooks) {
(_, _, true) => "MCP-only fallback (agent scaffolds skipped)".to_string(),
(true, true, false) => "Claude + Codex".to_string(),
(true, false, false) => "Claude".to_string(),
(false, true, false) => "Codex".to_string(),
(false, false, false) => "MCP-only fallback".to_string(),
};
println!(" integration: {integration_label}");
if claude_installed || codex_installed {
let g = if std::io::stderr().is_terminal() {
super::colors::GRAY
} else {
""
};
let w = if std::io::stderr().is_terminal() {
super::colors::WHITE
} else {
""
};
let b = if std::io::stderr().is_terminal() {
super::colors::BOLD
} else {
""
};
let r = if std::io::stderr().is_terminal() {
super::colors::RESET
} else {
""
};
println!();
println!(" {b}Enforcement{r}");
if claude_installed {
println!(
" {w}Claude:{r} {g}file reads blocked until knowledge consulted (pre-read hook){r}"
);
}
if codex_installed {
println!(
" {w}Codex:{r} {g}Bash reads blocked + gotchas injected on prompt submit{r}"
);
}
println!(" {w}Both:{r} {g}compliance tracking, edit capture, session analytics{r}");
}
println!();
let use_color = std::io::stderr().is_terminal();
let (blue, cyan, yellow, gray, white, bold, reset) = if use_color {
(
super::colors::BLUE,
super::colors::CYAN,
super::colors::YELLOW,
super::colors::GRAY,
super::colors::WHITE,
super::colors::BOLD,
super::colors::RESET,
)
} else {
("", "", "", "", "", "", "")
};
println!(
" {bold}Onboarding estimate:{reset} {white}22 min{reset} {gray}(0% gotcha coverage — confirm candidates to reduce){reset}"
);
let hotspot_paths: &[String] = git_signals
.as_ref()
.map(|g| g.hotspot_files.as_slice())
.unwrap_or(&[]);
let code_hotspots: Vec<&String> = hotspot_paths.iter().filter(|p| is_code_file(p)).collect();
let display_hotspots: Vec<&String> = if code_hotspots.is_empty() {
hotspot_paths.iter().collect()
} else {
code_hotspots
};
if !display_hotspots.is_empty() {
println!();
println!(
" {bold}Hotspot files{reset} {gray}(highest risk — most changed in last 90 days){reset}"
);
for path in display_hotspots.iter().take(5) {
println!(" {cyan}{path}{reset}");
}
if display_hotspots.len() > 5 {
println!(" {gray}… and {} more{reset}", display_hotspots.len() - 5);
}
}
let code_pairs: Vec<&(String, String, u32)> = co_change_pairs
.iter()
.filter(|(a, b, _)| is_code_file(a) && is_code_file(b))
.collect();
if !code_pairs.is_empty() {
println!();
println!(
" {bold}Co-change pairs{reset} {gray}(files that always change together){reset}"
);
for (a, b, count) in code_pairs.iter().take(3) {
let (freq_a, freq_b) = git_signals
.as_ref()
.map(|g| {
(
g.change_frequency
.get(a.as_str())
.copied()
.unwrap_or(1)
.max(1),
g.change_frequency
.get(b.as_str())
.copied()
.unwrap_or(1)
.max(1),
)
})
.unwrap_or((1, 1));
let pct = ((*count as f64 / freq_a.min(freq_b) as f64) * 100.0)
.round()
.min(100.0) as u32;
println!(" {cyan}{a}{reset} ↔ {cyan}{b}{reset} {gray}({pct}%){reset}");
}
}
let review_count = gotcha_candidates + cochange_count + revert_count + ownership_count;
if review_count > 0 {
println!();
println!(
" {bold}{yellow}Review backlog{reset} {white}{review_count}{reset} {gray}candidates pending confirmation{reset}"
);
println!(" {gray}Run {white}mati review{gray} — confirmed gotchas block file reads until consulted{reset}");
if hotspot_count > 0 {
println!(
" {yellow}{hotspot_count} hotspot files have zero confirmed gotchas{reset}"
);
}
}
println!();
println!(" {bold}{blue}Next steps{reset}");
let top_file = display_hotspots.first().map(|s| s.as_str());
let explain_cmd = if let Some(path) = top_file {
format!("mati explain {path}")
} else if total_file_count > 0 {
"mati explain <file>".to_string()
} else {
String::new()
};
let review_cmd = "mati review".to_string();
let status_cmd = "mati status".to_string();
let col = explain_cmd
.len()
.max(review_cmd.len())
.max(status_cmd.len())
+ 2;
if !explain_cmd.is_empty() {
let desc = if top_file.is_some() {
"← start here (highest-risk file)"
} else {
"file briefing — gotchas and decisions before editing"
};
println!(
" {white}{explain_cmd:<col$}{reset}{gray}{desc}{reset}",
col = col
);
}
if review_count > 0 {
let desc = format!("confirm {review_count} candidates for hook enforcement");
println!(
" {white}{review_cmd:<col$}{reset}{gray}{desc}{reset}",
col = col
);
}
{
let desc = "project knowledge dashboard";
println!(
" {white}{status_cmd:<col$}{reset}{gray}{desc}{reset}",
col = col
);
}
println!();
Ok(())
}
async fn build_codeowners_candidates(
root: &std::path::Path,
store: &Store,
device_id: Uuid,
clock_start: u64,
now: u64,
) -> Vec<Record> {
use mati_core::analysis::onboarding;
let Some(content) = crate::cli::suggest::read_codeowners(root) else {
return Vec::new();
};
let rules = onboarding::parse_codeowners(&content);
let candidates = onboarding::codeowners_candidates(&rules, device_id, clock_start, now);
if candidates.is_empty() {
return Vec::new();
}
let existing: std::collections::HashSet<String> =
match store.scan_prefix("gotcha:codeowners:").await {
Ok(records) => records.into_iter().map(|r| r.key).collect(),
Err(_) => std::collections::HashSet::new(),
};
candidates
.into_iter()
.filter(|r| !existing.contains(&r.key))
.collect()
}
struct CoChangeGotcha {
key: String,
source_path: String,
record: Record,
}
fn build_cochange_gotchas(
signals: &mati_core::analysis::GitSignals,
device_id: Uuid,
logical_clock_start: u64,
now: u64,
) -> Vec<CoChangeGotcha> {
const THRESHOLD: f64 = 0.70;
const STRONG_RATIO: f64 = 0.90;
const STRONG_COUNT: u32 = 20;
const MAX_PER_FILE: usize = 5;
const MIN_COUNT: u32 = 3;
let mut candidates: Vec<(String, String, u32, f64)> = Vec::new();
for (a, b, count) in &signals.co_change_pairs {
let freq_a = match signals.change_frequency.get(a) {
Some(&f) if f > 0 => f as f64,
_ => continue,
};
let freq_b = match signals.change_frequency.get(b) {
Some(&f) if f > 0 => f as f64,
_ => continue,
};
let ratio_a = *count as f64 / freq_a;
let ratio_b = *count as f64 / freq_b;
if ratio_a >= THRESHOLD && *count >= MIN_COUNT {
candidates.push((a.clone(), b.clone(), *count, ratio_a));
}
if ratio_b >= THRESHOLD && *count >= MIN_COUNT {
candidates.push((b.clone(), a.clone(), *count, ratio_b));
}
}
candidates.sort_by(|x, y| x.0.cmp(&y.0).then(y.2.cmp(&x.2)));
let mut per_source_count: HashMap<String, usize> = HashMap::new();
let mut clock_offset: u64 = 0;
let mut result: Vec<CoChangeGotcha> = Vec::new();
for (source, target, count, ratio) in candidates {
let seen = per_source_count.entry(source.clone()).or_insert(0);
if *seen >= MAX_PER_FILE {
continue;
}
*seen += 1;
let freq_source = signals.change_frequency.get(&source).copied().unwrap_or(1);
let pct = (ratio * 100.0).round() as u32;
let rule = format!(
"Always check `{target}` when editing this file — changed together in {count}/{freq_source} commits ({pct}%).",
);
let reason = "Co-change signal from git history — modifying one without the other is a known source of bugs.".to_string();
let (quality, conf_value, severity) = if ratio >= STRONG_RATIO && count >= STRONG_COUNT {
(QualityScore::cochange_strong(), 0.65_f32, Priority::High)
} else {
(QualityScore::cochange_default(), 0.45_f32, Priority::Normal)
};
let gotcha = GotchaRecord {
rule: rule.clone(),
reason,
severity: severity.clone(),
affected_files: vec![source.clone()],
ref_url: None,
discovered_session: now,
confirmed: false,
};
let key = format!("gotcha:cochange:{source}|{target}");
let mut rec =
Record::layer0_file_stub(&key, device_id, logical_clock_start + clock_offset, now);
rec.category = Category::Gotcha;
rec.source = RecordSource::StaticAnalysis;
rec.priority = severity;
rec.value = rule;
rec.quality = quality;
rec.confidence.value = conf_value;
rec.tags = vec!["co-change".to_string(), "auto-generated".to_string()];
rec.payload = serde_json::to_value(&gotcha).ok();
clock_offset += 1;
result.push(CoChangeGotcha {
key,
source_path: source,
record: rec,
});
}
result
}
struct RevertGotcha {
key: String,
source_path: String,
record: Record,
}
fn build_revert_gotchas(
signals: &mati_core::analysis::GitSignals,
change_frequency: &std::collections::HashMap<String, u32>,
device_id: Uuid,
logical_clock_start: u64,
now: u64,
) -> Vec<RevertGotcha> {
const MIN_REVERTS: u32 = 2;
const MIN_REVERT_RATE: f32 = 0.05;
let mut candidates: Vec<(&String, u32, f32)> = signals
.revert_counts
.iter()
.filter_map(|(path, &count)| {
if count < MIN_REVERTS {
return None;
}
let total = *change_frequency.get(path).unwrap_or(&0);
if total == 0 {
return None;
}
let rate = count as f32 / total as f32;
if rate >= MIN_REVERT_RATE {
Some((path, count, rate))
} else {
None
}
})
.collect();
candidates.sort_by(|a, b| {
b.2.partial_cmp(&a.2)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.1.cmp(&a.1))
.then_with(|| a.0.cmp(b.0))
});
let mut result: Vec<RevertGotcha> = Vec::new();
for (clock_offset, (path, count, rate)) in candidates.into_iter().enumerate() {
let clock_offset = clock_offset as u64;
let pct = (rate * 100.0).round() as u32;
let rule = format!(
"High revert rate ({pct}% of commits, {count} reverts) — this interface has been broken and undone repeatedly. Test carefully before touching.",
);
let reason =
"Repeated reverts in git history indicate contested or fragile logic.".to_string();
let gotcha = GotchaRecord {
rule: rule.clone(),
reason,
severity: Priority::Normal,
affected_files: vec![path.clone()],
ref_url: None,
discovered_session: now,
confirmed: false,
};
let key = format!("gotcha:revert:{path}");
let mut rec =
Record::layer0_file_stub(&key, device_id, logical_clock_start + clock_offset, now);
rec.category = Category::Gotcha;
rec.source = RecordSource::StaticAnalysis;
rec.priority = Priority::Normal;
rec.value = rule;
rec.quality = QualityScore::cochange_default();
rec.confidence.value = 0.35;
rec.tags = vec!["revert".to_string(), "auto-generated".to_string()];
rec.payload = serde_json::to_value(&gotcha).ok();
result.push(RevertGotcha {
key,
source_path: path.clone(),
record: rec,
});
}
result
}
struct OwnershipGotcha {
key: String,
source_path: String,
record: Record,
}
fn build_ownership_gotchas(
signals: &mati_core::analysis::GitSignals,
device_id: Uuid,
logical_clock_start: u64,
now: u64,
) -> Vec<OwnershipGotcha> {
const CONCENTRATION_THRESHOLD: f64 = 0.80;
const MIN_COMMITS: u32 = 5;
let hotspot_set: std::collections::HashSet<&String> = signals.hotspot_files.iter().collect();
let mut candidates: Vec<(&String, String, u32, f64)> = Vec::new();
for (path, author_counts) in &signals.author_commit_counts {
if !hotspot_set.contains(path) {
continue;
}
let total: u32 = author_counts.values().sum();
if total < MIN_COMMITS {
continue;
}
if let Some((top_author, &top_count)) = author_counts.iter().max_by_key(|(_, &c)| c) {
let ratio = top_count as f64 / total as f64;
if ratio >= CONCENTRATION_THRESHOLD {
candidates.push((path, top_author.clone(), top_count, ratio));
}
}
}
candidates.sort_by(|a, b| {
b.3.partial_cmp(&a.3)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(b.0))
});
let mut result: Vec<OwnershipGotcha> = Vec::new();
for (clock_offset, (path, top_author, top_count, ratio)) in candidates.into_iter().enumerate() {
let clock_offset = clock_offset as u64;
let total = signals
.change_frequency
.get(path)
.copied()
.unwrap_or(top_count);
let pct = (ratio * 100.0).round() as u32;
let rule = format!(
"{pct}% of commits by {top_author} ({top_count}/{total}) — key person dependency on this hotspot file.",
);
let reason = "Single-author dominance on a high-traffic file is a knowledge silo risk — context may be lost if that person is unavailable.".to_string();
let gotcha = GotchaRecord {
rule: rule.clone(),
reason,
severity: Priority::Normal,
affected_files: vec![path.clone()],
ref_url: None,
discovered_session: now,
confirmed: false,
};
let key = format!("gotcha:ownership:{path}");
let mut rec =
Record::layer0_file_stub(&key, device_id, logical_clock_start + clock_offset, now);
rec.category = Category::Gotcha;
rec.source = RecordSource::StaticAnalysis;
rec.priority = Priority::Normal;
rec.value = rule;
rec.quality = QualityScore::cochange_default();
rec.confidence.value = 0.40;
rec.tags = vec!["ownership".to_string(), "auto-generated".to_string()];
rec.payload = serde_json::to_value(&gotcha).ok();
result.push(OwnershipGotcha {
key,
source_path: path.clone(),
record: rec,
});
}
result
}
#[allow(dead_code)]
fn make_hash_record(key: &str, hash: &str, device_id: Uuid, now: u64) -> Record {
Record {
key: key.to_string(),
value: hash.to_string(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id,
logical_clock: 1,
wall_clock: now,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: None,
}
}
fn stale_dependency_keys(existing: &[Record], new_keys: &HashSet<&str>) -> Vec<String> {
existing
.iter()
.filter(|rec| rec.category == Category::Dependency)
.filter(|rec| !new_keys.contains(rec.key.as_str()))
.map(|rec| rec.key.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use mati_core::analysis::GitSignals;
fn make_signals(pairs: &[(&str, &str, u32)], freq: &[(&str, u32)]) -> GitSignals {
let mut signals = GitSignals::empty();
for (a, b, count) in pairs {
signals
.co_change_pairs
.push((a.to_string(), b.to_string(), *count));
}
for (path, f) in freq {
signals.change_frequency.insert(path.to_string(), *f);
}
signals
}
fn dummy() -> (Uuid, u64) {
(Uuid::new_v4(), 0)
}
fn make_record_with_category(key: &str, category: Category) -> Record {
Record {
key: key.to_string(),
value: "value".to_string(),
category,
priority: Priority::Normal,
tags: vec![],
created_at: 1,
updated_at: 1,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: Uuid::nil(),
logical_clock: 1,
wall_clock: 1,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: None,
}
}
#[test]
fn rule_text_contains_ratio_and_pct() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 9)], &[("a.rs", 10), ("b.rs", 10)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
let ga = gotchas.iter().find(|g| g.source_path == "a.rs").unwrap();
assert!(ga.record.value.contains("9/10"), "rule should contain 9/10");
assert!(ga.record.value.contains("90%"), "rule should contain 90%");
assert!(
ga.record.value.contains("`b.rs`"),
"rule should name the target"
);
}
#[test]
fn symmetric_pair_produces_two_gotchas() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 8)], &[("a.rs", 10), ("b.rs", 10)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
assert_eq!(gotchas.len(), 2);
assert!(gotchas.iter().any(|g| g.source_path == "a.rs"));
assert!(gotchas.iter().any(|g| g.source_path == "b.rs"));
}
#[test]
fn asymmetric_pair_only_constrained_file_gets_gotcha() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 4)], &[("a.rs", 30), ("b.rs", 4)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
assert_eq!(gotchas.len(), 1);
assert_eq!(gotchas[0].source_path, "b.rs");
assert!(gotchas[0].record.value.contains("`a.rs`"));
assert!(gotchas[0].record.value.contains("4/4"));
assert!(gotchas[0].record.value.contains("100%"));
}
#[test]
fn key_is_directional() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 4)], &[("a.rs", 30), ("b.rs", 4)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
assert_eq!(gotchas[0].key, "gotcha:cochange:b.rs|a.rs");
}
#[test]
fn normal_signal_gets_additionalcontext_tier() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 8)], &[("a.rs", 10), ("b.rs", 10)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
let ga = gotchas.iter().find(|g| g.source_path == "a.rs").unwrap();
assert!((ga.record.confidence.value - 0.45).abs() < 0.001);
assert!((ga.record.quality.value - 0.40).abs() < 0.001);
}
#[test]
fn strong_signal_gets_inject_tier() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 20)], &[("a.rs", 21), ("b.rs", 21)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
let ga = gotchas.iter().find(|g| g.source_path == "a.rs").unwrap();
assert!((ga.record.confidence.value - 0.65).abs() < 0.001);
assert!((ga.record.quality.value - 0.60).abs() < 0.001);
}
#[test]
fn volume_cap_is_five_per_source() {
let (dev, now) = dummy();
let pairs: Vec<(&str, &str, u32)> = (0..7)
.map(|i| {
(
"hub.rs",
Box::leak(format!("dep{i}.rs").into_boxed_str()) as &str,
10 - i as u32,
)
})
.collect();
let mut freqs: Vec<(&str, u32)> = vec![("hub.rs", 10)];
for i in 0..7u32 {
freqs.push((Box::leak(format!("dep{i}.rs").into_boxed_str()), 10 - i));
}
let signals = make_signals(&pairs, &freqs);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
let hub_gotchas: Vec<_> = gotchas
.iter()
.filter(|g| g.source_path == "hub.rs")
.collect();
assert!(
hub_gotchas.len() <= 5,
"expected ≤ 5 gotchas for hub.rs, got {}",
hub_gotchas.len()
);
}
#[test]
fn payload_deserializes_as_gotcha_record() {
let (dev, now) = dummy();
let signals = make_signals(&[("a.rs", "b.rs", 8)], &[("a.rs", 10), ("b.rs", 10)]);
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
let ga = gotchas.iter().find(|g| g.source_path == "a.rs").unwrap();
let gr: GotchaRecord = ga.record.payload_as().expect("payload should deserialize");
assert!(!gr.confirmed);
assert!(gr.rule.contains("b.rs"));
assert_eq!(gr.affected_files, vec!["a.rs"]);
}
#[test]
fn empty_signals_produce_no_gotchas() {
let (dev, now) = dummy();
let signals = GitSignals::empty();
let gotchas = build_cochange_gotchas(&signals, dev, 0, now);
assert!(gotchas.is_empty());
}
#[test]
fn quality_bump_for_file_with_cochange_gotcha_but_no_doc() {
let mut quality = QualityScore::layer0_default();
let mut conf_value: f32 = 0.10;
let gotcha_keys: Vec<String> = vec!["gotcha:cochange:src/a.rs|src/b.rs".to_string()];
let purpose = "";
if !purpose.is_empty() {
quality = QualityScore::doc_comment_default();
conf_value = 0.45;
}
if quality.value < 0.40
&& gotcha_keys
.iter()
.any(|k| k.starts_with("gotcha:cochange:"))
{
quality = QualityScore::doc_comment_default();
if conf_value < 0.45 {
conf_value = 0.45;
}
}
assert!(
(quality.value - 0.40).abs() < 0.001,
"expected quality 0.40, got {:.2}",
quality.value
);
assert!(
(conf_value - 0.45).abs() < 0.001,
"expected confidence 0.45, got {:.2}",
conf_value
);
}
#[test]
fn quality_bump_noop_when_doc_comment_already_promotes() {
let mut quality = QualityScore::layer0_default();
let mut conf_value: f32 = 0.10;
let gotcha_keys: Vec<String> = vec!["gotcha:cochange:src/a.rs|src/b.rs".to_string()];
let purpose = "Handles authentication logic for the web server.";
if !purpose.is_empty() {
quality = QualityScore::doc_comment_default();
conf_value = 0.45;
}
if quality.value < 0.40
&& gotcha_keys
.iter()
.any(|k| k.starts_with("gotcha:cochange:"))
{
quality = QualityScore::doc_comment_default();
if conf_value < 0.45 {
conf_value = 0.45;
}
}
assert!((quality.value - 0.40).abs() < 0.001);
assert!((conf_value - 0.45).abs() < 0.001);
}
#[test]
fn no_bump_for_file_without_cochange_keys() {
let mut quality = QualityScore::layer0_default();
let mut conf_value: f32 = 0.10;
let gotcha_keys: Vec<String> = vec![];
let purpose = "";
if !purpose.is_empty() {
quality = QualityScore::doc_comment_default();
conf_value = 0.45;
}
if quality.value < 0.40
&& gotcha_keys
.iter()
.any(|k| k.starts_with("gotcha:cochange:"))
{
quality = QualityScore::doc_comment_default();
if conf_value < 0.45 {
conf_value = 0.45;
}
}
assert!(
(quality.value - 0.10).abs() < 0.001,
"expected quality 0.10 (no bump), got {:.2}",
quality.value
);
assert!((conf_value - 0.10).abs() < 0.001);
}
#[test]
fn stale_dependency_keys_delete_legacy_and_removed_dep_records() {
let existing = vec![
make_record_with_category("dep:serde", Category::Dependency),
make_record_with_category("dep:cargo:serde", Category::Dependency),
make_record_with_category("dep:npm:react", Category::Dependency),
make_record_with_category("dep:cargo:old", Category::Dependency),
make_record_with_category("file:src/main.rs", Category::File),
];
let new_keys: HashSet<&str> = ["dep:cargo:serde", "dep:npm:react"].into_iter().collect();
let stale = stale_dependency_keys(&existing, &new_keys);
assert_eq!(stale.len(), 2);
assert!(stale.contains(&"dep:serde".to_string()));
assert!(stale.contains(&"dep:cargo:old".to_string()));
}
}
fn is_code_file(path: &str) -> bool {
let basename = path.rsplit('/').next().unwrap_or(path);
const EXCLUDED_NAMES: &[&str] = &[
"README.md",
"README",
"readme.md",
"CHANGELOG.md",
"CHANGES.md",
"HISTORY.md",
"LICENSE",
"LICENSE.md",
"LICENSE-MIT",
"LICENSE-APACHE",
"CONTRIBUTING.md",
"CODE_OF_CONDUCT.md",
"AGENTS.md",
"CLAUDE.md",
"CODEX.md",
".gitignore",
".gitattributes",
];
if EXCLUDED_NAMES.contains(&basename) {
return false;
}
const CODE_EXTENSIONS: &[&str] = &[
"rs", "go", "py", "ts", "tsx", "js", "jsx", "java", "kt", "swift", "c", "cpp", "h", "hpp",
"cs", "rb", "php", "ex", "exs", "zig", "hs", "ml", "scala", "clj", "toml", "yaml", "yml",
"json", "sql", "sh", "bash", "proto",
];
match path.rsplit('.').next() {
Some(ext) => CODE_EXTENSIONS.contains(&ext),
None => false,
}
}
pub fn install_scaffold(root: &std::path::Path, args: &InitArgs) -> Result<(bool, bool)> {
let explicit_platform = args.claude || args.codex;
let has_claude_dir = root.join(".claude").is_dir();
let has_codex_dir = root.join(".codex").is_dir();
let install_claude_integration = !args.no_hooks
&& if explicit_platform {
args.claude
} else {
has_claude_dir
};
let install_codex_integration = !args.no_hooks
&& if explicit_platform {
args.codex
} else {
has_codex_dir
};
if !args.no_hooks && !explicit_platform && !has_claude_dir && !has_codex_dir {
println!(" No platform detected (.claude/ or .codex/ not found).");
println!(" Skipping hook installation. To install, run:");
println!();
println!(" mati init --claude # for Claude Code");
println!(" mati init --codex # for Codex");
println!();
}
let mut claude_installed = false;
let mut codex_installed = false;
if install_claude_integration {
let claude_dir = root.join(".claude");
if !claude_dir.is_dir() {
let _ = std::fs::create_dir_all(&claude_dir);
}
let t = Instant::now();
match write_claude_md_stub(root) {
Ok(_) => println!(
" Writing .claude/CLAUDE.md stub... {:>3}ms",
t.elapsed().as_millis()
),
Err(e) => {
tracing::warn!("CLAUDE.md stub write failed: {e}");
println!(" Writing .claude/CLAUDE.md stub... skipped — {:#}", e);
}
}
let t = Instant::now();
match install_hooks(root) {
Ok(InstallResult::Installed { missing_deps, .. }) => {
claude_installed = true;
println!(
" Installing Claude integration... {:>3}ms",
t.elapsed().as_millis()
);
{
let g = if std::io::stderr().is_terminal() {
super::colors::GRAY
} else {
""
};
let r = if std::io::stderr().is_terminal() {
super::colors::RESET
} else {
""
};
println!(" {g}.claude/settings.json hook registrations + MCP server config{r}");
let claude_hooks: Vec<&str> = mati_core::scaffold::settings::HOOK_SCRIPTS
.iter()
.map(|(n, _)| n.trim_end_matches(".sh"))
.collect();
println!(
" {g}.claude/hooks/ {} hooks ({}){r}",
claude_hooks.len(),
claude_hooks.join(", ")
);
println!(" {g}.claude/CLAUDE.md knowledge capture + enrichment instructions{r}");
}
if !missing_deps.is_empty() {
eprintln!();
eprintln!(
" WARNING: Claude hook runtime dependencies missing: {}",
missing_deps.join(", ")
);
eprintln!(" Claude hook enforcement will fail open until they are installed.");
eprintln!();
}
}
Ok(InstallResult::NoClaude) => println!(
" Installing Claude integration... {:>3}ms",
t.elapsed().as_millis()
),
Err(e) => {
tracing::warn!("Claude integration installation failed: {e}");
println!(" Installing Claude integration... FAILED");
eprintln!();
eprintln!(" WARNING: Claude integration failed — {e:#}");
eprintln!(" Claude read interception will not work until this is fixed.");
eprintln!(" Fix the issue above, then re-run: mati init --claude");
eprintln!();
}
}
}
if install_codex_integration {
let t = Instant::now();
match install_codex(root, args.codex) {
Ok(CodexInstallResult::Installed { missing_deps, .. }) => {
codex_installed = true;
println!(
" Installing Codex integration... {:>3}ms",
t.elapsed().as_millis()
);
{
let g = if std::io::stderr().is_terminal() {
super::colors::GRAY
} else {
""
};
let r = if std::io::stderr().is_terminal() {
super::colors::RESET
} else {
""
};
println!(
" {g}.codex/config.toml MCP server + hooks feature flag{r}"
);
let codex_hooks: Vec<&str> = mati_core::scaffold::codex::CODEX_HOOK_SCRIPTS
.iter()
.map(|(n, _)| n.trim_end_matches(".sh"))
.collect();
println!(
" {g}.codex/hooks/ {} hooks ({}){r}",
codex_hooks.len(),
codex_hooks.join(", ")
);
println!(" {g}.codex/skills/mati/ skill instructions for agent guidance{r}");
}
if !missing_deps.is_empty() {
eprintln!();
eprintln!(
" WARNING: Codex hook runtime dependencies missing: {}",
missing_deps.join(", ")
);
eprintln!(" Codex Bash enforcement will fail open until they are installed.");
eprintln!();
}
}
Ok(CodexInstallResult::NoCodex) => println!(
" Installing Codex integration... {:>3}ms",
t.elapsed().as_millis()
),
Err(e) => {
tracing::warn!("Codex integration installation failed: {e}");
println!(" Installing Codex integration... FAILED");
eprintln!();
eprintln!(" WARNING: Codex integration failed — {e:#}");
eprintln!(
" Codex MCP/skill/hook support will not be available until this is fixed."
);
eprintln!(" Fix the issue above, then re-run: mati init --codex");
eprintln!();
}
}
}
Ok((claude_installed, codex_installed))
}
#[derive(Args)]
pub struct HooksArgs {
#[arg(short, long)]
pub path: Option<PathBuf>,
#[arg(long)]
pub codex: bool,
#[arg(long)]
pub claude: bool,
}
pub fn run_hooks(args: HooksArgs) -> Result<()> {
let root = match &args.path {
Some(p) => p.clone(),
None => std::env::current_dir()?,
};
let root = std::fs::canonicalize(&root)?;
let init_args = InitArgs {
path: Some(root.clone()),
no_hooks: false,
codex: args.codex,
claude: args.claude,
};
let project_name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string());
println!();
println!("◈ mati hooks — project: {}", project_name);
println!();
let (claude_installed, codex_installed) = install_scaffold(&root, &init_args)?;
if !claude_installed && !codex_installed {
println!(" No platform detected. Use --claude or --codex to force installation.");
}
println!();
Ok(())
}