Skip to main content

majordomus_cli/
generate.rs

1//! Generated projections that are committed for review: the OpenAPI document, the
2//! capability reference, the benchmark matrix, the registry manifest, and the shell
3//! tool's key allow-lists derived from the JSON Schemas. All come through this one
4//! pipeline; the committed files are caches of it, never sources, and `--check` says when
5//! they are stale.
6//!
7//! Every artifact is *typed*: it declares the document it projects, the encoding it is
8//! written in, the JSON Schema its content satisfies when it has one, and the source it
9//! was derived from. A structured document is written in every encoding this repository
10//! commits it in — JSON for a program, YAML for a person editing configuration beside it,
11//! Markdown for a reader — from one value, so the encodings cannot disagree. Every
12//! artifact carries a provenance header in the form its encoding allows, and
13//! [`manifest_document`] is the index of the whole set, itself generated.
14
15use 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
31/// Where generated artifacts live, relative to the repository root.
32pub const OUT_DIR: &str = "docs/generated";
33/// Where the registry dataset the site renders lives, relative to the repository root. The
34/// shell site generator owns `site/data/generated/` wholesale; this directory is the Rust
35/// executable's, so that no directory has two writers.
36pub const SITE_DATA_DIR: &str = "site/data/registry";
37
38/// The first thing every generated artifact says about itself, whatever its encoding
39/// wraps it in.
40pub const HEADER: &str = "GENERATED FILE — DO NOT EDIT DIRECTLY";
41
42/// The command that rewrites any of them.
43pub const REGENERATE: &str = "majordomus generate";
44
45/// The schema of `registry.json`.
46pub const REGISTRY_SCHEMA: &str = "majordomus/capability-registry/v1";
47
48/// The schema of `benchmarks.json`.
49pub const BENCHMARKS_SCHEMA: &str = "majordomus/benchmark-matrix/v1";
50
51/// The schema of `artifacts.json`, the manifest of every generated artifact.
52pub const MANIFEST_SCHEMA: &str = "majordomus/generated-artifacts/v1";
53
54/// The schema extension that names the allow-list a schema derives.
55pub const ALLOW_EXTENSION: &str = "x-majordomus-allow";
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58/// What can be generated.
59pub enum Target {
60    /// `docs/generated/openapi.{json,yaml}`.
61    OpenApi,
62    /// `docs/generated/capabilities.md` (the index), `docs/generated/modules/<id>.md`,
63    /// `docs/generated/cli.md` and `docs/generated/cli.{json,yaml}` (the command line as
64    /// clap declares it, with the examples declared beside it), and
65    /// `docs/generated/providers.{md,json,yaml}`: every provider the distribution declares,
66    /// with what this repository does with it — the one table the documents point at
67    /// instead of enumerating providers by hand (ADR 0024).
68    Docs,
69    /// `docs/generated/benchmarks.{md,json,yaml}`: every benchmark target and the
70    /// coverage, from the projection.
71    Benchmarks,
72    /// `docs/generated/registry.{json,yaml}`: the builtin registry as data,
73    /// `majordomus/capability-registry/v1`.
74    Registry,
75    /// `<share>/allow/<name>.txt` for every schema that carries `x-majordomus-allow`.
76    Allow,
77    /// The projections of every `.proto` document schema: the JSON Schema beside it and
78    /// `<share>/sections/<name>.txt`, the body half of its contract (see
79    /// [`crate::proto::project`]).
80    Documents,
81    /// The provider bootstraps the policy's `projections[]` declare, rendered from the
82    /// provider templates: `AGENTS.md`, `CLAUDE.md`, ... (see [`crate::providers`]). The
83    /// provider *table* is a document of [`Target::Docs`]: a repository that projects no
84    /// bootstrap still has providers, and a fixture that checks its bootstraps alone is not
85    /// asked for a document it never wrote.
86    Providers,
87    /// The files Zed and Google Antigravity read from a project: one
88    /// `.agents/skills/<id>/SKILL.md` per active skill and `.zed/tasks.json` from the tracked
89    /// justfile
90    /// (see the `editors` module, ADR 0140).
91    Editors,
92    /// `site/data/registry/registry.json`: the registry dataset GitHub Pages renders
93    /// (see [`crate::site`]).
94    Site,
95    /// `docs/generated/web.json`: the resolved web topology, `majordomus/web-topology/v1`.
96    ///
97    /// The site generator has no Rust toolchain and reads committed files; this is how the
98    /// topology reaches the published documentation without the site shelling out to this
99    /// executable, and how `generate --check` notices when it has gone stale.
100    Web,
101    /// `docs/generated/changelog.{json,yaml,md}`: the changelog of every published release,
102    /// composed from the layer's release records, the decisions dated inside each release's
103    /// window and the conventional commits in its range (see [`crate::release`]). Not the
104    /// unreleased section: that is `<last release>..HEAD`, which no committed file can
105    /// carry without going stale at the next commit, and which the served surfaces add at
106    /// request time.
107    ///
108    /// A generated document like any other, which is the point: `generate --check` is what
109    /// notices that the changelog has stopped describing the releases, so nobody has to
110    /// remember to update it.
111    Changelog,
112    /// Everything derived from the distribution model (see `crate::distribution`): the
113    /// release build matrix, the installer, the installation guide, the site's dataset,
114    /// and the public metadata of every recorded release — and `share/version.txt`, the
115    /// projection of the version the shell tool ships with and reads at start-up.
116    Distribution,
117    /// `docs/generated/artifacts.{json,yaml,md}`: every artifact of every other target,
118    /// with its encoding, schema, source and hash. Always planned over the whole set, so
119    /// that a manifest naming half the artifacts cannot exist.
120    Manifest,
121    /// The artifacts of every deployment object: `deploy/Dockerfile` and `.dockerignore`
122    /// (see [`crate::deploy::render`]).
123    Deployment,
124    /// `docs/generated/graph.json`: the composed graph as data, and
125    /// `docs/generated/graph.schema.json`: its schema, generated from the types.
126    Graph,
127    /// Every projection of the design system (see `design::render`): the
128    /// stylesheets both Tailwind builds import, the tokens and the declaration compiled
129    /// into the crate, the mark the Cockpit's shell inlines, the copies of the brand every
130    /// surface serves, the site's dataset, and `docs/generated/design.{json,yaml,md}`.
131    Design,
132    /// `docs/generated/economics.{json,yaml,md}`: the token-economics summary and the
133    /// human-readable benchmark report, computed from the committed run records by
134    /// [`crate::economics::summarize_tracked`] — the numbers the site publishes, never
135    /// typed, and never a record someone ran locally without committing it.
136    Economics,
137}
138
139impl Target {
140    /// Every target, in generation order. [`Target::Manifest`] is last because it indexes
141    /// the others.
142    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    /// Every target but the manifest, in generation order: the artifacts the manifest
163    /// indexes.
164    ///
165    /// Derived from [`Target::ALL`] rather than written beside it. It was a second list by
166    /// hand, and three times a target was added to `ALL` and forgotten here, which made
167    /// that target's artifacts the only ones the index said nothing about.
168    pub fn indexed() -> Vec<Target> {
169        Target::ALL
170            .iter()
171            .copied()
172            .filter(|t| *t != Target::Manifest)
173            .collect()
174    }
175
176    /// The name the command line and the manifest use.
177    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/// The encoding one generated artifact is written in.
201#[derive(
202    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
203)]
204#[serde(rename_all = "lowercase")]
205pub enum ArtifactFormat {
206    /// A JSON document: pretty-printed, one trailing newline, provenance as members.
207    Json,
208    /// The same document in the layer's YAML, provenance as a comment banner.
209    Yaml,
210    /// Markdown for a reader, provenance as an HTML comment.
211    Markdown,
212    /// Line-oriented text another program reads: the shell tool's allow-lists,
213    /// provenance as `#` comments.
214    Text,
215}
216
217/// The schema of `web.json`.
218pub const WEB_SCHEMA: &str = "majordomus/web-topology/v1";
219
220/// The schema of `economics.json`: the token-economics summary.
221pub const ECONOMICS_SCHEMA: &str = "majordomus/economics-summary/v1";
222
223/// The schema id of `docs/generated/providers.{json,yaml}`: every provider the distribution
224/// ships, as `share/providers.yaml` and the repository's policy describe it.
225pub const PROVIDERS_SCHEMA: &str = "majordomus/providers/v1";
226
227impl ArtifactFormat {
228    /// The file suffix, without the dot.
229    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    /// The format a path's suffix declares, `Text` for anything unrecognised.
239    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
249/// The three lines every provenance header says, whatever the encoding wraps them in.
250pub 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
258/// The provenance header of a Markdown artifact: an HTML comment, so it is invisible when
259/// rendered and unmissable in the file.
260pub 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
265/// The provenance header of a YAML or text artifact: `#` comments, which every reader of
266/// both — the layer's YAML parser, `grep -E -f`, awk — skips.
267pub 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
274/// The comment form a line-oriented text artifact opens its banner with, by suffix. A
275/// stylesheet or a script cannot start with `#`; an SVG is XML.
276///
277/// ```
278/// use majordomus_cli::generate::text_comment_opening;
279/// assert_eq!(text_comment_opening("share/design/theme.css"), "/*");
280/// assert_eq!(text_comment_opening("share/cockpit/favicon.svg"), "<!--");
281/// assert_eq!(text_comment_opening("share/allow/policy.txt"), "#");
282/// ```
283pub 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
291/// The one line a JSON artifact carries under `generated`.
292pub fn json_banner(source: &str) -> String {
293    format!("{HEADER}; source: {source}; regenerate with `{REGENERATE}`")
294}
295
296/// Where a structured document puts its provenance.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum HeaderStyle {
299    /// `schema`, `generated` and `generator` as members: this repository's own documents.
300    Members,
301    /// `x-majordomus-generated` and `x-majordomus-generator`: a document whose own
302    /// specification fixes the member names, so provenance goes in an extension. The
303    /// OpenAPI document is the only one.
304    Extension,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
308/// One generated file: what it projects, how it is encoded, what contract its content
309/// satisfies, where it came from, and the whole file.
310pub struct Artifact {
311    /// Repository-relative path.
312    pub path: String,
313    /// The document it projects: `registry`, `cli`, `openapi`, `capabilities`, ... Two
314    /// artifacts sharing an id are the same document in two encodings.
315    pub document: String,
316    /// The encoding.
317    pub format: ArtifactFormat,
318    /// The JSON Schema its content satisfies, by schema id, when it declares one.
319    pub schema: Option<String>,
320    /// One line: what it was derived from, as the header says it.
321    pub source: String,
322    /// The whole file.
323    pub content: String,
324}
325
326impl Artifact {
327    /// A Markdown artifact: the banner, then the body. Nothing writes the banner itself.
328    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    /// A text artifact another program reads line by line: the banner as `#` comments,
347    /// then the lines.
348    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    /// A JSON artifact whose own specification fixes its member names — a JSON Schema,
367    /// the OpenAPI document — so its provenance goes in `x-majordomus-` extensions rather
368    /// than in members the specification does not define. Both forms are read back by
369    /// [`violations`]; neither is more generated than the other.
370    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    /// A line-oriented text artifact whose banner takes the comment form its suffix
404    /// allows: `/* */` for a stylesheet or a script, `<!-- -->` for an SVG, `#` otherwise.
405    /// A stylesheet cannot open with `#` and an image cannot open with a shell comment;
406    /// [`violations`] asks for the same form by the same suffix.
407    ///
408    /// ```
409    /// use majordomus_cli::generate::Artifact;
410    ///
411    /// let css = Artifact::comment_text("a/b.css", "doc", "the source", "0.1.0", "body { }\n");
412    /// assert!(css.content.starts_with("/* GENERATED FILE"), "{}", css.content);
413    /// assert!(css.content.ends_with("body { }\n"));
414    ///
415    /// // the same body under a different suffix opens the way that suffix allows
416    /// let svg = Artifact::comment_text("a/b.svg", "doc", "the source", "0.1.0", "<svg/>\n");
417    /// assert!(svg.content.starts_with("<!-- GENERATED FILE"), "{}", svg.content);
418    /// ```
419    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    /// An artifact whose content is taken as it stands: a projection another module
445    /// renders whole and stamps itself (the provider bootstraps, the site dataset).
446    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/// One generated *document*: a value with an identity, a schema and a provenance line,
466/// projected into every encoding this repository commits it in. The encodings cannot
467/// disagree because there is one value; adding an encoding is a line in
468/// [`Document::artifacts`], never a second renderer.
469#[derive(Debug, Clone)]
470pub struct Document {
471    /// The document id, which is also the file stem: `registry`, `cli`, `openapi`.
472    pub id: String,
473    /// The directory the encodings are written into, repository-relative.
474    pub dir: String,
475    /// The JSON Schema the value satisfies, when it declares one.
476    pub schema: Option<String>,
477    /// One line: what it was derived from.
478    pub source: String,
479    /// Where the provenance goes.
480    pub style: HeaderStyle,
481    /// The value, before provenance is added.
482    pub value: Value,
483}
484
485impl Document {
486    /// A document of this repository's own, provenance as members.
487    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    /// The value with its provenance in it: what both encodings serialise.
504    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    /// The document in every encoding it is committed in: JSON, then YAML.
541    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}
560/// The registry's artifacts of the selected targets: the OpenAPI document, the
561/// reference index with one file per builtin module, and the registry manifest.
562/// `Target::Benchmarks` needs the repository's index for its cases and is answered by
563/// [`context_artifacts`]; `Target::Allow` and `Target::Providers` are not the registry's
564/// and are answered by [`allow_artifacts`] and [`crate::providers::artifacts`].
565pub 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                    // the document's own contract is the OpenAPI specification, named by
579                    // its `openapi` member; it declares no schema of ours
580                    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
657/// Every artifact of the selected targets, from every source: the registry (OpenAPI, the
658/// reference, the registry manifest, the benchmark matrix), the schemas (allow-lists), the
659/// policy and the templates (provider bootstraps), and the registry with the index (the
660/// site dataset). This is the one plan `generate` writes and `generate --check` compares;
661/// nothing else assembles artifacts.
662pub fn plan(app: &App, targets: &[Target]) -> Result<Vec<Artifact>> {
663    // the manifest indexes the artifacts of the plan it belongs to. Asked for alone
664    // (`generate manifest`) that plan is every other target, because a manifest of nothing
665    // but itself would be a lie; asked for beside a subset, it indexes that subset.
666    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
688/// Every artifact the manifest indexes: everything but the manifest itself.
689fn indexed_plan(app: &App, targets: &[Target]) -> Result<Vec<Artifact>> {
690    let mut out = context_artifacts(&app.context, crate::VERSION, targets)?;
691    // The schemas and their projections belong to the tool's data directory. When that
692    // directory is not inside the repository being generated — a test pointing a fixture at
693    // the distribution beside the crate, an installed tool run in someone else's tree —
694    // their paths do not resolve under this root, and writing them would reach outside it.
695    // There is nothing to project in that case, and saying so here is what keeps every
696    // caller of `write` inside the root it was given.
697    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        // The changelog is composed from the layer's own release records, the decisions
725        // dated inside each release's window, and the repository's commits up to the newest
726        // release — the published half of what `release.changelog` answers with. The
727        // unreleased section is `<last release>..HEAD`, and a file inside a commit cannot
728        // describe the commit it is in; a committed artifact that tried made every commit
729        // stale by construction. The API, MCP and the Cockpit add that section at request
730        // time, where HEAD is a fact and not a promise. The Markdown is the rendering a
731        // person reads; the JSON and YAML are the document every other reader gets, and all
732        // three are compared by `generate --check`.
733        let mut log =
734            crate::release::compose_published(app.repository.root(), &app.context.index.objects);
735        // A repository without the history of a release — an exported fixture, a shallow
736        // clone — cannot compose what that release changed, and composing it anyway wrote a
737        // changelog of empty sections and called the committed one stale (I1409). What such
738        // a release changed is kept as committed, and said, rather than judged on history
739        // this repository does not have.
740        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        // The committed document names its producer the way the served one does, so a page
764        // rendering either can find the same value on every other surface without a route
765        // or an id of its own.
766        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        // a projection that would differ by whether this machine has `just` is not derived
794        // from the tree; refuse instead of committing whichever half this runner could see
795        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            // The evidence subjects as the site reads them: every declaration a verdict can
830            // be asked about, what it is made of and the tests it reaches. Structure only —
831            // no ledger row, no commit — and read from kinds the site pass does not write,
832            // so both `generate` passes of the derivation agree byte for byte.
833            out.push(crate::evidence::subject::artifact(
834                &app.context,
835                crate::VERSION,
836            )?);
837            // The Why catalogue as the site reads it, and the graph of it. Both are
838            // derived from the catalogue alone — never from the index's fingerprint —
839            // so the two `generate` passes of the derivation graph agree byte for byte
840            // even though the pass between them adds documents to the index.
841            out.extend(crate::site::why_artifacts(&app.context)?);
842            // The product model as the site reads it: the features with everything
843            // derived, the matrix, the providers. Index-independent like the catalogue,
844            // so both `generate` passes of the derivation graph agree byte for byte.
845            out.extend(crate::site::product_artifacts(&app.context)?);
846        }
847    }
848    // The design and the version are the tool's own and are projected into the tool's own
849    // tree — the share directory, the crate, the site. Where the share is not inside this
850    // repository, or the crate is not here, there is nothing of this repository's to project.
851    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            // The builder pins the toolchain the repository builds with, read from the one
879            // file that declares it; a repository without an exact pin cannot render one.
880            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
892/// The projection of the version the shell tool reads at start-up, `share/version.txt`.
893///
894/// Its value is the authority read directly — [`crate::release::version::declared`] — and
895/// not this executable's own constant, which the foreign-generation guard of
896/// `majordomus generate` has already proved equal to it wherever the crate's sources are.
897/// Part of the distribution target because it is part of what the distribution ships: every
898/// archive carries `share/` whole, and the release plan's `generate distribution --check`
899/// holds it.
900///
901/// ```
902/// use majordomus_cli::generate::version_projection;
903/// use majordomus_cli::release::version::{parse_projection, MANIFEST, PROJECTION};
904/// let dir = tempfile::tempdir().unwrap();
905/// std::fs::create_dir_all(dir.path().join("apps/majordomus-cli")).unwrap();
906/// std::fs::write(dir.path().join(MANIFEST), "[package]\nversion = \"0.9.0\"\n").unwrap();
907/// let artifact = version_projection(dir.path());
908/// assert_eq!(artifact.path, PROJECTION);
909/// assert!(artifact.content.starts_with("# GENERATED FILE"));
910/// assert_eq!(parse_projection(&artifact.content).as_deref(), Some("0.9.0"));
911/// ```
912pub 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
924/// Every artifact the distribution model produces. The model is read from the tool's own
925/// data directory, which is the file an installed copy carries; the release records are
926/// read from the repository. Nothing here decides a platform, a name or a URL — it renders
927/// what `share/distribution.yaml` and `.ai/repo/releases/` already say.
928pub 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    // The provenance header goes after the title, not before it: the site's documentation
992    // projection strips a document's own first-line heading and would otherwise render two.
993    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
1029/// A source file a projection compiles, read as text: a failure names the path it read.
1030fn read_source(path: &Path) -> Result<String> {
1031    std::fs::read_to_string(path).map_err(|e| Error::io(path, e))
1032}
1033
1034/// A file of the tool's data directory, read as text.
1035/// Every projection of the design system. The declaration is read from the tool's data
1036/// directory, validated, and rendered by [`crate::design::render`]; a stylesheet that would
1037/// be malformed is refused here rather than written, because the CSS parser would drop the
1038/// defect in silence and every check downstream would stay green.
1039pub(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    // the declaration itself, compiled into the crate so the executable answers from the
1084    // design its stylesheets were projected from
1085    out.push(Artifact::verbatim(
1086        render::COMPILED_YAML,
1087        "design-declaration",
1088        ArtifactFormat::Yaml,
1089        None,
1090        source,
1091        // `.as_str()` rather than `&text`: with rhai linked, `String + &String` has more than
1092        // one `Add` implementation to choose from and no longer coerces (ADR 0069)
1093        comment_banner(source, version) + text.as_str(),
1094    ));
1095
1096    // the brand: one canonical file per mark under share/design/brand, and every copy a
1097    // surface serves written from it, so no copy is edited on its own
1098    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    // the kit's icons, declared once beside the site's templates and compiled into the
1130    // crate for the UI's shell (ADR 0135), so the two surfaces draw one icon set. A tree
1131    // without the site (a design fixture holds `share/` alone) has no set to compile and no
1132    // crate to compile it into; in this repository the set is present, and `ui::icons`
1133    // holds the compiled copy equal to it in a test, so its absence cannot pass unnoticed.
1134    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    // the site's dataset and the reference documents
1147    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
1179/// Every artifact of the selected targets, the benchmark matrix included: what
1180/// `majordomus generate` writes and `--check` compares.
1181pub 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        // the tracked records only: two checkouts of one commit must generate the same
1233        // bytes, whatever either has recorded locally and not committed
1234        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
1271/// Every provider as data: the product model's providers — the declaration decorated with
1272/// what this repository's policy renders through it, the client configuration it carries and
1273/// the hooks the policy wires — and the tool's own scratch roots. Answered by the same
1274/// capability `majordomus product providers`, `majordomus_providers` and
1275/// `GET /api/v1/product/providers` answer, so the file and the interfaces cannot disagree.
1276pub 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
1296/// `docs/generated/providers.md`: the same value as a table a person reads, with the
1297/// grammar of a scratch root beside it.
1298pub 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
1363/// The resolved web topology as data: every surface with its mount, category, visibility,
1364/// kind, producer, artifact, runtime feature and provenance, in route-precedence order.
1365///
1366/// It is the same value the `web.surfaces` capability answers and the same one the router
1367/// serves from — this file exists because the site generator runs without a Rust toolchain
1368/// and reads committed artifacts, not because the topology has a second source — less the
1369/// one part of that value which is a fact of a checkout rather than of the repository.
1370///
1371/// A surface that discovery declares from what says it exists is in it whether or not its
1372/// producer has run: the documentation, declared from the site's configuration, and the
1373/// crate's rustdoc, declared from the crate. Whether its directory is presently on disk is
1374/// a fact of a checkout, and so is the revision it was built from, which is dropped. A
1375/// surface known *only* from the declaration its producer wrote — a report — exists in the
1376/// checkout that ran the producer and in no other, so it is left out: the resolution a
1377/// process serves still holds it, and the committed file does not change because somebody
1378/// ran the suite. That is what keeps the file stable enough for `generate --check` to
1379/// compare, and what lets the site build trust that every published surface named here is
1380/// one every machine is expected to build.
1381pub fn web_topology(ctx: &Context) -> Value {
1382    web_document(&ctx.web)
1383}
1384
1385/// The projection of one resolved topology, apart from the context that resolved it, so
1386/// that what the committed file depends on can be measured over a directory rather than
1387/// over a whole index.
1388fn 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            // a built revision is a fact of one checkout's artifacts, never of the
1393            // repository: it would make this file differ per machine
1394            if let Some(map) = v.as_object_mut() {
1395                map.remove("built_from");
1396            }
1397            v
1398        })
1399        .collect();
1400    // The schema, the provenance and the generator are the document's, added by
1401    // `Document`: stating them here as well would be two statements of one thing, and the
1402    // encodings would then have to agree about which of them was right.
1403    serde_json::json!({
1404        "generated_root": crate::web::discover::GENERATED_ROOT,
1405        // the reservations as data, from the one place that declares them: the validator
1406        // refuses a topology that breaks one of these, and the site renders this map
1407        "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
1415/// The surfaces of a topology that every checkout of the repository has: all of them but
1416/// those known only from a declaration a producer wrote in this one.
1417///
1418/// Every committed number or list of surfaces is read through this, so that the projection
1419/// and a count of it cannot disagree: `site/data/registry/product.json` once counted the
1420/// whole resolution while `web.json` filtered it, and a worktree that had run
1421/// `majordomus web report tests` committed eleven surfaces where a clean checkout derives ten.
1422pub(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
1431/// Whether a surface was found only by walking the generated root: its mount, or any other
1432/// value, came from the declaration a producer wrote beside its output.
1433///
1434/// That provenance is the walk's alone — every surface discovery declares from a source
1435/// (the registry, the site's configuration, the crate) says so instead — so it is the
1436/// discovered fact of "this exists because a producer ran here", not a list of report ids
1437/// kept beside the reports.
1438fn 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
1445/// The schema of `graph.json`.
1446pub const GRAPH_SCHEMA: &str = "majordomus/capability-graph/v1";
1447
1448/// Text that must never reach a published artifact: a path belonging to the machine that
1449/// generated it, and the shapes credentials are written in. The list is short on purpose —
1450/// every entry is something no derivation of this repository can legitimately produce, so
1451/// a hit is a defect rather than a judgement call.
1452const 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
1473/// The first forbidden marker in `content`, with what it is, or `None` when the content is
1474/// safe to publish.
1475///
1476/// The example joins the marker and a name at run time: this file is committed, and a
1477/// committed file that named a home directory would fail the very gate the marker exists
1478/// to keep the artifacts through.
1479///
1480/// ```
1481/// use majordomus_cli::generate::forbidden_in;
1482/// assert!(forbidden_in("nodes are repository-relative").is_none());
1483/// let machine_path = format!("source: {}someone/dev", "/Users/");
1484/// assert_eq!(forbidden_in(&machine_path).map(|(m, _)| m), Some("/Users/"));
1485/// ```
1486pub 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
1493/// The composed graph as a published artifact: the graph itself, and the provenance JSON
1494/// cannot carry as a comment recorded as fields of the document.
1495///
1496/// Refuses rather than writes when the rendered document carries anything from
1497/// `FORBIDDEN`: this file is published to a website, and a leak that is generated is a
1498/// leak that regenerates.
1499///
1500/// Refuses, for the same reason, while the layer names a rule, a decision or a capability
1501/// that no object and no declaration answers to. The composed graph drops the edge such a
1502/// reference would draw, so the defect is invisible in the artifact: the file looks whole
1503/// and is quietly short of one edge. [`crate::graph::unresolved_relations`] is the verdict
1504/// that already decides it — this is the consumer its own contract names.
1505pub 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
1513/// [`graph_document`] over a composition already made, so the refusal of one that stopped
1514/// at a bound is reached by a test as it would be by a bound that comes back.
1515fn 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    // whole: the site's entity pages and its catalogue counts are read off this document,
1535    // and a prefix of the repository would drop what sorts past MAX_NODES without a word
1536    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
1556/// The composed graph as it may be committed: a graph that stopped at a bound is refused
1557/// with the count it held and the bound, and nothing is written, as an unresolved relation
1558/// is refused before it. The composition has no bound today; this is what keeps one that
1559/// comes back from committing a prefix that still reads as whole (I1966).
1560fn 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
1564/// The schema of the composed graph, generated from the types that define it rather than
1565/// written beside them.
1566pub fn graph_schema_document(version: &str) -> String {
1567    let schema = crate::capability::schema::CanonicalSchema::of::<crate::graph::Graph>();
1568    // a JSON Schema's own members are fixed by its specification, so the provenance rides
1569    // in the extension the manifest validator reads beside `generated`
1570    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
1582/// The builtin registry as data: modules, descriptors with their schemas, and the
1583/// declarative kinds by name. No fingerprint and no count of declarative objects, so the
1584/// file changes when the code changes and not when a document is added.
1585pub 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    // every descriptor as it is, plus the file it was composed in: the one place a reader
1591    // of the manifest (the site generator among them) learns where a capability's source is
1592    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
1619/// Every benchmark target and the coverage, from the projection of this repository.
1620pub 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
1709/// Every `.proto` document schema, from the distribution and then from the repository,
1710/// keyed by the identity each declares. A repository may add a schema; it may not redefine
1711/// one the distribution declares, and an attempt to is an error naming both files.
1712pub 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
1720/// The projections of the `.proto` document schemas: the JSON Schema beside each one, and
1721/// the section list the shell tool reads its body contract from.
1722///
1723/// Both are committed. `bin/majordomus doctor` is pure shell and validates a fresh
1724/// checkout with no Rust build having ever run, so the derived forms have to be in the
1725/// tree; `generate --check` is what keeps them honest.
1726pub 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        // Relative to the repository, never as this machine found it: the banner is
1738        // committed, so an absolute path would make the artifact differ by the directory it
1739        // was generated in — every other checkout would then regenerate all of them and
1740        // `generate --check` would fail for everyone but the last person to run it.
1741        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
1776/// The allow-lists of the `.proto` document schemas, derived from the same projection the
1777/// JSON Schema beside each one carries, so the two can never disagree.
1778pub 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
1805/// A share subdirectory as the plan names it: repository-relative when the share is inside
1806/// the repository, absolute otherwise.
1807///
1808/// Both sides are resolved before they are compared. `Share::open` canonicalises the share
1809/// directory while the repository root arrives as it was discovered, so a share *inside*
1810/// the repository failed `strip_prefix` whenever the two spellings differed — a symlinked
1811/// or firmlinked component is enough — and the fallback then wrote the generator's own
1812/// absolute path into a committed artifact. That made the output depend on which worktree
1813/// last ran `majordomus generate`, which is precisely what `derive-check` cannot see.
1814pub(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
1823/// The benchmark matrix as data: the same projection `benchmark_matrix` renders for a
1824/// reader, in the encoding a program reads. One computation, two encodings; a number that
1825/// differs between the Markdown and the JSON would be two computations, which is the
1826/// defect this exists to make impossible.
1827pub 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
1904// ---------------------------------------------------------------- the manifest
1905
1906/// The document id of the manifest, and the file stem of its encodings.
1907pub const MANIFEST_ID: &str = "artifacts";
1908
1909/// Where the manifest says it came from.
1910pub const MANIFEST_SOURCE: &str = "the generation plan itself, over every other target";
1911
1912/// The three files the manifest is written to, in the order [`manifest_artifacts`]
1913/// returns them.
1914pub 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
1922/// The index of every generated artifact: what each one projects, how it is encoded, what
1923/// contract it satisfies, where it came from, and its size and hash.
1924///
1925/// The manifest's own three files are listed with `describes_itself: true` and no hash:
1926/// a document that hashed itself would have no fixed point. Their staleness is not
1927/// unguarded — `generate --check` compares every artifact including these byte for byte,
1928/// which is a stronger statement than a hash the file makes about itself.
1929pub 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    // one row per document: the encodings it is committed in, which is the property a
1971    // reader and the enforcement rule actually ask about
1972    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
2014/// The manifest in its three encodings: JSON and YAML from the value, Markdown for a
2015/// reader. The Markdown is a rendering of the same value and holds nothing of its own.
2016pub 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
2034/// The manifest as a reader sees it: the documents with the encodings each is committed
2035/// in, then every file with its contract, size and hash.
2036fn 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            // the manifest's own encodings: see `manifest_document`
2080            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
2097/// The shell tool's allow-lists, one per schema that carries `x-majordomus-allow`, under
2098/// the share directory: a repository-relative path when the share is inside the
2099/// repository, absolute otherwise.
2100pub 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            // A schema derived from a `.proto` is projected beside its source, not here;
2115            // emitting it twice would make the plan disagree with itself.
2116            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
2135/// The allow-list a JSON Schema derives: one anchored pattern per key path the schema
2136/// allows, in the schema's order, as the shell tool's `mj_yaml_unknown_keys` reads them
2137/// (`grep -E -f`). A list of scalars is `name(\.[0-9]+)?`, a list of objects recurses
2138/// through `name\.[0-9]+\.`, a nested object through `name\.`. Local `$ref`s into
2139/// `$defs` are followed. No blank lines and no comments: every line is a pattern.
2140///
2141/// ```
2142/// use majordomus_cli::generate::allow_lines;
2143/// use serde_json::json;
2144/// let schema = json!({ "type": "object", "properties": {
2145///     "id": { "type": "string" },
2146///     "tags": { "type": "array", "items": { "type": "string" } },
2147///     "x-majordomus": { "type": "object", "properties": { "tests": { "type": "array", "items": { "type": "string" } } } }
2148/// } });
2149/// assert_eq!(allow_lines(&schema), [
2150///     "^id$",
2151///     "^tags(\\.[0-9]+)?$",
2152///     "^x-majordomus\\.tests(\\.[0-9]+)?$",
2153/// ]);
2154/// ```
2155pub fn allow_lines(schema: &Value) -> Vec<String> {
2156    let mut out = Vec::new();
2157    // A whole-document schema describes the front matter under `header` and the Markdown
2158    // body under `body`; the allow-list is the front matter's keys, because that is what
2159    // the shell tool reads a file's front matter against. A schema with no `header`
2160    // describes the metadata directly, as every YAML kind's does.
2161    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
2215// ---------------------------------------------------------------- the contract
2216
2217/// The contracts of the generated documents, read from `share/schemas/generated/`, keyed
2218/// by the schema id each one pins through the `const` of its `schema` member. A document
2219/// says which contract it satisfies; nothing here maps a file name to a file name.
2220pub 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    /// Load every schema in the directory. A file that pins no schema id is a schema for
2234    /// nothing and is refused, because it would silently validate no document.
2235    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    /// The schema ids that have a contract, sorted.
2261    pub fn ids(&self) -> impl Iterator<Item = &str> {
2262        self.by_id.keys().map(String::as_str)
2263    }
2264
2265    /// Every reason `value` fails the contract of `schema_id`; empty when it passes or
2266    /// when no contract is published for that id.
2267    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/// One thing wrong with a generated artifact, named by its path.
2279#[derive(Debug, Clone, PartialEq, Eq)]
2280pub struct Violation {
2281    /// The artifact, repository-relative.
2282    pub path: String,
2283    /// What is wrong with it.
2284    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
2293/// Does the content open with `banner`, allowing for a first line the format reserves?
2294///
2295/// Usually the banner is the first thing in the file. Two artifacts cannot put it there,
2296/// and neither is cutting a corner: an executable script must open with its `#!` line or
2297/// the kernel will not run it, and a Markdown page the site projects must open with its
2298/// own title, because the projection strips a document's first heading and a banner above
2299/// it would leave the page with none. Both carry the banner on the line after, which is
2300/// still the opening of the file and still impossible to miss. Anything further down is a
2301/// banner a reader scrolls past, and is refused.
2302pub 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
2313/// Every way a plan can be wrong, checked before a byte is written.
2314///
2315/// Three properties, and they are the whole of the enforcement rule
2316/// `project.generated-artifacts-are-typed@1`:
2317///
2318/// 1. **Every artifact carries a provenance header** in the form its encoding allows —
2319///    members in JSON, a comment banner in YAML and text, an HTML comment in Markdown —
2320///    except the provider bootstraps, which stamp themselves with the policy hash they
2321///    were rendered from and are checked by `majordomus update` instead.
2322/// 2. **Every structured artifact parses**, and a document that declares a schema
2323///    satisfies the contract published for it under `share/schemas/generated/`.
2324/// 3. **The manifest is the plan**: exactly the artifacts of the plan, each with the
2325///    encoding, contract, size and hash it actually has.
2326pub 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        // the provider bootstraps carry the `majordomus update` stamp, not this banner:
2347        // a region projection owns part of a file it did not write, and a header at the
2348        // top of it would be a claim over text this generator does not own
2349        let stamped_elsewhere = a.document.starts_with("providers/");
2350        // an editor's file is read by the editor, whose format decides where the banner can
2351        // go: after the front matter of a skill, as `//` lines in JSONC (ADR 0140)
2352        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        // the opening an encoding wraps the banner in: an HTML comment in Markdown, a `#`
2365        // comment in YAML and in line-oriented text, and nothing here for JSON, which
2366        // carries it as members and is checked below
2367        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    // --- the manifest is the plan. Only when the plan holds the artifacts it indexes:
2413    // `generate manifest` writes the manifest alone, and its content was computed from the
2414    // whole plan inside `plan`, so there is nothing here to compare it against.
2415    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
2477/// [`violations`] as an error: nothing, or one `Stale` naming every one of them. This is
2478/// what `generate` and `generate --check` refuse on.
2479pub 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
2490/// Write every artifact under `root`, creating the directory. Returns every artifact's path.
2491///
2492/// A file whose bytes are already the artifact's is left alone, so its modification time
2493/// says when it last changed rather than when it was last generated; any other is written to
2494/// a temporary sibling and renamed over the target, so a reader never sees half a file and
2495/// an interrupted run leaves the old one intact.
2496pub 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
2522/// Compare every artifact with what is on disk under `root`; write nothing.
2523pub 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
2540/// The capability reference's index: every module, every builtin capability with its
2541/// projections, and the declarative kinds by rule. Declarative objects are not enumerated
2542/// here because that inventory belongs to the repository's own state, changes with it, and
2543/// is answered live by `majordomus capabilities list`.
2544fn 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; // the kind vocabulary is documented in docs/CAPABILITIES.md
2626    s
2627}
2628
2629/// One module's reference: its metadata and each capability in full.
2630fn 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
2720/// The native command line as Markdown: every command with its arguments, from the clap
2721/// declaration ([`crate::cli::tree`]). The site renders the same tree from the registry
2722/// dataset; neither is typed by hand.
2723pub 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
2832/// The whole command line as the committed machine-readable projection,
2833/// `docs/generated/cli.json`: pretty JSON with a trailing newline, the same tree
2834/// `cli_reference` renders as Markdown. The website's generator reads this file and never
2835/// the Rust source.
2836pub 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
2840/// The examples of one command, as the reference prints them: the title, what it does, the
2841/// session a reader copies (the setup lines first, then the example itself), and what the
2842/// example test asserts about the run. Rendered from the same declaration the tests
2843/// execute, so nothing here can be true of the page and false of the executable.
2844fn cli_examples(c: &crate::cli::CommandDoc) -> String {
2845    let mut s = String::new();
2846    // A command that only groups others has none; a command a person can run cannot have
2847    // none, because `cli::validate` refuses to let one exist.
2848    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    /// A source that cannot be read is refused with its path, never compiled as empty.
2893    #[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    /// `docs/generated/web.json` is committed and `generate --check` compares it, so it
2906    /// may hold nothing that depends on which producers ran in the checkout that wrote it.
2907    /// The documentation and the rustdoc are the surfaces whose producers run in some
2908    /// checkouts and not others; each writes a declaration with the revision it was built
2909    /// from. The projection must be byte-identical with and without those declarations —
2910    /// and with two different revisions, which is two machines generating one commit.
2911    #[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        // the resolution does read the declarations: this is not equal because nothing
2958        // was looked at
2959        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        // and the document holds both surfaces, so the equality is not two empty documents
2982        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    /// The other producers that run in some checkouts and not others: the reports. A
2995    /// report is a surface only where its producer wrote it — the walk of the generated
2996    /// root finds it there and nowhere else — so a report reaching the committed file would
2997    /// make `generate --check` fail in exactly the checkout that ran the suite, and would
2998    /// hand the site build a published surface whose directory no other machine has. The
2999    /// declaration here is written by the reports' own `declare`, not by a fixture of it.
3000    #[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        // the resolution holds the report — it is served and composed where it was built
3015        let topology = discover::discover(root, Runtime::full()).unwrap();
3016        assert!(topology.get("tests").is_some(), "the walk found the report");
3017        // and the committed projection does not, because only this checkout has it
3018        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        // and a count of the committed surfaces is the count of the projection, not of the
3025        // resolution: the report is served here and counted nowhere
3026        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    /// A share directory inside the repository is named relative to it even when the root
3040    /// is spelled differently from the canonical path the share carries. `Share::open`
3041    /// canonicalises; the root does not, so the two spellings differ whenever a component
3042    /// is a symlink — and the banner then named the generator's own absolute path, making
3043    /// the artifact depend on the worktree it was generated in.
3044    #[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        // The share as `Share::open` hands it over: canonical. The root as it was
3055        // discovered: through the symlink.
3056        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    /// A share genuinely outside the repository keeps its absolute path — that is the
3062    /// documented behaviour and the only honest answer.
3063    #[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    /// The manifest indexes every target but itself, and it is generated last. Three times
3079    /// a target was added to `ALL` and left out of the list beside it, which made that
3080    /// target's artifacts the only ones the index said nothing about. The set is derived
3081    /// now; this is what holds the order.
3082    #[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    /// Every target has a name and every name is distinct: the manifest and the command
3101    /// line both address a target by it.
3102    #[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    /// The encoding is read from the suffix, and anything the generator does not encode
3115    /// structurally is line-oriented text — the shell tool's allow-lists are the case.
3116    #[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    /// A document whose value is not a mapping has nowhere to put members, so it is
3136    /// written as it stands rather than silently wrapped.
3137    #[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        // the YAML encoding still carries the banner, which is a comment and not a member
3143        assert!(arts[1].content.starts_with(&format!("# {HEADER}")));
3144        assert!(arts[1].content.contains("\n- 1\n"));
3145    }
3146
3147    /// The banner says the same three things in every encoding, and the JSON one names
3148    /// the command that rewrites the file.
3149    #[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    /// A schema id nothing publishes is not a failure: the document simply has no
3168    /// contract, and saying so is not the same as inventing one.
3169    #[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    /// Two schemas pinning one document would make the contract of that document
3192    /// ambiguous, so loading refuses rather than picking one.
3193    #[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    /// A violation prints as the artifact it is about, then the reason: the one line the
3204    /// command shows.
3205    #[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    /// The manifest of an empty plan still describes itself, in every encoding it is
3218    /// committed in, and none of its own three carries a hash.
3219    #[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        // and with nothing but itself in the plan, the verifier has nothing to compare
3233        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        // every marker is something no derivation of this repository can legitimately
3252        // produce, so a hit is a defect rather than a judgement call; the home directory
3253        // is joined at run time because this file is committed and the gate reads it too
3254        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        // the shape is schemars' rendering of `graph::Graph`, not a hand-written copy:
3322        // the fields it names are the struct's own
3323        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    /// An editor reads these files, so their banner sits where the editor's format allows;
3344    /// the contract still refuses one without it, and one whose JSON is broken below it.
3345    #[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    /// A write that cannot land leaves the old file and no staging file behind.
3377    #[test]
3378    fn a_write_that_cannot_land_leaves_nothing_half_written() {
3379        let dir = tempfile::tempdir().unwrap();
3380        // a non-empty directory sits where the file should go: the rename cannot replace it
3381        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        // a directory nothing can be written into refuses at the staging file
3398        #[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            // root writes anywhere; where that is so there is nothing to observe
3413            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        // and an unchanged file is not rewritten
3420        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    /// A repository holding one release record and none of its history, as an exported
3442    /// fixture does: its index, and the app that plans over it.
3443    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    /// A release whose history is not in the repository keeps what the committed changelog
3490    /// says it changed; with nothing committed, or with a committed changelog that cannot
3491    /// be read, there is nothing to keep and the composed section stands as composed.
3492    #[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        // nothing committed: the section is composed from the history it does not have
3516        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        // a committed changelog that does not parse is not a source of anything
3525        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        // a committed changelog naming the same release at the same commit is kept
3532        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        // the warning that names what is kept is formatted only where a subscriber listens
3560        // at warn, so the plan is made under one: its count and its range are executed
3561        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    /// The composed graph of a repository whose every reference resolves is the committed
3576    /// document: its schema, its generator, and the graph composed whole.
3577    #[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    /// A composition that stopped at a bound is refused, and nothing is rendered: committing
3596    /// it would publish a prefix of the repository that still reads as whole (I1966).
3597    #[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}