Skip to main content

agentstatedeveloper_core/
repair.rs

1//! ASG state repair — scan for corruption and apply safe auto-corrections.
2//!
3//! Three entry points:
4//!   - [`scan_asg`]: read-only; returns every detected ASG-side issue.
5//!   - [`scan_sidecar`]: read-only; flags files/dirs in `.asd/conclusions/`
6//!     that violate the Plan K sidecar inclusion rule (Plan K t-010).
7//!   - [`repair_asg`]: calls `scan_asg`, then optionally applies auto-fixable
8//!     corrections and re-scans to show what remains.
9//!
10//! ## Detected issue kinds
11//!
12//! | kind | severity | auto-fixable |
13//! |---|---|---|
14//! | `orphaned_effect` | warn | yes — deleted, unless it holds runtime or trace evidence |
15//! | `effect_id_mismatch` | error | no — ambiguous, needs manual resolution |
16//! | `malformed_effect` | error | no — unknown cause |
17//! | `orphaned_callee_ref` | warn | yes — ref removed from callees list |
18//! | `orphaned_caller_ref` | warn | yes — ref removed from callers list |
19//! | `orphaned_ledger` | warn | no — preserve audit trail |
20//! | `ledger_duplicate` | error | yes — keeps the copy under a live symbol (else the latest revision), removes the rest |
21//! | `empty_ledger_node` | warn | yes — removed |
22//! | `sidecar_unknown_file` | warn | no — manual review (might be intentional) |
23//! | `sidecar_unknown_dir` | warn | no — manual review |
24//! | `sidecar_wrong_extension` | warn | no — manual review |
25
26use std::collections::HashSet;
27
28use agentstategraph::{CommitOptions, Repository};
29use agentstategraph_core::IntentCategory;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32
33use crate::error::Result;
34use crate::paths;
35use crate::schema::{EffectDecl, LedgerEntry, ScratchEntry, ScratchStatus};
36
37// ---------------------------------------------------------------------------
38// Public types
39// ---------------------------------------------------------------------------
40
41/// Severity level for a [`RepairIssue`].
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum IssueSeverity {
45    /// Degraded quality but not blocking (e.g., stale ref).
46    Warn,
47    /// Data inconsistency that may cause incorrect query results (e.g., mismatched IDs).
48    Error,
49}
50
51/// A single integrity issue detected in the ASG.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RepairIssue {
54    /// Machine-readable issue type (see module docs).
55    pub kind: String,
56    pub severity: IssueSeverity,
57    /// ASG path that carries the corrupt/orphaned data.
58    pub path: String,
59    /// Human-readable description.
60    pub detail: String,
61    /// Whether `repair_asg(dry_run: false)` will correct this automatically.
62    pub auto_fixable: bool,
63}
64
65/// Result returned by [`repair_asg`].
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RepairReport {
68    /// Total issues detected before any fixes.
69    pub issues_found: usize,
70    /// Number of individual corrections applied (0 when `dry_run` is true).
71    pub fixes_applied: usize,
72    /// True when the caller passed `dry_run: true` — no writes were made.
73    pub dry_run: bool,
74    /// Issues remaining *after* fixes (same as initial list when `dry_run`).
75    pub issues: Vec<RepairIssue>,
76}
77
78// ---------------------------------------------------------------------------
79// Core scan
80// ---------------------------------------------------------------------------
81
82/// Read-only integrity scan.  Returns every detected [`RepairIssue`] without
83/// modifying any ASG state.
84pub fn scan_asg(repo: &Repository, ref_name: &str) -> Result<Vec<RepairIssue>> {
85    let mut issues = Vec::new();
86
87    let live_symbol_ids = build_live_symbol_ids(repo, ref_name);
88
89    check_effects(repo, ref_name, &live_symbol_ids, &mut issues);
90    check_callee_refs(repo, ref_name, &live_symbol_ids, &mut issues);
91    check_caller_refs(repo, ref_name, &live_symbol_ids, &mut issues);
92    check_ledger(repo, ref_name, &live_symbol_ids, &mut issues);
93    check_ledger_duplicates(repo, ref_name, &live_symbol_ids, &mut issues);
94    check_scratch(repo, ref_name, &live_symbol_ids, &mut issues);
95
96    Ok(issues)
97}
98
99// ---------------------------------------------------------------------------
100// Repair (scan + optionally fix)
101// ---------------------------------------------------------------------------
102
103/// Scan the ASG for integrity issues and, when `dry_run` is false, apply all
104/// auto-fixable corrections.
105///
106/// When `dry_run` is true the function behaves identically to [`scan_asg`]
107/// (no writes, `fixes_applied` is 0).
108pub fn repair_asg(
109    repo: &Repository,
110    ref_name: &str,
111    agent_id: &str,
112    dry_run: bool,
113) -> Result<RepairReport> {
114    repair_asg_with_cache(repo, None, ref_name, agent_id, dry_run)
115}
116
117/// [`repair_asg`], also pointing the ledger cache at the copy kept of each
118/// duplicated entry — the cache answers `list_entries`, and a row left on a
119/// removed copy would hide the entry from its symbol.
120pub fn repair_asg_with_cache(
121    repo: &Repository,
122    fts: Option<&crate::search_fts::SearchFtsDb>,
123    ref_name: &str,
124    agent_id: &str,
125    dry_run: bool,
126) -> Result<RepairReport> {
127    let issues = scan_asg(repo, ref_name)?;
128    let issues_found = issues.len();
129
130    if dry_run {
131        return Ok(RepairReport {
132            issues_found,
133            fixes_applied: 0,
134            dry_run: true,
135            issues,
136        });
137    }
138
139    // Every fix lands in one commit. A commit per dropped effect or rewritten
140    // edge list stored a fresh copy of the enclosing map each time — on a
141    // store with 54,685 orphaned effects, roughly 100 GB. The
142    // subtrees are cleaned in memory on top of the state the speculation
143    // forked from and written once each.
144    let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-repair")?;
145    let staged = (|| -> Result<(usize, usize, usize, LedgerCleanup)> {
146        let live = build_live_symbol_ids(repo, &fork);
147
148        // Fix 1: orphaned effect records → delete, keeping evidence.
149        let effects_root = format!("{}/effects", paths::ASD_ROOT);
150        let mut effects = crate::subtree::read_seed(repo, &fork, &effects_root)?;
151        let before = effects.len();
152        effects
153            .retain(|id, value| live.contains(id) || crate::effects::value_carries_evidence(value));
154        let effects_dropped = before - effects.len();
155        if effects_dropped > 0 {
156            repo.spec_set_json(spec, &effects_root, &serde_json::Value::Object(effects))
157                .map_err(|e| crate::error::AsdError::Other(e.to_string()))?;
158        }
159
160        // Fixes 2 and 3: orphaned callee and caller refs.
161        let callees = stage_edge_lists(repo, spec, &fork, &live, "callees")?;
162        let callers = stage_edge_lists(repo, spec, &fork, &live, "callers")?;
163
164        // Fix 4: an entry filed under several symbols keeps one copy; a
165        // symbol node with no entries left goes.
166        let ledger = stage_ledger_cleanup(repo, spec, &fork, &live)?;
167        Ok((effects_dropped, callees, callers, ledger))
168    })();
169    let fixes_applied = match staged {
170        Ok((0, 0, 0, ledger)) if ledger.kept.is_empty() && ledger.empty_nodes == 0 => {
171            let _ = repo.discard_speculation(spec);
172            0
173        }
174        Ok((effects, callees, callers, ledger)) => {
175            let LedgerCleanup { kept, empty_nodes } = ledger;
176            let opts = CommitOptions::new(
177                agent_id,
178                IntentCategory::Refine,
179                format!(
180                    "repair: drop {effects} orphaned effect(s), {callees} orphaned callee ref(s), \
181                     {callers} orphaned caller ref(s), {} duplicated ledger entr{}, \
182                     {empty_nodes} empty ledger node(s)",
183                    kept.len(),
184                    if kept.len() == 1 { "y" } else { "ies" }
185                ),
186            );
187            repo.commit_speculation(spec, opts)
188                .map_err(|e| crate::error::AsdError::Other(e.to_string()))?;
189            if let Some(fts) = fts {
190                for entry in &kept {
191                    fts.upsert_ledger_entry(entry, ref_name)
192                        .map_err(|e| crate::error::AsdError::Other(format!("ledger cache: {e}")))?;
193                }
194            }
195            effects + callees + callers + kept.len() + empty_nodes
196        }
197        Err(e) => {
198            let _ = repo.discard_speculation(spec);
199            return Err(e);
200        }
201    };
202
203    // Re-scan: show what remains after fixes.
204    let remaining = scan_asg(repo, ref_name)?;
205
206    Ok(RepairReport {
207        issues_found,
208        fixes_applied,
209        dry_run: false,
210        issues: remaining,
211    })
212}
213
214// ---------------------------------------------------------------------------
215// Helpers
216// ---------------------------------------------------------------------------
217
218fn build_live_symbol_ids(repo: &Repository, ref_name: &str) -> HashSet<String> {
219    let prefix = format!("{}/index/by-qname", paths::ASD_ROOT);
220    match repo.get_tree(ref_name, &prefix) {
221        Ok(serde_json::Value::Object(map)) => map
222            .values()
223            .filter_map(|v| v.get("symbol_id")?.as_str().map(|s| s.to_string()))
224            .collect(),
225        _ => HashSet::new(),
226    }
227}
228
229fn check_effects(
230    repo: &Repository,
231    ref_name: &str,
232    live: &HashSet<String>,
233    issues: &mut Vec<RepairIssue>,
234) {
235    let prefix = format!("{}/effects", paths::ASD_ROOT);
236    let map = match repo.get_tree(ref_name, &prefix) {
237        Ok(serde_json::Value::Object(m)) => m,
238        _ => return,
239    };
240    for (key, value) in &map {
241        match serde_json::from_value::<EffectDecl>(value.clone()) {
242            Err(e) => {
243                issues.push(RepairIssue {
244                    kind: "malformed_effect".to_string(),
245                    severity: IssueSeverity::Error,
246                    path: paths::effects_path(key),
247                    detail: format!("blob for '{}' fails to deserialize: {}", key, e),
248                    auto_fixable: false,
249                });
250            }
251            Ok(decl) => {
252                if decl.symbol_id != *key {
253                    issues.push(RepairIssue {
254                        kind: "effect_id_mismatch".to_string(),
255                        severity: IssueSeverity::Error,
256                        path: paths::effects_path(key),
257                        detail: format!(
258                            "key='{}' but internal symbol_id='{}' — likely bad merge conflict resolution",
259                            key, decl.symbol_id
260                        ),
261                        auto_fixable: false,
262                    });
263                }
264                if !live.contains(key) {
265                    let evidence = crate::effects::carries_evidence(&decl);
266                    issues.push(RepairIssue {
267                        kind: "orphaned_effect".to_string(),
268                        severity: IssueSeverity::Warn,
269                        path: paths::effects_path(key),
270                        detail: if evidence {
271                            format!(
272                                "symbol '{key}' not found in index; effect record is orphaned \
273                                 but holds runtime or trace evidence, so it is kept"
274                            )
275                        } else {
276                            format!("symbol '{key}' not found in index; effect record is orphaned")
277                        },
278                        auto_fixable: !evidence,
279                    });
280                }
281            }
282        }
283    }
284}
285
286fn check_callee_refs(
287    repo: &Repository,
288    ref_name: &str,
289    live: &HashSet<String>,
290    issues: &mut Vec<RepairIssue>,
291) {
292    let prefix = format!("{}/index/callees", paths::ASD_ROOT);
293    let map = match repo.get_tree(ref_name, &prefix) {
294        Ok(serde_json::Value::Object(m)) => m,
295        _ => return,
296    };
297    for (caller_id, value) in &map {
298        let callees = extract_str_array(value, "callees");
299        for callee_id in callees {
300            if !live.contains(&callee_id) {
301                issues.push(RepairIssue {
302                    kind: "orphaned_callee_ref".to_string(),
303                    severity: IssueSeverity::Warn,
304                    path: paths::callees_path(caller_id),
305                    detail: format!(
306                        "callee '{}' referenced by '{}' no longer exists in the index",
307                        callee_id, caller_id
308                    ),
309                    auto_fixable: true,
310                });
311            }
312        }
313    }
314}
315
316fn check_caller_refs(
317    repo: &Repository,
318    ref_name: &str,
319    live: &HashSet<String>,
320    issues: &mut Vec<RepairIssue>,
321) {
322    let prefix = format!("{}/index/callers", paths::ASD_ROOT);
323    let map = match repo.get_tree(ref_name, &prefix) {
324        Ok(serde_json::Value::Object(m)) => m,
325        _ => return,
326    };
327    for (callee_id, value) in &map {
328        let callers = extract_str_array(value, "callers");
329        for caller_id in callers {
330            if !live.contains(&caller_id) {
331                issues.push(RepairIssue {
332                    kind: "orphaned_caller_ref".to_string(),
333                    severity: IssueSeverity::Warn,
334                    path: paths::callers_path(callee_id),
335                    detail: format!(
336                        "caller '{}' in call list for '{}' no longer exists in the index",
337                        caller_id, callee_id
338                    ),
339                    auto_fixable: true,
340                });
341            }
342        }
343    }
344}
345
346fn check_ledger(
347    repo: &Repository,
348    ref_name: &str,
349    live: &HashSet<String>,
350    issues: &mut Vec<RepairIssue>,
351) {
352    let prefix = format!("{}/ledger", paths::ASD_ROOT);
353    let map = match repo.get_tree(ref_name, &prefix) {
354        Ok(serde_json::Value::Object(m)) => m,
355        _ => return,
356    };
357    for (symbol_id, entries) in &map {
358        if entries.as_object().is_some_and(|m| m.is_empty()) {
359            issues.push(RepairIssue {
360                kind: "empty_ledger_node".to_string(),
361                severity: IssueSeverity::Warn,
362                path: paths::ledger_symbol_path(symbol_id),
363                detail: format!(
364                    "no ledger entries left under '{symbol_id}' — a node an earlier \
365                     `asd hydrate` emptied while replaying a rebind"
366                ),
367                auto_fixable: true,
368            });
369        } else if !live.contains(symbol_id) {
370            issues.push(RepairIssue {
371                kind: "orphaned_ledger".to_string(),
372                severity: IssueSeverity::Warn,
373                path: paths::ledger_symbol_path(symbol_id),
374                detail: format!(
375                    "ledger entries for '{}' whose symbol is no longer in the index \
376                     (preserved for audit trail — run `asd ledger find` to inspect)",
377                    symbol_id
378                ),
379                auto_fixable: false,
380            });
381        }
382    }
383}
384
385fn check_ledger_duplicates(
386    repo: &Repository,
387    ref_name: &str,
388    live: &HashSet<String>,
389    issues: &mut Vec<RepairIssue>,
390) {
391    let ledger = match repo.get_tree(ref_name, &paths::ledger_root()) {
392        Ok(serde_json::Value::Object(m)) => m,
393        _ => return,
394    };
395    let index = match repo.get_tree(ref_name, &paths::ledger_index_root()) {
396        Ok(serde_json::Value::Object(m)) => m,
397        _ => Default::default(),
398    };
399    for (entry_id, symbols) in crate::ledger_dupes::locations(&ledger) {
400        if symbols.len() < 2 {
401            continue;
402        }
403        let keep = keeper(&entry_id, &symbols, &ledger, &index, live);
404        issues.push(RepairIssue {
405            kind: "ledger_duplicate".to_string(),
406            severity: IssueSeverity::Error,
407            path: paths::ledger_entry_index_path(&entry_id),
408            detail: format!(
409                "entry '{entry_id}' is filed under {} symbols ({}); the ledger cache shows it \
410                 under one of them only. --fix keeps the copy under '{keep}' and removes the rest",
411                symbols.len(),
412                symbols.join(", ")
413            ),
414            auto_fixable: true,
415        });
416    }
417}
418
419/// Which of the symbols an entry is filed under keeps it: see
420/// [`crate::ledger_dupes::claim`]; then the symbol the entry index names;
421/// then the smallest id, so the choice never depends on map order.
422fn keeper(
423    entry_id: &str,
424    symbols: &[String],
425    ledger: &serde_json::Map<String, serde_json::Value>,
426    index: &serde_json::Map<String, serde_json::Value>,
427    live: &HashSet<String>,
428) -> String {
429    let indexed_at = index.get(entry_id).and_then(|v| v.as_str());
430    symbols
431        .iter()
432        .filter_map(|sym| {
433            let entry: LedgerEntry =
434                serde_json::from_value(ledger.get(sym)?.get(entry_id)?.clone()).ok()?;
435            let (is_live, revised) = crate::ledger_dupes::claim(sym, &entry, live);
436            Some(((is_live, revised, indexed_at == Some(sym.as_str())), sym))
437        })
438        .max_by(|(a, sa), (b, sb)| a.cmp(b).then_with(|| sb.cmp(sa)))
439        .map(|(_, sym)| sym.clone())
440        .unwrap_or_else(|| symbols[0].clone())
441}
442
443/// What [`stage_ledger_cleanup`] changed.
444struct LedgerCleanup {
445    /// The copy kept of each duplicated entry — for the ledger cache.
446    kept: Vec<LedgerEntry>,
447    /// Symbol nodes removed for holding no entries.
448    empty_nodes: usize,
449}
450
451/// Keep one copy of every entry filed under several symbols, and drop symbol
452/// nodes left with no entries, in the ledger and entry-index subtrees as the
453/// speculation forked them, written once.
454fn stage_ledger_cleanup(
455    repo: &Repository,
456    spec: agentstategraph::SpecHandle,
457    fork: &str,
458    live: &HashSet<String>,
459) -> Result<LedgerCleanup> {
460    let mut ledger = crate::subtree::read_seed(repo, fork, &paths::ledger_root())?;
461    let dupes: Vec<(String, Vec<String>)> = crate::ledger_dupes::locations(&ledger)
462        .into_iter()
463        .filter(|(_, symbols)| symbols.len() > 1)
464        .collect();
465    let before = ledger.len();
466    ledger.retain(|_, entries| entries.as_object().is_none_or(|m| !m.is_empty()));
467    let empty_nodes = before - ledger.len();
468    if dupes.is_empty() {
469        if empty_nodes > 0 {
470            repo.spec_set_json(
471                spec,
472                &paths::ledger_root(),
473                &serde_json::Value::Object(ledger),
474            )
475            .map_err(|e| crate::error::AsdError::Other(e.to_string()))?;
476        }
477        return Ok(LedgerCleanup {
478            kept: Vec::new(),
479            empty_nodes,
480        });
481    }
482    let mut index = crate::subtree::read_seed(repo, fork, &paths::ledger_index_root())?;
483    let mut kept = Vec::with_capacity(dupes.len());
484    for (entry_id, symbols) in dupes {
485        let keep = keeper(&entry_id, &symbols, &ledger, &index, live);
486        for sym in symbols.iter().filter(|s| **s != keep) {
487            let emptied = match ledger.get_mut(sym) {
488                Some(serde_json::Value::Object(entries)) => {
489                    entries.remove(&entry_id);
490                    entries.is_empty()
491                }
492                _ => false,
493            };
494            if emptied {
495                ledger.remove(sym);
496            }
497        }
498        if let Some(entry) = ledger
499            .get(&keep)
500            .and_then(|m| m.get(&entry_id))
501            .and_then(|v| serde_json::from_value::<LedgerEntry>(v.clone()).ok())
502        {
503            kept.push(entry);
504        }
505        index.insert(entry_id, serde_json::Value::String(keep));
506    }
507    for (path, map) in [
508        (paths::ledger_root(), ledger),
509        (paths::ledger_index_root(), index),
510    ] {
511        repo.spec_set_json(spec, &path, &serde_json::Value::Object(map))
512            .map_err(|e| crate::error::AsdError::Other(e.to_string()))?;
513    }
514    Ok(LedgerCleanup { kept, empty_nodes })
515}
516
517/// Remove ids that are no longer live from every list in the `field` edge
518/// subtree (`callees` or `callers`) as the speculation forked it, and write
519/// the subtree once if anything changed. Returns the number of refs dropped.
520fn stage_edge_lists(
521    repo: &Repository,
522    spec: agentstategraph::SpecHandle,
523    fork: &str,
524    live: &HashSet<String>,
525    field: &str,
526) -> Result<usize> {
527    let prefix = format!("{}/index/{field}", paths::ASD_ROOT);
528    let mut map = crate::subtree::read_seed(repo, fork, &prefix)?;
529    let mut total_dropped = 0usize;
530    for value in map.values_mut() {
531        let all = extract_str_array(value, field);
532        let clean: Vec<&String> = all.iter().filter(|id| live.contains(*id)).collect();
533        let dropped = all.len() - clean.len();
534        if dropped > 0 {
535            *value = json!({ field: clean });
536            total_dropped += dropped;
537        }
538    }
539    if total_dropped > 0 {
540        repo.spec_set_json(spec, &prefix, &serde_json::Value::Object(map))
541            .map_err(|e| crate::error::AsdError::Other(e.to_string()))?;
542    }
543    Ok(total_dropped)
544}
545
546/// Drop orphaned callee and caller refs whose target symbol_id no longer
547/// exists in the live index, in one commit.  Returns the total number of
548/// individual refs dropped.  Called by [`hydrate_from_dir`] after a hydrate
549/// pass to clean up any stale edges the sidecar may have carried.
550pub fn drop_orphaned_edge_refs(repo: &Repository, ref_name: &str, agent_id: &str) -> Result<usize> {
551    let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-drop-edge-refs")?;
552    let staged = (|| -> Result<usize> {
553        let live = build_live_symbol_ids(repo, &fork);
554        Ok(stage_edge_lists(repo, spec, &fork, &live, "callees")?
555            + stage_edge_lists(repo, spec, &fork, &live, "callers")?)
556    })();
557    match staged {
558        Ok(0) => {
559            let _ = repo.discard_speculation(spec);
560            Ok(0)
561        }
562        Ok(dropped) => {
563            let opts = CommitOptions::new(
564                agent_id,
565                IntentCategory::Refine,
566                format!("repair: drop {dropped} orphaned edge ref(s)"),
567            );
568            repo.commit_speculation(spec, opts)
569                .map_err(|e| crate::error::AsdError::Other(e.to_string()))?;
570            Ok(dropped)
571        }
572        Err(e) => {
573            let _ = repo.discard_speculation(spec);
574            Err(e)
575        }
576    }
577}
578
579/// Informational check: find draft scratch entries whose `symbol_id` is no
580/// longer in the live index. Does not auto-delete; the agent may still need
581/// the note. Emits a `Warn`-level, non-auto-fixable issue per orphan.
582fn check_scratch(
583    repo: &Repository,
584    ref_name: &str,
585    live: &HashSet<String>,
586    issues: &mut Vec<RepairIssue>,
587) {
588    let prefix = paths::scratch_root();
589    let map = match repo.get_tree(ref_name, prefix) {
590        Ok(serde_json::Value::Object(m)) => m,
591        _ => return,
592    };
593    for (scratch_id, value) in &map {
594        let entry = match serde_json::from_value::<ScratchEntry>(value.clone()) {
595            Ok(e) => e,
596            Err(e) => {
597                issues.push(RepairIssue {
598                    kind: "malformed_scratch".to_string(),
599                    severity: IssueSeverity::Error,
600                    path: paths::scratch_entry_path(scratch_id),
601                    detail: format!("scratch entry '{scratch_id}' fails to deserialize: {e}"),
602                    auto_fixable: false,
603                });
604                continue;
605            }
606        };
607        // Only warn about Draft entries — Promoted/Discarded may legitimately
608        // outlive their symbol after cleanup cycles.
609        if entry.status != ScratchStatus::Draft {
610            continue;
611        }
612        if let Some(ref sym_id) = entry.symbol_id {
613            if !live.contains(sym_id) {
614                issues.push(RepairIssue {
615                    kind: "orphaned_scratch".to_string(),
616                    severity: IssueSeverity::Warn,
617                    path: paths::scratch_entry_path(scratch_id),
618                    detail: format!(
619                        "scratch entry '{scratch_id}' references symbol '{sym_id}' \
620                         which is no longer in the index (session: {}, workflow: {})",
621                        entry.session,
622                        entry.workflow.as_deref().unwrap_or("—"),
623                    ),
624                    auto_fixable: false,
625                });
626            }
627        }
628    }
629}
630
631fn extract_str_array(v: &serde_json::Value, field: &str) -> Vec<String> {
632    v.get(field)
633        .and_then(|f| f.as_array())
634        .map(|arr| {
635            arr.iter()
636                .filter_map(|x| x.as_str().map(|s| s.to_string()))
637                .collect()
638        })
639        .unwrap_or_default()
640}
641
642// ---------------------------------------------------------------------------
643// Plan K t-010: sidecar inclusion-rule lint
644// ---------------------------------------------------------------------------
645
646/// Scan `.asd/conclusions/` for files and directories that don't match
647/// the Plan K sidecar inclusion rule:
648///
649/// > The committed sidecar carries **judgment** — anything an agent
650/// > or human had to decide, classify, hypothesize, approve, or
651/// > otherwise commit mental effort to. Anything mechanically
652/// > derivable from source stays in the regenerable SQLite cache and
653/// > is gitignored.
654///
655/// Concretely, `.asd/conclusions/` should ONLY contain:
656///   - `<stem>.jsonl` for the default Class layout, where `<stem>`
657///     is one of the 7 `ConclusionClass::filename_stem()` values
658///   - `<stem>/<anything>.jsonl` for per-package layout (Plan K t-007)
659///
660/// Anything else — `.md` notes, `.json` dumps, `effects.jsonl`
661/// (regenerable!), random subdirs — is leakage. We warn so a
662/// contributor or `pre-commit` hook can review before the noise
663/// lands in git.
664///
665/// Read-only; never deletes. Returns an empty list when the directory
666/// doesn't exist (fresh project — nothing to lint yet).
667pub fn scan_sidecar(conclusions_dir: &std::path::Path) -> Vec<RepairIssue> {
668    use crate::schema::ConclusionClass;
669    let mut issues = Vec::new();
670    if !conclusions_dir.is_dir() {
671        return issues;
672    }
673    let known_stems: std::collections::HashSet<&'static str> = ConclusionClass::all()
674        .iter()
675        .map(|c| c.filename_stem())
676        .collect();
677
678    let entries = match std::fs::read_dir(conclusions_dir) {
679        Ok(e) => e,
680        Err(_) => return issues,
681    };
682    for entry in entries.flatten() {
683        let path = entry.path();
684        let rel = path
685            .strip_prefix(conclusions_dir)
686            .unwrap_or(&path)
687            .to_string_lossy()
688            .replace('\\', "/");
689        let name = path
690            .file_name()
691            .map(|n| n.to_string_lossy().into_owned())
692            .unwrap_or_default();
693
694        if path.is_dir() {
695            // Per-package layout subdirectory — name must match a
696            // known ConclusionClass stem.
697            if !known_stems.contains(name.as_str()) {
698                issues.push(RepairIssue {
699                    kind: "sidecar_unknown_dir".into(),
700                    severity: IssueSeverity::Warn,
701                    path: format!(".asd/conclusions/{rel}"),
702                    detail: format!(
703                        "subdirectory `{name}` is not a known ConclusionClass; \
704                         expected one of: {known_list}. If you intended a \
705                         per-package layout (Plan K t-007), the subdir name \
706                         must match a class stem.",
707                        known_list = sorted_known_stems_list(&known_stems),
708                    ),
709                    auto_fixable: false,
710                });
711                continue;
712            }
713            // Inside a valid class subdir, every file must be *.jsonl.
714            if let Ok(inner) = std::fs::read_dir(&path) {
715                for sub in inner.flatten() {
716                    let sub_path = sub.path();
717                    let sub_rel = sub_path
718                        .strip_prefix(conclusions_dir)
719                        .unwrap_or(&sub_path)
720                        .to_string_lossy()
721                        .replace('\\', "/");
722                    if sub_path.is_file()
723                        && sub_path.extension().and_then(|s| s.to_str()) != Some("jsonl")
724                    {
725                        issues.push(RepairIssue {
726                            kind: "sidecar_wrong_extension".into(),
727                            severity: IssueSeverity::Warn,
728                            path: format!(".asd/conclusions/{sub_rel}"),
729                            detail: format!(
730                                "file is not `.jsonl`; per-package shards must have \
731                                 the `.jsonl` extension to be picked up by import."
732                            ),
733                            auto_fixable: false,
734                        });
735                    }
736                }
737            }
738        } else if path.is_file() {
739            // Top-level file — must be `<known-stem>.jsonl`.
740            let stem = path
741                .file_stem()
742                .map(|s| s.to_string_lossy().into_owned())
743                .unwrap_or_default();
744            let ext = path.extension().and_then(|s| s.to_str());
745            if ext != Some("jsonl") {
746                issues.push(RepairIssue {
747                    kind: "sidecar_wrong_extension".into(),
748                    severity: IssueSeverity::Warn,
749                    path: format!(".asd/conclusions/{rel}"),
750                    detail: format!(
751                        "file is not `.jsonl`; the committed sidecar should \
752                         carry only JSONL ledger projections. Move other \
753                         artifacts out of `.asd/conclusions/` — derived caches \
754                         belong in `.asd/cache/` (gitignored)."
755                    ),
756                    auto_fixable: false,
757                });
758            } else if !known_stems.contains(stem.as_str()) {
759                issues.push(RepairIssue {
760                    kind: "sidecar_unknown_file".into(),
761                    severity: IssueSeverity::Warn,
762                    path: format!(".asd/conclusions/{rel}"),
763                    detail: format!(
764                        "file `{name}` doesn't match a known ConclusionClass \
765                         (one of: {known_list}). If a new judgment class \
766                         shipped, add the ConclusionClass variant. If this is \
767                         a regenerable artifact (e.g. effects, FTS), move it \
768                         to `.asd/cache/` (gitignored).",
769                        known_list = sorted_known_stems_list(&known_stems),
770                    ),
771                    auto_fixable: false,
772                });
773            }
774        }
775    }
776    issues
777}
778
779fn sorted_known_stems_list(stems: &std::collections::HashSet<&str>) -> String {
780    let mut v: Vec<&str> = stems.iter().copied().collect();
781    v.sort();
782    v.join(", ")
783}
784
785#[cfg(test)]
786mod sidecar_lint_tests {
787    use super::*;
788    use tempfile::tempdir;
789
790    fn touch(p: &std::path::Path) {
791        std::fs::write(p, b"").unwrap();
792    }
793
794    #[test]
795    fn empty_or_missing_dir_returns_no_issues() {
796        let tmp = tempdir().unwrap();
797        let missing = tmp.path().join("nope");
798        assert!(scan_sidecar(&missing).is_empty());
799
800        let empty = tmp.path().join("empty");
801        std::fs::create_dir(&empty).unwrap();
802        assert!(scan_sidecar(&empty).is_empty());
803    }
804
805    #[test]
806    fn known_class_files_pass_clean() {
807        let tmp = tempdir().unwrap();
808        for stem in [
809            "decisions",
810            "classifications",
811            "mappings",
812            "hazards",
813            "recipes",
814            "followups",
815            "thinking",
816        ] {
817            touch(&tmp.path().join(format!("{stem}.jsonl")));
818        }
819        let issues = scan_sidecar(tmp.path());
820        assert!(
821            issues.is_empty(),
822            "all-known-stem files must lint clean; got: {issues:?}"
823        );
824    }
825
826    #[test]
827    fn flags_unknown_top_level_file() {
828        let tmp = tempdir().unwrap();
829        touch(&tmp.path().join("effects.jsonl")); // not a ConclusionClass
830        let issues = scan_sidecar(tmp.path());
831        assert_eq!(issues.len(), 1);
832        assert_eq!(issues[0].kind, "sidecar_unknown_file");
833        assert!(issues[0].path.contains("effects.jsonl"));
834    }
835
836    #[test]
837    fn flags_wrong_extension_at_top_level() {
838        let tmp = tempdir().unwrap();
839        touch(&tmp.path().join("notes.md"));
840        let issues = scan_sidecar(tmp.path());
841        assert_eq!(issues.len(), 1);
842        assert_eq!(issues[0].kind, "sidecar_wrong_extension");
843    }
844
845    #[test]
846    fn flags_unknown_subdir_name() {
847        let tmp = tempdir().unwrap();
848        std::fs::create_dir(tmp.path().join("backups")).unwrap();
849        let issues = scan_sidecar(tmp.path());
850        assert_eq!(issues.len(), 1);
851        assert_eq!(issues[0].kind, "sidecar_unknown_dir");
852    }
853
854    #[test]
855    fn accepts_known_class_subdir_with_jsonl_shards() {
856        // Per-package layout: `.asd/conclusions/decisions/crates--core.jsonl`
857        let tmp = tempdir().unwrap();
858        let class_dir = tmp.path().join("decisions");
859        std::fs::create_dir(&class_dir).unwrap();
860        touch(&class_dir.join("crates--core.jsonl"));
861        touch(&class_dir.join("crates--cli.jsonl"));
862        let issues = scan_sidecar(tmp.path());
863        assert!(
864            issues.is_empty(),
865            "known class subdir with .jsonl shards must lint clean; got: {issues:?}"
866        );
867    }
868
869    #[test]
870    fn flags_wrong_extension_inside_class_subdir() {
871        let tmp = tempdir().unwrap();
872        let class_dir = tmp.path().join("decisions");
873        std::fs::create_dir(&class_dir).unwrap();
874        touch(&class_dir.join("crates--core.json")); // wrong ext
875        let issues = scan_sidecar(tmp.path());
876        assert_eq!(issues.len(), 1);
877        assert_eq!(issues[0].kind, "sidecar_wrong_extension");
878        assert!(issues[0].path.contains("decisions/"));
879    }
880}