1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum IssueSeverity {
45 Warn,
47 Error,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RepairIssue {
54 pub kind: String,
56 pub severity: IssueSeverity,
57 pub path: String,
59 pub detail: String,
61 pub auto_fixable: bool,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RepairReport {
68 pub issues_found: usize,
70 pub fixes_applied: usize,
72 pub dry_run: bool,
74 pub issues: Vec<RepairIssue>,
76}
77
78pub 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
99pub 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
117pub 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 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 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 let callees = stage_edge_lists(repo, spec, &fork, &live, "callees")?;
162 let callers = stage_edge_lists(repo, spec, &fork, &live, "callers")?;
163
164 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 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
214fn 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
419fn 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
443struct LedgerCleanup {
445 kept: Vec<LedgerEntry>,
447 empty_nodes: usize,
449}
450
451fn 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
517fn 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
546pub 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
579fn 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 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
642pub 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 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 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 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")); 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 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")); 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}