1use std::path::{Path, PathBuf};
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value};
20
21use crate::app::App;
22use crate::bench::{BenchmarkProjection, Coverage, CoverageState, SystemTarget, Transport};
23use crate::capability::registry::ModuleSource;
24use crate::capability::{CapabilityKind, CapabilityRegistry, CaseContext, Context, Provenance};
25use crate::error::{Error, Result};
26use crate::http::openapi;
27use crate::metadata::{yaml, KindSchema};
28use crate::policy::{sha256_hex, LoadedPolicy};
29use crate::share::Share;
30
31pub const OUT_DIR: &str = "docs/generated";
33pub const SITE_DATA_DIR: &str = "site/data/registry";
37
38pub const HEADER: &str = "GENERATED FILE — DO NOT EDIT DIRECTLY";
41
42pub const REGENERATE: &str = "majordomus generate";
44
45pub const REGISTRY_SCHEMA: &str = "majordomus/capability-registry/v1";
47
48pub const BENCHMARKS_SCHEMA: &str = "majordomus/benchmark-matrix/v1";
50
51pub const MANIFEST_SCHEMA: &str = "majordomus/generated-artifacts/v1";
53
54pub const ALLOW_EXTENSION: &str = "x-majordomus-allow";
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Target {
60 OpenApi,
62 Docs,
69 Benchmarks,
72 Registry,
75 Allow,
77 Documents,
81 Providers,
87 Editors,
92 Site,
95 Web,
101 Changelog,
112 Distribution,
117 Manifest,
121 Deployment,
124 Graph,
127 Design,
132 Economics,
137}
138
139impl Target {
140 pub const ALL: &'static [Target] = &[
143 Target::OpenApi,
144 Target::Docs,
145 Target::Benchmarks,
146 Target::Registry,
147 Target::Allow,
148 Target::Documents,
149 Target::Providers,
150 Target::Editors,
151 Target::Site,
152 Target::Web,
153 Target::Design,
154 Target::Changelog,
155 Target::Distribution,
156 Target::Graph,
157 Target::Deployment,
158 Target::Economics,
159 Target::Manifest,
160 ];
161
162 pub fn indexed() -> Vec<Target> {
169 Target::ALL
170 .iter()
171 .copied()
172 .filter(|t| *t != Target::Manifest)
173 .collect()
174 }
175
176 pub fn name(self) -> &'static str {
178 match self {
179 Target::OpenApi => "openapi",
180 Target::Docs => "docs",
181 Target::Benchmarks => "benchmarks",
182 Target::Registry => "registry",
183 Target::Allow => "allow",
184 Target::Documents => "documents",
185 Target::Providers => "providers",
186 Target::Editors => "editors",
187 Target::Site => "site",
188 Target::Web => "web",
189 Target::Changelog => "changelog",
190 Target::Distribution => "distribution",
191 Target::Manifest => "manifest",
192 Target::Deployment => "deployment",
193 Target::Graph => "graph",
194 Target::Design => "design",
195 Target::Economics => "economics",
196 }
197 }
198}
199
200#[derive(
202 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
203)]
204#[serde(rename_all = "lowercase")]
205pub enum ArtifactFormat {
206 Json,
208 Yaml,
210 Markdown,
212 Text,
215}
216
217pub const WEB_SCHEMA: &str = "majordomus/web-topology/v1";
219
220pub const ECONOMICS_SCHEMA: &str = "majordomus/economics-summary/v1";
222
223pub const PROVIDERS_SCHEMA: &str = "majordomus/providers/v1";
226
227impl ArtifactFormat {
228 pub fn suffix(self) -> &'static str {
230 match self {
231 ArtifactFormat::Json => "json",
232 ArtifactFormat::Yaml => "yaml",
233 ArtifactFormat::Markdown => "md",
234 ArtifactFormat::Text => "txt",
235 }
236 }
237
238 pub fn of_path(path: &str) -> ArtifactFormat {
240 match path.rsplit_once('.').map(|(_, s)| s) {
241 Some("json") => ArtifactFormat::Json,
242 Some("yaml") | Some("yml") => ArtifactFormat::Yaml,
243 Some("md") => ArtifactFormat::Markdown,
244 _ => ArtifactFormat::Text,
245 }
246 }
247}
248
249pub fn banner_lines(source: &str, version: &str) -> [String; 3] {
251 [
252 HEADER.to_string(),
253 format!("Source: {source}; regenerate with `{REGENERATE}`"),
254 format!("Generator: majordomus-cli {version}"),
255 ]
256}
257
258pub fn markdown_banner(source: &str, version: &str) -> String {
261 let [a, b, c] = banner_lines(source, version);
262 format!("<!-- {a}\n {b}\n {c} -->\n")
263}
264
265pub fn comment_banner(source: &str, version: &str) -> String {
268 banner_lines(source, version)
269 .iter()
270 .map(|l| format!("# {l}\n"))
271 .collect()
272}
273
274pub fn text_comment_opening(path: &str) -> &'static str {
284 match Path::new(path).extension().and_then(|e| e.to_str()) {
285 Some("css") | Some("js") | Some("mjs") => "/*",
286 Some("svg") | Some("html") | Some("xml") => "<!--",
287 _ => "#",
288 }
289}
290
291pub fn json_banner(source: &str) -> String {
293 format!("{HEADER}; source: {source}; regenerate with `{REGENERATE}`")
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum HeaderStyle {
299 Members,
301 Extension,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct Artifact {
311 pub path: String,
313 pub document: String,
316 pub format: ArtifactFormat,
318 pub schema: Option<String>,
320 pub source: String,
322 pub content: String,
324}
325
326impl Artifact {
327 pub fn markdown(
329 path: impl Into<String>,
330 document: impl Into<String>,
331 source: impl Into<String>,
332 version: &str,
333 body: &str,
334 ) -> Artifact {
335 let source = source.into();
336 Artifact {
337 path: path.into(),
338 document: document.into(),
339 format: ArtifactFormat::Markdown,
340 schema: None,
341 content: markdown_banner(&source, version) + body,
342 source,
343 }
344 }
345
346 pub fn text(
349 path: impl Into<String>,
350 document: impl Into<String>,
351 source: impl Into<String>,
352 version: &str,
353 body: &str,
354 ) -> Artifact {
355 let source = source.into();
356 Artifact {
357 path: path.into(),
358 document: document.into(),
359 format: ArtifactFormat::Text,
360 schema: None,
361 content: comment_banner(&source, version) + body,
362 source,
363 }
364 }
365
366 pub fn json_extension(
371 path: impl Into<String>,
372 document: impl Into<String>,
373 source: impl Into<String>,
374 version: &str,
375 value: Value,
376 ) -> Artifact {
377 let source = source.into();
378 let stamped = match value {
379 Value::Object(members) => {
380 let mut out = members;
381 out.insert(
382 "x-majordomus-generated".into(),
383 Value::String(json_banner(&source)),
384 );
385 out.insert(
386 "x-majordomus-generator".into(),
387 Value::String(format!("majordomus-cli {version}")),
388 );
389 Value::Object(out)
390 }
391 other => other,
392 };
393 Artifact {
394 path: path.into(),
395 document: document.into(),
396 format: ArtifactFormat::Json,
397 schema: None,
398 source,
399 content: openapi::render(&stamped),
400 }
401 }
402
403 pub fn comment_text(
420 path: impl Into<String>,
421 document: impl Into<String>,
422 source: impl Into<String>,
423 version: &str,
424 body: &str,
425 ) -> Artifact {
426 let path = path.into();
427 let source = source.into();
428 let [a, b, c] = banner_lines(&source, version);
429 let banner = match text_comment_opening(&path) {
430 "/*" => format!("/* {a}\n {b}\n {c} */\n"),
431 "<!--" => format!("<!-- {a}\n {b}\n {c} -->\n"),
432 _ => comment_banner(&source, version),
433 };
434 Artifact {
435 path,
436 document: document.into(),
437 format: ArtifactFormat::Text,
438 schema: None,
439 content: banner + body,
440 source,
441 }
442 }
443
444 pub fn verbatim(
447 path: impl Into<String>,
448 document: impl Into<String>,
449 format: ArtifactFormat,
450 schema: Option<String>,
451 source: impl Into<String>,
452 content: String,
453 ) -> Artifact {
454 Artifact {
455 path: path.into(),
456 document: document.into(),
457 format,
458 schema,
459 source: source.into(),
460 content,
461 }
462 }
463}
464
465#[derive(Debug, Clone)]
470pub struct Document {
471 pub id: String,
473 pub dir: String,
475 pub schema: Option<String>,
477 pub source: String,
479 pub style: HeaderStyle,
481 pub value: Value,
483}
484
485impl Document {
486 pub fn new(
488 id: impl Into<String>,
489 schema: impl Into<String>,
490 source: impl Into<String>,
491 value: Value,
492 ) -> Document {
493 Document {
494 id: id.into(),
495 dir: OUT_DIR.to_string(),
496 schema: Some(schema.into()),
497 source: source.into(),
498 style: HeaderStyle::Members,
499 value,
500 }
501 }
502
503 pub fn stamped(&self, version: &str) -> Value {
505 let Value::Object(members) = &self.value else {
506 return self.value.clone();
507 };
508 let mut out = Map::new();
509 match self.style {
510 HeaderStyle::Members => {
511 if let Some(schema) = &self.schema {
512 out.insert("schema".into(), Value::String(schema.clone()));
513 }
514 out.insert("generated".into(), Value::String(json_banner(&self.source)));
515 out.insert(
516 "generator".into(),
517 Value::String(format!("majordomus-cli {version}")),
518 );
519 for (k, v) in members {
520 out.entry(k.clone()).or_insert_with(|| v.clone());
521 }
522 }
523 HeaderStyle::Extension => {
524 for (k, v) in members {
525 out.insert(k.clone(), v.clone());
526 }
527 out.insert(
528 "x-majordomus-generated".into(),
529 Value::String(json_banner(&self.source)),
530 );
531 out.insert(
532 "x-majordomus-generator".into(),
533 Value::String(format!("majordomus-cli {version}")),
534 );
535 }
536 }
537 Value::Object(out)
538 }
539
540 pub fn artifacts(&self, version: &str) -> Vec<Artifact> {
542 let stamped = self.stamped(version);
543 let base = |format: ArtifactFormat, content: String| Artifact {
544 path: format!("{}/{}.{}", self.dir, self.id, format.suffix()),
545 document: self.id.clone(),
546 format,
547 schema: self.schema.clone(),
548 source: self.source.clone(),
549 content,
550 };
551 vec![
552 base(ArtifactFormat::Json, openapi::render(&stamped)),
553 base(
554 ArtifactFormat::Yaml,
555 yaml::render_with_banner(&stamped, &banner_lines(&self.source, version).join("\n")),
556 ),
557 ]
558 }
559}
560pub fn artifacts(
566 registry: &CapabilityRegistry,
567 version: &str,
568 cases: Option<&CaseContext<'_>>,
569 targets: &[Target],
570) -> Result<Vec<Artifact>> {
571 let mut out = Vec::new();
572 for t in targets {
573 match t {
574 Target::OpenApi => {
575 let doc = Document {
576 id: "openapi".into(),
577 dir: OUT_DIR.into(),
578 schema: None,
581 source: "the canonical Majordomus capability registry".into(),
582 style: HeaderStyle::Extension,
583 value: openapi::document(registry, version, cases)
584 .map_err(|reason| Error::Http { reason })?,
585 };
586 out.extend(doc.artifacts(version));
587 }
588 Target::Docs => {
589 out.push(Artifact::markdown(
590 format!("{OUT_DIR}/capabilities.md"),
591 "capabilities",
592 "the canonical Majordomus capability registry",
593 version,
594 &reference(registry),
595 ));
596 let cli = crate::cli::tree();
597 out.push(Artifact::markdown(
598 format!("{OUT_DIR}/cli.md"),
599 "cli",
600 "the clap declaration in apps/majordomus-cli/src/cli.rs and the examples beside it",
601 version,
602 &cli_reference(&cli),
603 ));
604 out.extend(
605 Document::new(
606 "cli",
607 crate::cli::SCHEMA,
608 "the clap declaration in apps/majordomus-cli/src/cli.rs and the examples beside it",
609 cli_document(&cli, version),
610 )
611 .artifacts(version),
612 );
613 for m in registry
614 .modules()
615 .filter(|m| m.source != ModuleSource::Declarative)
616 {
617 out.push(Artifact::markdown(
618 format!("{OUT_DIR}/modules/{}.md", m.id),
619 format!("modules/{}", m.id),
620 format!(
621 "the `{}` module of the canonical Majordomus capability registry",
622 m.id
623 ),
624 version,
625 &module_reference(registry, m.id.as_str()),
626 ));
627 }
628 }
629 Target::Registry => out.extend(
630 Document::new(
631 "registry",
632 REGISTRY_SCHEMA,
633 "the canonical capability registry",
634 registry_manifest(registry),
635 )
636 .artifacts(version),
637 ),
638 Target::Benchmarks
639 | Target::Allow
640 | Target::Documents
641 | Target::Providers
642 | Target::Editors
643 | Target::Site
644 | Target::Web
645 | Target::Changelog
646 | Target::Distribution
647 | Target::Deployment
648 | Target::Graph
649 | Target::Design
650 | Target::Economics
651 | Target::Manifest => {}
652 }
653 }
654 Ok(out)
655}
656
657pub fn plan(app: &App, targets: &[Target]) -> Result<Vec<Artifact>> {
663 let wants_manifest = targets.contains(&Target::Manifest);
667 let alone = targets == [Target::Manifest];
668 let indexed: Vec<Target> = if alone {
669 Target::indexed()
670 } else {
671 targets
672 .iter()
673 .copied()
674 .filter(|t| *t != Target::Manifest)
675 .collect()
676 };
677 let mut out = indexed_plan(app, &indexed)?;
678 if wants_manifest {
679 let manifest = manifest_document(&out);
680 if alone {
681 out.clear();
682 }
683 out.extend(manifest_artifacts(&manifest, crate::VERSION));
684 }
685 Ok(out)
686}
687
688fn indexed_plan(app: &App, targets: &[Target]) -> Result<Vec<Artifact>> {
690 let mut out = context_artifacts(&app.context, crate::VERSION, targets)?;
691 let share_is_here = app.share.dir().starts_with(app.repository.root());
698 if share_is_here && (targets.contains(&Target::Documents) || targets.contains(&Target::Allow)) {
699 let protos = document_schemas(&app.share, app.repository.root())?;
700 if targets.contains(&Target::Documents) {
701 out.extend(document_artifacts(
702 &protos,
703 &app.share,
704 app.repository.root(),
705 crate::VERSION,
706 )?);
707 }
708 if targets.contains(&Target::Allow) {
709 out.extend(proto_allow_artifacts(
710 &protos,
711 &app.share,
712 app.repository.root(),
713 crate::VERSION,
714 )?);
715 out.extend(allow_artifacts(
716 &app.schema,
717 &app.share,
718 app.repository.root(),
719 crate::VERSION,
720 ));
721 }
722 }
723 if targets.contains(&Target::Changelog) {
724 let mut log =
734 crate::release::compose_published(app.repository.root(), &app.context.index.objects);
735 let unreadable = crate::release::changelog::unreadable_releases(
741 app.repository.root(),
742 &app.context.index.objects,
743 );
744 if !unreadable.is_empty() {
745 let committed =
746 std::fs::read_to_string(app.repository.root().join(OUT_DIR).join("changelog.json"))
747 .ok()
748 .and_then(|text| {
749 serde_json::from_str::<crate::release::model::Changelog>(&text).ok()
750 });
751 let kept = committed
752 .map(|c| crate::release::changelog::keep_committed(&mut log, &c, &unreadable))
753 .unwrap_or_default();
754 if let (Some(newest), Some(oldest)) = (kept.first(), kept.last()) {
755 tracing::warn!(
756 "{OUT_DIR}/changelog.*: what {} release(s) changed ({newest} back to \
757 {oldest}) is kept as committed and not judged: their history is not in \
758 this repository's object database",
759 kept.len()
760 );
761 }
762 }
763 log.produced_by = crate::capability::builtin::release::produced_by(&app.context.registry);
767 let value = serde_json::to_value(&log).unwrap_or(serde_json::Value::Null);
768 out.extend(
769 Document::new(
770 "changelog",
771 crate::release::model::CHANGELOG_SCHEMA,
772 "the layer's release records, the decisions dated in each release's window, and the commits in its range",
773 value,
774 )
775 .artifacts(crate::VERSION),
776 );
777 out.push(Artifact::markdown(
778 format!("{OUT_DIR}/changelog.md"),
779 "changelog",
780 "the layer's release records, the decisions dated in each release's window, and the commits in its range",
781 crate::VERSION,
782 &crate::release::changelog::render(&log),
783 ));
784 }
785
786 if targets.contains(&Target::Editors) {
787 let editors = crate::editors::plan(
788 &app.context.index,
789 &app.context.registry,
790 app.repository.root(),
791 crate::VERSION,
792 );
793 if let Some(d) = editors
796 .diagnostics
797 .iter()
798 .find(|d| d.code == crate::editors::WORKFLOWS_UNREADABLE)
799 {
800 return Err(Error::Refused {
801 code: 12,
802 reason: format!("generate editors: {}; {}", d.message, d.remedy),
803 });
804 }
805 out.extend(editors.artifacts);
806 }
807
808 let needs_policy = targets.contains(&Target::Providers) || targets.contains(&Target::Site);
809 if needs_policy {
810 let policy = LoadedPolicy::load(&app.repository)?;
811 if targets.contains(&Target::Providers) {
812 out.extend(crate::providers::artifacts(
813 &app.repository,
814 &app.share,
815 &policy,
816 )?);
817 }
818 if targets.contains(&Target::Site) {
819 let dataset =
820 crate::site::dataset(&app.context, &app.schema, &policy, &app.repository)?;
821 out.push(Artifact::verbatim(
822 format!("{SITE_DATA_DIR}/registry.json"),
823 "site-registry",
824 ArtifactFormat::Json,
825 Some(crate::site::SCHEMA.to_string()),
826 "the capability registry and the index of this repository's layer",
827 crate::site::render(&dataset),
828 ));
829 out.push(crate::evidence::subject::artifact(
834 &app.context,
835 crate::VERSION,
836 )?);
837 out.extend(crate::site::why_artifacts(&app.context)?);
842 out.extend(crate::site::product_artifacts(&app.context)?);
846 }
847 }
848 let crate_is_here = app
852 .repository
853 .root()
854 .join(crate::release::version::MANIFEST)
855 .is_file();
856 if targets.contains(&Target::Distribution) {
857 out.extend(distribution_artifacts(app)?);
858 if share_is_here && crate_is_here {
859 out.push(version_projection(app.repository.root()));
860 }
861 }
862 if share_is_here && crate_is_here && targets.contains(&Target::Design) {
863 out.extend(design_artifacts(app)?);
864 }
865 if targets.contains(&Target::Deployment) {
866 for object in app
867 .context
868 .index
869 .objects
870 .iter()
871 .filter(|o| o.kind == crate::deploy::KIND)
872 {
873 let deployment = crate::deploy::Deployment::parse(object).map_err(|refusal| {
874 Error::InvalidDeployment {
875 reason: refusal.to_string(),
876 }
877 })?;
878 let rust = crate::deploy::render::builder_rust(app.repository.root())
881 .map_err(|reason| Error::InvalidDeployment { reason })?;
882 out.extend(crate::deploy::render::artifacts(
883 &deployment,
884 &object.provenance.path,
885 &rust,
886 ));
887 }
888 }
889 Ok(out)
890}
891
892pub fn version_projection(root: &Path) -> Artifact {
913 use crate::release::version::{declared, render_projection, MANIFEST, PROJECTION};
914 let version = declared(root).unwrap_or_else(|| crate::VERSION.to_string());
915 Artifact::text(
916 PROJECTION,
917 "version",
918 format!("{MANIFEST} ([package] version), the one place the version is authored"),
919 crate::VERSION,
920 &render_projection(&version),
921 )
922}
923
924pub fn distribution_artifacts(app: &App) -> Result<Vec<Artifact>> {
929 use crate::distribution::{release, render, Model, Releases};
930
931 let model = Model::load(&app.share)?;
932 let releases = Releases::load(app.repository.root())?;
933 let findings = releases.findings(&model);
934 if let Some(first) = findings.first() {
935 return Err(Error::InvalidRelease {
936 path: release::DIR.to_string(),
937 reason: if findings.len() == 1 {
938 first.clone()
939 } else {
940 format!("{first} (and {} more)", findings.len() - 1)
941 },
942 });
943 }
944
945 use crate::distribution::render::SOURCE as DIST_SOURCE;
946 let mut out = vec![
947 Artifact::verbatim(
948 format!("{OUT_DIR}/distribution-matrix.json"),
949 "distribution-matrix",
950 ArtifactFormat::Json,
951 None,
952 DIST_SOURCE,
953 render::matrix_json(&model),
954 ),
955 Artifact::verbatim(
956 format!("{SITE_DATA_DIR}/distribution.json"),
957 "site-distribution",
958 ArtifactFormat::Json,
959 None,
960 DIST_SOURCE,
961 render::site_dataset(&model, &releases),
962 ),
963 ];
964
965 let installer_template = read_share(&app.share, crate::distribution::INSTALLER_TEMPLATE)?;
966 out.push(Artifact::verbatim(
967 format!(
968 "{}/{}",
969 crate::distribution::PUBLIC_DIR,
970 model.installer.script
971 ),
972 "installer",
973 ArtifactFormat::Text,
974 None,
975 DIST_SOURCE,
976 render::installer(&model, &installer_template).map_err(|reason| {
977 Error::InvalidDistribution {
978 path: crate::distribution::INSTALLER_TEMPLATE.to_string(),
979 reason,
980 }
981 })?,
982 ));
983
984 let guide_template = read_share(&app.share, crate::distribution::GUIDE_TEMPLATE)?;
985 let guide = render::install_doc(&model, &releases, crate::VERSION, &guide_template).map_err(
986 |reason| Error::InvalidDistribution {
987 path: crate::distribution::GUIDE_TEMPLATE.to_string(),
988 reason,
989 },
990 )?;
991 let (title, rest) = guide.split_once('\n').unwrap_or((guide.as_str(), ""));
994 out.push(Artifact::verbatim(
995 crate::distribution::GUIDE.to_string(),
996 "install-guide",
997 ArtifactFormat::Markdown,
998 None,
999 "share/install/INSTALL.md.in (the prose) and share/distribution.yaml (every platform, name and URL)",
1000 format!(
1001 "{title}\n<!-- {HEADER}\n Source: share/install/INSTALL.md.in (the prose) and share/distribution.yaml (every platform, name and URL);\n regenerate with `majordomus generate`\n Generator: majordomus-cli {} -->\n{rest}",
1002 crate::VERSION
1003 ),
1004 ));
1005
1006 for r in &releases.releases {
1007 out.push(Artifact::verbatim(
1008 format!("{}/{}.json", release::PUBLIC_DIR, r.tag),
1009 format!("release/{}", r.tag),
1010 ArtifactFormat::Json,
1011 None,
1012 format!("the release record {}", r.tag),
1013 r.public_json(&model),
1014 ));
1015 }
1016 if let Some(latest) = releases.latest_stable() {
1017 out.push(Artifact::verbatim(
1018 format!("{}/{}.json", release::PUBLIC_DIR, release::LATEST),
1019 "release/latest",
1020 ArtifactFormat::Json,
1021 None,
1022 "the latest stable release record",
1023 release::latest_json(latest, &model),
1024 ));
1025 }
1026 Ok(out)
1027}
1028
1029fn read_source(path: &Path) -> Result<String> {
1031 std::fs::read_to_string(path).map_err(|e| Error::io(path, e))
1032}
1033
1034pub(crate) fn design_artifacts(app: &App) -> Result<Vec<Artifact>> {
1040 use crate::design::{render, DesignSystem, DOCUMENT_SCHEMA, SHARE_PATH, SITE_SCHEMA, SOURCE};
1041
1042 let path = app.share.dir().join(SHARE_PATH);
1043 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
1044 let design = DesignSystem::parse(&text).map_err(|reason| Error::InvalidDesign {
1045 path: SOURCE.to_string(),
1046 reason,
1047 })?;
1048 let version = crate::VERSION;
1049 let source = render::SOURCE_LINE;
1050 let mut out = Vec::new();
1051
1052 for (path, document, body) in [
1053 (
1054 render::THEME_CSS,
1055 "design-theme",
1056 render::theme_css(&design),
1057 ),
1058 (
1059 render::SURFACE_CSS,
1060 "design-surface",
1061 render::surface_css(&design),
1062 ),
1063 (
1064 render::STATUS_CSS,
1065 "design-status",
1066 render::status_css(&design),
1067 ),
1068 (
1069 render::TOKENS_CSS,
1070 "design-tokens",
1071 render::tokens_css(&design),
1072 ),
1073 ] {
1074 render::well_formed(&body).map_err(|reason| Error::InvalidDesign {
1075 path: path.to_string(),
1076 reason,
1077 })?;
1078 out.push(Artifact::comment_text(
1079 path, document, source, version, &body,
1080 ));
1081 }
1082
1083 out.push(Artifact::verbatim(
1086 render::COMPILED_YAML,
1087 "design-declaration",
1088 ArtifactFormat::Yaml,
1089 None,
1090 source,
1091 comment_banner(source, version) + text.as_str(),
1094 ));
1095
1096 let brand = |relative: &str| -> Result<String> {
1099 let p = app.share.dir().join("design").join(relative);
1100 std::fs::read_to_string(&p).map_err(|e| Error::io(&p, e))
1101 };
1102 let mark = brand(&design.identity.mark)?;
1103 let logo = brand(&design.identity.logo)?;
1104 let mark_source = format!("share/design/{}, the canonical mark", design.identity.mark);
1105 let logo_source = format!("share/design/{}, the canonical logo", design.identity.logo);
1106 for path in [
1107 render::COMPILED_MARK,
1108 "share/cockpit/favicon.svg",
1109 crate::ui::FAVICON,
1110 "site/static/favicon.svg",
1111 "site/static/images/logo-mark.svg",
1112 ] {
1113 out.push(Artifact::comment_text(
1114 path,
1115 "design-mark",
1116 mark_source.clone(),
1117 version,
1118 &mark,
1119 ));
1120 }
1121 out.push(Artifact::comment_text(
1122 "site/static/images/logo.svg",
1123 "design-logo",
1124 logo_source,
1125 version,
1126 &logo,
1127 ));
1128
1129 let icons_path = app.repository.root().join(crate::ui::icons::SOURCE);
1135 if icons_path.is_file() {
1136 let icons = read_source(&icons_path)?;
1137 out.push(Artifact::comment_text(
1138 crate::ui::icons::COMPILED,
1139 "ui-icons",
1140 format!("{}, the kit's icons", crate::ui::icons::SOURCE),
1141 version,
1142 &icons,
1143 ));
1144 }
1145
1146 out.push(Artifact::verbatim(
1148 render::SITE_JSON,
1149 "site-design",
1150 ArtifactFormat::Json,
1151 Some(SITE_SCHEMA.to_string()),
1152 source,
1153 openapi::render(&render::site_document(&design, version)),
1154 ));
1155 out.extend(
1156 Document::new(
1157 render::DOCUMENT,
1158 DOCUMENT_SCHEMA,
1159 source,
1160 render::document(&design),
1161 )
1162 .artifacts(version),
1163 );
1164 out.push(Artifact::markdown(
1165 format!("{OUT_DIR}/{}.md", render::DOCUMENT),
1166 render::DOCUMENT,
1167 source,
1168 version,
1169 &render::reference_markdown(&design),
1170 ));
1171 Ok(out)
1172}
1173
1174fn read_share(share: &Share, relative: &str) -> Result<String> {
1175 let path = share.dir().join(relative);
1176 std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))
1177}
1178
1179pub fn context_artifacts(
1182 ctx: &Context,
1183 version: &str,
1184 targets: &[Target],
1185) -> Result<Vec<Artifact>> {
1186 let cases = CaseContext { index: &ctx.index };
1187 let mut out = artifacts(&ctx.registry, version, Some(&cases), targets)?;
1188 if targets.contains(&Target::Benchmarks) {
1189 let source = "the benchmark projection of the canonical capability registry";
1190 out.push(Artifact::markdown(
1191 format!("{OUT_DIR}/benchmarks.md"),
1192 "benchmarks",
1193 source,
1194 version,
1195 &benchmark_matrix(ctx),
1196 ));
1197 out.extend(
1198 Document::new(
1199 "benchmarks",
1200 BENCHMARKS_SCHEMA,
1201 source,
1202 benchmark_document(ctx),
1203 )
1204 .artifacts(version),
1205 );
1206 }
1207 if targets.contains(&Target::Web) {
1208 out.extend(
1209 Document::new(
1210 "web",
1211 WEB_SCHEMA,
1212 "the resolved web topology of this repository",
1213 web_topology(ctx),
1214 )
1215 .artifacts(version),
1216 );
1217 }
1218 if targets.contains(&Target::Docs) {
1219 let source = "the provider declarations the distribution ships (share/providers.yaml), the templates beside them, and this repository's policy";
1220 let value = providers_document(ctx)?;
1221 out.push(Artifact::markdown(
1222 format!("{OUT_DIR}/providers.md"),
1223 "providers",
1224 source,
1225 version,
1226 &providers_markdown(&value),
1227 ));
1228 out.extend(Document::new("providers", PROVIDERS_SCHEMA, source, value).artifacts(version));
1229 }
1230 if targets.contains(&Target::Economics) {
1231 let source = "the token-economics methodology and every benchmark run committed under .ai/repo/benchmarks/economics";
1232 let summary = crate::economics::summarize_tracked(
1235 std::path::Path::new(&ctx.index.repository.root),
1236 &Default::default(),
1237 );
1238 out.push(Artifact::markdown(
1239 format!("{OUT_DIR}/economics.md"),
1240 "economics",
1241 source,
1242 version,
1243 &crate::economics::report::markdown(&summary),
1244 ));
1245 let value = serde_json::to_value(&summary).map_err(|e| Error::Protocol {
1246 reason: format!("the economics summary does not serialise: {e}"),
1247 })?;
1248 out.extend(Document::new("economics", ECONOMICS_SCHEMA, source, value).artifacts(version));
1249 }
1250 if targets.contains(&Target::Graph) {
1251 out.push(Artifact::verbatim(
1252 format!("{OUT_DIR}/graph.json"),
1253 "graph",
1254 ArtifactFormat::Json,
1255 Some(GRAPH_SCHEMA.to_string()),
1256 "the composed graph of the registry and the index",
1257 graph_document(ctx, version)?,
1258 ));
1259 out.push(Artifact::verbatim(
1260 format!("{OUT_DIR}/graph.schema.json"),
1261 "graph-schema",
1262 ArtifactFormat::Json,
1263 None,
1264 "the schema of the composed graph, generated from its types",
1265 graph_schema_document(version),
1266 ));
1267 }
1268 Ok(out)
1269}
1270
1271pub fn providers_document(ctx: &Context) -> Result<Value> {
1277 let id = ctx
1278 .registry
1279 .by_cli(&["product".to_string(), "providers".to_string()])
1280 .map(|c| c.id.to_string())
1281 .ok_or_else(|| Error::Protocol {
1282 reason: "no capability is exposed as `majordomus product providers`".into(),
1283 })?;
1284 let answer = ctx
1285 .execute(&id, serde_json::json!({}))
1286 .map_err(|e| Error::Protocol {
1287 reason: e.to_string(),
1288 })?;
1289 Ok(serde_json::json!({
1290 "scratch_roots": ctx.index.providers.scratch_roots,
1291 "count": answer["count"],
1292 "providers": answer["providers"],
1293 }))
1294}
1295
1296pub fn providers_markdown(value: &Value) -> String {
1299 let cell = |v: &Value| -> String {
1300 let items: Vec<String> = v
1301 .as_array()
1302 .into_iter()
1303 .flatten()
1304 .map(|x| match x {
1305 Value::String(s) => format!("`{s}`"),
1306 other => other
1307 .get("target")
1308 .and_then(Value::as_str)
1309 .map(|t| {
1310 if other.get("always_loaded") == Some(&Value::Bool(true)) {
1311 format!("`{t}` (always loaded)")
1312 } else {
1313 format!("`{t}`")
1314 }
1315 })
1316 .unwrap_or_default(),
1317 })
1318 .collect();
1319 if items.is_empty() {
1320 "—".to_string()
1321 } else {
1322 items.join(", ")
1323 }
1324 };
1325 let mut s = String::new();
1326 s.push_str("# Providers\n\n");
1327 s.push_str("Every provider the tool ships an adapter for, and what this repository does with each. The set is the templates under `share/providers/`; the title, the client configuration a provider reads and the scratch roots it creates checkouts under are `share/providers.yaml`; the bootstraps are the policy's `projections[]`; whether a client configuration is present and which hooks are wired are facts of this tree. `majordomus product providers`, the MCP tool `majordomus_providers`, `GET /api/v1/product/providers` and the site's provider cards answer from the same value. A document that names providers points here rather than listing them (ADR 0024).\n\n");
1328 s.push_str("| provider | title | bootstraps | client configuration | hooks | scratch roots |\n|---|---|---|---|---|---|\n");
1329 for p in value["providers"].as_array().into_iter().flatten() {
1330 let str_of = |k: &str| {
1331 p.get(k)
1332 .and_then(Value::as_str)
1333 .unwrap_or_default()
1334 .to_string()
1335 };
1336 s.push_str(&format!(
1337 "| `{}` | {} | {} | {} | {} | {} |\n",
1338 str_of("id"),
1339 str_of("title"),
1340 cell(&p["bootstraps"]),
1341 p.get("client_config")
1342 .and_then(Value::as_str)
1343 .map(|c| format!("`{c}`"))
1344 .unwrap_or_else(|| "—".to_string()),
1345 cell(&p["hooks"]),
1346 cell(&p["scratch_roots"]),
1347 ));
1348 }
1349 s.push_str(&format!(
1350 "\n{} provider(s).\n\n## The tool's own scratch roots\n\n",
1351 value["count"].as_u64().unwrap_or(0)
1352 ));
1353 s.push_str("A checkout under any of these, or under a provider's root above, is a session's scratch checkout to the worktree topology: reported, never migrated unasked, never cleaned up by the tool, refused by the commit guard with the remedy of continuing in the canonical worktree. A root the primary checkout itself lives under is skipped.\n\n");
1354 for r in value["scratch_roots"].as_array().into_iter().flatten() {
1355 if let Some(r) = r.as_str() {
1356 s.push_str(&format!("- `{r}`\n"));
1357 }
1358 }
1359 s.push_str("\nA root is expanded before it is compared: `<primary>/` is the primary checkout, `~` the home directory, `${NAME:-default}` an environment variable with a default, `$NAME` one without — a root whose variable is unset is skipped.\n");
1360 s
1361}
1362
1363pub fn web_topology(ctx: &Context) -> Value {
1382 web_document(&ctx.web)
1383}
1384
1385fn web_document(topology: &crate::web::Topology) -> Value {
1389 let surfaces: Vec<Value> = surfaces_every_checkout_has(topology)
1390 .map(|s| {
1391 let mut v = serde_json::to_value(s).unwrap_or(Value::Null);
1392 if let Some(map) = v.as_object_mut() {
1395 map.remove("built_from");
1396 }
1397 v
1398 })
1399 .collect();
1400 serde_json::json!({
1404 "generated_root": crate::web::discover::GENERATED_ROOT,
1405 "reserved": crate::web::discover::reserved()
1408 .into_iter()
1409 .map(|r| (r.role.to_string(), Value::String(r.path.to_string())))
1410 .collect::<serde_json::Map<String, Value>>(),
1411 "surfaces": surfaces,
1412 })
1413}
1414
1415pub(crate) fn surfaces_every_checkout_has(
1423 topology: &crate::web::Topology,
1424) -> impl Iterator<Item = &crate::web::Surface> {
1425 topology
1426 .surfaces
1427 .iter()
1428 .filter(|s| !only_where_its_producer_ran(s))
1429}
1430
1431fn only_where_its_producer_ran(surface: &crate::web::Surface) -> bool {
1439 surface
1440 .provenance
1441 .values()
1442 .any(|p| matches!(p, crate::web::Provenance::ProducerDeclaration { .. }))
1443}
1444
1445pub const GRAPH_SCHEMA: &str = "majordomus/capability-graph/v1";
1447
1448const FORBIDDEN: &[(&str, &str)] = &[
1453 (
1454 "/Users/",
1455 "an absolute path on the machine that generated this",
1456 ),
1457 (
1458 "/home/",
1459 "an absolute path on the machine that generated this",
1460 ),
1461 (
1462 "/root/",
1463 "an absolute path on the machine that generated this",
1464 ),
1465 ("-----BEGIN ", "a PEM block"),
1466 ("Authorization:", "an authorization header"),
1467 ("Bearer ", "a bearer token"),
1468 ("AKIA", "an access key id"),
1469 ("ghp_", "a personal access token"),
1470 ("github_pat_", "a personal access token"),
1471];
1472
1473pub fn forbidden_in(content: &str) -> Option<(&'static str, &'static str)> {
1487 FORBIDDEN
1488 .iter()
1489 .find(|(marker, _)| content.contains(marker))
1490 .map(|(marker, what)| (*marker, *what))
1491}
1492
1493pub fn graph_document(ctx: &Context, version: &str) -> Result<String> {
1506 graph_document_of(
1507 ctx,
1508 version,
1509 crate::graph::composed_complete(&ctx.registry, &ctx.index),
1510 )
1511}
1512
1513fn graph_document_of(
1516 ctx: &Context,
1517 version: &str,
1518 composed: crate::graph::Graph,
1519) -> Result<String> {
1520 let unresolved = crate::graph::unresolved_relations(&ctx.registry, &ctx.index.objects);
1521 if !unresolved.is_empty() {
1522 return Err(Error::UnresolvedRelation {
1523 findings: unresolved
1524 .iter()
1525 .map(|u| {
1526 format!(
1527 "{}: {} names `{}`, which resolves to nothing; {}",
1528 u.declared_in, u.key, u.reference, u.correction
1529 )
1530 })
1531 .collect(),
1532 });
1533 }
1534 let graph = committed_graph(composed)?;
1537 let doc = serde_json::json!({
1538 "schema": GRAPH_SCHEMA,
1539 "generated": format!("{HEADER}; source: the capability registry and every object of the index; regenerate with `majordomus generate`"),
1540 "generator": format!("majordomus-cli {version}"),
1541 "graph": graph,
1542 });
1543 let rendered = openapi::render(&doc);
1544 if let Some((marker, what)) = forbidden_in(&rendered) {
1545 return Err(Error::Http {
1546 reason: format!(
1547 "the composed graph carries {what} (`{marker}`) and would publish it; \
1548 a node's source is repository-relative, so find the derivation that put an \
1549 absolute path or a secret on a node before regenerating"
1550 ),
1551 });
1552 }
1553 Ok(rendered)
1554}
1555
1556fn committed_graph(graph: crate::graph::Graph) -> Result<crate::graph::Graph> {
1561 crate::graph::whole(graph).map_err(|reason| Error::Refused { code: 10, reason })
1562}
1563
1564pub fn graph_schema_document(version: &str) -> String {
1567 let schema = crate::capability::schema::CanonicalSchema::of::<crate::graph::Graph>();
1568 let doc = serde_json::json!({
1571 "$schema": "https://json-schema.org/draft/2020-12/schema",
1572 "$id": GRAPH_SCHEMA,
1573 "description": format!("{HEADER}; source: the Rust types of majordomus_cli::graph; regenerate with `majordomus generate`; generator majordomus-cli {version}"),
1574 "x-majordomus-generated": json_banner("the Rust types of majordomus_cli::graph"),
1575 "x-majordomus-generator": format!("majordomus-cli {version}"),
1576 "definitions": { "Graph": schema.schema },
1577 "$ref": "#/definitions/Graph",
1578 });
1579 openapi::render(&doc)
1580}
1581
1582pub fn registry_manifest(registry: &CapabilityRegistry) -> Value {
1586 let modules: Vec<&crate::capability::registry::ModuleInfo> = registry
1587 .modules()
1588 .filter(|m| m.source != ModuleSource::Declarative)
1589 .collect();
1590 let capabilities: Vec<Value> = registry
1593 .iter()
1594 .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
1595 .map(|c| {
1596 let mut v = serde_json::to_value(c).unwrap_or(Value::Null);
1597 if let Some(o) = v.as_object_mut() {
1598 o.insert(
1599 "source_path".into(),
1600 Value::String(c.provenance.source_path()),
1601 );
1602 }
1603 v
1604 })
1605 .collect();
1606 let declarative_kinds: Vec<&str> = registry
1607 .modules()
1608 .filter(|m| m.source == ModuleSource::Declarative)
1609 .map(|m| m.id.as_str())
1610 .collect();
1611 serde_json::json!({
1612 "modules": modules,
1613 "capabilities": capabilities,
1614 "declarative_kinds": declarative_kinds,
1615 "system_benchmark_targets": SystemTarget::ALL.iter().map(|s| serde_json::json!({ "key": s.key(), "transport": s.transport(), "description": s.description() })).collect::<Vec<_>>(),
1616 })
1617}
1618
1619pub fn benchmark_matrix(ctx: &Context) -> String {
1621 let projection = BenchmarkProjection::from_context(ctx);
1622 let coverage = Coverage::compute(ctx, &projection);
1623 let mut s = String::new();
1624 s.push_str("# Benchmark targets and coverage\n\n");
1625 s.push_str("Every externally callable operation is a benchmark target, derived from the registry: each executable capability directly and on every transport its exposure declares, with the cases its input type provides, plus the transports' own operations. Nothing below is listed by hand; `majordomus bench coverage` computes the same table live, `majordomus bench` times it, and `capabilities validate` fails when a requirement is missing.\n\n");
1626 s.push_str(
1627 "## Coverage\n\n| scope | required | covered | missing | waived |\n|---|---|---|---|---|\n",
1628 );
1629 for (name, t) in &coverage.tallies {
1630 s.push_str(&format!(
1631 "| {name} | {} | {} | {} | {} |\n",
1632 t.required, t.covered, t.missing, t.waived
1633 ));
1634 }
1635 s.push_str("\n## Capabilities\n\n| capability | module | kind | cache | direct | mcp | http | cases |\n|---|---|---|---|---|---|---|---|\n");
1636 for c in ctx.registry.iter().filter(|c| c.kind.is_executable()) {
1637 let cell = |transport: Transport| -> String {
1638 let line = coverage
1639 .lines
1640 .iter()
1641 .find(|l| l.subject == c.id.as_str() && l.transport == transport);
1642 match line.map(|l| l.state) {
1643 Some(CoverageState::Covered) => "covered".into(),
1644 Some(CoverageState::Missing) => "**missing**".into(),
1645 Some(CoverageState::Waived) => "waived".into(),
1646 Some(CoverageState::Inapplicable) => "inapplicable".into(),
1647 None => "—".into(),
1648 }
1649 };
1650 let mut cases: Vec<String> = projection
1651 .of_capability(c.id.as_str())
1652 .filter(|t| t.transport() == Transport::Direct)
1653 .filter_map(|t| match &t.kind {
1654 crate::bench::TargetKind::Capability { case, .. } => Some(format!("`{case}`")),
1655 _ => None,
1656 })
1657 .collect();
1658 cases.dedup();
1659 let cache = c.cache.summary();
1660 let kind = serde_json::to_value(c.kind)
1661 .ok()
1662 .and_then(|v| v.as_str().map(str::to_string))
1663 .unwrap_or_default();
1664 s.push_str(&format!(
1665 "| `{}` | {} | {} | {} | {} | {} | {} | {} |\n",
1666 c.id,
1667 c.module,
1668 kind,
1669 cache,
1670 cell(Transport::Direct),
1671 cell(Transport::Mcp),
1672 cell(Transport::Http),
1673 if cases.is_empty() {
1674 "—".to_string()
1675 } else {
1676 cases.join(", ")
1677 }
1678 ));
1679 }
1680 s.push_str("\n## System targets\n\n| key | transport | measures |\n|---|---|---|\n");
1681 for t in SystemTarget::ALL {
1682 s.push_str(&format!(
1683 "| `{}` | {} | {} |\n",
1684 t.key(),
1685 t.transport().name(),
1686 t.description()
1687 ));
1688 }
1689 s.push_str("\n## UI pages\n\nEvery screen and panel of the UI's own route list, read with `GET` and `Accept: text/html`: a screen over the pinned context and over the live view a served process answers from (which pays the per-request git stamp), a panel over the pinned context only. Each is measured cold (the capability cache cleared before every sample) and warm. A deferred panel is one the UI pulls in after the screen, because its source is slow; only the `full` profile measures it, with `cold_spawns` samples.\n\n| key | route | view | deferred |\n|---|---|---|---|\n");
1690 for t in projection.pages() {
1691 if let crate::bench::TargetKind::Page {
1692 route,
1693 view,
1694 deferred,
1695 } = &t.kind
1696 {
1697 s.push_str(&format!(
1698 "| `{}` | `{route}` | {} | {} |\n",
1699 t.key,
1700 view.name(),
1701 if *deferred { "yes" } else { "—" }
1702 ));
1703 }
1704 }
1705 s.push_str("\nCache modes: a cached capability is measured cold (the cache cleared before every sample) and warm (the same input repeated); the direct transport reports the handler invocations of each. Evidence: `.ai/local/benchmarks/` for local runs, `.ai/repo/benchmarks/rust/` for the accepted baselines and the regression policy.\n");
1706 s
1707}
1708
1709pub fn document_schemas(
1713 share: &Share,
1714 root: &Path,
1715) -> Result<std::collections::BTreeMap<String, crate::proto::ProtoFile>> {
1716 let dir = share.schemas_dir();
1717 crate::proto::read_dir(&dir, &relative_to(&dir, root))
1718}
1719
1720pub fn document_artifacts(
1727 protos: &std::collections::BTreeMap<String, crate::proto::ProtoFile>,
1728 share: &Share,
1729 root: &Path,
1730 version: &str,
1731) -> Result<Vec<Artifact>> {
1732 let schemas = relative_to(&share.schemas_dir(), root);
1733 let sections = relative_to(&share.sections_dir(), root);
1734 let mut out = Vec::new();
1735 for file in protos.values() {
1736 let schema = crate::proto::project::to_json_schema(file)?;
1737 let source = format!(
1742 "the document schema `{}`",
1743 relative_to(Path::new(&file.source), root)
1744 );
1745 let path = format!(
1746 "{schemas}/{}",
1747 crate::proto::project::schema_path(&file.schema_id)?
1748 );
1749 out.push(Artifact::json_extension(
1750 path.clone(),
1751 format!("schemas/{}", file.schema_id),
1752 source.clone(),
1753 version,
1754 serde_json::to_value(&schema).map_err(|e| Error::KindSchema {
1755 reason: format!("{}: cannot render the projected schema: {e}", file.source),
1756 })?,
1757 ));
1758 if let Some(name) = &file.allow_list {
1759 let lines = crate::proto::project::section_lines(file);
1760 out.push(Artifact::text(
1761 format!("{sections}/{name}.txt"),
1762 format!("sections/{name}"),
1763 source.clone(),
1764 version,
1765 &if lines.is_empty() {
1766 String::new()
1767 } else {
1768 lines.join("\n") + "\n"
1769 },
1770 ));
1771 }
1772 }
1773 Ok(out)
1774}
1775
1776pub fn proto_allow_artifacts(
1779 protos: &std::collections::BTreeMap<String, crate::proto::ProtoFile>,
1780 share: &Share,
1781 root: &Path,
1782 version: &str,
1783) -> Result<Vec<Artifact>> {
1784 let dir = relative_to(&share.allow_dir(), root);
1785 let mut out = Vec::new();
1786 for file in protos.values() {
1787 let Some(name) = &file.allow_list else {
1788 continue;
1789 };
1790 let schema = crate::proto::project::to_json_schema(file)?;
1791 out.push(Artifact::text(
1792 format!("{dir}/{name}.txt"),
1793 format!("allow/{name}"),
1794 format!(
1795 "the document schema `{}`",
1796 relative_to(Path::new(&file.source), root)
1797 ),
1798 version,
1799 &(allow_lines(&schema).join("\n") + "\n"),
1800 ));
1801 }
1802 Ok(out)
1803}
1804
1805pub(crate) fn relative_to(dir: &Path, root: &Path) -> String {
1815 let resolved = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1816 let (dir_resolved, root_resolved) = (resolved(dir), resolved(root));
1817 match dir_resolved.strip_prefix(&root_resolved) {
1818 Ok(relative) => relative.display().to_string(),
1819 Err(_) => dir.display().to_string(),
1820 }
1821}
1822
1823pub fn benchmark_document(ctx: &Context) -> Value {
1828 let projection = BenchmarkProjection::from_context(ctx);
1829 let coverage = Coverage::compute(ctx, &projection);
1830 let coverage_rows: Vec<Value> = coverage
1831 .tallies
1832 .iter()
1833 .map(|(name, t)| {
1834 serde_json::json!({
1835 "scope": name,
1836 "required": t.required,
1837 "covered": t.covered,
1838 "missing": t.missing,
1839 "waived": t.waived,
1840 "inapplicable": t.inapplicable,
1841 })
1842 })
1843 .collect();
1844 let capabilities: Vec<Value> = ctx
1845 .registry
1846 .iter()
1847 .filter(|c| c.kind.is_executable())
1848 .map(|c| {
1849 let state = |transport: Transport| -> &'static str {
1850 match coverage
1851 .lines
1852 .iter()
1853 .find(|l| l.subject == c.id.as_str() && l.transport == transport)
1854 .map(|l| l.state)
1855 {
1856 Some(CoverageState::Covered) => "covered",
1857 Some(CoverageState::Missing) => "missing",
1858 Some(CoverageState::Waived) => "waived",
1859 Some(CoverageState::Inapplicable) => "inapplicable",
1860 None => "none",
1861 }
1862 };
1863 let mut cases: Vec<String> = projection
1864 .of_capability(c.id.as_str())
1865 .filter(|t| t.transport() == Transport::Direct)
1866 .filter_map(|t| match &t.kind {
1867 crate::bench::TargetKind::Capability { case, .. } => Some(case.clone()),
1868 _ => None,
1869 })
1870 .collect();
1871 cases.dedup();
1872 serde_json::json!({
1873 "id": c.id,
1874 "module": c.module,
1875 "kind": enum_name(c.kind),
1876 "cache": cache_cell(c.cache),
1877 "direct": state(Transport::Direct),
1878 "mcp": state(Transport::Mcp),
1879 "http": state(Transport::Http),
1880 "cases": cases,
1881 })
1882 })
1883 .collect();
1884 serde_json::json!({
1885 "coverage": coverage_rows,
1886 "capabilities": capabilities,
1887 "system_targets": SystemTarget::ALL.iter().map(|s| serde_json::json!({
1888 "key": s.key(),
1889 "transport": s.transport().name(),
1890 "measures": s.description(),
1891 })).collect::<Vec<_>>(),
1892 "pages": projection.pages().filter_map(|t| match &t.kind {
1893 crate::bench::TargetKind::Page { route, view, deferred } => Some(serde_json::json!({
1894 "key": t.key,
1895 "route": route,
1896 "view": view.name(),
1897 "deferred": deferred,
1898 })),
1899 _ => None,
1900 }).collect::<Vec<_>>(),
1901 })
1902}
1903
1904pub const MANIFEST_ID: &str = "artifacts";
1908
1909pub const MANIFEST_SOURCE: &str = "the generation plan itself, over every other target";
1911
1912pub fn manifest_paths() -> [String; 3] {
1915 [
1916 format!("{OUT_DIR}/{MANIFEST_ID}.json"),
1917 format!("{OUT_DIR}/{MANIFEST_ID}.yaml"),
1918 format!("{OUT_DIR}/{MANIFEST_ID}.md"),
1919 ]
1920}
1921
1922pub fn manifest_document(indexed: &[Artifact]) -> Value {
1930 let mut entries: Vec<Value> = indexed
1931 .iter()
1932 .map(|a| {
1933 let mut o = Map::new();
1934 o.insert("path".into(), Value::String(a.path.clone()));
1935 o.insert("document".into(), Value::String(a.document.clone()));
1936 o.insert(
1937 "format".into(),
1938 serde_json::to_value(a.format).unwrap_or(Value::Null),
1939 );
1940 if let Some(schema) = &a.schema {
1941 o.insert("schema".into(), Value::String(schema.clone()));
1942 }
1943 o.insert("source".into(), Value::String(a.source.clone()));
1944 o.insert("bytes".into(), Value::from(a.content.len()));
1945 o.insert("sha256".into(), Value::String(sha256_hex(&a.content)));
1946 Value::Object(o)
1947 })
1948 .collect();
1949 for path in manifest_paths() {
1950 let mut o = Map::new();
1951 o.insert("path".into(), Value::String(path.clone()));
1952 o.insert("document".into(), Value::String(MANIFEST_ID.into()));
1953 o.insert(
1954 "format".into(),
1955 serde_json::to_value(ArtifactFormat::of_path(&path)).unwrap_or(Value::Null),
1956 );
1957 if ArtifactFormat::of_path(&path) != ArtifactFormat::Markdown {
1958 o.insert("schema".into(), Value::String(MANIFEST_SCHEMA.into()));
1959 }
1960 o.insert("source".into(), Value::String(MANIFEST_SOURCE.into()));
1961 o.insert("describes_itself".into(), Value::Bool(true));
1962 entries.push(Value::Object(o));
1963 }
1964 entries.sort_by(|a, b| {
1965 a.get("path")
1966 .and_then(Value::as_str)
1967 .cmp(&b.get("path").and_then(Value::as_str))
1968 });
1969
1970 let mut documents: Vec<Value> = Vec::new();
1973 for entry in &entries {
1974 let id = entry.get("document").and_then(Value::as_str).unwrap_or("");
1975 let format = entry.get("format").cloned().unwrap_or(Value::Null);
1976 match documents
1977 .iter_mut()
1978 .find(|d| d.get("id").and_then(Value::as_str) == Some(id))
1979 {
1980 Some(Value::Object(d)) => {
1981 if let Some(Value::Array(formats)) = d.get_mut("formats") {
1982 if !formats.contains(&format) {
1983 formats.push(format);
1984 }
1985 }
1986 }
1987 _ => {
1988 let mut d = Map::new();
1989 d.insert("id".into(), Value::String(id.to_string()));
1990 if let Some(schema) = entry.get("schema") {
1991 d.insert("schema".into(), schema.clone());
1992 }
1993 d.insert(
1994 "source".into(),
1995 entry.get("source").cloned().unwrap_or(Value::Null),
1996 );
1997 d.insert("formats".into(), Value::Array(vec![format]));
1998 documents.push(Value::Object(d));
1999 }
2000 }
2001 }
2002
2003 documents.sort_by(|a, b| {
2004 a.get("id")
2005 .and_then(Value::as_str)
2006 .cmp(&b.get("id").and_then(Value::as_str))
2007 });
2008 serde_json::json!({
2009 "documents": documents,
2010 "artifacts": entries,
2011 })
2012}
2013
2014pub fn manifest_artifacts(document: &Value, version: &str) -> Vec<Artifact> {
2017 let doc = Document::new(
2018 MANIFEST_ID,
2019 MANIFEST_SCHEMA,
2020 MANIFEST_SOURCE,
2021 document.clone(),
2022 );
2023 let mut out = doc.artifacts(version);
2024 out.push(Artifact::markdown(
2025 format!("{OUT_DIR}/{MANIFEST_ID}.md"),
2026 MANIFEST_ID,
2027 MANIFEST_SOURCE,
2028 version,
2029 &manifest_reference(document),
2030 ));
2031 out
2032}
2033
2034fn manifest_reference(document: &Value) -> String {
2037 let mut s = String::new();
2038 s.push_str("# Generated artifacts\n\n");
2039 s.push_str("Every file `majordomus generate` writes, with the document it projects, the encoding it is written in, the JSON Schema its content satisfies, and where it came from. Nothing below is a list kept by hand: it is the generation plan, generated. A file under a path this table does not name is not generated by this repository, and a file this table names that differs from the plan is stale — `majordomus generate --check` says which, byte for byte.\n\n");
2040 s.push_str("## Documents\n\n| document | encodings | schema | source |\n|---|---|---|---|\n");
2041 for d in document
2042 .get("documents")
2043 .and_then(Value::as_array)
2044 .map(Vec::as_slice)
2045 .unwrap_or_default()
2046 {
2047 let formats: Vec<String> = d
2048 .get("formats")
2049 .and_then(Value::as_array)
2050 .map(|f| {
2051 f.iter()
2052 .filter_map(Value::as_str)
2053 .map(|s| format!("`{s}`"))
2054 .collect()
2055 })
2056 .unwrap_or_default();
2057 s.push_str(&format!(
2058 "| `{}` | {} | {} | {} |\n",
2059 d.get("id").and_then(Value::as_str).unwrap_or(""),
2060 formats.join(", "),
2061 d.get("schema")
2062 .and_then(Value::as_str)
2063 .map(|x| format!("`{x}`"))
2064 .unwrap_or_else(|| "—".into()),
2065 d.get("source").and_then(Value::as_str).unwrap_or(""),
2066 ));
2067 }
2068 s.push_str(
2069 "\n## Files\n\n| path | document | format | bytes | sha256 |\n|---|---|---|---|---|\n",
2070 );
2071 for a in document
2072 .get("artifacts")
2073 .and_then(Value::as_array)
2074 .map(Vec::as_slice)
2075 .unwrap_or_default()
2076 {
2077 let hash = match a.get("sha256").and_then(Value::as_str) {
2078 Some(h) => format!("`{}`", &h[..16.min(h.len())]),
2079 None => "— (this file)".to_string(),
2081 };
2082 s.push_str(&format!(
2083 "| `{}` | `{}` | {} | {} | {} |\n",
2084 a.get("path").and_then(Value::as_str).unwrap_or(""),
2085 a.get("document").and_then(Value::as_str).unwrap_or(""),
2086 a.get("format").and_then(Value::as_str).unwrap_or(""),
2087 a.get("bytes")
2088 .and_then(Value::as_u64)
2089 .map(|b| b.to_string())
2090 .unwrap_or_else(|| "—".into()),
2091 hash,
2092 ));
2093 }
2094 s
2095}
2096
2097pub fn allow_artifacts(
2101 schema: &KindSchema,
2102 share: &Share,
2103 root: &Path,
2104 version: &str,
2105) -> Vec<Artifact> {
2106 let dir = share.allow_dir();
2107 let dir = dir
2108 .strip_prefix(root)
2109 .map(|p| p.to_path_buf())
2110 .unwrap_or(dir);
2111 schema
2112 .schemas()
2113 .filter_map(|(name, sch)| {
2114 if sch
2117 .json
2118 .get(crate::proto::project::DERIVED_EXTENSION)
2119 .is_some()
2120 {
2121 return None;
2122 }
2123 let list = sch.json.get(ALLOW_EXTENSION).and_then(Value::as_str)?;
2124 Some(Artifact::text(
2125 format!("{}/{list}.txt", dir.display()),
2126 format!("allow/{list}"),
2127 format!("the JSON Schema `{name}` of the kind it validates"),
2128 version,
2129 &(allow_lines(&sch.json).join("\n") + "\n"),
2130 ))
2131 })
2132 .collect()
2133}
2134
2135pub fn allow_lines(schema: &Value) -> Vec<String> {
2156 let mut out = Vec::new();
2157 let start = schema
2162 .get("properties")
2163 .and_then(|p| p.get("header"))
2164 .unwrap_or(schema);
2165 walk_allow(schema, start, "", &mut out);
2166 out
2167}
2168
2169fn resolve<'a>(root: &'a Value, node: &'a Value) -> &'a Value {
2170 match node
2171 .get("$ref")
2172 .and_then(Value::as_str)
2173 .and_then(|r| r.strip_prefix("#/"))
2174 {
2175 Some(pointer) => pointer.split('/').fold(root, |v, seg| &v[seg]),
2176 None => node,
2177 }
2178}
2179
2180fn escape(key: &str) -> String {
2181 let mut s = String::with_capacity(key.len());
2182 for c in key.chars() {
2183 if ".+*?()[]{}^$|\\".contains(c) {
2184 s.push('\\');
2185 }
2186 s.push(c);
2187 }
2188 s
2189}
2190
2191fn walk_allow(root: &Value, node: &Value, prefix: &str, out: &mut Vec<String>) {
2192 let node = resolve(root, node);
2193 let Some(props) = node.get("properties").and_then(Value::as_object) else {
2194 return;
2195 };
2196 for (key, prop) in props {
2197 let prop = resolve(root, prop);
2198 let path = format!("{prefix}{}", escape(key));
2199 let is_object = prop.get("properties").is_some();
2200 let items = prop.get("items").map(|i| resolve(root, i));
2201 if is_object {
2202 walk_allow(root, prop, &format!("{path}\\."), out);
2203 } else if let Some(items) = items {
2204 if items.get("properties").is_some() {
2205 walk_allow(root, items, &format!("{path}\\.[0-9]+\\."), out);
2206 } else {
2207 out.push(format!("^{path}(\\.[0-9]+)?$"));
2208 }
2209 } else {
2210 out.push(format!("^{path}$"));
2211 }
2212 }
2213}
2214
2215pub struct GeneratedSchemas {
2221 by_id: std::collections::BTreeMap<String, (String, jsonschema::Validator)>,
2222}
2223
2224impl std::fmt::Debug for GeneratedSchemas {
2225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2226 f.debug_struct("GeneratedSchemas")
2227 .field("documents", &self.by_id.keys().collect::<Vec<_>>())
2228 .finish()
2229 }
2230}
2231
2232impl GeneratedSchemas {
2233 pub fn load(dir: &Path) -> Result<GeneratedSchemas> {
2236 let mut by_id = std::collections::BTreeMap::new();
2237 for (name, json) in crate::share::read_generated_schema_dir(dir)? {
2238 let id = json
2239 .pointer("/properties/schema/const")
2240 .and_then(Value::as_str)
2241 .ok_or_else(|| Error::KindSchema {
2242 reason: format!(
2243 "{}/{name}.schema.json pins no document: a schema of a generated document declares the id it validates as properties.schema.const",
2244 dir.display()
2245 ),
2246 })?
2247 .to_string();
2248 let validator = jsonschema::validator_for(&json).map_err(|e| Error::KindSchema {
2249 reason: format!("{}/{name}.schema.json: {e}", dir.display()),
2250 })?;
2251 if let Some((first, _)) = by_id.insert(id.clone(), (name.clone(), validator)) {
2252 return Err(Error::KindSchema {
2253 reason: format!("schema id '{id}' is pinned by both {first} and {name}"),
2254 });
2255 }
2256 }
2257 Ok(GeneratedSchemas { by_id })
2258 }
2259
2260 pub fn ids(&self) -> impl Iterator<Item = &str> {
2262 self.by_id.keys().map(String::as_str)
2263 }
2264
2265 pub fn violations(&self, schema_id: &str, value: &Value) -> Vec<String> {
2268 let Some((_, validator)) = self.by_id.get(schema_id) else {
2269 return Vec::new();
2270 };
2271 validator
2272 .iter_errors(value)
2273 .map(|e| format!("{}: {e}", e.instance_path()))
2274 .collect()
2275 }
2276}
2277
2278#[derive(Debug, Clone, PartialEq, Eq)]
2280pub struct Violation {
2281 pub path: String,
2283 pub reason: String,
2285}
2286
2287impl std::fmt::Display for Violation {
2288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2289 write!(f, "{}: {}", self.path, self.reason)
2290 }
2291}
2292
2293pub fn opens_with_banner(content: &str, banner: &str) -> bool {
2303 if content.starts_with(banner) {
2304 return true;
2305 }
2306 let Some((first, rest)) = content.split_once('\n') else {
2307 return false;
2308 };
2309 let reserved = first.starts_with("#!") || first.starts_with("# ");
2310 reserved && rest.starts_with(banner)
2311}
2312
2313pub fn violations(artifacts: &[Artifact], schemas: &GeneratedSchemas) -> Vec<Violation> {
2327 let mut out = Vec::new();
2328 let at = |path: &str, reason: String| Violation {
2329 path: path.to_string(),
2330 reason,
2331 };
2332 for a in artifacts {
2333 if a.format != ArtifactFormat::of_path(&a.path) {
2334 out.push(at(
2335 &a.path,
2336 format!(
2337 "declares format {} and its suffix says {}",
2338 a.format.suffix(),
2339 ArtifactFormat::of_path(&a.path).suffix()
2340 ),
2341 ));
2342 }
2343 if a.source.trim().is_empty() {
2344 out.push(at(&a.path, "names no source".into()));
2345 }
2346 let stamped_elsewhere = a.document.starts_with("providers/");
2350 if a.document.starts_with(crate::editors::DOCUMENT_PREFIX) {
2353 if !crate::editors::banner_is_present(&a.content) {
2354 out.push(at(&a.path, "carries no generated-file banner".into()));
2355 }
2356 if a.format == ArtifactFormat::Json {
2357 let body = crate::editors::jsonc_body(&a.content).unwrap_or(&a.content);
2358 if let Err(e) = serde_json::from_str::<Value>(body) {
2359 out.push(at(&a.path, format!("is not JSON below its banner: {e}")));
2360 }
2361 }
2362 continue;
2363 }
2364 let opening = match a.format {
2368 ArtifactFormat::Markdown if !stamped_elsewhere => Some(format!("<!-- {HEADER}")),
2369 ArtifactFormat::Yaml => Some(format!("# {HEADER}")),
2370 ArtifactFormat::Text => Some(format!("{} {HEADER}", text_comment_opening(&a.path))),
2371 _ => None,
2372 };
2373 if opening.is_some_and(|o| !opens_with_banner(&a.content, &o)) {
2374 out.push(at(&a.path, "carries no generated-file banner".into()));
2375 }
2376 if a.format == ArtifactFormat::Json {
2377 match serde_json::from_str::<Value>(&a.content) {
2378 Err(e) => out.push(at(&a.path, format!("is not JSON: {e}"))),
2379 Ok(value) => {
2380 let banner = value
2381 .get("generated")
2382 .or_else(|| value.get("x-majordomus-generated"))
2383 .and_then(Value::as_str);
2384 match banner {
2385 Some(b) if b.starts_with(HEADER) => {}
2386 _ if stamped_elsewhere => {}
2387 _ => out.push(at(
2388 &a.path,
2389 "carries no `generated` member saying it is generated".into(),
2390 )),
2391 }
2392 if let Some(schema) = &a.schema {
2393 let declared = value.get("schema").and_then(Value::as_str);
2394 if declared.is_some() && declared != Some(schema.as_str()) {
2395 out.push(at(
2396 &a.path,
2397 format!(
2398 "declares schema '{schema}' and carries '{}'",
2399 declared.unwrap_or("")
2400 ),
2401 ));
2402 }
2403 for reason in schemas.violations(schema, &value) {
2404 out.push(at(&a.path, format!("does not satisfy {schema} — {reason}")));
2405 }
2406 }
2407 }
2408 }
2409 }
2410 }
2411
2412 let manifest = artifacts
2416 .iter()
2417 .find(|a| a.document == MANIFEST_ID && a.format == ArtifactFormat::Json)
2418 .filter(|_| artifacts.iter().any(|a| a.document != MANIFEST_ID));
2419 if let Some(manifest) = manifest {
2420 let listed: Vec<Value> = serde_json::from_str::<Value>(&manifest.content)
2421 .ok()
2422 .and_then(|v| v.get("artifacts").and_then(Value::as_array).cloned())
2423 .unwrap_or_default();
2424 let mut planned: Vec<&Artifact> = artifacts.iter().collect();
2425 planned.sort_by(|a, b| a.path.cmp(&b.path));
2426 if listed.len() != planned.len() {
2427 out.push(at(
2428 &manifest.path,
2429 format!(
2430 "lists {} artifacts and the plan has {}",
2431 listed.len(),
2432 planned.len()
2433 ),
2434 ));
2435 }
2436 for entry in &listed {
2437 let path = entry
2438 .get("path")
2439 .and_then(Value::as_str)
2440 .unwrap_or_default();
2441 let Some(a) = artifacts.iter().find(|a| a.path == path) else {
2442 out.push(at(
2443 &manifest.path,
2444 format!("lists {path}, which is not planned"),
2445 ));
2446 continue;
2447 };
2448 if entry.get("describes_itself").is_some() {
2449 continue;
2450 }
2451 if entry.get("sha256").and_then(Value::as_str) != Some(sha256_hex(&a.content).as_str())
2452 {
2453 out.push(at(
2454 &manifest.path,
2455 format!("records a stale hash for {path}"),
2456 ));
2457 }
2458 if entry.get("bytes").and_then(Value::as_u64) != Some(a.content.len() as u64) {
2459 out.push(at(
2460 &manifest.path,
2461 format!("records a stale size for {path}"),
2462 ));
2463 }
2464 }
2465 for a in &planned {
2466 if !listed
2467 .iter()
2468 .any(|e| e.get("path").and_then(Value::as_str) == Some(a.path.as_str()))
2469 {
2470 out.push(at(&manifest.path, format!("does not list {}", a.path)));
2471 }
2472 }
2473 }
2474 out
2475}
2476
2477pub fn verify(artifacts: &[Artifact], schemas: &GeneratedSchemas) -> Result<()> {
2480 let violations = violations(artifacts, schemas);
2481 if violations.is_empty() {
2482 Ok(())
2483 } else {
2484 Err(Error::Stale {
2485 files: violations.iter().map(Violation::to_string).collect(),
2486 })
2487 }
2488}
2489
2490pub fn write(root: &Path, artifacts: &[Artifact]) -> Result<Vec<PathBuf>> {
2497 let mut written = Vec::new();
2498 for a in artifacts {
2499 let path = root.join(&a.path);
2500 if std::fs::read(&path).is_ok_and(|on_disk| on_disk == a.content.as_bytes()) {
2501 written.push(path);
2502 continue;
2503 }
2504 if let Some(dir) = path.parent() {
2505 std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
2506 }
2507 let name = path
2508 .file_name()
2509 .map(|n| n.to_string_lossy().into_owned())
2510 .unwrap_or_default();
2511 let staging = path.with_file_name(format!(".{name}.mj-tmp.{}", std::process::id()));
2512 std::fs::write(&staging, &a.content).map_err(|e| Error::io(&staging, e))?;
2513 if let Err(e) = std::fs::rename(&staging, &path) {
2514 let _ = std::fs::remove_file(&staging);
2515 return Err(Error::io(&path, e));
2516 }
2517 written.push(path);
2518 }
2519 Ok(written)
2520}
2521
2522pub fn check(root: &Path, artifacts: &[Artifact]) -> Result<()> {
2524 let mut stale = Vec::new();
2525 for a in artifacts {
2526 let path = root.join(&a.path);
2527 match std::fs::read_to_string(&path) {
2528 Ok(on_disk) if on_disk == a.content => {}
2529 Ok(_) => stale.push(format!("{} (differs)", a.path)),
2530 Err(_) => stale.push(format!("{} (missing)", a.path)),
2531 }
2532 }
2533 if stale.is_empty() {
2534 Ok(())
2535 } else {
2536 Err(Error::Stale { files: stale })
2537 }
2538}
2539
2540fn reference(registry: &CapabilityRegistry) -> String {
2545 let mut s = String::new();
2546 s.push_str("# Capability reference\n\n");
2547 s.push_str("Every capability this executable ships, as the registry holds it. MCP tools and resources, HTTP routes, the OpenAPI document (`openapi.json` beside this file, and `/openapi.json` when serving), Swagger UI, the command line's `capabilities` commands, the benchmark targets (`benchmarks.md`) and the registry manifest (`registry.json`) are projections of the same entries; nothing below is declared anywhere else.\n\n");
2548 s.push_str("## Modules\n\n| module | title | stability | capabilities | reference |\n|---|---|---|---|---|\n");
2549 for m in registry
2550 .modules()
2551 .filter(|m| m.source != ModuleSource::Declarative)
2552 {
2553 let stability = m
2554 .stability
2555 .and_then(|st| serde_json::to_value(st).ok())
2556 .and_then(|v| v.as_str().map(str::to_string))
2557 .unwrap_or_default();
2558 s.push_str(&format!(
2559 "| `{}` | {} | {} | {} | [`modules/{}.md`](modules/{}.md) |\n",
2560 m.id, m.title, stability, m.capabilities, m.id, m.id
2561 ));
2562 }
2563 s.push_str("\n## Executable capabilities\n\n");
2564 s.push_str("| id | module | kind | stability | MCP tool | MCP resource | HTTP | CLI | cache | benchmark |\n|---|---|---|---|---|---|---|---|---|---|\n");
2565 for c in registry
2566 .iter()
2567 .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
2568 {
2569 s.push_str(&format!(
2570 "| `{}` | `{}` | {} | {} | {} | {} | {} | {} | {} | {} |\n",
2571 c.id,
2572 c.module,
2573 enum_name(c.kind),
2574 enum_name(c.stability),
2575 c.exposure
2576 .mcp
2577 .as_ref()
2578 .and_then(|m| m.tool.clone())
2579 .map(|t| format!("`{t}`"))
2580 .unwrap_or("—".into()),
2581 c.exposure
2582 .mcp
2583 .as_ref()
2584 .and_then(|m| m.resource.as_ref())
2585 .map(|r| format!("`{}`", r.uri))
2586 .unwrap_or("—".into()),
2587 c.exposure
2588 .http
2589 .as_ref()
2590 .map(|h| format!("`{} {}`", h.method.as_str(), h.path))
2591 .unwrap_or("—".into()),
2592 c.exposure
2593 .cli
2594 .as_ref()
2595 .map(|x| format!("`majordomus {}`", x.path.join(" ")))
2596 .unwrap_or("—".into()),
2597 cache_cell(c.cache),
2598 benchmark_cell(c.benchmark),
2599 ));
2600 }
2601 s.push_str("\n## Declarative resources\n\n");
2602 s.push_str("Every object of the repository's AI layer is a capability of kind `resource` with the id `<kind>.<identity>` (`rule.majordomus.scope-integrity@1`, `prompt.continue`, `document.docs/CLI.md`), exposed as the MCP resource `majordomus://<kind>/<identity>` and read over HTTP through `objects.get`; its module is its kind. They are not listed here: they are the repository's, not the executable's, and `majordomus capabilities list --kind resource` answers for the repository at hand. Kinds present in this repository at generation: ");
2603 let kinds: Vec<String> = registry
2604 .modules()
2605 .filter(|m| m.source == ModuleSource::Declarative)
2606 .map(|m| format!("`{}`", m.id))
2607 .collect();
2608 s.push_str(&if kinds.is_empty() {
2609 "none".to_string()
2610 } else {
2611 kinds.join(", ")
2612 });
2613 s.push_str(".\n\n## Infrastructure routes\n\n");
2614 s.push_str("The HTTP projection's own routes, not capabilities: ");
2615 s.push_str(
2616 &openapi::infrastructure_routes()
2617 .iter()
2618 .map(|r| format!("`{}`", r.path))
2619 .collect::<Vec<_>>()
2620 .join(", "),
2621 );
2622 s.push_str(
2623 ". `/swagger` is a Swagger UI shell that loads `/openapi.json`; it embeds no specification. `/docs/` is this repository's own documentation, and `/mcp` is MCP over HTTP on the shared server.\n",
2624 );
2625 let _ = CapabilityKind::Query; s
2627}
2628
2629fn module_reference(registry: &CapabilityRegistry, module: &str) -> String {
2631 let mut s = String::new();
2632 if let Some(m) = registry.module(module) {
2633 s.push_str(&format!(
2634 "# Module `{}` — {}\n\n{}\n\n",
2635 m.id, m.title, m.description
2636 ));
2637 if let Some(st) = m.stability {
2638 s.push_str(&format!(
2639 "Stability: {}. Capabilities: {}.\n\n",
2640 enum_name(st),
2641 m.capabilities
2642 ));
2643 }
2644 }
2645 for c in registry.iter().filter(|c| {
2646 c.module.as_str() == module && matches!(c.provenance, Provenance::Builtin { .. })
2647 }) {
2648 s.push_str(&format!(
2649 "## `{}` — {}\n\n{}\n\n",
2650 c.id, c.title, c.description
2651 ));
2652 s.push_str("| | |\n|---|---|\n");
2653 s.push_str(&format!("| kind | {} |\n", enum_name(c.kind)));
2654 s.push_str(&format!("| stability | {} |\n", enum_name(c.stability)));
2655 if let Some(t) = c.exposure.mcp.as_ref().and_then(|m| m.tool.as_ref()) {
2656 s.push_str(&format!("| MCP tool | `{t}` |\n"));
2657 }
2658 if let Some(r) = c.exposure.mcp.as_ref().and_then(|m| m.resource.as_ref()) {
2659 s.push_str(&format!("| MCP resource | `{}` |\n", r.uri));
2660 }
2661 if let Some(h) = &c.exposure.http {
2662 s.push_str(&format!("| HTTP | `{} {}` |\n", h.method.as_str(), h.path));
2663 }
2664 if let Some(x) = &c.exposure.cli {
2665 s.push_str(&format!("| CLI | `majordomus {}` |\n", x.path.join(" ")));
2666 }
2667 s.push_str(&format!("| cache | {} |\n", cache_cell(c.cache)));
2668 s.push_str(&format!(
2669 "| benchmark | {} |\n",
2670 benchmark_cell(c.benchmark)
2671 ));
2672 s.push_str(&format!("| provenance | {} |\n", c.provenance));
2673 if !c.tags.is_empty() {
2674 s.push_str(&format!("| tags | {} |\n", c.tags.join(", ")));
2675 }
2676 s.push('\n');
2677 let (props, required) = c.input.properties();
2678 if props.is_empty() {
2679 s.push_str("Input: none.\n\n");
2680 } else {
2681 s.push_str("| input | type | required | description |\n|---|---|---|---|\n");
2682 for (name, schema) in props {
2683 let ty = match schema.get("type") {
2684 Some(serde_json::Value::String(t)) => t.clone(),
2685 Some(serde_json::Value::Array(a)) => a
2686 .iter()
2687 .filter_map(|v| v.as_str())
2688 .collect::<Vec<_>>()
2689 .join(" or "),
2690 _ => schema
2691 .get("$ref")
2692 .and_then(|r| r.as_str())
2693 .map(|r| r.rsplit('/').next().unwrap_or(r).to_string())
2694 .unwrap_or("object".into()),
2695 };
2696 let desc = schema
2697 .get("description")
2698 .and_then(|d| d.as_str())
2699 .unwrap_or("")
2700 .replace('|', "\\|");
2701 s.push_str(&format!(
2702 "| `{name}` | {ty} | {} | {desc} |\n",
2703 if required.contains(&name) {
2704 "yes"
2705 } else {
2706 "no"
2707 }
2708 ));
2709 }
2710 s.push('\n');
2711 }
2712 s.push_str(&format!(
2713 "Output: `{}`.\n\n",
2714 c.output.name.as_deref().unwrap_or("object")
2715 ));
2716 }
2717 s
2718}
2719
2720pub fn cli_reference(tree: &crate::cli::CommandDoc) -> String {
2724 let mut s = String::new();
2725 s.push_str("# Command line of the Rust executable\n\n");
2726 s.push_str(&format!("{}\n\n", tree.about));
2727 if let Some(long) = &tree.long_about {
2728 s.push_str(&format!("{long}\n\n"));
2729 }
2730 s.push_str(&format!("Every command below is declared once, in [`{}`](../../{}), together with its examples; this file is a projection of that declaration, as `--help` is, as `docs/generated/cli.json` is, and as the website's reference under `{}/` is. Every example printed here is executed against the built executable by `apps/majordomus-cli/tests/cli_examples.rs`. The task lifecycle (`init`, `start`, `check`, `finish`, `doctor`, ...) is the *shell* tool `bin/majordomus`, a different program, documented in `docs/CLI.md`.\n\n", crate::cli::DECLARATION, crate::cli::DECLARATION, crate::cli::ROUTE_PREFIX));
2731 s.push_str("## Commands\n\n| command | route | does |\n|---|---|---|\n");
2732 for c in tree.flatten().into_iter().skip(1) {
2733 s.push_str(&format!(
2734 "| [`{}`](#{}) | `{}` | {} |\n",
2735 c.path.join(" "),
2736 c.path.join("-"),
2737 c.route,
2738 c.about
2739 ));
2740 }
2741 s.push('\n');
2742 for c in tree.flatten() {
2743 let name = c.path.join(" ");
2744 let anchor = c.path.join("-");
2745 s.push_str(&format!("<a id=\"{anchor}\"></a>\n## `{name}`\n\n"));
2746 if !c.subcommands.is_empty() || c.path.len() > 1 {
2747 s.push_str(&format!("{}\n\n", c.about));
2748 }
2749 if let (Some(long), true) = (&c.long_about, c.path.len() > 1) {
2750 s.push_str(&format!("{long}\n\n"));
2751 }
2752 if !c.subcommands.is_empty() {
2753 s.push_str("Subcommands: ");
2754 s.push_str(
2755 &c.subcommands
2756 .iter()
2757 .map(|sc| format!("[`{}`](#{})", sc.path.join(" "), sc.path.join("-")))
2758 .collect::<Vec<_>>()
2759 .join(", "),
2760 );
2761 s.push_str(".\n\n");
2762 }
2763 s.push_str(&format!("```text\n{}\n```\n\n", c.usage));
2764 if c.args.is_empty() {
2765 s.push_str("Arguments: none.\n\n");
2766 } else {
2767 s.push_str("| argument | value | default | description |\n|---|---|---|---|\n");
2768 for a in &c.args {
2769 let flag = match (&a.long, a.short, a.positional) {
2770 (_, _, true) => format!(
2771 "`<{}>`",
2772 a.value_name.clone().unwrap_or(a.name.to_uppercase())
2773 ),
2774 (Some(l), Some(sh), _) => format!("`-{sh}`, `--{l}`"),
2775 (Some(l), None, _) => format!("`--{l}`"),
2776 (None, Some(sh), _) => format!("`-{sh}`"),
2777 (None, None, _) => format!("`{}`", a.name),
2778 };
2779 let value = if !a.takes_value {
2780 "flag".to_string()
2781 } else if !a.possible_values.is_empty() {
2782 a.possible_values
2783 .iter()
2784 .map(|v| format!("`{}`", v.name))
2785 .collect::<Vec<_>>()
2786 .join(" \\| ")
2787 } else {
2788 format!(
2789 "`<{}>`",
2790 a.value_name.clone().unwrap_or(a.name.to_uppercase())
2791 )
2792 };
2793 let default = if a.defaults.is_empty() || !a.takes_value {
2794 if a.required {
2795 "required".to_string()
2796 } else {
2797 "—".to_string()
2798 }
2799 } else {
2800 a.defaults
2801 .iter()
2802 .map(|d| format!("`{d}`"))
2803 .collect::<Vec<_>>()
2804 .join(", ")
2805 };
2806 let mut help = a.help.replace('|', "\\|");
2807 if a.global {
2808 help.push_str(" (accepted by every subcommand)");
2809 }
2810 let values: Vec<String> = a
2811 .possible_values
2812 .iter()
2813 .filter_map(|v| {
2814 v.help
2815 .as_ref()
2816 .map(|h| format!("`{}`: {}", v.name, h.replace('|', "\\|")))
2817 })
2818 .collect();
2819 if !values.is_empty() {
2820 help.push_str(" — ");
2821 help.push_str(&values.join("; "));
2822 }
2823 s.push_str(&format!("| {flag} | {value} | {default} | {help} |\n"));
2824 }
2825 s.push('\n');
2826 }
2827 s.push_str(&cli_examples(c));
2828 }
2829 s
2830}
2831
2832pub fn cli_document(tree: &crate::cli::CommandDoc, version: &str) -> Value {
2837 serde_json::to_value(crate::cli::document(tree, version)).unwrap_or(Value::Null)
2838}
2839
2840fn cli_examples(c: &crate::cli::CommandDoc) -> String {
2845 let mut s = String::new();
2846 if c.examples.is_empty() {
2849 return s;
2850 }
2851 s.push_str("Examples:\n\n");
2852 for e in &c.examples {
2853 s.push_str(&format!("- **{}** — {}\n\n", e.title, e.description));
2854 s.push_str(" ```console\n");
2855 for step in &e.setup {
2856 s.push_str(&format!(" $ {}\n", step.command));
2857 }
2858 s.push_str(&format!(" $ {}\n ```\n\n", e.command));
2859 s.push_str(&format!(" Verified: {}.\n\n", e.expectation));
2860 }
2861 s
2862}
2863
2864fn enum_name<T: serde::Serialize>(v: T) -> String {
2865 serde_json::to_value(v)
2866 .ok()
2867 .and_then(|v| v.as_str().map(str::to_string))
2868 .unwrap_or_default()
2869}
2870
2871fn cache_cell(policy: crate::capability::CachePolicy) -> String {
2872 policy.summary()
2873}
2874
2875fn benchmark_cell(policy: crate::capability::BenchmarkPolicy) -> String {
2876 match policy {
2877 crate::capability::BenchmarkPolicy::Required => "required".into(),
2878 crate::capability::BenchmarkPolicy::RequiredWhen { precondition } => {
2879 format!("required when {}", enum_name(precondition))
2880 }
2881 crate::capability::BenchmarkPolicy::Waived { reason } => {
2882 format!("waived ({})", enum_name(reason))
2883 }
2884 }
2885}
2886
2887#[cfg(test)]
2888mod tests {
2889 use super::*;
2890 use serde_json::json;
2891
2892 #[test]
2894 fn a_source_that_cannot_be_read_is_refused_with_its_path() {
2895 let tmp = tempfile::tempdir().unwrap();
2896 let path = tmp.path().join("icons.toml");
2897 let Err(Error::Io { path: named, .. }) = read_source(&path) else {
2898 panic!("a missing source was read");
2899 };
2900 assert_eq!(named, path);
2901 std::fs::write(&path, "[icons]\n").unwrap();
2902 assert_eq!(read_source(&path).unwrap(), "[icons]\n");
2903 }
2904
2905 #[test]
2912 fn the_web_projection_is_the_same_whether_or_not_a_producer_ran() {
2913 use crate::web::discover::{self, Runtime};
2914 let tmp = tempfile::tempdir().unwrap();
2915 let root = tmp.path();
2916 let krate = root.join(crate::capability::model::CRATE_DIR);
2917 std::fs::create_dir_all(krate.join("src")).unwrap();
2918 std::fs::write(krate.join("Cargo.toml"), "").unwrap();
2919 std::fs::write(krate.join("src/lib.rs"), "//! x\n").unwrap();
2920 std::fs::create_dir_all(root.join("site")).unwrap();
2921 std::fs::write(root.join(discover::SITE_CONFIG), "base_url = \"/\"\n").unwrap();
2922
2923 let projection = || {
2924 let topology = discover::discover(root, Runtime::full()).unwrap();
2925 serde_json::to_string_pretty(&web_document(&topology)).unwrap()
2926 };
2927 let produce = |revision: &str| {
2928 for (artifact, id, mount) in [
2929 (
2930 discover::DOCS_ARTIFACT,
2931 discover::DOCS,
2932 discover::DOCS_MOUNT,
2933 ),
2934 (
2935 discover::RUSTDOC_ARTIFACT,
2936 discover::RUSTDOC,
2937 discover::RUSTDOC_MOUNT,
2938 ),
2939 ] {
2940 let dir = root.join(artifact);
2941 std::fs::create_dir_all(&dir).unwrap();
2942 std::fs::write(dir.join("index.html"), "<h1>built</h1>").unwrap();
2943 std::fs::write(
2944 dir.join(discover::DECLARATION_FILE),
2945 json!({
2946 "schema": discover::DECLARATION_SCHEMA, "id": id, "mount": mount,
2947 "availability": "both", "built_from": revision,
2948 })
2949 .to_string(),
2950 )
2951 .unwrap();
2952 }
2953 };
2954
2955 let unbuilt = projection();
2956 produce("1111111111111111111111111111111111111111");
2957 let topology = discover::discover(root, Runtime::full()).unwrap();
2960 for id in [discover::DOCS, discover::RUSTDOC] {
2961 assert_eq!(
2962 topology.get(id).and_then(|s| s.built_from.as_deref()),
2963 Some("1111111111111111111111111111111111111111"),
2964 "{id}"
2965 );
2966 }
2967 let built_here = projection();
2968 produce("2222222222222222222222222222222222222222");
2969 let built_elsewhere = projection();
2970 assert_eq!(
2971 unbuilt, built_here,
2972 "a producer running changed the projection"
2973 );
2974 assert_eq!(
2975 built_here, built_elsewhere,
2976 "a revision reached the projection"
2977 );
2978 assert!(!built_here.contains("built_from"), "{built_here}");
2979 assert!(!built_here.contains("1111111"), "{built_here}");
2980
2981 let doc: Value = serde_json::from_str(&built_here).unwrap();
2983 let ids: Vec<&str> = doc["surfaces"]
2984 .as_array()
2985 .unwrap()
2986 .iter()
2987 .filter_map(|s| s["id"].as_str())
2988 .collect();
2989 assert!(ids.contains(&discover::DOCS), "{ids:?}");
2990 assert!(ids.contains(&discover::RUSTDOC), "{ids:?}");
2991 assert_eq!(doc["reserved"]["rustdoc"], discover::RUSTDOC_MOUNT);
2992 }
2993
2994 #[test]
3001 fn a_report_on_disk_does_not_reach_the_web_projection() {
3002 use crate::web::discover::{self, Runtime};
3003 let tmp = tempfile::tempdir().unwrap();
3004 let root = tmp.path();
3005 std::fs::create_dir_all(root.join("site")).unwrap();
3006 std::fs::write(root.join(discover::SITE_CONFIG), "base_url = \"/\"\n").unwrap();
3007 let projection = || {
3008 let topology = discover::discover(root, Runtime::full()).unwrap();
3009 serde_json::to_string_pretty(&web_document(&topology)).unwrap()
3010 };
3011
3012 let before = projection();
3013 crate::web::report::declare(root, "tests", "The test run", "scripts/test").unwrap();
3014 let topology = discover::discover(root, Runtime::full()).unwrap();
3016 assert!(topology.get("tests").is_some(), "the walk found the report");
3017 let after = projection();
3019 assert_eq!(
3020 before, after,
3021 "a report on disk reached the committed projection"
3022 );
3023 assert!(!after.contains("\"tests\""), "{after}");
3024 let committed = surfaces_every_checkout_has(&topology).count();
3027 let projected = serde_json::from_str::<Value>(&after).unwrap()["surfaces"]
3028 .as_array()
3029 .unwrap()
3030 .len();
3031 assert_eq!(committed, projected);
3032 assert_eq!(
3033 committed + 1,
3034 topology.surfaces.len(),
3035 "the report is in the resolution"
3036 );
3037 }
3038
3039 #[test]
3045 #[cfg(unix)]
3046 fn a_share_inside_the_repository_is_named_relative_to_it() {
3047 let tmp = tempfile::tempdir().unwrap();
3048 let real = tmp.path().join("repository");
3049 std::fs::create_dir_all(real.join("share").join("schemas")).unwrap();
3050
3051 let linked = tmp.path().join("by-another-name");
3052 std::os::unix::fs::symlink(&real, &linked).unwrap();
3053
3054 let schemas = real.join("share").join("schemas").canonicalize().unwrap();
3057 assert_eq!(relative_to(&schemas, &linked), "share/schemas");
3058 assert_eq!(relative_to(&schemas, &real), "share/schemas");
3059 }
3060
3061 #[test]
3064 fn a_share_outside_the_repository_keeps_its_absolute_path() {
3065 let tmp = tempfile::tempdir().unwrap();
3066 let root = tmp.path().join("repository");
3067 let outside = tmp.path().join("elsewhere").join("share");
3068 std::fs::create_dir_all(&root).unwrap();
3069 std::fs::create_dir_all(&outside).unwrap();
3070
3071 let named = relative_to(&outside, &root);
3072 assert!(
3073 Path::new(&named).is_absolute(),
3074 "a share outside the repository cannot be named relative to it: {named}"
3075 );
3076 }
3077
3078 #[test]
3083 fn every_target_but_the_manifest_is_indexed_and_the_manifest_is_last() {
3084 assert_eq!(
3085 Target::ALL.last(),
3086 Some(&Target::Manifest),
3087 "the manifest indexes the others, so it is generated after them"
3088 );
3089 assert!(
3090 !Target::indexed().contains(&Target::Manifest),
3091 "the manifest does not index itself"
3092 );
3093 assert_eq!(
3094 Target::indexed(),
3095 Target::ALL[..Target::ALL.len() - 1].to_vec(),
3096 "the indexed targets are ALL up to the manifest, in generation order"
3097 );
3098 }
3099
3100 #[test]
3103 fn every_target_is_named_once() {
3104 let names: Vec<&str> = Target::ALL.iter().map(|t| t.name()).collect();
3105 let mut unique = names.clone();
3106 unique.sort_unstable();
3107 unique.dedup();
3108 assert_eq!(names.len(), unique.len(), "{names:?}");
3109 assert!(names.iter().all(|n| !n.is_empty()));
3110 assert_eq!(Target::Manifest.name(), "manifest");
3111 assert_eq!(Target::indexed().len(), Target::ALL.len() - 1);
3112 }
3113
3114 #[test]
3117 fn the_suffix_names_the_encoding() {
3118 for (path, format) in [
3119 ("docs/generated/registry.json", ArtifactFormat::Json),
3120 ("docs/generated/registry.yaml", ArtifactFormat::Yaml),
3121 ("a/b.yml", ArtifactFormat::Yaml),
3122 ("docs/generated/cli.md", ArtifactFormat::Markdown),
3123 ("share/allow/rule.txt", ArtifactFormat::Text),
3124 ("AGENTS", ArtifactFormat::Text),
3125 ("weird.suffix", ArtifactFormat::Text),
3126 ] {
3127 assert_eq!(ArtifactFormat::of_path(path), format, "{path}");
3128 }
3129 assert_eq!(ArtifactFormat::Json.suffix(), "json");
3130 assert_eq!(ArtifactFormat::Markdown.suffix(), "md");
3131 assert_eq!(ArtifactFormat::Text.suffix(), "txt");
3132 assert_eq!(ArtifactFormat::Yaml.suffix(), "yaml");
3133 }
3134
3135 #[test]
3138 fn a_value_that_is_not_a_mapping_carries_no_members() {
3139 let doc = Document::new("fixture", "x/v1", "a test", json!([1, 2, 3]));
3140 assert_eq!(doc.stamped("test"), json!([1, 2, 3]));
3141 let arts = doc.artifacts("test");
3142 assert!(arts[1].content.starts_with(&format!("# {HEADER}")));
3144 assert!(arts[1].content.contains("\n- 1\n"));
3145 }
3146
3147 #[test]
3150 fn the_banner_is_one_text_in_three_wrappings() {
3151 let [a, b, c] = banner_lines("nowhere", "9.9.9");
3152 assert_eq!(a, HEADER);
3153 assert!(b.starts_with("Source: nowhere;") && b.contains(REGENERATE));
3154 assert_eq!(c, "Generator: majordomus-cli 9.9.9");
3155 assert_eq!(
3156 markdown_banner("nowhere", "9.9.9"),
3157 format!("<!-- {a}\n {b}\n {c} -->\n")
3158 );
3159 assert_eq!(
3160 comment_banner("nowhere", "9.9.9"),
3161 format!("# {a}\n# {b}\n# {c}\n")
3162 );
3163 assert!(json_banner("nowhere").starts_with(HEADER));
3164 assert!(json_banner("nowhere").contains(REGENERATE));
3165 }
3166
3167 #[test]
3170 fn an_unpublished_schema_id_validates_nothing_and_the_set_names_what_it_holds() {
3171 let dir = tempfile::tempdir().unwrap();
3172 std::fs::write(
3173 dir.path().join("thing.schema.json"),
3174 r#"{"type":"object","properties":{"schema":{"const":"x/v1"}},"required":["schema"]}"#,
3175 )
3176 .unwrap();
3177 let schemas = GeneratedSchemas::load(dir.path()).unwrap();
3178 assert_eq!(schemas.ids().collect::<Vec<_>>(), ["x/v1"]);
3179 assert!(format!("{schemas:?}").contains("x/v1"));
3180 assert_eq!(
3181 schemas.violations("nobody/v1", &json!({})),
3182 Vec::<String>::new()
3183 );
3184 assert!(!schemas.violations("x/v1", &json!({})).is_empty());
3185 assert_eq!(
3186 schemas.violations("x/v1", &json!({ "schema": "x/v1" })),
3187 Vec::<String>::new()
3188 );
3189 }
3190
3191 #[test]
3194 fn two_contracts_for_one_document_are_refused() {
3195 let dir = tempfile::tempdir().unwrap();
3196 let body = r#"{"type":"object","properties":{"schema":{"const":"x/v1"}}}"#;
3197 std::fs::write(dir.path().join("one.schema.json"), body).unwrap();
3198 std::fs::write(dir.path().join("two.schema.json"), body).unwrap();
3199 let err = GeneratedSchemas::load(dir.path()).unwrap_err().to_string();
3200 assert!(err.contains("x/v1") && err.contains("both"), "{err}");
3201 }
3202
3203 #[test]
3206 fn a_violation_names_the_artifact_first() {
3207 let v = Violation {
3208 path: "docs/generated/x.md".into(),
3209 reason: "carries no generated-file banner".into(),
3210 };
3211 assert_eq!(
3212 v.to_string(),
3213 "docs/generated/x.md: carries no generated-file banner"
3214 );
3215 }
3216
3217 #[test]
3220 fn the_manifest_of_nothing_is_still_the_manifest() {
3221 let doc = manifest_document(&[]);
3222 let entries = doc["artifacts"].as_array().unwrap();
3223 assert_eq!(entries.len(), 3);
3224 assert!(entries.iter().all(|e| e["describes_itself"] == true));
3225 assert!(entries.iter().all(|e| e.get("sha256").is_none()));
3226 let arts = manifest_artifacts(&doc, "test");
3227 assert_eq!(
3228 arts.iter().map(|a| a.path.as_str()).collect::<Vec<_>>(),
3229 manifest_paths()
3230 );
3231 assert!(arts[2].content.contains("— (this file)"));
3232 let dir = tempfile::tempdir().unwrap();
3234 let schemas = GeneratedSchemas::load(dir.path()).unwrap();
3235 let manifest_only = violations(&arts, &schemas);
3236 assert!(
3237 !manifest_only
3238 .iter()
3239 .any(|v| v.reason.contains("does not list")),
3240 "{manifest_only:?}"
3241 );
3242 }
3243
3244 #[test]
3245 fn the_graph_is_a_target_of_the_one_plan_and_not_a_script_of_its_own() {
3246 assert!(Target::ALL.contains(&Target::Graph));
3247 }
3248
3249 #[test]
3250 fn a_published_artifact_is_refused_when_it_carries_a_machine_path_or_a_credential() {
3251 for (content, marker) in [
3255 (
3256 format!("\"source\": \"{}someone/dev/x\"", "/Users/"),
3257 "/Users/",
3258 ),
3259 ("\"source\": \"/home/someone/x\"".to_string(), "/home/"),
3260 ("-----BEGIN PRIVATE KEY-----".to_string(), "-----BEGIN "),
3261 ("Authorization: Bearer abc".to_string(), "Authorization:"),
3262 ("AKIAIOSFODNN7EXAMPLE".to_string(), "AKIA"),
3263 ("ghp_0123456789".to_string(), "ghp_"),
3264 ] {
3265 assert_eq!(
3266 forbidden_in(&content).map(|(m, _)| m),
3267 Some(marker),
3268 "{content}"
3269 );
3270 }
3271 }
3272
3273 #[test]
3274 fn a_composed_graph_that_stopped_at_a_bound_is_refused_and_not_committed() {
3275 use crate::graph::{Builder, Node};
3276 let thing = |id: &str| Node {
3277 id: id.into(),
3278 kind: "thing".into(),
3279 label: id.into(),
3280 summary: None,
3281 route: None,
3282 source: None,
3283 status: None,
3284 external: false,
3285 facts: Default::default(),
3286 };
3287 let mut b = Builder::new("composed", "Everything", "two things, room for one", "test")
3288 .node_kind("thing", "a thing")
3289 .limit(1);
3290 b.node(thing("a"));
3291 b.node(thing("b"));
3292 let err = committed_graph(b.finish()).expect_err("a prefix is not committed");
3293 assert_eq!(err.exit_code(), 10);
3294 let message = err.to_string();
3295 assert!(message.contains("stopped at 1 nodes"), "{message}");
3296 assert!(
3297 message.contains(&crate::graph::MAX_NODES.to_string()),
3298 "{message}"
3299 );
3300
3301 let mut whole = Builder::new("composed", "Everything", "one thing", "test")
3302 .node_kind("thing", "a thing");
3303 whole.node(thing("a"));
3304 assert!(
3305 committed_graph(whole.finish()).is_ok(),
3306 "a graph that stopped nowhere is committed"
3307 );
3308 }
3309
3310 #[test]
3311 fn a_graph_of_repository_relative_sources_is_publishable() {
3312 let safe = r#"{"nodes":[{"id":"majordomus://rule/a","source":".ai/repo/rules/a.md"}]}"#;
3313 assert!(forbidden_in(safe).is_none());
3314 }
3315
3316 #[test]
3317 fn the_schema_of_the_graph_comes_from_the_types_that_define_it() {
3318 let doc = graph_schema_document("test");
3319 let parsed: Value = serde_json::from_str(&doc).expect("the schema is JSON");
3320 assert_eq!(parsed["$id"], Value::String(GRAPH_SCHEMA.into()));
3321 let graph = &parsed["definitions"]["Graph"]["properties"];
3324 for field in [
3325 "id",
3326 "nodes",
3327 "edges",
3328 "node_kinds",
3329 "edge_kinds",
3330 "metadata",
3331 ] {
3332 assert!(graph.get(field).is_some(), "the schema names `{field}`");
3333 }
3334 assert!(
3335 parsed["description"]
3336 .as_str()
3337 .unwrap_or_default()
3338 .contains(HEADER),
3339 "JSON carries its provenance as a field, having no comment to carry it in"
3340 );
3341 }
3342
3343 #[test]
3346 fn an_editor_artifact_without_its_banner_or_with_broken_json_is_refused() {
3347 let dir = tempfile::tempdir().unwrap();
3348 let schemas = GeneratedSchemas::load(dir.path()).unwrap();
3349 let json = |content: &str| {
3350 Artifact::verbatim(
3351 ".zed/tasks.json",
3352 "editors/zed-tasks",
3353 ArtifactFormat::Json,
3354 None,
3355 "the justfile",
3356 content.to_string(),
3357 )
3358 };
3359 let reasons = |a: Artifact| -> Vec<String> {
3360 violations(&[a], &schemas)
3361 .into_iter()
3362 .map(|v| v.reason)
3363 .collect()
3364 };
3365 assert_eq!(reasons(json("[1]\n")), ["carries no generated-file banner"]);
3366 let banner = crate::editors::jsonc_banner("the justfile", "0.0.0");
3367 assert!(reasons(json(&format!("{banner}[1]\n"))).is_empty());
3368 let broken = reasons(json(&format!("{banner}[1,\n")));
3369 assert_eq!(broken.len(), 1, "{broken:?}");
3370 assert!(
3371 broken[0].starts_with("is not JSON below its banner"),
3372 "{broken:?}"
3373 );
3374 }
3375
3376 #[test]
3378 fn a_write_that_cannot_land_leaves_nothing_half_written() {
3379 let dir = tempfile::tempdir().unwrap();
3380 std::fs::create_dir_all(dir.path().join("out/file.txt/inside")).unwrap();
3382 let a = Artifact::verbatim(
3383 "out/file.txt",
3384 "doc",
3385 ArtifactFormat::Text,
3386 None,
3387 "s",
3388 "x\n".into(),
3389 );
3390 assert!(write(dir.path(), &[a]).is_err());
3391 let left: Vec<String> = std::fs::read_dir(dir.path().join("out"))
3392 .unwrap()
3393 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
3394 .collect();
3395 assert_eq!(left, ["file.txt"], "no staging file remains");
3396
3397 #[cfg(unix)]
3399 {
3400 use std::os::unix::fs::PermissionsExt;
3401 let locked = dir.path().join("locked");
3402 std::fs::create_dir_all(&locked).unwrap();
3403 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
3404 let c = Artifact::verbatim(
3405 "locked/new.txt",
3406 "doc",
3407 ArtifactFormat::Text,
3408 None,
3409 "s",
3410 "x\n".into(),
3411 );
3412 if std::fs::write(locked.join("probe"), "x").is_err() {
3414 assert!(write(dir.path(), &[c]).is_err());
3415 }
3416 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
3417 }
3418
3419 let b = Artifact::verbatim(
3421 "kept.txt",
3422 "doc",
3423 ArtifactFormat::Text,
3424 None,
3425 "s",
3426 "same\n".into(),
3427 );
3428 write(dir.path(), std::slice::from_ref(&b)).unwrap();
3429 let before = std::fs::metadata(dir.path().join("kept.txt"))
3430 .unwrap()
3431 .modified()
3432 .unwrap();
3433 write(dir.path(), std::slice::from_ref(&b)).unwrap();
3434 let after = std::fs::metadata(dir.path().join("kept.txt"))
3435 .unwrap()
3436 .modified()
3437 .unwrap();
3438 assert_eq!(before, after);
3439 }
3440
3441 fn released_without_history() -> (crate::synthetic::SyntheticRepository, crate::cli::RepoArgs) {
3444 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3445 let sources = repo.root().join(".ai/repo/knowledge/sources.yaml");
3446 let mut text = std::fs::read_to_string(&sources).unwrap();
3447 text.push_str(
3448 " - id: release\n kind: release-record\n discovery: vcs\n \
3449 pathspec: ':(glob).ai/repo/releases/*.yaml'\n required: false\n",
3450 );
3451 std::fs::write(&sources, text).unwrap();
3452 let manifest = repo.root().join(".ai/manifest.yaml");
3453 let mut text = std::fs::read_to_string(&manifest).unwrap();
3454 text.push_str(" releases: repo/releases\n");
3455 std::fs::write(&manifest, text).unwrap();
3456 let releases = repo.root().join(".ai/repo/releases");
3457 std::fs::create_dir_all(&releases).unwrap();
3458 std::fs::write(
3459 releases.join("v1.0.0.yaml"),
3460 "schema: release/v1\nversion: \"1.0.0\"\ntag: v1.0.0\nchannel: stable\n\
3461 commit: 1111111111111111111111111111111111111111\n\
3462 published_at: \"2026-10-01T00:00:00Z\"\nartifacts:\n - target: linux-x86_64-gnu\n \
3463 name: majordomus-v1.0.0-x86_64-unknown-linux-gnu.tar.gz\n \
3464 url: https://github.com/example/synthetic/releases/download/v1.0.0/\
3465 majordomus-v1.0.0-x86_64-unknown-linux-gnu.tar.gz\n \
3466 sha256: 3333333333333333333333333333333333333333333333333333333333333333\n \
3467 size: 1\n",
3468 )
3469 .unwrap();
3470 let args = crate::cli::RepoArgs {
3471 repo: Some(repo.root().to_path_buf()),
3472 share: Some(crate::synthetic::crate_share()),
3473 discovery: crate::cli::DiscoveryMode::Filesystem,
3474 ..Default::default()
3475 };
3476 (repo, args)
3477 }
3478
3479 fn planned_changelog(args: &crate::cli::RepoArgs) -> crate::release::model::Changelog {
3480 let app = crate::app::App::load(args).unwrap();
3481 let plan = plan(&app, &[Target::Changelog]).unwrap();
3482 let json = plan
3483 .iter()
3484 .find(|a| a.path == format!("{OUT_DIR}/changelog.json"))
3485 .expect("the changelog is planned");
3486 serde_json::from_str(&json.content).unwrap()
3487 }
3488
3489 #[test]
3493 fn a_release_without_its_history_keeps_what_the_committed_changelog_says() {
3494 use crate::release::model::{Change, ChangeGroup, ChangeKind};
3495 let (repo, args) = released_without_history();
3496 let app = crate::app::App::load(&args).unwrap();
3497 assert!(
3498 app.context
3499 .index
3500 .objects
3501 .iter()
3502 .any(|o| o.kind == crate::release::changelog::RELEASE_KIND),
3503 "the fixture's release record is indexed: {:?}",
3504 app.context.index.diagnostics
3505 );
3506 assert_eq!(
3507 crate::release::changelog::unreadable_releases(
3508 app.repository.root(),
3509 &app.context.index.objects
3510 ),
3511 ["1.0.0"],
3512 "the fixture's release must be one whose history is absent"
3513 );
3514
3515 let composed = planned_changelog(&args);
3517 let section = composed
3518 .sections
3519 .iter()
3520 .find(|s| s.version == "1.0.0")
3521 .expect("the release has a section");
3522 assert!(section.groups.is_empty(), "{section:?}");
3523
3524 let out = repo.root().join(OUT_DIR);
3526 std::fs::create_dir_all(&out).unwrap();
3527 std::fs::write(out.join("changelog.json"), "{ not json").unwrap();
3528 let unparsed = planned_changelog(&args);
3529 assert_eq!(unparsed.sections, composed.sections);
3530
3531 let mut committed = composed.clone();
3533 let kept = Change {
3534 kind: ChangeKind::Fix,
3535 scope: None,
3536 subject: "what the committed changelog said".into(),
3537 breaking: false,
3538 commit: "2222222222222222222222222222222222222222".into(),
3539 url: None,
3540 references: Vec::new(),
3541 };
3542 for s in committed
3543 .sections
3544 .iter_mut()
3545 .filter(|s| s.version == "1.0.0")
3546 {
3547 s.groups = vec![ChangeGroup {
3548 kind: ChangeKind::Fix,
3549 heading: "Fixed".into(),
3550 rank: 1,
3551 changes: vec![kept.clone()],
3552 }];
3553 }
3554 std::fs::write(
3555 out.join("changelog.json"),
3556 serde_json::to_string(&committed).unwrap(),
3557 )
3558 .unwrap();
3559 let subscriber = tracing_subscriber::fmt()
3562 .with_max_level(tracing::Level::WARN)
3563 .with_writer(std::io::sink)
3564 .finish();
3565 let planned = tracing::subscriber::with_default(subscriber, || planned_changelog(&args));
3566 let section = planned
3567 .sections
3568 .iter()
3569 .find(|s| s.version == "1.0.0")
3570 .unwrap();
3571 assert_eq!(section.groups.len(), 1, "{section:?}");
3572 assert_eq!(section.groups[0].changes, [kept]);
3573 }
3574
3575 #[test]
3578 fn the_graph_document_of_a_whole_repository_is_committed() {
3579 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3580 let ctx = repo.context().unwrap();
3581 let rendered = graph_document(&ctx, "9.9.9").unwrap();
3582 let doc: serde_json::Value = serde_json::from_str(&rendered).unwrap();
3583 assert_eq!(doc["schema"], GRAPH_SCHEMA);
3584 assert_eq!(doc["generator"], "majordomus-cli 9.9.9");
3585 assert_eq!(doc["graph"]["metadata"]["truncated"], false);
3586 let nodes = doc["graph"]["nodes"].as_array().expect("graph nodes");
3587 assert!(
3588 nodes
3589 .iter()
3590 .any(|n| n["id"].as_str() == Some("capability:worktree.status")),
3591 "the registry is composed into the graph"
3592 );
3593 }
3594
3595 #[test]
3598 fn a_graph_that_stopped_at_a_bound_is_not_committed() {
3599 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3600 let ctx = repo.context().unwrap();
3601 let mut composed = crate::graph::composed_complete(&ctx.registry, &ctx.index);
3602 composed.metadata.truncated = true;
3603 match graph_document_of(&ctx, "9.9.9", composed) {
3604 Err(Error::Refused { code, reason }) => {
3605 assert_eq!(code, 10);
3606 assert!(reason.contains("stopped at"), "{reason}");
3607 }
3608 other => panic!("{other:?}"),
3609 }
3610 }
3611}