1use std::collections::{BTreeMap, BTreeSet};
32use std::path::{Path, PathBuf};
33use std::process::Command;
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use crate::index::Index;
39use crate::metadata::KindSchema;
40use crate::repository::{Lifecycle, Repository};
41
42const HEAD_BYTES: u64 = 256 * 1024;
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67pub struct LayoutFinding {
68 pub path: String,
70 pub contract: String,
72 pub excludes: String,
74 pub reason: String,
76}
77
78impl crate::order::Ordered for LayoutFinding {
80 fn order_key(&self) -> crate::order::OrderKey<'_> {
81 crate::order::OrderKey::plain(&self.path, &self.contract)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99pub struct LayoutReport {
100 pub measured: bool,
102 pub reason: Option<String>,
104 pub checked: usize,
106 pub findings: Vec<LayoutFinding>,
108 pub passes: bool,
110}
111
112impl LayoutReport {
113 pub fn of(
132 measured: bool,
133 reason: Option<String>,
134 checked: usize,
135 mut findings: Vec<LayoutFinding>,
136 ) -> Self {
137 crate::order::canonical(&mut findings);
138 let passes = measured && findings.is_empty();
139 LayoutReport {
140 measured,
141 reason,
142 checked,
143 findings,
144 passes,
145 }
146 }
147
148 fn unmeasured(reason: String) -> Self {
149 Self::of(false, Some(reason), 0, Vec::new())
150 }
151}
152
153struct Declared {
155 contract: String,
156 excludes: Vec<String>,
157}
158
159pub fn check(repository: &Repository, index: &Index, kinds: &KindSchema) -> LayoutReport {
203 let root = repository.root();
204 let manifest = repository.manifest();
205 let tracked_half = format!(".ai/{}", manifest.repo.path.trim_end_matches('/'));
206 let local_half = format!(".ai/{}", manifest.local.path.trim_end_matches('/'));
207
208 let listed = git_lines(root, &["ls-files", "-s", "--", &tracked_half]).and_then(|tracked| {
211 git_lines(
212 root,
213 &[
214 "ls-files",
215 "-c",
216 "-i",
217 "--exclude-standard",
218 "--",
219 &tracked_half,
220 ],
221 )
222 .map(|ignored| (tracked, ignored))
223 });
224 let (tracked, ignored) = match listed {
225 Ok(lists) => lists,
226 Err(e) => return LayoutReport::unmeasured(e),
227 };
228
229 let mut findings = Vec::new();
230 let contracts = declared_layouts(index);
231 for path in &ignored {
232 let Some(d) = nearest(&contracts, path) else {
233 continue;
234 };
235 let Some(excluded) = d
236 .excludes
237 .iter()
238 .find(|e| matches!(e.as_str(), "cache" | "runtime"))
239 else {
240 continue;
241 };
242 findings.push(LayoutFinding {
243 path: path.clone(),
244 contract: d.contract.clone(),
245 excludes: excluded.clone(),
246 reason: "tracked although git's ignore rules match it: state that does not travel"
247 .into(),
248 });
249 }
250
251 let mut checked = tracked.len();
252 if manifest.local.excludes.iter().any(|e| e == "canonical") {
253 let blobs: BTreeMap<String, String> =
255 tracked.iter().filter_map(|l| staged_blob(l)).collect();
256 let canonical: BTreeSet<String> = kinds
257 .kinds()
258 .filter(|(_, k)| k.lifecycle.unwrap_or(Lifecycle::Canonical) == Lifecycle::Canonical)
259 .filter_map(|(_, k)| k.schema.as_deref())
260 .flat_map(|s| {
261 let short = s.split_once('.').map_or(s, |(_, rest)| rest);
262 [s.to_string(), short.to_string()]
263 })
264 .collect();
265 let files = local_files(&root.join(&local_half));
266 checked += files.len();
267 let ids = match hash_objects(root, &files) {
268 Ok(ids) => ids,
269 Err(e) => return LayoutReport::unmeasured(e),
270 };
271 for (file, id) in files.iter().zip(ids) {
272 let rel = file
273 .strip_prefix(root)
274 .unwrap_or(file)
275 .to_string_lossy()
276 .replace('\\', "/");
277 if let Some(original) = blobs.get(&id) {
278 findings.push(LayoutFinding {
279 path: rel,
280 contract: "manifest local".into(),
281 excludes: "canonical".into(),
282 reason: format!(
283 "a copy of {original}: authored knowledge belongs in the tracked half"
284 ),
285 });
286 continue;
287 }
288 if let Some(schema) = declared_schema(file).filter(|s| canonical.contains(s)) {
289 findings.push(LayoutFinding {
290 path: rel,
291 contract: "manifest local".into(),
292 excludes: "canonical".into(),
293 reason: format!("declares `schema: {schema}`, a canonical kind: authored knowledge belongs in the tracked half"),
294 });
295 }
296 }
297 }
298 LayoutReport::of(true, None, checked, findings)
299}
300
301fn declared_layouts(index: &Index) -> BTreeMap<String, Declared> {
303 index
304 .objects
305 .iter()
306 .filter(|o| o.kind == "context")
307 .filter_map(|o| {
308 let layout = o.metadata.get("layout")?;
309 let dir = Path::new(&o.provenance.path)
310 .parent()
311 .map(|p| p.to_string_lossy().replace('\\', "/"))
312 .unwrap_or_default();
313 let excludes = layout
314 .get("excludes")
315 .and_then(|e| e.as_array())
316 .map(|a| {
317 a.iter()
318 .filter_map(|v| v.as_str().map(str::to_string))
319 .collect()
320 })
321 .unwrap_or_default();
322 Some((
323 dir,
324 Declared {
325 contract: o.identity.clone(),
326 excludes,
327 },
328 ))
329 })
330 .collect()
331}
332
333fn nearest<'a>(contracts: &'a BTreeMap<String, Declared>, path: &str) -> Option<&'a Declared> {
335 Path::new(path)
336 .ancestors()
337 .skip(1)
338 .find_map(|dir| contracts.get(&dir.to_string_lossy().replace('\\', "/")))
339}
340
341fn declared_schema(file: &Path) -> Option<String> {
343 let meta = std::fs::metadata(file).ok()?;
344 if meta.len() > HEAD_BYTES {
345 return None;
346 }
347 let text = std::fs::read_to_string(file).ok()?;
348 text.lines().take(40).find_map(|l| {
349 let v = l.strip_prefix("schema:")?.trim();
350 Some(v.trim_matches(|c| c == '"' || c == '\'').to_string())
351 })
352}
353
354fn local_files(dir: &Path) -> Vec<PathBuf> {
357 let mut out = Vec::new();
358 let mut stack = vec![dir.to_path_buf()];
359 while let Some(d) = stack.pop() {
360 let Ok(entries) = std::fs::read_dir(&d) else {
361 continue;
362 };
363 for e in entries.flatten() {
364 let p = e.path();
365 match e.file_type() {
366 Ok(t) if t.is_dir() => stack.push(p),
367 Ok(t)
368 if t.is_file()
369 && p.extension()
370 .is_some_and(|x| x == "md" || x == "yaml" || x == "yml")
371 && e.metadata().is_ok_and(|m| m.len() > 0) =>
372 {
373 out.push(p)
374 }
375 _ => {}
377 }
378 }
379 }
380 out
381}
382
383const HASH_CHUNK: usize = 256;
386
387fn hash_objects(root: &Path, files: &[PathBuf]) -> Result<Vec<String>, String> {
391 let mut ids = Vec::with_capacity(files.len());
392 for chunk in files.chunks(HASH_CHUNK) {
393 let paths: Vec<String> = chunk.iter().map(|f| f.display().to_string()).collect();
394 let mut args = vec!["hash-object", "--no-filters", "--"];
395 args.extend(paths.iter().map(String::as_str));
396 ids.extend(git_lines(root, &args)?);
397 }
398 Ok(ids)
399}
400
401fn staged_blob(line: &str) -> Option<(String, String)> {
404 let (meta, path) = line.split_once('\t')?;
405 let blob = meta.split_whitespace().nth(1)?;
406 Some((blob.to_string(), path.to_string()))
407}
408
409fn git_lines(root: &Path, args: &[&str]) -> Result<Vec<String>, String> {
411 program_lines("git", root, args)
412}
413
414fn program_lines(program: &str, root: &Path, args: &[&str]) -> Result<Vec<String>, String> {
417 let out = Command::new(program)
418 .arg("-C")
419 .arg(root)
420 .args(args)
421 .output()
422 .map_err(|e| format!("git could not start: {e}"))?;
423 if !out.status.success() {
424 return Err(format!(
425 "git {} failed: {}",
426 args.join(" "),
427 String::from_utf8_lossy(&out.stderr).trim()
428 ));
429 }
430 Ok(String::from_utf8_lossy(&out.stdout)
431 .lines()
432 .filter(|l| !l.is_empty())
433 .map(str::to_string)
434 .collect())
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::share::Share;
441 use crate::synthetic::{crate_share, SyntheticRepository};
442
443 const RULE: &str = ".ai/repo/rules/project/rule-0.v1.md";
444
445 fn layer(excludes: bool) -> SyntheticRepository {
448 let repo = SyntheticRepository::small().expect("a synthetic repository");
449 if excludes {
450 let manifest = repo.root().join(".ai/manifest.yaml");
451 let text = std::fs::read_to_string(&manifest).unwrap().replace(
452 " implicit_context: false\n",
453 " implicit_context: false\n lifecycle: runtime\n excludes: [canonical]\n",
454 );
455 std::fs::write(&manifest, text).unwrap();
456 }
457 git(repo.root(), &["init", "-q"]);
458 git(repo.root(), &["add", ".ai"]);
459 repo
460 }
461
462 fn git(root: &Path, args: &[&str]) {
463 let status = Command::new("git")
464 .arg("-C")
465 .arg(root)
466 .args(args)
467 .status()
468 .expect("git");
469 assert!(status.success(), "git {}", args.join(" "));
470 }
471
472 fn local(repo: &SyntheticRepository, rel: &str, body: &[u8]) {
473 let path = repo.root().join(".ai/local").join(rel);
474 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
475 std::fs::write(path, body).unwrap();
476 }
477
478 fn checked(repo: &SyntheticRepository) -> LayoutReport {
479 let repository = Repository::discover(repo.root()).unwrap();
480 let share = Share::locate(Some(&crate_share()), repo.root()).unwrap();
481 let kinds = KindSchema::load(&share, &repository).unwrap();
482 check(&repository, &repo.index().unwrap(), &kinds)
483 }
484
485 #[test]
486 fn a_local_copy_of_a_tracked_file_is_refused_and_names_the_original() {
487 let repo = layer(true);
488 local(
489 &repo,
490 "notes/copied.md",
491 &std::fs::read(repo.root().join(RULE)).unwrap(),
492 );
493 local(&repo, "notes/mine.md", b"what this machine noticed today\n");
494 local(&repo, "empty.md", b"");
495
496 let report = checked(&repo);
497 assert!(report.measured, "{:?}", report.reason);
498 assert!(!report.passes);
499 assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
500 let f = &report.findings[0];
501 assert_eq!(f.path, ".ai/local/notes/copied.md");
502 assert_eq!(f.contract, "manifest local");
503 assert_eq!(f.excludes, "canonical");
504 assert!(f.reason.contains(RULE), "{}", f.reason);
505 let tracked = git_lines(repo.root(), &["ls-files", "--", ".ai/repo"]).unwrap();
507 assert_eq!(report.checked, tracked.len() + 2);
508 }
509
510 #[test]
511 fn a_local_file_declaring_a_canonical_schema_is_refused_and_a_runtime_one_is_not() {
512 let repo = layer(true);
513 local(
514 &repo,
515 "drafts/I9999.yaml",
516 b"schema: majordomus.issue/v1\nid: I9999\n",
517 );
518 local(
519 &repo,
520 "sessions/now.md",
521 b"---\nschema: \"majordomus.session-context/v1\"\n---\n\nruntime state\n",
522 );
523
524 let report = checked(&repo);
525 let paths: Vec<&str> = report.findings.iter().map(|f| f.path.as_str()).collect();
526 assert_eq!(paths, [".ai/local/drafts/I9999.yaml"]);
527 assert!(
528 report.findings[0]
529 .reason
530 .contains("schema: majordomus.issue/v1"),
531 "{}",
532 report.findings[0].reason
533 );
534 }
535
536 #[test]
537 fn a_local_half_that_excludes_nothing_is_not_judged() {
538 let repo = layer(false);
539 local(
540 &repo,
541 "notes/copied.md",
542 &std::fs::read(repo.root().join(RULE)).unwrap(),
543 );
544
545 let report = checked(&repo);
546 assert!(report.passes, "{:?}", report.findings);
547 let tracked = git_lines(repo.root(), &["ls-files", "--", ".ai/repo"]).unwrap();
548 assert_eq!(
549 report.checked,
550 tracked.len(),
551 "the local half is not walked"
552 );
553 }
554
555 #[test]
556 fn a_root_git_cannot_read_is_unmeasured_and_does_not_pass() {
557 let repo = SyntheticRepository::small().unwrap();
558 if git_lines(repo.root(), &["rev-parse", "--show-toplevel"]).is_ok() {
559 return; }
561 let report = checked(&repo);
562 assert!(!report.measured);
563 assert!(!report.passes);
564 assert_eq!(report.checked, 0);
565 assert!(report.reason.unwrap().contains("ls-files"));
566 }
567
568 #[test]
571 fn findings_are_ordered_by_path_and_then_by_contract() {
572 let at = |path: &str, contract: &str| LayoutFinding {
573 path: path.into(),
574 contract: contract.into(),
575 excludes: "canonical".into(),
576 reason: "a copy".into(),
577 };
578 let found = vec![
579 at(".ai/local/b.md", "manifest local"),
580 at(".ai/local/a.md", "ai.repo"),
581 at(".ai/local/a.md", "ai.local"),
582 ];
583 let report = LayoutReport::of(true, None, 3, found);
584 let order: Vec<(&str, &str)> = report
585 .findings
586 .iter()
587 .map(|f| (f.path.as_str(), f.contract.as_str()))
588 .collect();
589 assert_eq!(
590 order,
591 [
592 (".ai/local/a.md", "ai.local"),
593 (".ai/local/a.md", "ai.repo"),
594 (".ai/local/b.md", "manifest local"),
595 ]
596 );
597 }
598
599 fn contract(repo: &SyntheticRepository, dir: &str, id: &str, excludes: &str) {
601 let path = repo.root().join(dir).join("README.md");
602 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
603 let text = format!(
604 "---\nschema: context/v1\nid: {id}\nkind: context\ntitle: {id}\n\
605 description: The {id} section.\nstatus: active\nscope: subtree\n\
606 providers: [\"*\"]\naudience: [human, agent]\ncomposition: extend\n\
607 order: 20\nlayout:\n lifecycle: canonical\n excludes: [{excludes}]\n---\n\n\
608 # {id}\n"
609 );
610 std::fs::write(path, text).unwrap();
611 }
612
613 #[test]
617 fn a_tracked_file_the_ignore_rules_match_is_refused_only_where_a_layout_excludes_it() {
618 let repo = SyntheticRepository::small().unwrap();
619 let sources = repo.root().join(".ai/repo/knowledge/sources.yaml");
620 let text = std::fs::read_to_string(&sources).unwrap().replacen(
621 "sources:\n",
622 "sources:\n - id: context\n kind: context\n discovery: vcs\n \
623 pathspec: ':(glob).ai/**/README.md'\n required: false\n",
624 1,
625 );
626 std::fs::write(&sources, text).unwrap();
627 contract(&repo, ".ai/repo/state", "ai.state", "runtime");
628 contract(&repo, ".ai/repo/profiles", "ai.profiles", "canonical");
629 for rel in [
630 ".ai/repo/state/deep/now.state",
631 ".ai/repo/profiles/stray.state",
632 ".ai/repo/stray.state",
633 ] {
634 std::fs::create_dir_all(repo.root().join(rel).parent().unwrap()).unwrap();
635 std::fs::write(repo.root().join(rel), "pid: 42\n").unwrap();
636 }
637 git(repo.root(), &["init", "-q"]);
638 git(repo.root(), &["add", ".ai"]);
639 std::fs::write(repo.root().join(".gitignore"), "*.state\n").unwrap();
640
641 let report = checked(&repo);
642 assert!(report.measured, "{:?}", report.reason);
643 assert!(!report.passes);
644 assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
645 let f = &report.findings[0];
646 assert_eq!(f.path, ".ai/repo/state/deep/now.state");
647 assert_eq!(f.contract, "ai.state");
648 assert_eq!(f.excludes, "runtime");
649 assert!(
650 f.reason.contains("git's ignore rules match it"),
651 "{}",
652 f.reason
653 );
654 }
655
656 #[test]
659 fn an_absent_local_half_passes_having_judged_the_tracked_half() {
660 let repo = layer(true);
661 assert!(!repo.root().join(".ai/local").exists());
662
663 let report = checked(&repo);
664 assert!(report.passes, "{:?} {:?}", report.reason, report.findings);
665 let tracked = git_lines(repo.root(), &["ls-files", "--", ".ai/repo"]).unwrap();
666 assert_eq!(report.checked, tracked.len());
667 assert_eq!(hash_objects(repo.root(), &[]), Ok(Vec::new()));
668 }
669
670 #[cfg(unix)]
673 #[test]
674 fn a_local_file_git_cannot_hash_leaves_the_check_unmeasured() {
675 use std::os::unix::fs::PermissionsExt;
676 let repo = layer(true);
677 local(&repo, "notes/sealed.md", b"a note\n");
678 let sealed = repo.root().join(".ai/local/notes/sealed.md");
679 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o000)).unwrap();
680
681 let report = checked(&repo);
682 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o644)).unwrap();
683 assert!(!report.measured);
684 assert!(!report.passes);
685 let reason = report.reason.unwrap();
686 assert!(
687 reason.contains("git hash-object") && reason.contains("failed"),
688 "{reason}"
689 );
690 }
691
692 #[test]
695 fn a_file_name_with_a_line_break_is_hashed_as_one_file() {
696 let dir = tempfile::tempdir().unwrap();
697 let odd = dir.path().join("one\ntwo.md");
698 std::fs::write(&odd, "two\n").unwrap();
699 let plain = dir.path().join("two.md");
700 std::fs::write(&plain, "two\n").unwrap();
701
702 let ids = hash_objects(dir.path(), &[odd, plain]).unwrap();
703 assert_eq!(ids.len(), 2, "{ids:?}");
704 assert_eq!(ids[0], ids[1], "the same bytes are the same blob");
705 }
706
707 #[test]
709 fn a_staged_line_names_its_blob_and_its_path() {
710 assert_eq!(
711 staged_blob("100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0\t.ai/repo/a.md"),
712 Some((
713 "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391".to_string(),
714 ".ai/repo/a.md".to_string()
715 ))
716 );
717 assert_eq!(staged_blob("no tab here"), None);
718 assert_eq!(staged_blob("100644\t.ai/repo/a.md"), None);
719 }
720
721 #[test]
723 fn a_git_that_cannot_start_is_said() {
724 let dir = tempfile::tempdir().unwrap();
725 let missing = dir.path().join("no-such-git");
726 let err = program_lines(missing.to_str().unwrap(), dir.path(), &["status"]).unwrap_err();
727 assert!(err.starts_with("git could not start"), "{err}");
728 }
729
730 #[test]
733 fn a_schema_is_read_only_from_a_small_text_file() {
734 let dir = tempfile::tempdir().unwrap();
735 let small = dir.path().join("small.yaml");
736 std::fs::write(&small, "schema: 'majordomus.issue/v1'\nid: I1\n").unwrap();
737 assert_eq!(
738 declared_schema(&small).as_deref(),
739 Some("majordomus.issue/v1")
740 );
741
742 assert_eq!(declared_schema(&dir.path().join("missing.yaml")), None);
743
744 let large = dir.path().join("large.yaml");
745 let mut body = b"schema: majordomus.issue/v1\n".to_vec();
746 body.resize(HEAD_BYTES as usize + 1, b'#');
747 std::fs::write(&large, body).unwrap();
748 assert_eq!(declared_schema(&large), None, "larger than the head budget");
749
750 let binary = dir.path().join("binary.yaml");
751 std::fs::write(&binary, b"schema: majordomus.issue/v1\n\xff\xfe\n").unwrap();
752 assert_eq!(declared_schema(&binary), None, "not UTF-8");
753 }
754}