From f8142bd8dd6a7e7f61f70d42ad4f014f2447f111 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:50:30 +0100 Subject: [PATCH 01/26] feat(provision-set): baked canon and engine-drift check Vendor the provisioning canon from standards@93342bf6 and bake it into the binary, pinned by a digest over sorted (path, sha256) pairs and tested equal to a walk of the vendored tree. `provision-set check` byte-compares the four build/just engine files with the canon; drift is terminal (a drifted checker cannot judge), otherwise the repository's provision-check.sh runs and its exit code propagates. guix/channels.scm is excluded from the comparison because `just toolchain-refresh` re-pins it per repository; the commit-pin predicate in provision-check.sh covers it. Proved: the check/ fixture exits 1 (and 3 content FAILs with a fresh engine); rsr-template-repo exits 0 with 0 FAIL, 0 WARN. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- crates/launcher-common/src/lib.rs | 1 + .../launcher-common/src/provisioning/canon.rs | 234 ++++++++++++++++++ .../launcher-common/src/provisioning/check.rs | 124 ++++++++++ .../launcher-common/src/provisioning/mod.rs | 7 + .../fixtures/provisioning/check/Justfile | 20 ++ .../fixtures/provisioning/check/README.adoc | 8 + .../fixtures/provisioning/check/channels.scm | 18 ++ .../fixtures/provisioning/check/guix.scm | 2 + .../fixtures/provisioning/check/launcher.sh | 42 ++++ .../fixtures/provisioning/check/manifest.scm | 1 + .../fixtures/provisioning/check/mise.lock | 0 .../fixtures/provisioning/check/mise.toml | 5 + .../provisioning/lang/docsr/README.adoc | 1 + .../fixtures/provisioning/lang/idr/Justfile | 2 + .../fixtures/provisioning/lang/idr/idr.ipkg | 3 + .../provisioning/lang/idr/src/Idr.idr | 5 + .../provisioning/lang/rustd/.gitignore | 1 + .../provisioning/lang/rustd/Cargo.lock | 16 ++ .../provisioning/lang/rustd/Cargo.toml | 7 + .../fixtures/provisioning/lang/rustd/Justfile | 7 + .../provisioning/lang/rustd/deno.json | 0 .../provisioning/lang/rustd/src/main.rs | 1 + .../provisioning/lang/rustr/.gitignore | 1 + .../provisioning/lang/rustr/Cargo.lock | 7 + .../provisioning/lang/rustr/Cargo.toml | 6 + .../fixtures/provisioning/lang/rustr/Justfile | 6 + .../provisioning/lang/rustr/deno.json | 0 .../provisioning/lang/rustr/src/main.rs | 3 + .../fixtures/provisioning/lang/srcc/README | 1 + .../fixtures/provisioning/render/all.vars | 19 ++ .../fixtures/provisioning/render/render.sh | 14 ++ .../provisioning/render/rsr-spec.vars | 3 + .../provisioning/render/rsr-warm.vars | 8 + .../fixtures/provisioning/render/rsr.vars | 11 + crates/launcher/src/cmd_provision_set.rs | 58 +++++ crates/launcher/src/main.rs | 7 + standards/provisioning/CANON | 1 + .../provisioning/PROVISIONING-STANDARD.adoc | 189 ++++++++++++++ .../provisioning-standard_praxis.deed | 127 ++++++++++ .../provisioning_praxis.deed.tmpl | 32 +++ .../provisioning/templates/Justfile.tmpl | 17 ++ .../templates/README-ai-install.adoc.tmpl | 65 +++++ .../docs/AI_INSTALLATION_GUIDE.adoc.tmpl | 139 +++++++++++ .../templates/docs/SETUP.adoc.tmpl | 200 +++++++++++++++ .../provisioning/templates/guix/channels.scm | 18 ++ .../templates/guix/guix.scm.cargo.tmpl | 40 +++ .../templates/guix/guix.scm.source.tmpl | 43 ++++ .../templates/guix/manifest.scm.tmpl | 14 ++ .../provisioning/templates/launcher.sh.tmpl | 42 ++++ .../templates/llm-warmup-dev.adoc.tmpl | 49 ++++ .../templates/llm-warmup-maintainer.adoc.tmpl | 42 ++++ .../templates/llm-warmup-user.adoc.tmpl | 38 +++ .../provisioning/templates/mise.toml.tmpl | 14 ++ 53 files changed, 1719 insertions(+) create mode 100644 crates/launcher-common/src/provisioning/canon.rs create mode 100644 crates/launcher-common/src/provisioning/check.rs create mode 100644 crates/launcher-common/src/provisioning/mod.rs create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/Justfile create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/README.adoc create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/channels.scm create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/guix.scm create mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/mise.lock create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/mise.toml create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustd/deno.json create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustr/deno.json create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs create mode 100644 crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README create mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/all.vars create mode 100755 crates/launcher-common/tests/fixtures/provisioning/render/render.sh create mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars create mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars create mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars create mode 100644 crates/launcher/src/cmd_provision_set.rs create mode 100644 standards/provisioning/CANON create mode 100644 standards/provisioning/PROVISIONING-STANDARD.adoc create mode 100644 standards/provisioning/provisioning-standard_praxis.deed create mode 100644 standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl create mode 100644 standards/provisioning/templates/Justfile.tmpl create mode 100644 standards/provisioning/templates/README-ai-install.adoc.tmpl create mode 100644 standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl create mode 100644 standards/provisioning/templates/docs/SETUP.adoc.tmpl create mode 100644 standards/provisioning/templates/guix/channels.scm create mode 100644 standards/provisioning/templates/guix/guix.scm.cargo.tmpl create mode 100644 standards/provisioning/templates/guix/guix.scm.source.tmpl create mode 100644 standards/provisioning/templates/guix/manifest.scm.tmpl create mode 100755 standards/provisioning/templates/launcher.sh.tmpl create mode 100644 standards/provisioning/templates/llm-warmup-dev.adoc.tmpl create mode 100644 standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl create mode 100644 standards/provisioning/templates/llm-warmup-user.adoc.tmpl create mode 100644 standards/provisioning/templates/mise.toml.tmpl diff --git a/crates/launcher-common/src/lib.rs b/crates/launcher-common/src/lib.rs index 618f2dc..b6d5ca4 100644 --- a/crates/launcher-common/src/lib.rs +++ b/crates/launcher-common/src/lib.rs @@ -29,6 +29,7 @@ pub mod integration; pub mod integrity; pub mod metadata_block; pub mod platform; +pub mod provisioning; pub mod standard; pub mod template; diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs new file mode 100644 index 0000000..abd8f1e --- /dev/null +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! The provisioning canon: `standards/3-practice/provisioning/templates`, +//! vendored at `standards/provisioning/` and baked into the binary. +//! +//! The baked table is the default. `--canon DIR` (or +//! `$LAUNCH_SCAFFOLDER_PROVISIONING_CANON`) selects a directory laid out like +//! `standards/provisioning/` instead — a `CANON` file and a `templates/` tree — +//! so an unreleased canon can be tried without rebuilding. +//! +//! Two tests keep the table honest: it must list exactly the files under +//! `templates/` (a file added to the vendor copy and not to the table would be +//! silently never minted), and its content is pinned by digest, so a re-vendor +//! announces itself in the test diff rather than passing unnoticed. + +use anyhow::{Context, Result, bail}; +use std::borrow::Cow; +use std::path::{Path, PathBuf}; + +/// Environment override for the canon directory. +pub const CANON_ENV: &str = "LAUNCH_SCAFFOLDER_PROVISIONING_CANON"; + +/// `standards@` the baked table was vendored from. +pub const BAKED_CANON_REF: &str = include_str!("../../../../standards/provisioning/CANON"); + +/// Engine files: copied byte-for-byte, owned by realign, and byte-compared by +/// `provision-set check`. `guix/channels.scm` is deliberately absent: it is +/// minted once and then re-pinned per repository by `just toolchain-refresh`, +/// so a byte comparison would fail every refreshed repository. It is checked +/// instead by the commit-pin predicate in `provision-check.sh`. +pub const ENGINE_FILES: &[&str] = &[ + "build/just/provision-check.sh", + "build/just/provision-lib.sh", + "build/just/provision-modes.sh", + "build/just/provision.just", +]; + +/// Every canon file, by path relative to `templates/`, sorted bytewise. +pub static BAKED: &[(&str, &[u8])] = &[ + ( + ".machine_readable/descriptiles/provisioning_praxis.deed.tmpl", + include_bytes!( + "../../../../standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl" + ), + ), + ( + "Justfile.tmpl", + include_bytes!("../../../../standards/provisioning/templates/Justfile.tmpl"), + ), + ( + "README-ai-install.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/README-ai-install.adoc.tmpl"), + ), + ( + "build/just/provision-check.sh", + include_bytes!( + "../../../../standards/provisioning/templates/build/just/provision-check.sh" + ), + ), + ( + "build/just/provision-lib.sh", + include_bytes!("../../../../standards/provisioning/templates/build/just/provision-lib.sh"), + ), + ( + "build/just/provision-modes.sh", + include_bytes!( + "../../../../standards/provisioning/templates/build/just/provision-modes.sh" + ), + ), + ( + "build/just/provision.just", + include_bytes!("../../../../standards/provisioning/templates/build/just/provision.just"), + ), + ( + "docs/AI_INSTALLATION_GUIDE.adoc.tmpl", + include_bytes!( + "../../../../standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl" + ), + ), + ( + "docs/SETUP.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/docs/SETUP.adoc.tmpl"), + ), + ( + "guix/channels.scm", + include_bytes!("../../../../standards/provisioning/templates/guix/channels.scm"), + ), + ( + "guix/guix.scm.cargo.tmpl", + include_bytes!("../../../../standards/provisioning/templates/guix/guix.scm.cargo.tmpl"), + ), + ( + "guix/guix.scm.source.tmpl", + include_bytes!("../../../../standards/provisioning/templates/guix/guix.scm.source.tmpl"), + ), + ( + "guix/manifest.scm.tmpl", + include_bytes!("../../../../standards/provisioning/templates/guix/manifest.scm.tmpl"), + ), + ( + "launcher.sh.tmpl", + include_bytes!("../../../../standards/provisioning/templates/launcher.sh.tmpl"), + ), + ( + "llm-warmup-dev.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/llm-warmup-dev.adoc.tmpl"), + ), + ( + "llm-warmup-maintainer.adoc.tmpl", + include_bytes!( + "../../../../standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl" + ), + ), + ( + "llm-warmup-user.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/llm-warmup-user.adoc.tmpl"), + ), + ( + "mise.toml.tmpl", + include_bytes!("../../../../standards/provisioning/templates/mise.toml.tmpl"), + ), +]; + +/// Where canon bytes come from. +#[derive(Debug, Clone)] +pub enum Canon { + Baked, + Dir(PathBuf), +} + +impl Canon { + /// The explicit override if given, else the baked table. + pub fn resolve(dir: Option<&Path>) -> Result { + match dir { + None => Ok(Canon::Baked), + Some(d) => { + let t = d.join("templates"); + if !t.is_dir() { + bail!("canon override {} has no templates/ directory", d.display()); + } + Ok(Canon::Dir(d.to_path_buf())) + } + } + } + + /// The `standards@` this canon names. + pub fn reference(&self) -> Result { + let raw = match self { + Canon::Baked => BAKED_CANON_REF.to_string(), + Canon::Dir(d) => std::fs::read_to_string(d.join("CANON")) + .with_context(|| format!("reading {}", d.join("CANON").display()))?, + }; + Ok(raw.trim().to_string()) + } + + /// The bytes of one canon file. + pub fn file(&self, rel: &str) -> Result> { + match self { + Canon::Baked => BAKED + .iter() + .find(|(p, _)| *p == rel) + .map(|(_, b)| Cow::Borrowed(*b)) + .with_context(|| format!("{rel} is not in the baked provisioning canon")), + Canon::Dir(d) => { + let p = d.join("templates").join(rel); + std::fs::read(&p) + .map(Cow::Owned) + .with_context(|| format!("reading canon file {}", p.display())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vendor_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../standards/provisioning/templates") + } + + #[test] + fn the_table_lists_exactly_the_vendored_files() { + let mut on_disk: Vec = walkdir::WalkDir::new(vendor_dir()) + .into_iter() + .map(|e| e.expect("walk vendored canon")) + .filter(|e| e.file_type().is_file()) + .map(|e| { + e.path() + .strip_prefix(vendor_dir()) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + on_disk.sort(); + let table: Vec = BAKED.iter().map(|(p, _)| p.to_string()).collect(); + assert_eq!( + table, on_disk, + "regenerate BAKED from standards/provisioning/templates" + ); + } + + #[test] + fn every_engine_file_is_in_the_table() { + for e in ENGINE_FILES { + assert!(BAKED.iter().any(|(p, _)| p == e), "{e} missing from BAKED"); + } + assert!(!ENGINE_FILES.contains(&"guix/channels.scm")); + } + + /// Digest over sorted `path NUL sha256 LF` lines. A re-vendor changes it; + /// bump this pin and `standards/provisioning/CANON` in the same commit. + #[test] + fn the_baked_canon_is_pinned_by_content() { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + for (p, b) in BAKED { + h.update(p.as_bytes()); + h.update([0u8]); + h.update(format!("{:x}", Sha256::digest(b)).as_bytes()); + h.update(b"\n"); + } + let got = format!("{:x}", h.finalize()); + assert_eq!( + got, PINNED_DIGEST, + "the baked provisioning canon changed: confirm standards/provisioning/CANON \ + names the commit it came from, then update this pin in the same commit" + ); + assert!(BAKED_CANON_REF.trim().starts_with("standards@")); + } + + const PINNED_DIGEST: &str = "72ae31ad63adf81f2e2bf8a27c16bf1921b0426cf6942ef097ab55f5e7e84b0a"; +} diff --git a/crates/launcher-common/src/provisioning/check.rs b/crates/launcher-common/src/provisioning/check.rs new file mode 100644 index 0000000..7ccc6fb --- /dev/null +++ b/crates/launcher-common/src/provisioning/check.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! `provision-set check`: the engine files must equal the canon byte for byte, +//! and only then is the repository's own `provision-check.sh` trusted to judge +//! the rest. A drifted checker cannot be trusted to check, so drift is terminal. + +use super::canon::{Canon, ENGINE_FILES}; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +/// One engine file that does not match the canon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Drift { + Missing(String), + Differs(String), +} + +impl std::fmt::Display for Drift { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Drift::Missing(p) => write!(f, "{p}: missing"), + Drift::Differs(p) => write!(f, "{p}: differs from the canon"), + } + } +} + +/// Every engine file under `target` that is absent or not byte-identical to the canon. +pub fn engine_drift(target: &Path, canon: &Canon) -> Result> { + let mut out = Vec::new(); + for rel in ENGINE_FILES { + let want = canon.file(rel)?; + match std::fs::read(target.join(rel)) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + out.push(Drift::Missing(rel.to_string())) + } + Err(e) => { + return Err(e).with_context(|| format!("reading {}", target.join(rel).display())); + } + Ok(have) if have != *want => out.push(Drift::Differs(rel.to_string())), + Ok(_) => {} + } + } + Ok(out) +} + +/// Run the repository's `build/just/provision-check.sh` and return its exit +/// code. A child killed by a signal is a failure (1), never a pass. +pub fn conformance(target: &Path, dev: bool) -> Result { + let script = target.join("build/just/provision-check.sh"); + let mut cmd = Command::new("bash"); + cmd.arg(&script); + if dev { + cmd.arg("--dev"); + } + cmd.arg(target); + let status = cmd + .status() + .with_context(|| format!("running {}", script.display()))?; + Ok(status.code().unwrap_or(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provisioning::canon::BAKED; + + fn mint_engine(dir: &Path) { + for rel in ENGINE_FILES { + let (_, b) = BAKED.iter().find(|(p, _)| p == rel).unwrap(); + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b).unwrap(); + } + } + + fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ls-provcheck-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn identical_engine_has_no_drift() { + let d = scratch("clean"); + mint_engine(&d); + assert!(engine_drift(&d, &Canon::Baked).unwrap().is_empty()); + } + + /// Planted positives: one byte changed and one file removed must both be seen. + #[test] + fn a_changed_byte_and_a_missing_file_are_both_drift() { + let d = scratch("dirty"); + mint_engine(&d); + let lib = d.join("build/just/provision-lib.sh"); + let mut b = std::fs::read(&lib).unwrap(); + b.push(b'\n'); + std::fs::write(&lib, b).unwrap(); + std::fs::remove_file(d.join("build/just/provision.just")).unwrap(); + let got = engine_drift(&d, &Canon::Baked).unwrap(); + assert_eq!( + got, + vec![ + Drift::Differs("build/just/provision-lib.sh".into()), + Drift::Missing("build/just/provision.just".into()), + ] + ); + } + + /// A refreshed channels.scm is not drift: it is not an engine file. + #[test] + fn a_repinned_channels_scm_is_not_drift() { + let d = scratch("chan"); + mint_engine(&d); + std::fs::create_dir_all(d.join("guix")).unwrap(); + std::fs::write( + d.join("guix/channels.scm"), + "(list (channel (name 'guix) (commit \"0000000000000000000000000000000000000000\")))\n", + ) + .unwrap(); + assert!(engine_drift(&d, &Canon::Baked).unwrap().is_empty()); + } +} diff --git a/crates/launcher-common/src/provisioning/mod.rs b/crates/launcher-common/src/provisioning/mod.rs new file mode 100644 index 0000000..d9a26b5 --- /dev/null +++ b/crates/launcher-common/src/provisioning/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! The provisioning set (`standards/3-practice/provisioning/PROVISIONING-STANDARD.adoc`): +//! the canon it is minted from and the conformance check run against it. + +pub mod canon; +pub mod check; diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/Justfile b/crates/launcher-common/tests/fixtures/provisioning/check/Justfile new file mode 100644 index 0000000..ab56fa4 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/Justfile @@ -0,0 +1,20 @@ +mod provision 'build/just/provision.just' +default: + @just --list +setup: provision::setup +doctor: provision::doctor +heal: provision::heal +dev-shell: provision::dev-shell +toolchain-refresh: provision::toolchain-refresh +ai-setup: provision::ai-setup +eval: provision::eval +config-show: provision::config-show +opsm: provision::opsm +build: provision::build +test: provision::test +bench: provision::bench +lint: provision::lint +fmt: provision::fmt +run: provision::run +deps: provision::deps +ai-warmup who="user": (provision::ai-warmup who) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/README.adoc b/crates/launcher-common/tests/fixtures/provisioning/check/README.adoc new file mode 100644 index 0000000..e97d20e --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/README.adoc @@ -0,0 +1,8 @@ += X + +[[ai-install]] +== AI-Assisted Installation +Say it. + +== Other +__NOT_OURS__ diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/channels.scm b/crates/launcher-common/tests/fixtures/provisioning/check/channels.scm new file mode 100644 index 0000000..54ac61a --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/channels.scm @@ -0,0 +1,18 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; channels.scm — the Guix revision this repository's guix.scm and manifest.scm +;; were verified against. Reproduce that exact Guix with: +;; +;; guix time-machine -C channels.scm -- shell -m manifest.scm +;; +;; Refresh it (after re-verifying) with: just toolchain-refresh +;; Canon pin, verified 2026-09-30 (docker.io/metacall/guix, guix describe). +(list (channel + (name 'guix) + (url "https://codeberg.org/guix/guix.git") + (branch "master") + (commit "ae77aeb9543de2661d739104bc4d3803d8c6f38b") + (introduction + (make-channel-introduction + "9edb3f66fd807b096b48283debdcddccfea34bad" + (openpgp-fingerprint + "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA"))))) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/guix.scm b/crates/launcher-common/tests/fixtures/provisioning/check/guix.scm new file mode 100644 index 0000000..e399098 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/guix.scm @@ -0,0 +1,2 @@ +(use-modules (guix packages)) +(package (name "x") (source (local-file "."))) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh b/crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh new file mode 100755 index 0000000..1bb1320 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 test +# +# @launcher-deed begin +# ;; SPDX-License-Identifier: MPL-2.0 +# (praxis-deed +# :schema-version "1.0.0" +# :canonical-name "x-launcher" +# :beholding-chora #u5"estate/chora" +# (artefact :type "launcher" :version "0.1.0" +# :generator "provision-set") +# (app :name "x" :display "x" +# :url "https://github.com/x" :archetype "x") +# (compliance :standard-version "0.5.0" +# :standards ("launcher-standard.adoc" +# "PROVISIONING-STANDARD.adoc")) +# (modes :accepted ("--setup" "--doctor" "--heal" "--ai-setup" "--help" "--version" +# "--start" "--stop" "--status" "--auto" "--integ" "--disinteg")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("provision" "diagnose" "heal") +# :deferred ("install" "run"))) +# @launcher-deed end +# +# The launcher for a library / tool / theory / docs repository +# (launcher-standard 0.5.0, archetype profile). Every mode is implemented in +# build/just/provision-modes.sh, and every provisioning mode delegates to the +# Justfile. This file is minted by `provision-set`; do not hand-edit it. +# Repo-specific facts belong in .machine_readable/descriptiles/provisioning.deed. +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +if [ ! -f "$REPO_DIR/build/just/provision-modes.sh" ]; then + echo "launcher.sh: build/just/provision-modes.sh is missing — this checkout is incomplete." >&2 + echo "Re-clone, or restore it: git checkout -- build/just/provision-modes.sh" >&2 + exit 1 +fi +# shellcheck source=build/just/provision-modes.sh +. "$REPO_DIR/build/just/provision-modes.sh" + +hp_launcher_main "$@" diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm b/crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm new file mode 100644 index 0000000..fa68547 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm @@ -0,0 +1 @@ +(specifications->manifest (list "git")) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/mise.lock b/crates/launcher-common/tests/fixtures/provisioning/check/mise.lock new file mode 100644 index 0000000..e69de29 diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/mise.toml b/crates/launcher-common/tests/fixtures/provisioning/check/mise.toml new file mode 100644 index 0000000..394fe2d --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/mise.toml @@ -0,0 +1,5 @@ +[settings] +lockfile = true +[tools] +just = "latest" +shellcheck = "latest" diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc b/crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc new file mode 100644 index 0000000..c8e289c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc @@ -0,0 +1 @@ += Hi diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile new file mode 100644 index 0000000..8b135a4 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile @@ -0,0 +1,2 @@ +doctor: + @echo FAKE-GREEN diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg new file mode 100644 index 0000000..6071d68 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg @@ -0,0 +1,3 @@ +package idr +sourcedir = "src" +modules = Idr diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr new file mode 100644 index 0000000..29ce4a5 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr @@ -0,0 +1,5 @@ +module Idr + +export +x : Nat +x = 1 diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock new file mode 100644 index 0000000..838ca11 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "rustd" +version = "0.1.0" +dependencies = [ + "itoa", +] diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml new file mode 100644 index 0000000..0b0ab9c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "rustd" +version = "0.1.0" +edition = "2024" + +[dependencies] +itoa = "1" diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile new file mode 100644 index 0000000..698e179 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile @@ -0,0 +1,7 @@ +# Run with extra args, e.g. just run --verbose +# cargo run -- {{args}} +run *args: + cargo run -- {{args}} + +setup: + @echo own-setup diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/deno.json b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/deno.json new file mode 100644 index 0000000..e69de29 diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs new file mode 100644 index 0000000..484251b --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs @@ -0,0 +1 @@ +fn main() { let mut b = itoa::Buffer::new(); println!("{}", b.format(42)); } diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock new file mode 100644 index 0000000..105605a --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "rustr" +version = "0.1.0" diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml new file mode 100644 index 0000000..436ac9d --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rustr" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile new file mode 100644 index 0000000..3e0afd6 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile @@ -0,0 +1,6 @@ +# Build the binary +build: + cargo build + +doctor: + @echo own-doctor diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/deno.json b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/deno.json new file mode 100644 index 0000000..e69de29 diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README b/crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README new file mode 100644 index 0000000..587be6b --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README @@ -0,0 +1 @@ +x diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/all.vars b/crates/launcher-common/tests/fixtures/provisioning/render/all.vars new file mode 100644 index 0000000..e16ba16 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/render/all.vars @@ -0,0 +1,19 @@ +DOC_LICENSE CC-BY-SA-4.0 +YEAR 2026 +COPYRIGHT_HOLDER Jonathan D.A. Jewell (hyperpolymath) +APP_NAME rsr-template-repo +REPO_SLUG hyperpolymath/rsr-template-repo +ARCHETYPE library +LANGS idris2, zig +GUIX_PREFIX build/ +GUIX_GAPS idris2 +TOOL_TABLE |idris2 |`idris2` |not a mise tool: install pack (https://github.com/stefan-hoeck/idris2-pack#installation), then `pack install-app idris2`\n|zig |`zig` |https://ziglang.org/download/ or Guix `zig`\n|(recipes) |`just`, `shellcheck` |your OS package manager, e.g. `dnf install just ShellCheck` +SYSTEM_DEPS_SECTION === System packages for Idris2\n\nIdris2 is built by pack on top of Chez Scheme and GMP, which come from your OS:\n\n* Fedora: `sudo dnf install chez-scheme gmp-devel`\n* Debian/Ubuntu: `sudo apt install chezscheme libgmp-dev`\n* macOS: `brew install chezscheme gmp`\n\nThe Guix development shell (`build/manifest.scm`) already includes both. +LICENSE MPL-2.0 +SPEC_WHAT_IT_IS It is not a project in its own right: it is the template every new hyperpolymath\nrepository is created from. It ships CI/CD, machine-readable metadata\n(`.machine_readable/descriptiles/`), an AI gatekeeper deed\n(`rsr-template-repo_chora.deed`), a typed ABI/FFI seam (Idris2 over Zig), Guix and\nmise toolchains, and governance files, all passing the RSR validators before you\nadd a line of your own. +SPEC_USAGE A person uses it to start a new repository:\n\n. On GitHub, *Use this template* (or clone it).\n. In the new checkout, run `just repo-init`. It asks for the project name, owner,\n author and licence contact (listed in `.machine_readable/ai/PLACEHOLDERS.adoc`),\n replaces every placeholder token, then validates the result. `just repo-init\n julia-library` applies the one archetype overlay shipped in `archetypes/`.\n. Run `just validate` (structure + metadata) and `just doctor` (toolchain).\n\nAn AI running `repo-init` must pipe answers to stdin and must never set\n`RSR_NON_INTERACTIVE=1`: that stubs `read`, and the substitution loops then never end. +SPEC_PRIVACY Nothing leaves the machine. `mise install` and `just setup` install tools per-user\nunder the mise data directory; `just repo-init` rewrites files only inside the\ncheckout. No remote repository is created and nothing is pushed until you do it. +SPEC_ARCHITECTURE The template's own code is the ABI/FFI seam that every minted repo inherits:\n\n* `src/interface/Abi/` + `src/interface/abi.ipkg`: Idris2 types and layout proofs\n (the ABI owns the contract).\n* `src/interface/ffi/`: the Zig implementation (`build.zig`, `src/main.zig`,\n `test/integration_test.zig`), exporting C symbols named `_*`.\n* `src/interface/generated/`: C headers generated from the ABI.\n\nEverything else is scaffolding: `.machine_readable/` (descriptiles, contractiles,\npolicies), `build/just/*.just` (the task phases the root `Justfile` imports), and\n`.github/workflows/` (the CI a minted repo starts with). +SPEC_DEV_COMMANDS Template-specific commands:\n\n[source,bash]\n----\njust repo-init # instantiate: fills every placeholder token, then validates\njust validate # RSR shape + metadata + AI-install completeness\njust quality # fmt-check, lint, test\njust test-all # test, e2e, aspect, bench, readiness\njust repo-map # regenerate docs/architecture/REPOSITORY-MAP.adoc (CI checks it)\n----\n\nThe golden path is `just test && just quality`. In the template itself `test` and\n`lint` fail on the placeholder tokens in `src/interface/ffi/src/main.zig`; that is\ndeliberate. Test a change to the FFI in a scratch copy after `repo-init`. +SPEC_LAYOUT * Top level: `archetypes/ benches/ build/ ci/ docs/ examples/ features/ LICENSES/\n scripts/ session/ src/ tests/ verification/ www/`. The root file list is fenced by\n `.machine_readable/root-allow.txt`; a new root file needs a line there.\n* `build/just/`: provisioning (`provision.just`, `provision-lib.sh`), `repo-init.just`,\n `validate.just`, and the other imported phases.\n* `build/guix.scm`, `build/manifest.scm`, `build/channels.scm`: the Guix route.\n* `tests/`: e2e, aspect, shape and workflow checks. `benches/`: benchmark harness.\n* `docs/`: SETUP, AI installation guide, onboarding warm-ups, architecture map. +SPEC_GOTCHAS * `.machine_readable/contractiles/Justfile` must stay byte-identical to the root\n `Justfile`; copy it after every edit.\n* Never put literal placeholder tokens in prose that `repo-init` will process: it\n substitutes them. Write "placeholder token" instead.\n* `build/` is not an artefact directory here: `just clean` deliberately leaves it,\n because `build/just/repo-init.just` lives there.\n* Descriptile and policy files are A2ML, which is retired: do not extend them or\n build anything new on them. diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/render.sh b/crates/launcher-common/tests/fixtures/provisioning/render/render.sh new file mode 100755 index 0000000..25efe0c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/render/render.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# render.sh TEMPLATE VARSFILE — replace __KEY__ with values from VARSFILE (KEYvalue, +# value may contain \n for newlines). Unknown slots are left for provision-check to catch. +set -euo pipefail +# bash 5.2 expands & in a ${var//pat/rep} replacement to the match; values are literal. +shopt -u patsub_replacement 2>/dev/null || true +tmpl=$1 vars=$2 +content=$(cat "$tmpl") +while IFS=$'\t' read -r k v; do + [ -z "$k" ] && continue + v=$(printf '%b' "$v") + content=${content//"__${k}__"/$v} +done < "$vars" +printf '%s\n' "$content" diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars b/crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars new file mode 100644 index 0000000..e4206c9 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars @@ -0,0 +1,3 @@ +SPEC_RELEASE A release is a signed tag `v` on `main`; pushing it runs `.github/workflows/release.yml`, which builds the release notes, publishes the GitHub release (`softprops/action-gh-release`) and attests build provenance. Bump `version` in the Justfile and `CITATION.cff` first, in the same PR. This repository is a GitHub *template*: a release changes what every new repository is born with, so re-run `./launcher.sh --doctor` and `bash build/just/provision-check.sh .` on a fresh clone before tagging. +SPEC_CI `main` is protected by a ruleset with these rule types: `required_signatures`, `required_linear_history`, `non_fast_forward`, `deletion`, `code_scanning`, `code_coverage` and `copilot_code_review` (read 2026-09-30; re-read with `gh api repos/hyperpolymath/rsr-template-repo/rules/branches/main`). Only squash merges are allowed and auto-merge is on. Every commit must be SSH-signed or the PR cannot land. The workflows under `.github/workflows/` are themselves the estate's reference copies: a change here propagates to every repository minted from the template, so change the canonical reusable in `hyperpolymath/standards` first and pin to it here. +SPEC_DECISIONS Rulings that bind this repository in particular: bun is the only JavaScript runtime (deno, TypeScript, ReScript, Python are banned), A2ML is retired (leave existing `.a2ml` files alone, build nothing new on them), and Justfile changes are mirrored in `.machine_readable/contractiles/Justfile`, which is kept byte-identical. diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars b/crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars new file mode 100644 index 0000000..a7ddb73 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars @@ -0,0 +1,8 @@ +LICENSE MPL-2.0 +SPEC_WHAT_IT_IS It is not a project in its own right: it is the template every new hyperpolymath\nrepository is created from. It ships CI/CD, machine-readable metadata\n(`.machine_readable/descriptiles/`), an AI gatekeeper deed\n(`rsr-template-repo_chora.deed`), a typed ABI/FFI seam (Idris2 over Zig), Guix and\nmise toolchains, and governance files, all passing the RSR validators before you\nadd a line of your own. +SPEC_USAGE A person uses it to start a new repository:\n\n. On GitHub, *Use this template* (or clone it).\n. In the new checkout, run `just repo-init`. It asks for the project name, owner,\n author and licence contact (listed in `.machine_readable/ai/PLACEHOLDERS.adoc`),\n replaces every placeholder token, then validates the result. `just repo-init\n julia-library` applies the one archetype overlay shipped in `archetypes/`.\n. Run `just validate` (structure + metadata) and `just doctor` (toolchain).\n\nAn AI running `repo-init` must pipe answers to stdin and must never set\n`RSR_NON_INTERACTIVE=1`: that stubs `read`, and the substitution loops then never end. +SPEC_PRIVACY Nothing leaves the machine. `mise install` and `just setup` install tools per-user\nunder the mise data directory; `just repo-init` rewrites files only inside the\ncheckout. No remote repository is created and nothing is pushed until you do it. +SPEC_ARCHITECTURE The template's own code is the ABI/FFI seam that every minted repo inherits:\n\n* `src/interface/Abi/` + `src/interface/abi.ipkg`: Idris2 types and layout proofs\n (the ABI owns the contract).\n* `src/interface/ffi/`: the Zig implementation (`build.zig`, `src/main.zig`,\n `test/integration_test.zig`), exporting C symbols named `_*`.\n* `src/interface/generated/`: C headers generated from the ABI.\n\nEverything else is scaffolding: `.machine_readable/` (descriptiles, contractiles,\npolicies), `build/just/*.just` (the task phases the root `Justfile` imports), and\n`.github/workflows/` (the CI a minted repo starts with). +SPEC_DEV_COMMANDS Template-specific commands:\n\n[source,bash]\n----\njust repo-init # instantiate: fills every placeholder token, then validates\njust validate # RSR shape + metadata + AI-install completeness\njust quality # fmt-check, lint, test\njust test-all # test, e2e, aspect, bench, readiness\njust repo-map # regenerate docs/architecture/REPOSITORY-MAP.adoc (CI checks it)\n----\n\nThe golden path is `just test && just quality`. In the template itself `test` and\n`lint` fail on the placeholder tokens in `src/interface/ffi/src/main.zig`; that is\ndeliberate. Test a change to the FFI in a scratch copy after `repo-init`. +SPEC_LAYOUT * Top level: `archetypes/ benches/ build/ ci/ docs/ examples/ features/ LICENSES/\n scripts/ session/ src/ tests/ verification/ www/`. The root file list is fenced by\n `.machine_readable/root-allow.txt`; a new root file needs a line there.\n* `build/just/`: provisioning (`provision.just`, `provision-lib.sh`), `repo-init.just`,\n `validate.just`, and the other imported phases.\n* `build/guix.scm`, `build/manifest.scm`, `build/channels.scm`: the Guix route.\n* `tests/`: e2e, aspect, shape and workflow checks. `benches/`: benchmark harness.\n* `docs/`: SETUP, AI installation guide, onboarding warm-ups, architecture map. +SPEC_GOTCHAS * `.machine_readable/contractiles/Justfile` must stay byte-identical to the root\n `Justfile`; copy it after every edit.\n* Never put literal placeholder tokens in prose that `repo-init` will process: it\n substitutes them. Write "placeholder token" instead.\n* `build/` is not an artefact directory here: `just clean` deliberately leaves it,\n because `build/just/repo-init.just` lives there.\n* Descriptile and policy files are A2ML, which is retired: do not extend them or\n build anything new on them. diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars b/crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars new file mode 100644 index 0000000..4de4e98 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars @@ -0,0 +1,11 @@ +DOC_LICENSE CC-BY-SA-4.0 +YEAR 2026 +COPYRIGHT_HOLDER Jonathan D.A. Jewell (hyperpolymath) +APP_NAME rsr-template-repo +REPO_SLUG hyperpolymath/rsr-template-repo +ARCHETYPE library +LANGS idris2, zig +GUIX_PREFIX build/ +GUIX_GAPS idris2 +TOOL_TABLE |idris2 |`idris2` |not a mise tool: install pack (https://github.com/stefan-hoeck/idris2-pack#installation), then `pack install-app idris2`\n|zig |`zig` |https://ziglang.org/download/ or Guix `zig`\n|(recipes) |`just`, `shellcheck` |your OS package manager, e.g. `dnf install just ShellCheck` +SYSTEM_DEPS_SECTION === System packages for Idris2\n\nIdris2 is built by pack on top of Chez Scheme and GMP, which come from your OS:\n\n* Fedora: `sudo dnf install chez-scheme gmp-devel`\n* Debian/Ubuntu: `sudo apt install chezscheme libgmp-dev`\n* macOS: `brew install chezscheme gmp`\n\nThe Guix development shell (`build/manifest.scm`) already includes both. diff --git a/crates/launcher/src/cmd_provision_set.rs b/crates/launcher/src/cmd_provision_set.rs new file mode 100644 index 0000000..1847b78 --- /dev/null +++ b/crates/launcher/src/cmd_provision_set.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! `provision-set` subcommand — mint, realign and check a repository's +//! provisioning set against `PROVISIONING-STANDARD.adoc`. + +use anyhow::Result; +use clap::{Args as ClapArgs, Subcommand}; +use launch_scaffolder_common::provisioning::{ + canon::{CANON_ENV, Canon}, + check, +}; +use std::path::PathBuf; + +#[derive(Debug, ClapArgs)] +pub struct Args { + /// Use a canon directory (a `CANON` file and a `templates/` tree) instead + /// of the canon baked into the binary. + #[arg(long, value_name = "DIR", env = CANON_ENV, global = true)] + canon: Option, + + #[command(subcommand)] + action: Action, +} + +#[derive(Debug, Subcommand)] +enum Action { + /// Fail if the engine files differ from the canon; otherwise run the + /// repository's provision-check.sh and exit with its code. + Check { + /// Downgrade unfilled repository-specific slots to warnings. + #[arg(long)] + dev: bool, + /// Repository to check. + #[arg(default_value = ".")] + target: PathBuf, + }, +} + +pub fn run(args: Args) -> Result<()> { + let canon = Canon::resolve(args.canon.as_deref())?; + match args.action { + Action::Check { dev, target } => { + let drift = check::engine_drift(&target, &canon)?; + if !drift.is_empty() { + eprintln!( + "provision-set check: engine differs from {} — run `launch-scaffolder provision-set realign`:", + canon.reference()? + ); + for d in &drift { + eprintln!(" FAIL {d}"); + } + std::process::exit(1); + } + let code = check::conformance(&target, dev)?; + std::process::exit(code); + } + } +} diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 841fd2f..a5b44c2 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -20,6 +20,7 @@ use clap::{Parser, Subcommand}; mod cmd_config; mod cmd_mint; mod cmd_provision; +mod cmd_provision_set; mod cmd_realign; mod cmd_standard; @@ -60,6 +61,11 @@ enum Command { /// Install (--integ) or uninstall (--disinteg) a launcher on the current system. Provision(cmd_provision::Args), + /// Mint, realign or check the repository provisioning set + /// (guix, mise, Justfile, launcher, setup and AI-install docs). + #[command(name = "provision-set")] + ProvisionSet(cmd_provision_set::Args), + /// Get, set, or validate the config section of an existing launcher. Config(cmd_config::Args), @@ -91,6 +97,7 @@ fn main() -> Result<()> { match cli.command { Command::Mint(args) => cmd_mint::run(args, cli.standard.as_deref()), Command::Provision(args) => cmd_provision::run(args, cli.standard.as_deref()), + Command::ProvisionSet(args) => cmd_provision_set::run(args), Command::Config(args) => cmd_config::run(args, cli.standard.as_deref()), Command::Realign(args) => cmd_realign::run(args, cli.standard.as_deref()), Command::Standard(args) => cmd_standard::run(args, cli.standard.as_deref()), diff --git a/standards/provisioning/CANON b/standards/provisioning/CANON new file mode 100644 index 0000000..a153a03 --- /dev/null +++ b/standards/provisioning/CANON @@ -0,0 +1 @@ +standards@93342bf6052a5fa5178d7f4bfa3e883f04fa8456 diff --git a/standards/provisioning/PROVISIONING-STANDARD.adoc b/standards/provisioning/PROVISIONING-STANDARD.adoc new file mode 100644 index 0000000..fab92cb --- /dev/null +++ b/standards/provisioning/PROVISIONING-STANDARD.adoc @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Provisioning Standard +:toc: +:toclevels: 2 +:revdate: 2026-09-30 +:revnumber: 1.0.0 + +*Question this answers:* what must every repository carry so that nobody who +clones it ever has to search for how to install, configure, run, test, benchmark, +diagnose or repair it? + +Machine-readable counterpart: link:provisioning-standard_praxis.deed[provisioning-standard_praxis.deed]. +Templates: link:templates/[templates/]. Launcher modes: `docs/UX-standards/launcher-standard.adoc` +§Provisioning modes (v0.5.0). + +== 1. The provisioning set + +Every repository in `hyperpolymath/*` and `metadatastician/*` carries the files +below, except forks, archived repositories, `007` and the two vaults +(`dev-notes-vault`, `memory-vault`). + +[cols="3,1,4"] +|=== +|File |Kind |Purpose + +|`launcher.sh` |generated |`--setup`, `--doctor`, `--heal`, `--ai-setup`, plus runtime modes for the `app` archetype +|`build/just/provision.just` |engine |the `provision::` just module: every verb below +|`build/just/provision-lib.sh` |engine |the implementation of every verb (bash only, shellcheck-clean) +|`build/just/provision-modes.sh` |engine |the launcher's provisioning dispatch +|`channels.scm` |engine |the pinned Guix commit (placed beside `guix.scm`, see below) +|`Justfile` |merged |`mod provision` plus root delegations; the repo's own recipes are kept +|`mise.toml` + `mise.lock` |minted |the toolchain: `latest` in `mise.toml`, made concrete and checksummed in `mise.lock` +|`guix.scm`, `manifest.scm` |minted |the Guix package and development shell (at the root, or all three under `build/` where the repository already keeps `build/guix.scm`) +|`.machine_readable/descriptiles/provisioning_praxis.deed` |minted |this repo's facts: archetype, languages, verb overrides, system deps, ports, config +|`docs/SETUP.adoc` |minted |the complete manual route, with the doctor-code troubleshooting table +|`docs/AI_INSTALLATION_GUIDE.adoc` |minted |the steps an AI assistant follows +|`llm-warmup-{user,dev,maintainer}.adoc` |minted |paste-in context for any AI, one per audience (at the root, or in `docs/onboarding/` or `docs/` where the repository already keeps them) +|README `[[ai-install]]` section |inserted |"Just say it": one sentence to give any AI +|=== + +*Placement.* A file sits at the root only when something needs it there: +`launcher.sh`, `Justfile`, `mise.toml` and `mise.lock` are found by the tools that +read them. The Guix trio and the warm-ups follow the layout the repository already +has, so `rsr-template-repo` keeps `build/guix.scm` and +`docs/onboarding/llm-warmup-*.adoc`. One resolver in `provision-lib.sh` decides +this (`provision-lib.sh guix-dir` and `set-files`), and doctor, `dev-shell`, +`toolchain-refresh` and `provision-check.sh` all ask it, so no two of them can +disagree about where a file lives. Both `guix.scm` and `build/guix.scm` present is +PV-W35. + +*Engine* files are identical estate-wide and `provision-set realign` overwrites +them. The *generated* `launcher.sh` is rendered from the template and the repo's +deed. Realign re-renders it only when its header carries the `@launcher-deed` +block, which marks it as generated. A hand-written launcher is kept, and gets +the provisioning modes by sourcing `build/just/provision-modes.sh`. The template +renders the launcher for `library`, `tool`, `theory` and `docs`; an `app` launcher, +which needs its runtime modes, is rendered by `launch-scaffolder mint` from the +app's own config and sources the same file. + +A minted set whose deed `:repo` differs from the checkout's own slug was +*inherited* (for example from `rsr-template-repo` through GitHub's "Use this +template"). Realign treats inherited files as stubs and re-mints them, so a +new repository is never born describing its template. *Minted* files are written once, then owned by the repository: realign +creates them when missing and replaces them only while they are still stubs +(doctor PV-W24–W28), never once filled. One exception: a `mise.toml` that names a +banned tool (PV-W23) is replaced too, and its other `[tools]` entries are carried +over, because a repository cannot own a toolchain the estate has banned. The Justfile is merged: an existing +custom `doctor`, `setup` or `heal` recipe is renamed `doctor-local`, +`setup-local` or `heal-local`, and the canon verbs run it (§3). + +== 2. The verbs + +`just ` and `just provision::` are the same. `./launcher.sh --setup`, +`--doctor`, `--heal` and `--ai-setup` call the engine directly, so a root recipe +of the same name can never shadow them. + +[cols="1,4"] +|=== +|Verb |Contract + +|`setup` |`mise install`, the repo's dependencies per language, `setup-local`, then `doctor` +|`doctor` |PASS / WARN / FAIL for every requirement, each with a code and its fix; the last line is `: N PASS, N WARN, N FAIL`; exits non-zero if and only if something FAILed +|`heal` |applies every fix marked *auto*, runs `heal-local`, then `doctor` +|`dev-shell` |`guix shell -m /manifest.scm` when Guix is present, otherwise the mise environment +|`toolchain-refresh` |`mise up --bump`, re-lock `mise.lock`, re-pin `channels.scm` (the TRUST-DEFAULTS-POLICY recipe) +|`build`, `test`, `bench`, `lint`, `fmt`, `fmt-check`, `run`, `deps` |the deed's override, else the language default, else an explicit "N/A for this archetype" and exit 0; nothing is faked. A language default runs only when what it needs exists (a `test` step in `build.zig`, test files for `bun test`, which exits 0 on none) +|`eval` |`test` + `bench` with timings, saved under `.eval/` +|`config-show` |where the toolchain and configuration are declared +|`ai-setup` |prints the "Just say it" sentence, read from the first listing block of the README's `[[ai-install]]` section so it is written in one place, and the guide path +|`ai-warmup ` |prints that warm-up for pasting into any AI +|`opsm` |how to fetch this repository with OPSM, and how to get OPSM +|`langs`, `search`, `version` |discovery helpers +|=== + +Archetypes (`app`, `library`, `tool`, `theory`, `docs`) come from the deed. Only +`app` has runtime modes (start/stop/status); the others say N/A. + +== 3. Repository-specific checks + +A repository keeps its own checks as root recipes named `doctor-local`, +`setup-local` and `heal-local`, or as scripts `build/just/{doctor,setup,heal}-local.sh`. +The canon verbs run both. A failing `doctor-local` is a FAIL (PV-E50), so it turns +`just doctor`, `./launcher.sh --doctor` and CI red together. +A `build/just/doctor-local.sh` runs sourced in a subshell, so an `exit` or a +tripped `set -e` in it cannot end the doctor early; it is reported as FAIL (PV-E51) +and the checks it did finish still count. + +The estate's pre-existing boilerplate doctors (the `rsr-diag` and +`toolchain-check` families, 326 of 346 measured on 2026-09-30) print `[FAIL]` +and exit 0; they are replaced by `doctor: provision::doctor`, not renamed. + +== 4. Toolchain + +* *mise*: tools at `latest`, `[settings] lockfile = true`; `mise.lock` pins each + to a version and per-platform checksum. The base set is `just` and `shellcheck`; + each detected language adds its own (rust, zig, julia, erlang+elixir, + erlang+gleam, opam, bun, lychee for docs). Banned tools (python, deno, node, + npm, yarn, go, java, make) never appear. +* *Guix*: `channels.scm` pins the commit; `manifest.scm` lists the development + shell. Rust repositories get a `cargo-build-system` `guix.scm`; every other + ecosystem gets a *source* package (`copy-build-system`) that says so, because a + hermetic compiled build needs that ecosystem's dependencies packaged in Guix, + which they are not. A spec may name an output (`rust:cargo`). + At commit ae77aeb Guix does not package idris2, gleam, bun or lychee. For those + tools the Guix shell provides mise. +* *just ≥ 1.42*: a root recipe depending on a module recipe + (`doctor: provision::doctor`) fails on 1.31, 1.36, 1.40 and 1.41 and works from + 1.42 (measured 2026-09-30). Doctor enforces the floor as PV-E02. +* *bun* is the only JavaScript runtime. Leftover deno, Nix, Makefile, Python or + npm files are WARNs (PV-W30–W34), not FAILs. Each deno repository gets one + migration issue. + +== 5. Template slots + +Templates carry two tiers of placeholder: + +* `+__X__+`: mechanical, filled by the generator from evidence (name, slug, + languages, tools, licence, copyright holder). +* `+__SPEC_X__+`: repository-specific prose (what it is, the questions to ask, + privacy notice, verification, usage, uninstall, architecture, gotchas, + release, CI). This is written by reading the repository. + +`provision-set check` treats any `+__X__+` residue as a hard failure. `+__SPEC_X__+` +residue is a hard failure at the pre-PR gate. In a development checkout, doctor +reports it as PV-W29 instead. AsciiDoc renders a stray `+__X__+` as italics, so +residue is visible as garbled text. + +The pipeline order is fixed: *mint → specialise → verify → PR*. + +== 6. Licence of minted files + +Minted files carry an SPDX header from birth. That is authoring, not +relicensing. The header matches the repository's classification in +`3-practice/LICENCE-POLICY.adoc`: + +* code and config (`+__LICENSE__+`) are MPL-2.0 for sole-owner repos, AGPL-3.0-or-later for the son-shared repos, and PMPL-1.0-or-later only for the register; +* prose docs (`+__DOC_LICENSE__+`) are CC-BY-SA-4.0 in sole-owner repos. + +When the generator cannot classify a repository from evidence, it refuses to mint +and records the repository in the campaign ledger. It never guesses, never +rewrites an existing header, and never touches forks. The engine files are +estate-wide and stay MPL-2.0 wherever they are vendored. + +== 7. OPSM + +Every `docs/SETUP.adoc` has an OPSM section with +`opsm install https://github.com/.git --registry git` and the steps to build +OPSM itself. Repositories without a registry package are fetched through OPSM's +git adapter, and the section says so. + +== 8. Conformance + +A repository conforms when, on a fresh clone: + +. `./launcher.sh --help` and `--version` exit 0; +. `just --list` parses and lists every verb in §2; +. `mise.toml` names no banned tool, and `mise.lock` gives every `mise.toml` tool a + concrete version and carries `sha256` checksums; +. `guix.scm`, `manifest.scm` and `channels.scm` are not stubs. The test is positive: + `guix.scm` must define every package field (name, version, source, build-system, + home-page, synopsis, description, licence), `manifest.scm` must list + specifications and `channels.scm` must pin a 40-hex commit; +. no template residue remains (§5); +. `just doctor` exits 0 once `just setup` has run. + +`templates/build/just/provision-check.sh` checks items 1–5 without network access. +It and doctor ask the same predicates in `provision-lib.sh` (`mise-banned`, +`mise-lock-gaps`, `guix-stub`), so the CI gate and `just doctor` cannot disagree. diff --git a/standards/provisioning/provisioning-standard_praxis.deed b/standards/provisioning/provisioning-standard_praxis.deed new file mode 100644 index 0000000..b49f7ed --- /dev/null +++ b/standards/provisioning/provisioning-standard_praxis.deed @@ -0,0 +1,127 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: MPL-2.0 +;; +;; provisioning-standard_praxis.deed — what every repository carries so that +;; nobody who clones it has to search for how to set it up, run it, test it, +;; diagnose it or repair it. +;; +;; Prose counterpart: 3-practice/provisioning/PROVISIONING-STANDARD.adoc +;; Launcher modes: launcher/launcher-standard_praxis.deed (provisioning-modes) +;; Templates: 3-practice/provisioning/templates/ +;; Offline checker: templates/build/just/provision-check.sh +(praxis-deed + :schema-version "1.0.0" + :canonical-name "provisioning-standard" + :beholding-chora #u5"estate/chora" + :standard-version "1.0.0" + :standard-date "2026-09-30" + :compliance ("PROVISIONING-STANDARD.adoc" "launcher-standard.adoc" + "TRUST-DEFAULTS-POLICY.adoc" "LICENCE-POLICY.adoc") + + ;; ------------------------------------------------------------------ scope + (scope + :orgs ("hyperpolymath" "metadatastician") + :exclude ("forks" "archived" "007" "dev-notes-vault" "memory-vault")) + + ;; ------------------------------------------------------------ artefact set + ;; engine = identical estate-wide; `provision-set realign` overwrites it. + ;; generated = rendered from the template + deed; realign re-renders it only + ;; when it carries the @launcher-deed block (hand-written ones are + ;; kept and source build/just/provision-modes.sh). + ;; minted = written once, then owned by the repository; realign creates it + ;; when missing and replaces it only while it is still a stub, or + ;; when the deed's :repo is another repository's (inherited). + ;; merged = the repository's own content is kept; the canon is added. + (artefacts + (file :path "launcher.sh" :kind generated :mode "100755") + (file :path "build/just/provision.just" :kind engine) + (file :path "build/just/provision-lib.sh" :kind engine :mode "100755") + (file :path "build/just/provision-modes.sh" :kind engine :mode "100755") + (file :path "build/just/provision-check.sh" :kind engine :mode "100755") + (file :path "channels.scm" :kind engine) + (file :path "Justfile" :kind merged) + (file :path "mise.toml" :kind minted) + (file :path "mise.lock" :kind minted) + (file :path "guix.scm" :kind minted) + (file :path "manifest.scm" :kind minted) + (file :path ".machine_readable/descriptiles/provisioning_praxis.deed" :kind minted) + (file :path "docs/SETUP.adoc" :kind minted) + (file :path "docs/AI_INSTALLATION_GUIDE.adoc" :kind minted) + (file :path "llm-warmup-user.adoc" :kind minted) + (file :path "llm-warmup-dev.adoc" :kind minted) + (file :path "llm-warmup-maintainer.adoc" :kind minted) + ;; placement: the Guix trio lives beside guix.scm (root, or build/ when the + ;; repository already has build/guix.scm); warm-ups at the root, or in + ;; docs/onboarding/ or docs/ when already kept there. Resolved by + ;; `provision-lib.sh guix-dir` / `set-files`, shared by doctor and check. + (placement :root-required ("launcher.sh" "Justfile" "mise.toml" "mise.lock") + :guix-dir ("." "build") :warmup-dir ("." "docs/onboarding" "docs")) + (section :file "README.adoc" :anchor "ai-install" :kind merged)) + + ;; ------------------------------------------------------------------- verbs + ;; Every verb exists both as a root recipe and as provision::. + ;; Launcher modes call the engine directly, so no root recipe can shadow them. + (verbs + :provisioning ("setup" "doctor" "heal" "dev-shell" "toolchain-refresh") + :lifecycle ("build" "test" "bench" "eval" "lint" "fmt" "fmt-check" "run" "deps") + :guidance ("ai-setup" "ai-warmup" "config-show" "opsm")) + (launcher-modes + (mode :flag "--setup" :verb "setup") + (mode :flag "--doctor" :verb "doctor") + (mode :flag "--heal" :verb "heal") + (mode :flag "--ai-setup" :verb "ai-setup")) + ;; A repository's own checks: root recipes -local, or scripts + ;; build/just/-local.sh. A failing doctor-local is FAIL PV-E50. + ;; A doctor-local.sh that exits (or trips set -e) before its end is FAIL PV-E51. + (local-hooks :verbs ("doctor" "setup" "heal") :suffix "-local") + (not-applicable :behaviour "print why and exit 0; never fake a result") + + (archetypes + (archetype :name app :runtime-modes #t) + (archetype :name library :runtime-modes #f) + (archetype :name tool :runtime-modes #f) + (archetype :name theory :runtime-modes #f) + (archetype :name docs :runtime-modes #f)) + + ;; --------------------------------------------------------------- toolchain + (toolchain + :mise-versions "latest" + :mise-lockfile #t + :mise-base ("just" "shellcheck") + :js-runtime "bun" + :banned-tools ("python" "deno" "node" "npm" "yarn" "pnpm" "go" "java" "kotlin" "make") + ;; Measured 2026-09-30: a root recipe depending on a module recipe fails on + ;; just 1.31, 1.36, 1.40, 1.41 and works from 1.42. + :just-floor "1.42.0" + :guix-channels "channels.scm" + :guix-package (rust "cargo-build-system" other "copy-build-system source package") + :guix-gaps ("idris2" "gleam" "bun" "lychee")) + + ;; ------------------------------------------------------------- slot tiers + (slots + (tier :name mechanical :pattern "__X__" :filled-by "provision-set mint" + :residue fail) + (tier :name specific :pattern "__SPEC_X__" :filled-by "per-repository specialisation" + :residue fail :residue-in-dev-checkout "warn PV-W29")) + (pipeline :order ("mint" "specialise" "verify" "pr")) + + ;; ----------------------------------------------------------------- licence + ;; Minted files carry SPDX from birth, chosen by the repository's + ;; classification in LICENCE-POLICY.adoc. Unclassifiable = refuse to mint. + (licence + :code-slot "__LICENSE__" + :doc-slot "__DOC_LICENSE__" + :sole ("MPL-2.0" "CC-BY-SA-4.0") + :son-shared ("AGPL-3.0-or-later" "AGPL-3.0-or-later") + :register-only "PMPL-1.0-or-later" + :unclassifiable "refuse and record in the campaign ledger" + :existing-headers "never rewritten") + + ;; ----------------------------------------------------------- doctor codes + ;; Full table: templates/docs/SETUP.adoc.tmpl §Troubleshooting. + (doctor + :summary-line "{name}: N PASS, N WARN, N FAIL" + :exit "non-zero iff any FAIL" + :fail-codes ("PV-E01" "PV-E02" "PV-E03" "PV-E10" "PV-E11" "PV-E20" "PV-E27" "PV-E40" "PV-E50" "PV-E51") + :warn-codes ("PV-W01" "PV-W20" "PV-W21" "PV-W22" "PV-W23" "PV-W24" "PV-W25" "PV-W26" + "PV-W27" "PV-W28" "PV-W29" "PV-W30" "PV-W31" "PV-W32" "PV-W33" "PV-W34" "PV-W35"))) diff --git a/standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl b/standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl new file mode 100644 index 0000000..5428097 --- /dev/null +++ b/standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl @@ -0,0 +1,32 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; provisioning_praxis.deed: the repo-specific facts behind `just setup/doctor/run/…`. +;; build/just/provision-lib.sh reads each `:key "value"` field below. Rules: +;; * one key per line, value in double quotes, and no double quote inside a value +;; (put anything that needs quoting in build/just/setup-local.sh or a script); +;; * an empty value "" means "use the per-language default"; +;; * a verb key (build test bench lint fmt fmt-check run deps) replaces the default command. +;; Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +(praxis-deed + :schema-version "1.0.0" + :canonical-name "__APP_NAME__-provisioning" + :repo "__REPO_SLUG__" + :archetype "__ARCHETYPE__" ; app | library | tool | theory | docs + :languages "__LANGS__" ; detected; informational + ;; Contract-verb overrides ("" = per-language default, which `just langs` shows) + :build "" + :test "" + :bench "" + :lint "" + :fmt "" + :fmt-check "" + :run "" + :deps "" + ;; Facts shown by `just config-show` and checked by `just doctor` + :config "" ; where runtime configuration lives + :ports "" ; ports an app listens on (estate: no 8080-class) + :system-deps "" ; OS commands that must exist, space-separated + ;; What `just ai-setup` and `just opsm` print ("" = the generic line) + :ai-say-it "" + :opsm-note "") diff --git a/standards/provisioning/templates/Justfile.tmpl b/standards/provisioning/templates/Justfile.tmpl new file mode 100644 index 0000000..779138e --- /dev/null +++ b/standards/provisioning/templates/Justfile.tmpl @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: __LICENSE__ +# SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +# +# __APP_NAME__ — every task is a recipe here; `just` lists them. +# The provisioning contract (setup doctor heal eval dev-shell toolchain-refresh +# ai-setup ai-warmup config-show opsm) comes from build/just/provision.just, and +# the per-language defaults for build/test/bench/lint/fmt/fmt-check/run from the same +# module. Define a recipe here to override one for this repository. +# Needs just >= 1.42 (a root recipe depending on a module recipe, e.g. `doctor: provision::doctor`). + +mod provision 'build/just/provision.just' + +# List every recipe +default: + @just --list --list-submodules + +__DELEGATIONS__ diff --git a/standards/provisioning/templates/README-ai-install.adoc.tmpl b/standards/provisioning/templates/README-ai-install.adoc.tmpl new file mode 100644 index 0000000..ddc6d1b --- /dev/null +++ b/standards/provisioning/templates/README-ai-install.adoc.tmpl @@ -0,0 +1,65 @@ +[TIP] +==== +*AI-assisted install:* tell any AI assistant + +`Set up __APP_NAME__ from https://github.com/__REPO_SLUG__` + +It reads this repository, asks a few questions and does the rest. <>. +==== + +[[ai-install]] +== AI-Assisted Installation (Recommended) + +=== Just say it + +You do not need to read the rest of this README. Say this to any AI assistant +that can read a URL and run commands (or give you commands to paste): + +[source,text] +---- +Set up __APP_NAME__ from https://github.com/__REPO_SLUG__ +---- + +The URL is the key: it leads the assistant to +link:docs/AI_INSTALLATION_GUIDE.adoc[`docs/AI_INSTALLATION_GUIDE.adoc`], the +complete step-by-step recipe for this repository. The assistant checks your +system, installs the prerequisites, fetches and sets up __APP_NAME__, and verifies +it with `just doctor`. + +=== Other ways to say it + +* "Install https://github.com/__REPO_SLUG__ for me" +* "Get __APP_NAME__ working on my machine — https://github.com/__REPO_SLUG__" +* `./launcher.sh --ai-setup` (or `just ai-setup`) prints the line and copies it to your clipboard. + +=== What you will be asked + +. Your operating system (Linux, macOS, or Windows via WSL2). +. Where to put it (default `~/src/__APP_NAME__`). +. To confirm the privacy notice below. +__SPEC_QUESTIONS__ + +=== Privacy and security + +[IMPORTANT] +==== +Setting up __APP_NAME__ clones this repository and installs its developer tools +per-user with mise (no administrator rights, nothing sent anywhere). + +__SPEC_PRIVACY__ +==== + +=== After install + +__SPEC_USAGE__ + +=== Uninstall + +Tell your assistant "Uninstall __APP_NAME__", or delete the checkout +(`rm -rf ~/src/__APP_NAME__`). Shared tools are removed with `mise uninstall`. + +=== Troubleshooting + +Run `just doctor`; each FAIL carries a code explained in +link:docs/SETUP.adoc#troubleshooting[`docs/SETUP.adoc` §Troubleshooting], and +`just heal` applies the automatic fixes. Prefer to do it yourself? The full +manual route is link:docs/SETUP.adoc[`docs/SETUP.adoc`]; developers and +maintainers can brief an AI with `just ai-warmup dev` / `just ai-warmup maintainer`. diff --git a/standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl b/standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl new file mode 100644 index 0000000..9b58c2d --- /dev/null +++ b/standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += AI Installation Guide — __APP_NAME__ +:toc: + +[IMPORTANT] +==== +*This file is written for an AI assistant.* A person asked you to set up +__APP_NAME__ (https://github.com/__REPO_SLUG__). Follow these steps in order. Run +each command yourself if you can run commands; otherwise give the person one +command at a time and wait for its output. Never skip the privacy notice (step 2) +or the verification (step 6). If a step fails, go to <>; do not +improvise a different installer. +==== + +== What this is + +__APP_NAME__ is a __ARCHETYPE__ written in __LANGS__. +__SPEC_WHAT_IT_IS__ + +== 1. Ask the person + +Ask only these, then proceed: + +. *Which operating system?* Linux (which distribution), macOS, or Windows. On +Windows, the steps run inside WSL2; if they have no WSL2, have them run +`wsl --install` in an administrator PowerShell and reboot first. +. *Where should it live?* Default: `~/src/__APP_NAME__`. +__SPEC_QUESTIONS__ + +== 2. Tell the person what will happen (privacy notice) + +Show them this, and continue only after they agree: + +____ +Setting up __APP_NAME__ will: + +* clone https://github.com/__REPO_SLUG__ into the folder you chose; +* install developer tools with mise into `~/.local/share/mise` (per-user, no root), + from their official releases: __MISE_TOOLS__; +* fetch this project's own dependencies from their public registries. + +__SPEC_PRIVACY__ + +It will not: change system settings, need administrator rights (except the OS +packages in step 3, which you approve individually), send data anywhere, or +install anything outside the folders above. +____ + +== 3. Prerequisites + +Check each; install only what is missing. + +[source,bash] +---- +git --version # any version +mise --version # any version +---- + +* Missing git: Fedora `sudo dnf install git` · Debian/Ubuntu `sudo apt install git` · macOS `xcode-select --install`. +* Missing mise: use the OS package manager (macOS `brew install mise` · Fedora + `sudo dnf copr enable jdxcode/mise && sudo dnf install mise` · Windows + `winget install jdx.mise` · others: https://mise.jdx.dev/installing-mise.html). + Only if none fits: download the installer, show it to the user, and run it + once they agree: `curl -fsSLo mise-install.sh https://mise.run && sh mise-install.sh`. + Never pipe a download into a shell. Then run the activation line it + prints (e.g. `echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc`) + and open a new shell. +* A C toolchain, if `cc --version` fails: Fedora `sudo dnf install gcc` · Debian/Ubuntu `sudo apt install build-essential`. +__SYSTEM_DEPS_AI__ + +== 4. Fetch and set up + +[source,bash] +---- +git clone https://github.com/__REPO_SLUG__.git ~/src/__APP_NAME__ +cd ~/src/__APP_NAME__ +./launcher.sh --setup +---- + +`./launcher.sh --setup` installs `just` if needed, runs `mise install`, fetches +this project's dependencies, and ends with `just doctor`. It may take several +minutes the first time; that is normal. + +== 5. If you cannot run the launcher + +Run the same steps directly: + +[source,bash] +---- +mise trust && mise install +just setup +---- + +The full manual route, with every step explained, is `docs/SETUP.adoc`. + +== 6. Verify + +[source,bash] +---- +just doctor +---- + +Success is: exit status 0 and the last line reads `__APP_NAME__: N PASS, N WARN, 0 FAIL`. +WARN lines are advice; mention them to the person but they do not block use. +Then confirm it works: + +[source,bash] +---- +__SPEC_VERIFY__ +---- + +== 7. Tell the person how to use it + +__SPEC_USAGE__ + +Every task is a `just` recipe; `just` lists them. `./launcher.sh --help` explains +the launcher. + +== Uninstall + +[source,bash] +---- +rm -rf ~/src/__APP_NAME__ # the checkout (and everything it built) +---- + +Tools mise installed are shared with other projects; remove one only if nothing +else uses it: `mise uninstall @` (`mise ls` lists them). +__SPEC_UNINSTALL__ + +[[if-a-step-fails]] +== If a step fails + +. Run `just doctor` and read the code on each FAIL line (PV-E…). +. Look it up in `docs/SETUP.adoc` §Troubleshooting and apply the fix. `just heal` + applies every automatic fix. +. Re-run `just doctor`. +. If it still fails, save `just doctor > doctor.txt 2>&1` and help the person open + an issue at https://github.com/__REPO_SLUG__/issues with that file. diff --git a/standards/provisioning/templates/docs/SETUP.adoc.tmpl b/standards/provisioning/templates/docs/SETUP.adoc.tmpl new file mode 100644 index 0000000..769a47a --- /dev/null +++ b/standards/provisioning/templates/docs/SETUP.adoc.tmpl @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += Setting up __APP_NAME__ by hand +:toc: +:toclevels: 2 + +This is the complete manual route: every step, in order, with nothing assumed. +You do not need it if you use `./launcher.sh --setup` (or ask an AI, see +link:AI_INSTALLATION_GUIDE.adoc[AI_INSTALLATION_GUIDE.adoc]), but those do +exactly what is written here, so this page is also what to read when they stop. + +[horizontal] +Repository:: https://github.com/__REPO_SLUG__ +Kind:: __ARCHETYPE__ (__LANGS__) +Toolchain declared in:: `mise.toml` (versions `latest`, made concrete in `mise.lock`), and +`__GUIX_PREFIX__manifest.scm` / `__GUIX_PREFIX__guix.scm` / `__GUIX_PREFIX__channels.scm` for Guix +Every command below is also a `just` recipe:: run `just` to list them + +[[prerequisites]] +== 1. Prerequisites + +You need three things from your operating system. Everything else is installed +for you in step 3. + +[cols="1,2,3"] +|=== +|Tool |Why |Install + +|git +|to fetch the repository +|Fedora `sudo dnf install git` · Debian/Ubuntu `sudo apt install git` · macOS `xcode-select --install` · Windows `winget install Git.Git` + +|a C toolchain +|some language toolchains link native code +|Fedora `sudo dnf install gcc` · Debian/Ubuntu `sudo apt install build-essential` · macOS (included with the Xcode tools above) · Windows: use WSL2 + +|mise +|installs every other tool at the version this repo declares +|macOS `brew install mise` · Fedora `sudo dnf copr enable jdxcode/mise && sudo dnf install mise` · Windows `winget install jdx.mise` · Debian/Ubuntu and the rest: https://mise.jdx.dev/installing-mise.html — or download the installer, read it, then run it: `curl -fsSLo mise-install.sh https://mise.run && less mise-install.sh && sh mise-install.sh`. Then follow the one line it prints to activate it in your shell +|=== + +NOTE: On Windows, use WSL2 (`wsl --install`) and follow the Linux steps inside +it. The launcher detects the platform and says so in `./launcher.sh --version`. + +__SYSTEM_DEPS_SECTION__ + +== 2. Get the code + +[source,bash] +---- +git clone https://github.com/__REPO_SLUG__.git +cd __APP_NAME__ +---- + +Or with OPSM, the odds-and-sods package manager (see <>): +`opsm install https://github.com/__REPO_SLUG__.git --registry git` + +== 3. Install the toolchain + +[source,bash] +---- +mise trust # allow this repository's mise.toml +mise install # installs everything mise.toml lists, at mise.lock's versions +---- + +That installs: + +[cols="1,2,3"] +|=== +|Language |Tools |If mise cannot supply it + +__TOOL_TABLE__ +|=== + +`just` itself is installed by `mise install`. Without mise, install just from your +OS instead (`dnf install just`, `brew install just`, `cargo install just`); this +repository needs just 1.42 or newer. + +== 4. Set it up + +[source,bash] +---- +just setup +---- + +`just setup` repeats step 3 (harmless), fetches this repository's own +dependencies (__LANGS__), and finishes by running `just doctor`. + +== 5. Check it + +[source,bash] +---- +just doctor # PASS / WARN / FAIL for every requirement, with the fix for each +---- + +`just doctor` exits 0 when nothing FAILs. A WARN is advice, not a failure. +Every code it prints is explained in <>. + +== 6. Use it + +[source,bash] +---- +just build # build +just test # run the tests +just bench # run the benchmarks (says N/A when there are none) +just eval # tests + benchmarks with timings, saved under .eval/ +just run # run it (says N/A for a library) +just config-show # where the toolchain and config are declared +---- + +Anything that does not apply to a __ARCHETYPE__ says so and exits 0; nothing is faked. + +== 7. Keep it current + +[source,bash] +---- +just toolchain-refresh # mise up --bump, re-lock mise.lock, re-pin __GUIX_PREFIX__channels.scm +just heal # re-apply safe fixes if something drifted +---- + +[[guix]] +== The Guix route (alternative to steps 3–4) + +With GNU Guix installed you can skip mise for everything Guix packages: + +[source,bash] +---- +guix shell -m __GUIX_PREFIX__manifest.scm # the development shell +guix time-machine -C __GUIX_PREFIX__channels.scm -- shell -m __GUIX_PREFIX__manifest.scm # the exact, pinned Guix +guix build -f __GUIX_PREFIX__guix.scm # the package +---- + +Tools Guix does not package: __GUIX_GAPS__. The Guix shell includes mise, so inside +it run `mise install` for those. `just dev-shell` picks Guix when it is present and mise +when it is not. + +[[opsm]] +== OPSM (odds-and-sods package manager) + +[source,bash] +---- +opsm install https://github.com/__REPO_SLUG__.git --registry git +---- + +__APP_NAME__ has no registry package, so OPSM fetches it through its git +adapter; then provision it with `./launcher.sh --setup`. To get OPSM itself +(Erlang/OTP 26+ and Elixir 1.16+ required): + +[source,bash] +---- +git clone https://github.com/hyperpolymath/odds-and-sods-package-manager.git +cd odds-and-sods-package-manager/opsm_ex +mix deps.get && mix escript.build +install -m 0755 opsm ~/.local/bin/opsm +opsm --version +---- + +`just opsm` prints the same instructions. + +[[troubleshooting]] +== Troubleshooting + +Run `just doctor`, find the code it printed, apply the fix. `just heal` applies +every fix marked *auto* for you. + +[cols="1,3,3,1"] +|=== +|Code |Meaning |Fix |Auto + +|PV-E01 |git is not installed |Install git (step 1) |no +|PV-E02 |just is missing or older than 1.42 |`mise use -g just@latest` |no +|PV-E03 |a tool in mise.toml is not installed |`just setup` (or `mise install`) |yes +|PV-E10 |a language toolchain binary is missing |`just setup`; the doctor line names the manual route |yes, via mise +|PV-E11 |a system dependency from the provisioning deed is missing |install it from your OS (see <>) |no +|PV-E20 |mise.toml is missing |restore it: `git checkout -- mise.toml` |no +|PV-E27 |launcher.sh is not executable |`chmod +x launcher.sh` |yes +|PV-E40 |neither Guix nor mise is installed, so there is no dev shell |install mise (step 1) |no +|PV-E50 |this repository's own `doctor-local` checks failed |read the output above the summary line; the recipe is in the Justfile |no +|PV-E51 |`build/just/doctor-local.sh` exited before its end (an `exit` or a failing command under `set -e`) |the doctor names the exit status; fix the script so it reports with pass/warn/fail instead of exiting |no +|PV-W01 |mise is not installed |install mise (step 1) |no +|PV-W20 |mise.lock is missing, so `latest` is not concrete |`just toolchain-refresh` |yes +|PV-W21 |both mise.toml and .mise.toml exist |merge into mise.toml, delete .mise.toml |no +|PV-W22 |a .tool-versions file competes with mise.toml |fold it into mise.toml |no +|PV-W23 |mise.toml pins a tool the estate does not use |remove that line |no +|PV-W24 |a Guix file is an unfilled template |regenerate it (maintainers: `provision-set realign`) |no +|PV-W25 |guix.scm is missing |as PV-W24 |no +|PV-W26 |manifest.scm is missing |as PV-W24; mise still works |no +|PV-W27 |launcher.sh is missing |as PV-W24 |no +|PV-W28 |a setup document is missing |as PV-W24 |no +|PV-W29 |a setup document still holds unfilled template slots |replace each `__SPEC_…__` slot with the facts for this repository |no +|PV-W30 |Deno files remain |the estate runtime is bun; a migration issue tracks it |no +|PV-W31 |Nix files remain |use Guix |no +|PV-W32 |a Makefile remains |use the Justfile |no +|PV-W33 |Python files remain |not an estate language |no +|PV-W34 |npm/yarn/pnpm/TypeScript files remain |use bun |no +|PV-W35 |both guix.scm and build/guix.scm exist |keep one; the engine uses build/ |no +|=== + +Still stuck? `just doctor > doctor.txt 2>&1` and open an issue at +https://github.com/__REPO_SLUG__/issues with that file attached. diff --git a/standards/provisioning/templates/guix/channels.scm b/standards/provisioning/templates/guix/channels.scm new file mode 100644 index 0000000..54ac61a --- /dev/null +++ b/standards/provisioning/templates/guix/channels.scm @@ -0,0 +1,18 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; channels.scm — the Guix revision this repository's guix.scm and manifest.scm +;; were verified against. Reproduce that exact Guix with: +;; +;; guix time-machine -C channels.scm -- shell -m manifest.scm +;; +;; Refresh it (after re-verifying) with: just toolchain-refresh +;; Canon pin, verified 2026-09-30 (docker.io/metacall/guix, guix describe). +(list (channel + (name 'guix) + (url "https://codeberg.org/guix/guix.git") + (branch "master") + (commit "ae77aeb9543de2661d739104bc4d3803d8c6f38b") + (introduction + (make-channel-introduction + "9edb3f66fd807b096b48283debdcddccfea34bad" + (openpgp-fingerprint + "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA"))))) diff --git a/standards/provisioning/templates/guix/guix.scm.cargo.tmpl b/standards/provisioning/templates/guix/guix.scm.cargo.tmpl new file mode 100644 index 0000000..29c9998 --- /dev/null +++ b/standards/provisioning/templates/guix/guix.scm.cargo.tmpl @@ -0,0 +1,40 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; guix.scm — builds __APP_NAME__ hermetically with Guix (offline, from Cargo.lock). +;; +;; guix build -f guix.scm # the package +;; guix shell -D -f guix.scm # its build environment +;; +;; Crate inputs live in build/guix/crates.scm, generated from Cargo.lock by +;; guix import crate --lockfile=Cargo.lock __APP_NAME__ +;; `just toolchain-refresh` regenerates it; never hand-edit it. +(use-modules (guix packages) (guix gexp) + (guix build-system cargo) + ((guix licenses) #:prefix license:)) + +;; The repository root: this file sits at the root or in build/ (PROVISIONING-STANDARD §1). +(define %source-dir + (let ((d (dirname (current-filename)))) + (if (string=? (basename d) "build") (dirname d) d))) +(load (string-append %source-dir "/build/guix/crates.scm")) ; defines %crate-inputs + +;; Build products and caches never enter the source (git-predicate is not used: +;; it breaks on tarball checkouts and on bind-mounted trees). +(define %ignored + '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) + +(package + (name "__APP_NAME__") + (version "__APP_VERSION__") + (source (local-file %source-dir "__APP_NAME__-checkout" + #:recursive? #t + #:select? (lambda (file stat) + (not (member (basename file) %ignored))))) + (build-system cargo-build-system) + (arguments (list #:install-source? #f)) + (inputs %crate-inputs) + (home-page "https://github.com/__REPO_SLUG__") + (synopsis "__SYNOPSIS__") + (description "__DESCRIPTION__") + (license __GUIX_LICENSE__)) diff --git a/standards/provisioning/templates/guix/guix.scm.source.tmpl b/standards/provisioning/templates/guix/guix.scm.source.tmpl new file mode 100644 index 0000000..bfc5433 --- /dev/null +++ b/standards/provisioning/templates/guix/guix.scm.source.tmpl @@ -0,0 +1,43 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; guix.scm — __APP_NAME__ as a Guix package. +;; +;; guix build -f guix.scm # installs the source tree to share/__APP_NAME__ +;; guix shell -D -f guix.scm # the full development toolchain +;; +;; This is a SOURCE package, and says so: a hermetic compiled build needs this +;; repository's __LANGS__ dependencies packaged in Guix, which they are not +;; (Guix builds offline). The toolchain below is real — `guix shell -D -f guix.scm` +;; then `just setup` gives a working environment. See docs/SETUP.adoc §Guix. +(use-modules (guix packages) (guix gexp) + (guix build-system copy) + (gnu packages) + ((guix licenses) #:prefix license:)) + +;; The repository root: this file sits at the root or in build/ (PROVISIONING-STANDARD §1). +(define %source-dir + (let ((d (dirname (current-filename)))) + (if (string=? (basename d) "build") (dirname d) d))) +;; A spec may name an output ("rust:cargo"); plain specification->package cannot. +(define (spec->input spec) + (call-with-values (lambda () (specification->package+output spec)) + (lambda (pkg out) (if (string=? out "out") pkg (list pkg out))))) + +(define %ignored + '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) + +(package + (name "__APP_NAME__") + (version "__APP_VERSION__") + (source (local-file %source-dir "__APP_NAME__-checkout" + #:recursive? #t + #:select? (lambda (file stat) + (not (member (basename file) %ignored))))) + (build-system copy-build-system) + (arguments (list #:install-plan #~'(("." "share/__APP_NAME__/")))) + (native-inputs (map spec->input (list __GUIX_PKG_SPECS__))) + (home-page "https://github.com/__REPO_SLUG__") + (synopsis "__SYNOPSIS__") + (description "__DESCRIPTION__") + (license __GUIX_LICENSE__)) diff --git a/standards/provisioning/templates/guix/manifest.scm.tmpl b/standards/provisioning/templates/guix/manifest.scm.tmpl new file mode 100644 index 0000000..f465e7e --- /dev/null +++ b/standards/provisioning/templates/guix/manifest.scm.tmpl @@ -0,0 +1,14 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; manifest.scm — the Guix development shell for __APP_NAME__. +;; +;; guix shell -m manifest.scm # or: just dev-shell +;; guix time-machine -C channels.scm -- shell -m manifest.scm # the pinned Guix +;; +;; Minted by provision-set from the languages detected here (__LANGS__). +;; Tools Guix does not package: __GUIX_GAPS__. They come from mise, which this +;; shell provides: guix shell -m manifest.scm -- mise install +;; Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +(specifications->manifest + (list __GUIX_SPECS__)) diff --git a/standards/provisioning/templates/launcher.sh.tmpl b/standards/provisioning/templates/launcher.sh.tmpl new file mode 100755 index 0000000..1777fde --- /dev/null +++ b/standards/provisioning/templates/launcher.sh.tmpl @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: __LICENSE__ +# SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +# +# @launcher-deed begin +# ;; SPDX-License-Identifier: __LICENSE__ +# (praxis-deed +# :schema-version "1.0.0" +# :canonical-name "__APP_NAME__-launcher" +# :beholding-chora #u5"estate/chora" +# (artefact :type "launcher" :version "__APP_VERSION__" +# :generator "provision-set") +# (app :name "__APP_NAME__" :display "__APP_NAME__" +# :url "https://github.com/__REPO_SLUG__" :archetype "__ARCHETYPE__") +# (compliance :standard-version "0.5.0" +# :standards ("launcher-standard.adoc" +# "PROVISIONING-STANDARD.adoc")) +# (modes :accepted ("--setup" "--doctor" "--heal" "--ai-setup" "--help" "--version" +# "--start" "--stop" "--status" "--auto" "--integ" "--disinteg")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("provision" "diagnose" "heal") +# :deferred ("install" "run"))) +# @launcher-deed end +# +# The launcher for a library / tool / theory / docs repository +# (launcher-standard 0.5.0, archetype profile). Every mode is implemented in +# build/just/provision-modes.sh, and every provisioning mode delegates to the +# Justfile. This file is minted by `provision-set`; do not hand-edit it. +# Repo-specific facts belong in .machine_readable/descriptiles/provisioning_praxis.deed. +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +if [ ! -f "$REPO_DIR/build/just/provision-modes.sh" ]; then + echo "launcher.sh: build/just/provision-modes.sh is missing — this checkout is incomplete." >&2 + echo "Re-clone, or restore it: git checkout -- build/just/provision-modes.sh" >&2 + exit 1 +fi +# shellcheck source=build/just/provision-modes.sh +. "$REPO_DIR/build/just/provision-modes.sh" + +hp_launcher_main "$@" diff --git a/standards/provisioning/templates/llm-warmup-dev.adoc.tmpl b/standards/provisioning/templates/llm-warmup-dev.adoc.tmpl new file mode 100644 index 0000000..c85e118 --- /dev/null +++ b/standards/provisioning/templates/llm-warmup-dev.adoc.tmpl @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += LLM warm-up — __APP_NAME__ (developer) + +Paste this into an AI assistant before changing code in __APP_NAME__. + +== Orientation + +__APP_NAME__ (https://github.com/__REPO_SLUG__): a __ARCHETYPE__ in __LANGS__. + +__SPEC_ARCHITECTURE__ + +== Toolchain + +* Declared in `mise.toml` (specs `latest`), made concrete in `mise.lock`. Never + hand-edit versions; `just toolchain-refresh` bumps and re-locks. +* Guix alternative: `__GUIX_PREFIX__manifest.scm` (dev shell), `__GUIX_PREFIX__guix.scm` (package), + `__GUIX_PREFIX__channels.scm` (pinned Guix). `just dev-shell` picks whichever is present. +* `just` ≥ 1.42. The shared recipes live in `build/just/provision.just` + (`mod provision`); root recipes in `Justfile` override them. + +== The loop + +[source,bash] +---- +just setup # once +just build +just test +just bench # N/A when there are no benchmarks +just eval # test + bench with timings, logged under .eval/ +just lint && just fmt +just doctor # before you open a PR +---- + +__SPEC_DEV_COMMANDS__ + +== Where things are + +__SPEC_LAYOUT__ + +== Rules that bite + +* Languages: bun is the JS runtime; Python, Deno, TypeScript, ReScript, Nix, + Go and Makefiles are banned. New code follows the repository's existing languages. +* Every new file carries an SPDX header: `__LICENSE__` for code and config, + `__DOC_LICENSE__` for prose docs. Never change an existing file's licence. +* Commits are signed; history is linear (squash merges). + +__SPEC_GOTCHAS__ diff --git a/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl b/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl new file mode 100644 index 0000000..62085cb --- /dev/null +++ b/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += LLM warm-up — __APP_NAME__ (maintainer) + +Paste this into an AI assistant before release, CI or governance work on +__APP_NAME__. Read `llm-warmup-dev.adoc` first; this adds what a maintainer owns. + +== The provisioning set + +This repository is provisioned from the estate canon +(https://github.com/hyperpolymath/standards, `3-practice/provisioning/`). +Repo-specific facts live in `.machine_readable/descriptiles/provisioning_praxis.deed`. +`provision-set realign` overwrites only the engine: `launcher.sh`, +`build/just/provision*.{just,sh}` and `channels.scm`; change behaviour through the +deed, never by hand-editing those. Everything else (`guix.scm` once real, +`manifest.scm`, `mise.toml`, the Justfile, `docs/` and these warm-ups) is minted +once and then belongs to this repository: keep it true as the code changes. + +== Keeping it current + +* `just toolchain-refresh` — `mise up --bump`, `mise lock`, re-pin `channels.scm`. + Commit the lock changes on their own. +* `just doctor` must be clean before a release; WARN codes are tracked, not ignored. +* The `provisioning-check` workflow reports drift from the canon. + +== Release + +__SPEC_RELEASE__ + +== CI and branch rules + +__SPEC_CI__ + +Rulesets can block a merge for reasons other than checks (review threads, +signatures, code owners): enumerate the ruleset's rule types before calling a PR +mergeable. + +== Decisions + +Owner rulings for the estate are recorded in `hyperpolymath/standards` +(issue #787 is the decision surface). Do not re-open a ruled question; cite it. +__SPEC_DECISIONS__ diff --git a/standards/provisioning/templates/llm-warmup-user.adoc.tmpl b/standards/provisioning/templates/llm-warmup-user.adoc.tmpl new file mode 100644 index 0000000..64f149c --- /dev/null +++ b/standards/provisioning/templates/llm-warmup-user.adoc.tmpl @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += LLM warm-up — __APP_NAME__ (user) + +Paste this whole file into any AI assistant before asking it about +__APP_NAME__. It tells the assistant what the project is, how a person gets it +running, and where to look when something goes wrong. + +== What it is + +__APP_NAME__ (https://github.com/__REPO_SLUG__) is a __ARCHETYPE__ written in __LANGS__. + +__SPEC_WHAT_IT_IS__ + +== Getting it running + +The supported routes, in order of ease: + +. *Ask an AI*: follow `docs/AI_INSTALLATION_GUIDE.adoc` in the repository. +. *The launcher*: `git clone https://github.com/__REPO_SLUG__.git && cd __APP_NAME__ && ./launcher.sh --setup` +. *By hand*: `docs/SETUP.adoc`, every step explained. + +Prerequisites are git, a C toolchain and mise; everything else is installed by +`mise install`, at the versions pinned in `mise.lock`. + +== Using it + +__SPEC_USAGE__ + +== When something is wrong + +Run `just doctor`. Every FAIL line carries a code (PV-E…) that is explained, with +its fix, in `docs/SETUP.adoc` §Troubleshooting. `just heal` applies the automatic +fixes. Do not guess an installer that the repository does not document. + +== What it touches + +__SPEC_PRIVACY__ diff --git a/standards/provisioning/templates/mise.toml.tmpl b/standards/provisioning/templates/mise.toml.tmpl new file mode 100644 index 0000000..be4bc98 --- /dev/null +++ b/standards/provisioning/templates/mise.toml.tmpl @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: __LICENSE__ +# SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +# +# The toolchain for __APP_NAME__ (__LANGS__). Specs are `latest`; mise.lock +# records the concrete version and per-platform checksum of each, so every +# checkout installs the same bits. Refresh both with: just toolchain-refresh +# Canon: hyperpolymath/standards 3-practice/provisioning (TRUST-DEFAULTS §toolchain). +# Estate policy: no python, deno, node/npm/yarn, make or nix here; the JS runtime is bun. + +[settings] +lockfile = true + +[tools] +__MISE_TOOLS_TOML__ From 783dfaed465169385d6c0df002490e51e98b863e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:01:14 +0100 Subject: [PATCH 02/26] feat(provision-set): mint/realign with licence classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mint and realign are one operation (standard §1): engine files are byte-copied, minted files are created when missing and replaced only while stubs or inherited; a mise.toml pinning a banned tool is replaced with its other [tools] entries (and their pins) carried over. Facts come from the engine's provision-lib.sh run against the target. The licence is classified from the repository's own licence text before any write; a refusal exits 3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../src/provisioning/licence.rs | 281 ++++++ .../launcher-common/src/provisioning/mint.rs | 923 ++++++++++++++++++ .../launcher-common/src/provisioning/mod.rs | 2 + crates/launcher/src/cmd_provision_set.rs | 68 ++ 4 files changed, 1274 insertions(+) create mode 100644 crates/launcher-common/src/provisioning/licence.rs create mode 100644 crates/launcher-common/src/provisioning/mint.rs diff --git a/crates/launcher-common/src/provisioning/licence.rs b/crates/launcher-common/src/provisioning/licence.rs new file mode 100644 index 0000000..2da3d4b --- /dev/null +++ b/crates/launcher-common/src/provisioning/licence.rs @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Classify a repository's licence from its own licence text, as +//! `PROVISIONING-STANDARD.adoc` §6 requires before a single file is minted. +//! +//! Minted files carry an SPDX header from birth, so the header must match the +//! repository's classification in `3-practice/LICENCE-POLICY.adoc`. Evidence is +//! the repository's licence files and nothing else: when they do not settle the +//! question the answer is a refusal, never a guess. + +use std::path::Path; + +/// LICENCE-POLICY Rule 2: the only repositories that may carry PMPL. A PMPL +/// declaration anywhere else is drift, so it is refused, not minted. +pub const PMPL_REGISTER: &[&str] = &[ + "palimpsest-license", + "palimpsest-plasma", + "consent-aware-web", + "insolvency-tycoon", + "sim-public-relations", +]; + +/// Repositories outside the provisioning set (standard §1): `007` is all +/// rights reserved and the vaults are private stores. +pub const OUT_OF_SCOPE: &[&str] = &["007", "dev-notes-vault", "memory-vault"]; + +/// A classified repository: the SPDX identifiers minted files carry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Licence { + /// `__LICENSE__`: code, config and scripts. + pub code: &'static str, + /// `__DOC_LICENSE__`: prose documents. + pub doc: &'static str, + /// The Guix `license` field. + pub guix: &'static str, + /// Why `doc` was chosen, when no ruling names it. The campaign puts this + /// in the PR body so the owner can ratify or correct it. + pub unratified: Option<&'static str>, +} + +const MPL: Licence = Licence { + code: "MPL-2.0", + doc: "CC-BY-SA-4.0", + guix: "license:mpl2.0", + unratified: None, +}; +const AGPL: Licence = Licence { + code: "AGPL-3.0-or-later", + doc: "AGPL-3.0-or-later", + guix: "license:agpl3+", + unratified: Some( + "LICENCE-POLICY Rule 3 names no prose licence for son-shared repositories, so docs carry the code licence", + ), +}; +// Guix has no PMPL; LICENCE-POLICY Rule 2 makes MPL-2.0 its legal fallback. +const PMPL: Licence = Licence { + code: "PMPL-1.0-or-later", + doc: "PMPL-1.0-or-later", + guix: "license:mpl2.0", + unratified: Some( + "LICENCE-POLICY Rule 2 names no prose licence for PMPL repositories, so docs carry the code licence", + ), +}; + +/// Why a repository was not classified. Each is a ledger line, not an error to +/// work around. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + OutOfScope(String), + NoLicenceFile, + Unrecognised(Vec), + Ambiguous(Vec<&'static str>), + PmplOutsideRegister(String), +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::OutOfScope(r) => { + write!(f, "{r} is outside the provisioning set (standard §1)") + } + Refusal::NoLicenceFile => write!(f, "no LICENSE, LICENCE, COPYING or LICENSES/ file"), + Refusal::Unrecognised(files) => write!( + f, + "licence text in {} is not MPL-2.0, AGPL-3.0 or PMPL (third-party or fork?)", + files.join(", ") + ), + Refusal::Ambiguous(found) => { + write!(f, "licence files disagree: {}", found.join(" and ")) + } + Refusal::PmplOutsideRegister(r) => write!( + f, + "{r} declares PMPL but is not in the LICENCE-POLICY Rule 2 register (drift: flag, do not mint)" + ), + } + } +} + +impl std::error::Error for Refusal {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Signal { + Mpl, + Agpl, + Pmpl, +} + +impl Signal { + fn name(self) -> &'static str { + match self { + Signal::Mpl => "MPL-2.0", + Signal::Agpl => "AGPL-3.0", + Signal::Pmpl => "PMPL", + } + } +} + +/// What one licence text declares. PMPL is derived from MPL and names it, so a +/// Palimpsest text is PMPL however often it mentions Mozilla. +fn signal(text: &str) -> Option { + let t = text.to_ascii_lowercase(); + if t.contains("palimpsest") || t.contains("pmpl-1.0") { + Some(Signal::Pmpl) + } else if t.contains("gnu affero general public license") || t.contains("agpl-3.0") { + Some(Signal::Agpl) + } else if (t.contains("mozilla public license") && t.contains("2.0")) || t.contains("mpl-2.0") { + Some(Signal::Mpl) + } else { + None + } +} + +fn is_licence_name(name: &str) -> bool { + let n = name.to_ascii_uppercase(); + n.starts_with("LICENSE") || n.starts_with("LICENCE") || n.starts_with("COPYING") +} + +/// Classify `target`, whose repository name is `repo`. +pub fn classify(target: &Path, repo: &str) -> Result { + if OUT_OF_SCOPE.contains(&repo) { + return Err(Refusal::OutOfScope(repo.to_string())); + } + // The root licence files are the declaration. LICENSES/ (REUSE layout) is + // read only when the root has none, and there its CC-BY-SA text is the prose + // licence, not a competing code licence. + let mut files: Vec = read_dir_sorted(target) + .into_iter() + .filter(|p| { + p.is_file() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(is_licence_name) + }) + .collect(); + if files.is_empty() { + files = read_dir_sorted(&target.join("LICENSES")) + .into_iter() + .filter(|p| p.is_file()) + .collect(); + } + if files.is_empty() { + return Err(Refusal::NoLicenceFile); + } + let mut found: Vec = Vec::new(); + let mut unread = Vec::new(); + for f in &files { + let text = std::fs::read_to_string(f).unwrap_or_default(); + match signal(&text) { + Some(s) => { + if !found.contains(&s) { + found.push(s); + } + } + None => unread.push( + f.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + ), + } + } + match found.as_slice() { + [] => Err(Refusal::Unrecognised(unread)), + [Signal::Mpl] => Ok(MPL), + [Signal::Agpl] => Ok(AGPL), + [Signal::Pmpl] if PMPL_REGISTER.contains(&repo) => Ok(PMPL), + [Signal::Pmpl] => Err(Refusal::PmplOutsideRegister(repo.to_string())), + many => Err(Refusal::Ambiguous(many.iter().map(|s| s.name()).collect())), + } +} + +fn read_dir_sorted(dir: &Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir) + .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect()) + .unwrap_or_default(); + v.sort(); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo(files: &[(&str, &str)]) -> std::path::PathBuf { + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let d = std::env::temp_dir().join(format!("licence-test-{}-{n}", std::process::id())); + for (p, body) in files { + let f = d.join(p); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(f, body).unwrap(); + } + std::fs::create_dir_all(&d).unwrap(); + d + } + + const MPL_TEXT: &str = + "Mozilla Public License Version 2.0\n==================================\n"; + const AGPL_TEXT: &str = " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n"; + const PMPL_TEXT: &str = + "Palimpsest-MPL License 1.0\nderived from the Mozilla Public License Version 2.0\n"; + + #[test] + fn mpl_repo_gets_cc_by_sa_docs() { + let d = repo(&[("LICENSE", MPL_TEXT)]); + assert_eq!(classify(&d, "aerie"), Ok(MPL)); + } + + #[test] + fn agpl_control_is_classified_agpl_and_flagged() { + let d = repo(&[("LICENSE.txt", AGPL_TEXT)]); + let l = classify(&d, "idaptik").unwrap(); + assert_eq!(l.code, "AGPL-3.0-or-later"); + assert_eq!(l.guix, "license:agpl3+"); + assert!(l.unratified.is_some()); + } + + #[test] + fn pmpl_only_inside_the_register() { + let d = repo(&[("LICENSE", PMPL_TEXT)]); + assert_eq!( + classify(&d, "insolvency-tycoon").unwrap().code, + "PMPL-1.0-or-later" + ); + assert_eq!( + classify(&d, "aerie"), + Err(Refusal::PmplOutsideRegister("aerie".into())) + ); + } + + #[test] + fn refusals_name_their_reason() { + assert_eq!(classify(&repo(&[]), "x"), Err(Refusal::NoLicenceFile)); + assert_eq!( + classify(&repo(&[("LICENSE", "MIT License\n")]), "x"), + Err(Refusal::Unrecognised(vec!["LICENSE".into()])) + ); + assert_eq!( + classify(&repo(&[("LICENSE", MPL_TEXT), ("COPYING", AGPL_TEXT)]), "x"), + Err(Refusal::Ambiguous(vec!["AGPL-3.0", "MPL-2.0"])) + ); + assert_eq!( + classify(&repo(&[("LICENSE", MPL_TEXT)]), "007"), + Err(Refusal::OutOfScope("007".into())) + ); + } + + #[test] + fn reuse_layout_is_read_when_the_root_has_no_licence() { + let d = repo(&[ + ("LICENSES/MPL-2.0.txt", MPL_TEXT), + ( + "LICENSES/CC-BY-SA-4.0.txt", + "Creative Commons Attribution-ShareAlike 4.0\n", + ), + ]); + assert_eq!(classify(&d, "x"), Ok(MPL)); + } +} diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs new file mode 100644 index 0000000..2fb9e1e --- /dev/null +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -0,0 +1,923 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! `provision-set mint` (and `realign`, which is the same operation): write a +//! repository's provisioning set from the canon, following the ownership rules +//! of `PROVISIONING-STANDARD.adoc` §1. +//! +//! * Engine files are copied byte for byte and always realigned. +//! * Minted files are created when missing and replaced only while they are +//! still stubs, or when the set was inherited from another repository (the +//! deed's `:repo` names someone else). Once filled, the repository owns them. +//! * A `mise.toml` that pins a banned tool is replaced, carrying over its other +//! `[tools]` entries. +//! +//! Every fact about the repository (languages, tools, Guix specs, where the +//! files go) comes from the engine's own `provision-lib.sh`, run against the +//! target, so the generator and `just doctor` can never disagree about them. +//! Only `.tmpl` files are slot-filled; `__SPEC_*__` slots are left for the +//! repository-specific pass (standard §5). + +use super::canon::{Canon, ENGINE_FILES}; +use super::licence::{self, Licence}; +use anyhow::{Context, Result, bail}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// `__COPYRIGHT_HOLDER__` for every minted file. +pub const HOLDER: &str = "Jonathan D.A. Jewell (hyperpolymath) "; + +/// The contract verbs (standard §2): `just ` must work in every repository. +pub const VERBS: &[&str] = &[ + "setup", + "doctor", + "heal", + "dev-shell", + "toolchain-refresh", + "ai-setup", + "ai-warmup", + "eval", + "config-show", + "opsm", + "build", + "test", + "bench", + "lint", + "fmt", + "fmt-check", + "run", + "deps", +]; + +const ARCHETYPES: &[&str] = &["app", "library", "tool", "theory", "docs"]; +const DEED: &str = ".machine_readable/descriptiles/provisioning_praxis.deed"; +const LIB: &str = "build/just/provision-lib.sh"; +const WRAP: usize = 80; + +#[derive(Debug, Default, Clone)] +pub struct Options { + /// `owner/name`; default: the `origin` remote. + pub repo: Option, + /// Overrides the deed's archetype. + pub archetype: Option, + /// `__YEAR__`; default: `SOURCE_DATE_EPOCH`, else the current year. + pub year: Option, +} + +/// What happened to one file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Act { + Created, + Replaced(String), + Kept(String), + Skipped(String), +} + +impl std::fmt::Display for Act { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Act::Created => write!(f, "created"), + Act::Replaced(why) => write!(f, "replaced ({why})"), + Act::Kept(why) => write!(f, "kept ({why})"), + Act::Skipped(why) => write!(f, "skipped ({why})"), + } + } +} + +#[derive(Debug)] +pub struct Report { + pub slug: String, + pub licence: Licence, + pub archetype: String, + pub langs: Vec, + pub inherited_from: Option, + pub files: Vec<(String, Act)>, +} + +/// Mint (or realign) the provisioning set in `target`. +pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { + let target = &target + .canonicalize() + .with_context(|| format!("resolving {}", target.display()))?; + let slug = match &opts.repo { + Some(s) => s.clone(), + None => origin_slug(target).unwrap_or_else(|| { + format!( + "hyperpolymath/{}", + target.file_name().unwrap_or_default().to_string_lossy() + ) + }), + }; + let name = slug.rsplit('/').next().unwrap_or(&slug).to_string(); + // Refuse before writing anything (standard §6). + let licence = licence::classify(target, &name)?; + let mut files = Vec::new(); + + for rel in ENGINE_FILES { + let act = write_file(target, rel, &canon.file(rel)?, "engine realigned")?; + files.push((rel.to_string(), act)); + } + + let lib = Lib(target.to_path_buf()); + let langs = lib.lines(&["langs"])?; + let old_deed = std::fs::read_to_string(target.join(DEED)).ok(); + let deed_repo = old_deed.as_deref().and_then(|d| deed_field(d, "repo")); + let inherited_from = deed_repo.filter(|r| r != &slug && !r.contains("__")); + let archetype = match &opts.archetype { + Some(a) => a.clone(), + None => old_deed + .as_deref() + .filter(|_| inherited_from.is_none()) + .and_then(|d| deed_field(d, "archetype")) + .filter(|a| ARCHETYPES.contains(&a.as_str())) + .unwrap_or_else(|| if langs == ["docs"] { "docs" } else { "library" }.to_string()), + }; + if !ARCHETYPES.contains(&archetype.as_str()) { + bail!( + "archetype {archetype:?} is not one of {}", + ARCHETYPES.join(", ") + ); + } + + let year = opts.year.unwrap_or_else(current_year); + let mut vars: BTreeMap<&str, String> = BTreeMap::new(); + vars.insert("APP_NAME", name.clone()); + vars.insert("REPO_SLUG", slug.clone()); + vars.insert("YEAR", year.to_string()); + vars.insert("COPYRIGHT_HOLDER", HOLDER.to_string()); + vars.insert("LICENSE", licence.code.to_string()); + vars.insert("DOC_LICENSE", licence.doc.to_string()); + vars.insert("GUIX_LICENSE", licence.guix.to_string()); + vars.insert("ARCHETYPE", archetype.clone()); + vars.insert("LANGS", langs.join(", ")); + + let m = Minter { + target, + canon, + lib: &lib, + inherited: inherited_from.is_some(), + }; + + // The deed first: the fact verbs below read it. + files.push(( + DEED.to_string(), + m.minted(DEED, &format!("{DEED}.tmpl"), &vars)?, + )); + + let guix_dir = lib.out(&["guix-dir"])?; + let gpre = if guix_dir == "build" { "build/" } else { "" }; + let (app_version, synopsis, description) = describe(target, &name); + let guix_specs = lib.words(&["guix-specs"])?; + let mise_tools = lib.words(&["mise-tools"])?; + vars.insert("GUIX_PREFIX", gpre.to_string()); + vars.insert("APP_VERSION", app_version); + vars.insert("SYNOPSIS", scheme_escape(&synopsis)); + vars.insert("DESCRIPTION", scheme_escape(&description)); + vars.insert("GUIX_GAPS", or_none(lib.out(&["guix-gaps"])?)); + vars.insert("TOOL_TABLE", lib.out(&["tool-table"])?); + vars.insert("SYSTEM_DEPS_SECTION", lib.out(&["system-deps", "adoc"])?); + vars.insert("SYSTEM_DEPS_AI", lib.out(&["system-deps", "ai"])?); + vars.insert( + "MISE_TOOLS", + mise_tools + .iter() + .map(|t| format!("`{t}`")) + .collect::>() + .join(", "), + ); + vars.insert("GUIX_SPECS", quoted_atoms(&guix_specs)); + vars.insert("GUIX_PKG_SPECS", quoted_atoms(&guix_specs)); + + // mise.toml: minted, but a banned tool is a replace condition. + let banned = lib.predicate(&["mise-banned"])?; + let mut carried = Vec::new(); + if let Some(hits) = &banned { + carried = carry_over_tools(target, hits)?; + } + vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried)); + let act = match banned { + Some(hits) if target.join("mise.toml").exists() => m.force( + "mise.toml", + "mise.toml.tmpl", + &vars, + &format!("pinned banned tool(s): {hits}"), + )?, + _ => m.minted("mise.toml", "mise.toml.tmpl", &vars)?, + }; + files.push(("mise.toml".into(), act)); + + // The Guix trio, beside whichever guix.scm the repository already keeps. + let cargo = langs.iter().any(|l| l == "rust") && target.join("Cargo.toml").is_file(); + let guix_tmpl = if cargo { + "guix/guix.scm.cargo.tmpl" + } else { + "guix/guix.scm.source.tmpl" + }; + let gs = format!("{gpre}guix.scm"); + files.push((gs.clone(), m.minted(&gs, guix_tmpl, &vars)?)); + let gm = format!("{gpre}manifest.scm"); + files.push((gm.clone(), m.minted(&gm, "guix/manifest.scm.tmpl", &vars)?)); + let gc = format!("{gpre}channels.scm"); + files.push((gc.clone(), m.minted_bytes(&gc, "guix/channels.scm")?)); + if cargo && !target.join("build/guix/crates.scm").is_file() { + files.push(( + "build/guix/crates.scm".into(), + Act::Skipped("generate with `guix import crate --lockfile=Cargo.lock`; doctor reports PV-W24 until then".into()), + )); + } + + // Docs and warm-ups go where set-files puts them (root, docs/ or docs/onboarding/). + let set_files = lib.lines(&["set-files"])?; + let place = |base: &str| -> String { + set_files + .iter() + .find(|p| p.rsplit('/').next() == Some(base)) + .cloned() + .unwrap_or_else(|| base.to_string()) + }; + for (dest, tmpl) in [ + ("docs/SETUP.adoc".to_string(), "docs/SETUP.adoc.tmpl"), + ( + "docs/AI_INSTALLATION_GUIDE.adoc".to_string(), + "docs/AI_INSTALLATION_GUIDE.adoc.tmpl", + ), + (place("llm-warmup-user.adoc"), "llm-warmup-user.adoc.tmpl"), + (place("llm-warmup-dev.adoc"), "llm-warmup-dev.adoc.tmpl"), + ( + place("llm-warmup-maintainer.adoc"), + "llm-warmup-maintainer.adoc.tmpl", + ), + ] { + files.push((dest.clone(), m.minted(&dest, tmpl, &vars)?)); + } + + // launcher.sh: generated for library/tool/theory/docs; a hand-written + // launcher or an app's launcher (minted from its own config) is kept. + let launcher = target.join("launcher.sh"); + let act = match std::fs::read_to_string(&launcher) { + Ok(s) if !generated_launcher(&s) => { + Act::Kept("hand-written: give it the provisioning modes by sourcing build/just/provision-modes.sh".into()) + } + Err(_) if archetype == "app" => { + Act::Skipped("an app launcher is minted by `launch-scaffolder mint` from the app's config".into()) + } + _ => { + let body = render(&canon_text(canon, "launcher.sh.tmpl")?, &vars); + write_file(target, "launcher.sh", body.as_bytes(), "re-rendered from the deed")? + } + }; + files.push(("launcher.sh".into(), act)); + + // Justfile: created whole when absent; merging into an existing one is a + // separate step that must prove `just --summary` keeps every recipe. + let justfile = ["Justfile", "justfile", ".justfile"] + .into_iter() + .find(|j| target.join(j).is_file()); + let act = match justfile { + Some(j) => Act::Kept(format!( + "{j} exists: merge delegations with `provision-set merge-justfile`" + )), + None => { + vars.insert( + "DELEGATIONS", + delegations(&canon_text(canon, "build/just/provision.just")?, &[]), + ); + let body = render(&canon_text(canon, "Justfile.tmpl")?, &vars); + write_file(target, "Justfile", body.as_bytes(), "")? + } + }; + files.push((justfile.unwrap_or("Justfile").into(), act)); + + Ok(Report { + slug, + licence, + archetype, + langs, + inherited_from, + files, + }) +} + +struct Minter<'a> { + target: &'a Path, + canon: &'a Canon, + lib: &'a Lib, + inherited: bool, +} + +impl Minter<'_> { + /// A minted file: created when missing, replaced while a stub or inherited. + fn minted(&self, dest: &str, tmpl: &str, vars: &BTreeMap<&str, String>) -> Result { + let body = render(&canon_text(self.canon, tmpl)?, vars); + match self.stub_reason(dest)? { + Some(why) => write_file(self.target, dest, body.as_bytes(), &why), + None => Ok(Act::Kept("filled: owned by the repository".into())), + } + } + + fn minted_bytes(&self, dest: &str, src: &str) -> Result { + match self.stub_reason(dest)? { + Some(why) => write_file(self.target, dest, &self.canon.file(src)?, &why), + None => Ok(Act::Kept("filled: owned by the repository".into())), + } + } + + fn force( + &self, + dest: &str, + tmpl: &str, + vars: &BTreeMap<&str, String>, + why: &str, + ) -> Result { + let body = render(&canon_text(self.canon, tmpl)?, vars); + write_file(self.target, dest, body.as_bytes(), why) + } + + /// Why `dest` may be (re)written, or `None` when the repository owns it. + fn stub_reason(&self, dest: &str) -> Result> { + let path = self.target.join(dest); + let Ok(text) = std::fs::read_to_string(&path) else { + return Ok(Some("missing".into())); + }; + if self.inherited { + return Ok(Some("inherited from another repository's set".into())); + } + if dest.ends_with(".scm") { + return Ok(self + .lib + .predicate(&["guix-stub", dest])? + .map(|r| format!("Guix stub: {r}"))); + } + if let Some(slot) = mechanical_residue(&text) { + return Ok(Some(format!("never minted: __{slot}__ unfilled"))); + } + if dest.contains("llm-warmup-") + && text.contains("for overview.") + && text.contains("Key Commands") + { + return Ok(Some("generic warm-up boilerplate".into())); + } + Ok(None) + } +} + +/// The mechanical slots the generator fills. Residue of one of these means the +/// file was never minted; `__SPEC_*__` residue means it was minted but not yet +/// specialised, which is the repository's to finish (doctor PV-W29). +const MECHANICAL: &[&str] = &[ + "APP_NAME", + "REPO_SLUG", + "YEAR", + "COPYRIGHT_HOLDER", + "LICENSE", + "DOC_LICENSE", + "GUIX_LICENSE", + "ARCHETYPE", + "LANGS", + "GUIX_PREFIX", + "APP_VERSION", + "SYNOPSIS", + "DESCRIPTION", + "GUIX_GAPS", + "TOOL_TABLE", + "SYSTEM_DEPS_SECTION", + "SYSTEM_DEPS_AI", + "MISE_TOOLS", + "MISE_TOOLS_TOML", + "GUIX_SPECS", + "GUIX_PKG_SPECS", + "DELEGATIONS", +]; + +fn mechanical_residue(text: &str) -> Option<&'static str> { + MECHANICAL + .iter() + .copied() + .find(|k| text.contains(&format!("__{k}__"))) +} + +/// Replace every `__KEY__` whose KEY is in `vars`, in one left-to-right pass, so +/// a value can never be re-substituted (a description that mentions `__init__` +/// stays as written). A value marked as a list of atoms is wrapped at +/// [`WRAP`] columns with its continuation lines aligned under the first atom. +pub fn render(tmpl: &str, vars: &BTreeMap<&str, String>) -> String { + let mut out = String::with_capacity(tmpl.len()); + let mut rest = tmpl; + while let Some(i) = rest.find("__") { + out.push_str(&rest[..i]); + let after = &rest[i + 2..]; + let key = after.find("__").map(|j| &after[..j]).filter(|k| { + k.starts_with(|c: char| c.is_ascii_uppercase()) + && k.chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + }); + match key.and_then(|k| vars.get(k).map(|v| (k, v))) { + Some((k, v)) => { + if let Some(atoms) = v.strip_prefix(ATOMS) { + let col = out.len() - out.rfind('\n').map_or(0, |n| n + 1); + out.push_str(&wrap_atoms(atoms, col)); + } else { + out.push_str(v); + } + rest = &after[k.len() + 2..]; + } + None => { + out.push('_'); + rest = &rest[i + 1..]; + } + } + } + out.push_str(rest); + out +} + +/// Marks a value as space-separated Scheme atoms for [`render`] to wrap. +const ATOMS: &str = "\u{0}atoms\u{0}"; + +fn quoted_atoms(specs: &[String]) -> String { + let atoms: Vec = specs + .iter() + .map(|s| format!("\"{}\"", scheme_escape(s))) + .collect(); + format!("{ATOMS}{}", atoms.join(" ")) +} + +fn wrap_atoms(atoms: &str, col: usize) -> String { + let mut out = String::new(); + let mut width = col; + for (n, a) in atoms.split(' ').filter(|a| !a.is_empty()).enumerate() { + if n > 0 { + // Room for " atom" plus the closing parens the template adds. + if width + 1 + a.len() > WRAP - 3 { + out.push('\n'); + out.push_str(&" ".repeat(col)); + width = col; + } else { + out.push(' '); + width += 1; + } + } + out.push_str(a); + width += a.len(); + } + out +} + +fn scheme_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn or_none(s: String) -> String { + if s.trim().is_empty() { + "none".into() + } else { + s + } +} + +/// `[tools]` lines: the canon's tools, then the other entries carried over from +/// a replaced `mise.toml`. A carried entry keeps its own value: the +/// repository's pin of a tool the canon also lists is a decision, not drift. +fn mise_tools_toml(tools: &[String], carried: &[(String, String)]) -> String { + let value = |t: &String| { + carried + .iter() + .find(|(k, _)| k == t) + .map_or_else(|| "\"latest\"".to_string(), |(_, v)| v.clone()) + }; + let mut lines: Vec = tools + .iter() + .map(|t| format!("{} = {}", toml_key(t), value(t))) + .collect(); + for (k, v) in carried { + if !tools.iter().any(|t| t == k) { + lines.push(format!("{} = {v}", toml_key(k))); + } + } + lines.join("\n") +} + +fn toml_key(k: &str) -> String { + if k.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + k.to_string() + } else { + format!("\"{}\"", k.replace('\\', "\\\\").replace('"', "\\\"")) + } +} + +/// The non-banned `[tools]` entries of an existing `mise.toml`, values as TOML. +fn carry_over_tools(target: &Path, banned: &str) -> Result> { + let text = std::fs::read_to_string(target.join("mise.toml"))?; + let table: toml::Table = toml::from_str(&text).context("parsing mise.toml")?; + let banned: Vec<&str> = banned.split_whitespace().collect(); + let mut out = Vec::new(); + if let Some(tools) = table.get("tools").and_then(|t| t.as_table()) { + for (k, v) in tools { + if !banned.contains(&k.as_str()) { + out.push((k.clone(), v.to_string())); + } + } + } + Ok(out) +} + +/// One root delegation per contract verb the root Justfile does not define, +/// each with the module recipe's own doc comment, e.g. +/// `build: provision::build` or `ai-warmup who="user": (provision::ai-warmup who)`. +pub fn delegations(provision_just: &str, existing: &[String]) -> String { + let mut out = Vec::new(); + let mut doc: Option<&str> = None; + for line in provision_just.lines() { + if let Some(d) = line.strip_prefix("# ") { + doc = Some(d); + continue; + } + let sig = line.trim_end(); + let is_recipe = sig.starts_with(|c: char| c.is_ascii_lowercase()) + && sig.ends_with(':') + && !sig.contains(":="); + if !is_recipe { + if !line.starts_with(' ') { + doc = None; + } + continue; + } + let sig = &sig[..sig.len() - 1]; + let (name, params) = sig.split_once(' ').unwrap_or((sig, "")); + if VERBS.contains(&name) && !existing.iter().any(|e| e == name) { + let comment = doc.map(|d| format!("# {d}\n")).unwrap_or_default(); + if params.is_empty() { + out.push(format!("{comment}{name}: provision::{name}")); + } else { + let args: Vec<&str> = params + .split_whitespace() + .map(|p| p.split('=').next().unwrap_or(p)) + .collect(); + out.push(format!( + "{comment}{name} {params}: (provision::{name} {})", + args.join(" ") + )); + } + } + doc = None; + } + out.join("\n\n") +} + +/// `true` for a launcher this generator rendered (and may re-render). +fn generated_launcher(text: &str) -> bool { + text.contains("@launcher-deed begin") && text.contains(":generator \"provision-set\"") +} + +/// `:key "value"` from a deed. +fn deed_field(deed: &str, key: &str) -> Option { + let pat = format!(":{key} "); + deed.lines().find_map(|l| { + let l = l.trim_start(); + let rest = l.strip_prefix(&pat)?.trim_start().strip_prefix('"')?; + rest.split_once('"').map(|(v, _)| v.to_string()) + }) +} + +/// `owner/name` from the `origin` remote of a GitHub checkout. +fn origin_slug(target: &Path) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(target) + .args(["remote", "get-url", "origin"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let url = String::from_utf8(out.stdout).ok()?; + let url = url.trim().trim_end_matches('/').trim_end_matches(".git"); + let path = url + .split_once("github.com") + .map(|(_, p)| p.trim_start_matches([':', '/']))?; + let mut parts = path.splitn(2, '/'); + let (o, r) = (parts.next()?, parts.next()?); + (!o.is_empty() && !r.is_empty() && !r.contains('/')).then(|| format!("{o}/{r}")) +} + +/// `(version, synopsis, description)`: the Cargo package's, else the README's +/// first prose paragraph, else the name. +fn describe(target: &Path, name: &str) -> (String, String, String) { + let cargo: Option = std::fs::read_to_string(target.join("Cargo.toml")) + .ok() + .and_then(|t| toml::from_str(&t).ok()); + let field = |k: &str| -> Option { + let c = cargo.as_ref()?; + let pkg = c.get("package")?.as_table()?; + match pkg.get(k)? { + toml::Value::String(s) => Some(s.clone()), + // `version.workspace = true`: the workspace's own value. + _ => c + .get("workspace")? + .get("package")? + .get(k)? + .as_str() + .map(str::to_string), + } + }; + let version = field("version").unwrap_or_else(|| "0.1.0".into()); + let description = field("description") + .or_else(|| readme_paragraph(target)) + .unwrap_or_else(|| format!("{name}, a hyperpolymath repository.")); + let description = description.split_whitespace().collect::>().join(" "); + (version, synopsis_of(&description), description) +} + +fn readme_paragraph(target: &Path) -> Option { + let text = ["README.adoc", "README.md", "README"] + .iter() + .find_map(|f| std::fs::read_to_string(target.join(f)).ok())?; + let mut para = Vec::new(); + let mut in_block = false; + for line in text.lines() { + let t = line.trim(); + if t == "----" || t == "...." || t == "```" || t.starts_with("```") || t == "////" { + in_block = !in_block; + continue; + } + let markup = t.starts_with(['=', '#', ':', '[', '!', '<', '|', '*', '-', '.', '+', '>']) + || t.starts_with("//") + || t.starts_with("image:") + || t.starts_with("ifdef") + || t.starts_with("endif") + || t.starts_with("toc::"); + if in_block || t.is_empty() || markup { + if !para.is_empty() && (t.is_empty() || markup) { + break; + } + continue; + } + para.push(t); + } + (!para.is_empty()).then(|| para.join(" ")) +} + +/// The first sentence, without its full stop, at most 79 characters. +fn synopsis_of(description: &str) -> String { + let first = description + .split(". ") + .next() + .unwrap_or(description) + .trim_end_matches('.'); + if first.len() <= 79 { + return first.to_string(); + } + let mut s = String::new(); + for w in first.split_whitespace() { + if s.len() + w.len() + 1 > 79 { + break; + } + if !s.is_empty() { + s.push(' '); + } + s.push_str(w); + } + s +} + +fn current_year() -> i64 { + let secs = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) + }); + year_of_days(secs.div_euclid(86_400)) +} + +/// The proleptic Gregorian year of a day count since 1970-01-01 (Hinnant's +/// `civil_from_days`). +fn year_of_days(z: i64) -> i64 { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + yoe + era * 400 + i64::from(m <= 2) +} + +fn canon_text(canon: &Canon, rel: &str) -> Result { + String::from_utf8(canon.file(rel)?.into_owned()).with_context(|| format!("{rel} is not UTF-8")) +} + +/// Write `bytes` to `target/rel` unless it already holds them. Shell scripts and +/// the launcher are made executable. +fn write_file(target: &Path, rel: &str, bytes: &[u8], why: &str) -> Result { + let path = target.join(rel); + let old = std::fs::read(&path).ok(); + if old.as_deref() == Some(bytes) { + set_exec(&path, rel)?; + return Ok(Act::Kept("up to date".into())); + } + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?; + set_exec(&path, rel)?; + Ok(if old.is_some() { + Act::Replaced(why.to_string()) + } else { + Act::Created + }) +} + +fn set_exec(path: &Path, rel: &str) -> Result<()> { + #[cfg(unix)] + if rel.ends_with(".sh") { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {}", path.display()))?; + } + let _ = (path, rel); + Ok(()) +} + +/// The engine's `provision-lib.sh`, run against the target. A verb that fails +/// is an error: a fact the generator cannot establish is never guessed. +struct Lib(PathBuf); + +impl Lib { + fn run(&self, args: &[&str]) -> Result { + Command::new("bash") + .arg(self.0.join(LIB)) + .args(args) + .env("PROVISION_ROOT", &self.0) + .current_dir(&self.0) + .output() + .with_context(|| format!("running {LIB} {}", args.join(" "))) + } + + fn out(&self, args: &[&str]) -> Result { + let o = self.run(args)?; + if !o.status.success() { + bail!( + "{LIB} {} failed ({}): {}", + args.join(" "), + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ); + } + Ok(String::from_utf8(o.stdout)? + .trim_end_matches('\n') + .to_string()) + } + + fn lines(&self, args: &[&str]) -> Result> { + Ok(self + .out(args)? + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect()) + } + + fn words(&self, args: &[&str]) -> Result> { + Ok(self + .out(args)? + .split_whitespace() + .map(str::to_string) + .collect()) + } + + /// A predicate verb: exit 0 is "no", exit 1 is "yes, because ". + fn predicate(&self, args: &[&str]) -> Result> { + let o = self.run(args)?; + match o.status.code() { + Some(0) => Ok(None), + Some(1) => Ok(Some(String::from_utf8_lossy(&o.stdout).trim().to_string())), + _ => bail!( + "{LIB} {} failed ({}): {}", + args.join(" "), + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vars(kv: &[(&'static str, &str)]) -> BTreeMap<&'static str, String> { + kv.iter().map(|(k, v)| (*k, v.to_string())).collect() + } + + #[test] + fn render_is_single_pass_and_leaves_unknown_slots() { + let v = vars(&[("A", "__B__ and __init__"), ("B", "x")]); + assert_eq!( + render("__A__ / __B__ / __SPEC_Q__ / a__b", &v), + "__B__ and __init__ / x / __SPEC_Q__ / a__b" + ); + assert_eq!(render("___A__", &vars(&[("A", "v")])), "_v"); + } + + #[test] + fn atoms_wrap_under_the_first_atom() { + let specs: Vec = [ + "git", + "bash", + "coreutils", + "nss-certs", + "just", + "mise", + "shellcheck", + "chez-scheme", + "gmp", + "gcc-toolchain", + "zig", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + let v: BTreeMap<&str, String> = [("S", quoted_atoms(&specs))].into_iter().collect(); + let out = render(" (list __S__))", &v); + for line in out.lines() { + assert!(line.len() <= WRAP, "{line:?} is wider than {WRAP}"); + } + let cont: Vec<&str> = out.lines().skip(1).collect(); + assert!(!cont.is_empty(), "eleven specs must wrap"); + assert!(cont.iter().all(|l| l.starts_with(" \"")), "{out}"); + let tokens: Vec<&str> = out.split_whitespace().collect(); + assert_eq!(tokens[1], "\"git\""); + assert_eq!(tokens.last(), Some(&"\"zig\"))")); + } + + #[test] + fn delegations_cover_every_verb_with_its_doc() { + let canon = Canon::Baked; + let pj = canon_text(&canon, "build/just/provision.just").unwrap(); + let d = delegations(&pj, &[]); + for v in VERBS { + assert!( + d.lines() + .any(|l| l.starts_with(&format!("{v}:")) || l.starts_with(&format!("{v} "))), + "no delegation for {v}:\n{d}" + ); + } + assert!(d.contains("# Diagnose the environment")); + assert!(d.contains("ai-warmup who=\"user\": (provision::ai-warmup who)")); + assert!(d.contains("fmt-check: provision::fmt-check")); + assert!(!d.contains("langs:"), "langs is not a contract verb"); + let some = delegations(&pj, &["doctor".into(), "build".into()]); + assert!(!some.contains("doctor:") && !some.contains("build:") && some.contains("heal:")); + } + + #[test] + fn deed_fields_and_slugs() { + let d = "(praxis-deed\n :repo \"hyperpolymath/rsr-template-repo\"\n :archetype \"library\" ; app | …\n"; + assert_eq!( + deed_field(d, "repo").as_deref(), + Some("hyperpolymath/rsr-template-repo") + ); + assert_eq!(deed_field(d, "archetype").as_deref(), Some("library")); + assert_eq!(deed_field(d, "ports"), None); + } + + #[test] + fn synopsis_is_one_short_sentence() { + assert_eq!(synopsis_of("Does a thing. Then more."), "Does a thing"); + let long = "word ".repeat(40); + assert!(synopsis_of(&long).len() <= 79); + assert_eq!(scheme_escape(r#"a "q" \ b"#), r#"a \"q\" \\ b"#); + } + + #[test] + fn years_from_day_counts() { + assert_eq!(year_of_days(0), 1970); + assert_eq!(year_of_days(20_454), 2026); // 2026-01-01 + assert_eq!(year_of_days(20_453), 2025); // 2025-12-31 + assert_eq!(year_of_days(11_016), 2000); // 2000-02-29 + } + + #[test] + fn carried_pins_survive_and_extras_follow() { + let tools = vec!["just".to_string(), "rust".to_string()]; + let carried = vec![ + ("rust".to_string(), "\"1.85\"".to_string()), + ("cargo:cargo-nextest".to_string(), "\"latest\"".to_string()), + ]; + assert_eq!( + mise_tools_toml(&tools, &carried), + "just = \"latest\"\nrust = \"1.85\"\n\"cargo:cargo-nextest\" = \"latest\"" + ); + } + + #[test] + fn mechanical_residue_ignores_spec_slots() { + assert_eq!(mechanical_residue("x __SPEC_USAGE__ y"), None); + assert_eq!(mechanical_residue("x __APP_NAME__ y"), Some("APP_NAME")); + } +} diff --git a/crates/launcher-common/src/provisioning/mod.rs b/crates/launcher-common/src/provisioning/mod.rs index d9a26b5..cc8f1b4 100644 --- a/crates/launcher-common/src/provisioning/mod.rs +++ b/crates/launcher-common/src/provisioning/mod.rs @@ -5,3 +5,5 @@ pub mod canon; pub mod check; +pub mod licence; +pub mod mint; diff --git a/crates/launcher/src/cmd_provision_set.rs b/crates/launcher/src/cmd_provision_set.rs index 1847b78..12cc9d2 100644 --- a/crates/launcher/src/cmd_provision_set.rs +++ b/crates/launcher/src/cmd_provision_set.rs @@ -8,6 +8,8 @@ use clap::{Args as ClapArgs, Subcommand}; use launch_scaffolder_common::provisioning::{ canon::{CANON_ENV, Canon}, check, + licence::Refusal, + mint::{self, Act, Options}, }; use std::path::PathBuf; @@ -34,8 +36,32 @@ enum Action { #[arg(default_value = ".")] target: PathBuf, }, + /// Write the provisioning set: engine files realigned, minted files + /// created when missing and replaced only while they are stubs. + Mint(MintArgs), + /// The same operation as `mint`, named for an existing repository. + Realign(MintArgs), } +#[derive(Debug, ClapArgs)] +struct MintArgs { + /// `owner/name` (default: the `origin` remote). + #[arg(long)] + repo: Option, + /// app | library | tool | theory | docs (default: the deed's, else inferred). + #[arg(long)] + archetype: Option, + /// Copyright year (default: SOURCE_DATE_EPOCH, else this year). + #[arg(long)] + year: Option, + /// Repository to provision. + #[arg(default_value = ".")] + target: PathBuf, +} + +/// Exit code for a licence refusal (standard §6): a ledger line, not a crash. +pub const EXIT_REFUSED: i32 = 3; + pub fn run(args: Args) -> Result<()> { let canon = Canon::resolve(args.canon.as_deref())?; match args.action { @@ -54,5 +80,47 @@ pub fn run(args: Args) -> Result<()> { let code = check::conformance(&target, dev)?; std::process::exit(code); } + Action::Mint(m) | Action::Realign(m) => { + let opts = Options { + repo: m.repo, + archetype: m.archetype, + year: m.year, + }; + match mint::mint(&m.target, &canon, &opts) { + Ok(r) => { + println!( + "{} — {} (docs {}), archetype {}, languages {}", + r.slug, + r.licence.code, + r.licence.doc, + r.archetype, + r.langs.join(", ") + ); + if let Some(from) = &r.inherited_from { + println!(" inherited set from {from}: re-minted"); + } + if let Some(why) = r.licence.unratified { + println!(" UNRATIFIED doc licence: {why}"); + } + for (path, act) in &r.files { + let tag = match act { + Act::Created => "create", + Act::Replaced(_) => "replace", + Act::Kept(_) => "keep", + Act::Skipped(_) => "SKIP", + }; + println!(" {tag:<8} {path}: {act}"); + } + Ok(()) + } + Err(e) => match e.downcast_ref::() { + Some(refusal) => { + eprintln!("provision-set: refused: {refusal}"); + std::process::exit(EXIT_REFUSED); + } + None => Err(e), + }, + } + } } } From 331b2ee8bede94aefaa635c69b5ab81924760f31 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:08:11 +0100 Subject: [PATCH 03/26] feat(provision-set): merge the contract into existing Justfiles `just --summary` before and after is the judge: the merge is kept only when every contract verb, every provision:: verb and every original recipe (renamed ones as *-local) is listed, else the original bytes are restored. Boilerplate doctor/heal bodies are replaced by the canon; custom ones become *-local; a custom verb beside an existing *-local is refused. Column-0 lines inside shebang bodies, one way earlier sweeps broke estate Justfiles (67 of the 334 local-clone Justfiles did not parse at HEAD on 2026-10-01), are re-indented as a mechanical repair. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../src/provisioning/justfile.rs | 463 ++++++++++++++++++ .../launcher-common/src/provisioning/mint.rs | 10 +- .../launcher-common/src/provisioning/mod.rs | 1 + 3 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 crates/launcher-common/src/provisioning/justfile.rs diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs new file mode 100644 index 0000000..9e31cd5 --- /dev/null +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Merge the provisioning contract into an existing Justfile +//! (`PROVISIONING-STANDARD.adoc` §1: the Justfile is *merged*, never replaced). +//! +//! * `mod provision 'build/just/provision.just'` is added once. +//! * Every contract verb the Justfile does not define gets a root delegation; +//! a verb it does define (its own `build`, `test`, …) is its override and kept. +//! * A `doctor`, `setup` or `heal` that is estate boilerplate (the generic +//! "Running diagnostics for" family) is removed: the canon verb does that job +//! properly. A custom one is renamed `doctor-local` / `setup-local` / +//! `heal-local`, which the canon verb runs. +//! +//! `just` itself is the judge of the result: the merge is kept only when +//! `just --summary` afterwards lists every contract verb and every recipe the +//! file had before (renamed ones under their new name). Otherwise the original +//! bytes are restored and the merge is reported as skipped. + +use super::mint::{Act, VERBS, delegations}; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +/// Text found only in the generic, repo-agnostic doctor/heal bodies that +/// earlier estate sweeps stamped into Justfiles. +pub const BOILERPLATE: &[&str] = &[ + "Running diagnostics for", + "Toolchain Health Check", + "Attempting auto-repair for", + "Heal — Automatic Tool Installation", +]; + +/// The verbs a repository may already implement in its own way. +const LOCAL: &[&str] = &["setup", "doctor", "heal"]; + +const MOD_LINE: &str = "mod provision 'build/just/provision.just'"; + +/// Merge into `target/name`. `provision_just` is the canon module's text. +pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { + let path = target.join(name); + let original = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let before = summary(target, name); + + let lines: Vec<&str> = original.lines().collect(); + let mut drop = vec![false; lines.len()]; + let mut renames: Vec<(usize, &str)> = Vec::new(); + let mut replaced = Vec::new(); + let mut renamed = Vec::new(); + + for verb in LOCAL { + let Some((start, header, end)) = span(&lines, verb) else { + if before.as_ref().is_ok_and(|b| b.iter().any(|r| r == verb)) { + return Ok(Act::Skipped(format!( + "`{verb}` comes from an import, not {name}: merge it by hand" + ))); + } + continue; + }; + let deps = lines[header].split_once(':').map_or("", |(_, d)| d); + if deps.contains("provision::") { + continue; // already a delegation + } + let body = lines[header..end].join("\n"); + if BOILERPLATE.iter().any(|m| body.contains(m)) { + drop[start..end].iter_mut().for_each(|d| *d = true); + replaced.push(*verb); + } else { + let local = format!("{verb}-local"); + if before.as_ref().is_ok_and(|b| b.contains(&local)) || span(&lines, &local).is_some() { + return Ok(Act::Skipped(format!( + "a custom `{verb}` and a `{local}` both exist: merge them by hand" + ))); + } + renames.push((header, verb)); + renamed.push(*verb); + } + } + + // A file `just` cannot parse is merged only when a mechanical repair is + // available: removing boilerplate, or re-indenting lines an earlier sweep + // left at column 0 inside a shebang body (the body is one script, so the + // indent changes nothing it runs). `just` judges the result below. + let mut indent = vec![false; lines.len()]; + if before.is_err() { + for (i, l) in lines.iter().enumerate() { + if header_name(l).is_none() || drop[i] { + continue; + } + let name = header_name(l).unwrap_or_default(); + let Some((_, h, end)) = span(&lines, name).filter(|(_, h, _)| *h == i) else { + continue; + }; + if !lines + .get(h + 1) + .is_some_and(|b| b.trim_start().starts_with("#!")) + { + continue; + } + for j in h + 1..end { + if !lines[j].is_empty() && !lines[j].starts_with([' ', '\t']) { + indent[j] = true; + } + } + } + } + if let Err(e) = &before + && replaced.is_empty() + && !indent.contains(&true) + { + return Ok(Act::Skipped(format!( + "{name} does not parse, and no mechanical repair applies: {e}" + ))); + } + + let mut out = String::with_capacity(original.len() + 2048); + for (i, line) in lines.iter().enumerate() { + if drop[i] { + continue; + } + match renames.iter().find(|(h, _)| *h == i) { + Some((_, verb)) => { + let at = line.find(verb).unwrap_or(0); + out.push_str(&line[..at + verb.len()]); + out.push_str("-local"); + out.push_str(&line[at + verb.len()..]); + } + None => { + if indent[i] { + out.push_str(" "); + } + out.push_str(line); + } + } + out.push('\n'); + } + // Removing a block can leave a run of blank lines; keep at most one. + while out.contains("\n\n\n") { + out = out.replace("\n\n\n", "\n\n"); + } + + let defined: Vec = recipe_names(&out); + let has_mod = out.lines().any(|l| { + let l = l.trim_start(); + (l.starts_with("mod provision") || l.starts_with("mod? provision")) + && l["mod".len()..] + .trim_start_matches('?') + .trim_start() + .starts_with("provision") + }); + let added: Vec<&str> = VERBS + .iter() + .copied() + .filter(|v| !defined.iter().any(|d| d == v)) + .collect(); + if has_mod && added.is_empty() && replaced.is_empty() && renamed.is_empty() { + return Ok(Act::Kept("already merged".into())); + } + let block = delegations(provision_just, &defined); + if !out.ends_with("\n\n") { + out.push('\n'); + } + out.push_str("# --- Provisioning contract (PROVISIONING-STANDARD §2) ---------------------\n"); + out.push_str( + "# Merged by `launch-scaffolder provision-set`. A recipe defined above overrides\n", + ); + out.push_str("# the canon one; doctor/setup/heal run this repo's *-local recipes.\n"); + if !has_mod { + out.push_str(MOD_LINE); + out.push_str("\n\n"); + } + if !block.is_empty() { + out.push_str(&block); + out.push('\n'); + } + + std::fs::write(&path, &out).with_context(|| format!("writing {}", path.display()))?; + let after = summary(target, name); + let lost = verify(before.as_deref().ok(), after.as_deref(), &renamed); + if let Some(why) = lost { + std::fs::write(&path, &original) + .with_context(|| format!("restoring {}", path.display()))?; + return Ok(Act::Skipped(format!( + "merge rejected by `just --summary`, original restored: {why}" + ))); + } + + let mut what = Vec::new(); + if !added.is_empty() { + what.push(format!("{} delegation(s) added", added.len())); + } + if !replaced.is_empty() { + what.push(format!( + "boilerplate {} replaced by the canon", + replaced.join("/") + )); + } + if !renamed.is_empty() { + what.push(format!( + "custom {} kept as {}", + renamed.join("/"), + renamed + .iter() + .map(|v| format!("{v}-local")) + .collect::>() + .join("/") + )); + } + if before.is_err() { + what.push("repairs a Justfile that did not parse".into()); + } + Ok(Act::Replaced(what.join("; "))) +} + +/// Why the merged file is unacceptable, or `None`. +fn verify( + before: Option<&[String]>, + after: Result<&[String], &anyhow::Error>, + renamed: &[&str], +) -> Option { + let after = match after { + Ok(a) => a, + Err(e) => return Some(format!("the merged file does not parse: {e}")), + }; + let has = |r: &str| after.iter().any(|a| a == r); + let mut missing: Vec = VERBS + .iter() + .filter(|v| !has(v)) + .map(|v| v.to_string()) + .collect(); + missing.extend( + VERBS + .iter() + .filter(|v| !has(&format!("provision::{v}"))) + .map(|v| format!("provision::{v}")), + ); + for r in before.unwrap_or_default() { + let now = if renamed.contains(&r.as_str()) { + format!("{r}-local") + } else { + r.clone() + }; + if !has(&now) { + missing.push(now); + } + } + (!missing.is_empty()).then(|| format!("missing {}", missing.join(", "))) +} + +/// `just --summary` for the Justfile, as recipe names. +fn summary(target: &Path, name: &str) -> Result> { + let o = Command::new("just") + .arg("--justfile") + .arg(target.join(name)) + .arg("--working-directory") + .arg(target) + .arg("--summary") + .output() + .context("running `just --summary` (is just >= 1.42 on PATH?)")?; + if !o.status.success() { + let err = String::from_utf8_lossy(&o.stderr); + anyhow::bail!("{}", err.lines().next().unwrap_or("just failed").trim()); + } + Ok(String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .map(str::to_string) + .collect()) +} + +/// The recipe name a column-0 line declares, if it is a recipe header. +fn header_name(line: &str) -> Option<&str> { + let l = line.strip_prefix('@').unwrap_or(line); + let end = l.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))?; + let name = &l[..end]; + if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + return None; + } + if matches!( + name, + "set" + | "alias" + | "export" + | "import" + | "mod" + | "if" + | "else" + | "fi" + | "for" + | "done" + | "then" + ) { + return None; + } + // Parameters are words, optionally `=default` (quoted or bare), then `:`. + let mut rest = &l[end..]; + loop { + rest = rest.trim_start_matches([' ', '\t']); + if let Some(r) = rest.strip_prefix(':') { + return (!r.starts_with('=')).then_some(name); + } + let r = rest.trim_start_matches(['+', '*', '$']); + let w = r + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) + .unwrap_or(r.len()); + if w == 0 { + return None; + } + rest = &r[w..]; + if let Some(r) = rest.strip_prefix('=') { + rest = match r.chars().next() { + Some(q @ ('"' | '\'')) => &r[1..][r[1..].find(q)? + 1..], + _ => &r[r.find([' ', ':']).unwrap_or(r.len())..], + }; + } + } +} + +/// A column-0 line that starts something new: a header or a directive. +fn starts_item(line: &str) -> bool { + header_name(line).is_some() + || ["set ", "alias ", "export ", "import", "mod ", "mod? ", "["] + .iter() + .any(|p| line.starts_with(p)) +} + +/// `(first line of the doc comment, header line, end)` of recipe `name`. +/// +/// The body is everything up to the next column-0 header or directive (and +/// that item's own doc comment), so a body with stray unindented lines, which +/// `just` rejects, is still taken whole. +fn span(lines: &[&str], name: &str) -> Option<(usize, usize, usize)> { + let header = lines.iter().position(|l| header_name(l) == Some(name))?; + let mut start = header; + while start > 0 && (lines[start - 1].starts_with('#') || lines[start - 1].starts_with('[')) { + start -= 1; + } + let mut end = header + 1; + while end < lines.len() && !starts_item(lines[end]) { + end += 1; + } + // Trailing comments and blanks before the next item belong to it. + while end > header + 1 { + let l = lines[end - 1]; + if l.trim().is_empty() || l.starts_with('#') { + end -= 1; + } else { + break; + } + } + Some((start, header, end)) +} + +fn recipe_names(text: &str) -> Vec { + text.lines() + .filter_map(header_name) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_are_recognised_and_directives_are_not() { + assert_eq!(header_name("build:"), Some("build")); + assert_eq!(header_name("@doctor: build"), Some("doctor")); + assert_eq!(header_name("release version:"), Some("release")); + assert_eq!(header_name("ai-warmup who=\"user: x\":"), Some("ai-warmup")); + assert_eq!(header_name("serve port='8080' *args:"), Some("serve")); + assert_eq!(header_name("x := \"y\""), None); + assert_eq!(header_name("set shell := [\"bash\"]"), None); + assert_eq!(header_name("if command -v x; then"), None); + assert_eq!(header_name(" echo hi:"), None); + assert_eq!(header_name("# doctor:"), None); + } + + #[test] + fn a_span_takes_stray_unindented_lines_and_leaves_the_next_doc() { + let src = "# Diagnose\ndoctor:\n #!/usr/bin/env bash\n a\n# Optional tools\nif x; then\n b\nfi\n c\n\n# Repair\nheal:\n d\n"; + let lines: Vec<&str> = src.lines().collect(); + assert_eq!(span(&lines, "doctor"), Some((0, 1, 9))); + assert_eq!(span(&lines, "heal"), Some((10, 11, 13))); + } + + fn repo(justfile: &str) -> std::path::PathBuf { + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let d = std::env::temp_dir().join(format!("justfile-test-{}-{n}", std::process::id())); + let canon = crate::provisioning::canon::Canon::Baked; + for rel in crate::provisioning::canon::ENGINE_FILES { + let p = d.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, canon.file(rel).unwrap()).unwrap(); + } + std::fs::write(d.join("Justfile"), justfile).unwrap(); + d + } + + fn provision_just() -> String { + String::from_utf8( + crate::provisioning::canon::Canon::Baked + .file("build/just/provision.just") + .unwrap() + .into_owned(), + ) + .unwrap() + } + + const BROKEN: &str = "# Build\nbuild:\n cargo build\n\n# Self-diagnostic\ndoctor:\n #!/usr/bin/env bash\n echo \"Running diagnostics for x\"\nif command -v y >/dev/null; then\n echo ok\nfi\n\n# Help\nhelp-me:\n #!/usr/bin/env bash\n echo \"\"\necho \"FIRST TIME SETUP:\"\n"; + + #[test] + #[ignore = "needs just >= 1.42 on PATH"] + fn boilerplate_is_replaced_and_a_broken_file_repaired() { + let d = repo(BROKEN); + assert!( + summary(&d, "Justfile").is_err(), + "the control must not parse" + ); + let act = merge(&d, "Justfile", &provision_just()).unwrap(); + assert!(matches!(act, Act::Replaced(_)), "{act}"); + let after = summary(&d, "Justfile").unwrap(); + for v in VERBS { + assert!(after.iter().any(|r| r == v), "{v} missing"); + } + assert!(after.iter().any(|r| r == "help-me") && after.iter().any(|r| r == "build")); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(!text.contains("Running diagnostics for")); + assert!(text.contains(" echo \"FIRST TIME SETUP:\"")); + assert_eq!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Kept("already merged".into()) + ); + } + + #[test] + #[ignore = "needs just >= 1.42 on PATH"] + fn a_custom_doctor_becomes_doctor_local() { + let d = repo("doctor:\n @echo mine\n"); + merge(&d, "Justfile", &provision_just()).unwrap(); + let after = summary(&d, "Justfile").unwrap(); + assert!(after.iter().any(|r| r == "doctor-local") && after.iter().any(|r| r == "doctor")); + let clash = repo("doctor:\n @echo a\ndoctor-local:\n @echo b\n"); + assert!(matches!( + merge(&clash, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + } + + #[test] + #[ignore = "needs just >= 1.42 on PATH"] + fn an_unrepairable_file_is_left_byte_identical() { + let src = "build:\n cargo build\nthis is not just syntax\n"; + let d = repo(src); + assert!(matches!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + } +} diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 2fb9e1e..0ceba2a 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -268,15 +268,15 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { }; files.push(("launcher.sh".into(), act)); - // Justfile: created whole when absent; merging into an existing one is a - // separate step that must prove `just --summary` keeps every recipe. + // Justfile: created whole when absent, otherwise merged (justfile.rs), which + // `just --summary` must accept or the original is restored. let justfile = ["Justfile", "justfile", ".justfile"] .into_iter() .find(|j| target.join(j).is_file()); let act = match justfile { - Some(j) => Act::Kept(format!( - "{j} exists: merge delegations with `provision-set merge-justfile`" - )), + Some(j) => { + super::justfile::merge(target, j, &canon_text(canon, "build/just/provision.just")?)? + } None => { vars.insert( "DELEGATIONS", diff --git a/crates/launcher-common/src/provisioning/mod.rs b/crates/launcher-common/src/provisioning/mod.rs index cc8f1b4..fae8762 100644 --- a/crates/launcher-common/src/provisioning/mod.rs +++ b/crates/launcher-common/src/provisioning/mod.rs @@ -5,5 +5,6 @@ pub mod canon; pub mod check; +pub mod justfile; pub mod licence; pub mod mint; From d096aafa93ef23e2d976449e261520e2f4f1d5f3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:10:04 +0100 Subject: [PATCH 04/26] feat(provision-set): insert the README [[ai-install]] section The TIP goes after the document header and the section before the first level-2 heading (outside delimited blocks). A README that already has the anchor is left alone; one with its own "AI-Assisted Installation" section only gains the anchor (11 estate READMEs). README.md is skipped with a ledger note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../launcher-common/src/provisioning/mint.rs | 6 + .../launcher-common/src/provisioning/mod.rs | 1 + .../src/provisioning/readme.rs | 190 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 crates/launcher-common/src/provisioning/readme.rs diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 0ceba2a..8c6ef80 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -268,6 +268,12 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { }; files.push(("launcher.sh".into(), act)); + let readme = render(&canon_text(canon, "README-ai-install.adoc.tmpl")?, &vars); + files.push(( + "README.adoc".into(), + super::readme::insert(target, &readme)?, + )); + // Justfile: created whole when absent, otherwise merged (justfile.rs), which // `just --summary` must accept or the original is restored. let justfile = ["Justfile", "justfile", ".justfile"] diff --git a/crates/launcher-common/src/provisioning/mod.rs b/crates/launcher-common/src/provisioning/mod.rs index fae8762..1addeb7 100644 --- a/crates/launcher-common/src/provisioning/mod.rs +++ b/crates/launcher-common/src/provisioning/mod.rs @@ -8,3 +8,4 @@ pub mod check; pub mod justfile; pub mod licence; pub mod mint; +pub mod readme; diff --git a/crates/launcher-common/src/provisioning/readme.rs b/crates/launcher-common/src/provisioning/readme.rs new file mode 100644 index 0000000..6de79a3 --- /dev/null +++ b/crates/launcher-common/src/provisioning/readme.rs @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Insert the README `[[ai-install]]` section (`PROVISIONING-STANDARD.adoc` §1: +//! *inserted*, never regenerated). +//! +//! The rendered `README-ai-install.adoc.tmpl` has two parts: a TIP that goes +//! straight after the document header, and the `[[ai-install]]` section that +//! goes before the first level-2 section. Once present the section belongs to +//! the README and is never touched again; a README that already has an +//! "AI-Assisted Installation" section of its own only gains the anchor, so the +//! launcher's `ai-setup` can read its sentence. + +use super::mint::Act; +use anyhow::{Context, Result}; +use std::path::Path; + +const ANCHOR: &str = "[[ai-install]]"; + +/// Insert `rendered` (the template with the repo's values) into the README. +pub fn insert(target: &Path, rendered: &str) -> Result { + let path = target.join("README.adoc"); + if !path.is_file() { + let why = if target.join("README.md").is_file() { + "README.md: the section is AsciiDoc; add it by hand" + } else { + "no README.adoc to insert the AI-install section into" + }; + return Ok(Act::Skipped(why.into())); + } + let text = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let Some(out) = merged(&text, rendered) else { + return Ok(Act::Kept("README.adoc already has [[ai-install]]".into())); + }; + std::fs::write(&path, &out.0).with_context(|| format!("writing {}", path.display()))?; + Ok(Act::Replaced(out.1.into())) +} + +/// The README with the section in place and what was done, or `None` when it +/// already has the anchor. +fn merged(text: &str, rendered: &str) -> Option<(String, &'static str)> { + let lines: Vec<&str> = text.lines().collect(); + if lines.iter().any(|l| l.trim() == ANCHOR) { + return None; + } + let sections = level2_headings(&lines); + + // An existing section of the same purpose gains the anchor and nothing else. + if let Some(&i) = sections.iter().find(|&&i| { + lines[i] + .to_ascii_lowercase() + .starts_with("== ai-assisted install") + }) { + let mut out: Vec<&str> = lines.clone(); + out.insert(i, ANCHOR); + return Some(( + join(&out), + "anchor added to the existing AI-assisted section", + )); + } + + let (tip, section) = match rendered.split_once(&format!("\n{ANCHOR}")) { + Some((t, s)) => (t.trim_end(), format!("{ANCHOR}{}", s.trim_end())), + None => ("", rendered.trim_end().to_string()), + }; + + // The section goes before the first level-2 heading, above its own anchor + // and attribute lines; with none it is appended. + let at = sections.first().map_or(lines.len(), |&h| { + let mut s = h; + while s > 0 && (lines[s - 1].starts_with('[') || lines[s - 1].starts_with("//")) { + s -= 1; + } + s + }); + let head = header_end(&lines).min(at); + + let mut out: Vec = Vec::with_capacity(lines.len() + 80); + out.extend(lines[..head].iter().map(|l| l.to_string())); + if !tip.is_empty() { + pad(&mut out); + out.push(tip.to_string()); + out.push(String::new()); + } + out.extend(lines[head..at].iter().map(|l| l.to_string())); + pad(&mut out); + out.push(section); + out.push(String::new()); + out.extend(lines[at..].iter().map(|l| l.to_string())); + let mut s = out.join("\n"); + while s.contains("\n\n\n") { + s = s.replace("\n\n\n", "\n\n"); + } + if !s.ends_with('\n') { + s.push('\n'); + } + Some((s, "AI-install section inserted")) +} + +fn join(lines: &[&str]) -> String { + let mut s = lines.join("\n"); + s.push('\n'); + s +} + +/// Ensure the output ends with a blank line before a new block. +fn pad(out: &mut Vec) { + if out.last().is_some_and(|l| !l.trim().is_empty()) { + out.push(String::new()); + } +} + +/// Indices of `== ` headings outside delimited blocks. +fn level2_headings(lines: &[&str]) -> Vec { + let mut open: Option<&str> = None; + let mut v = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let t = l.trim_end(); + let delim = t.len() >= 4 + && ["-", ".", "=", "*", "+", "_", "/"] + .iter() + .any(|c| t.chars().all(|x| x.to_string() == *c)) + || t == "```" + || t.starts_with("|==="); + if delim { + match open { + Some(o) if o == t => open = None, + None => open = Some(t), + _ => {} + } + continue; + } + if open.is_none() && t.starts_with("== ") { + v.push(i); + } + } + v +} + +/// The line after the document header (`= Title` and the attribute/author lines +/// that follow it up to the first blank line); 0 when there is no title. +fn header_end(lines: &[&str]) -> usize { + let Some(t) = lines.iter().position(|l| l.starts_with("= ")) else { + return 0; + }; + lines[t..] + .iter() + .position(|l| l.trim().is_empty()) + .map_or(lines.len(), |p| t + p) +} + +#[cfg(test)] +mod tests { + use super::*; + + const R: &str = "[TIP]\n====\nsay it\n====\n\n[[ai-install]]\n== AI-Assisted Installation (Recommended)\n\nbody\n"; + + #[test] + fn tip_after_header_section_before_first_heading() { + let src = "// SPDX\n= Title\n:toc:\n\nIntro.\n\n[#usage]\n== Usage\n\n----\n== not a heading\n----\n"; + let (out, _) = merged(src, R).unwrap(); + let tip = out.find("[TIP]").unwrap(); + let intro = out.find("Intro.").unwrap(); + let anchor = out.find("[[ai-install]]").unwrap(); + let usage = out.find("[#usage]").unwrap(); + assert!( + out.find(":toc:").unwrap() < tip && tip < intro && intro < anchor && anchor < usage + ); + assert!(!out.contains("\n\n\n")); + assert!(merged(&out, R).is_none(), "second run must be a no-op"); + } + + #[test] + fn an_existing_section_only_gains_the_anchor() { + let src = "= T\n\n== AI-Assisted Installation\n\nSay X.\n"; + let (out, what) = merged(src, R).unwrap(); + assert_eq!( + out, + "= T\n\n[[ai-install]]\n== AI-Assisted Installation\n\nSay X.\n" + ); + assert!(what.contains("anchor")); + } + + #[test] + fn headings_inside_blocks_are_ignored_and_no_heading_appends() { + let src = "= T\n\n....\n== x\n....\n"; + let (out, _) = merged(src, R).unwrap(); + assert!(out.trim_end().ends_with("body")); + } +} From a90da063edb1e88089ee0c3e7f0f8d7c715f3aa7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:11:54 +0100 Subject: [PATCH 05/26] fix(provision-set): repair more sweep damage; refuse a provision recipe Measured on the estate dry-run: 21 Justfiles carry column-0 `//` comments and 18 a duplicate guix-shell whose body is the Nix sweep's dead `flake.guix` fallback; both stop `just` parsing. When a file does not parse, `//` becomes `#` and that duplicate is dropped. A recipe named `provision` (arghda-core) clashes with `mod provision` and is refused rather than renamed, since other recipes may depend on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../src/provisioning/justfile.rs | 85 ++++++++++++++++--- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index 9e31cd5..93b5323 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -77,20 +77,43 @@ pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { } } + if lines.iter().any(|l| header_name(l) == Some("provision")) { + return Ok(Act::Skipped(format!( + "{name} has its own `provision` recipe, which clashes with `mod provision`: rename it by hand" + ))); + } + // A file `just` cannot parse is merged only when a mechanical repair is - // available: removing boilerplate, or re-indenting lines an earlier sweep - // left at column 0 inside a shebang body (the body is one script, so the - // indent changes nothing it runs). `just` judges the result below. + // available, each undoing damage an earlier estate sweep did: + // * boilerplate removal (above); + // * lines left at column 0 inside a shebang body are re-indented (the body + // is one script, so the indent changes nothing it runs); + // * column-0 `//` comments become `#`; + // * a duplicate recipe whose body is the Nix sweep's dead `flake.guix` + // fallback is dropped (`guix develop` and `flake.guix` do not exist). + // `just` judges the result below. let mut indent = vec![false; lines.len()]; + let mut slashes = vec![false; lines.len()]; + let mut repaired = !replaced.is_empty(); if before.is_err() { for (i, l) in lines.iter().enumerate() { - if header_name(l).is_none() || drop[i] { + let Some(name) = header_name(l) else { + continue; + }; + if drop[i] { continue; } - let name = header_name(l).unwrap_or_default(); - let Some((_, h, end)) = span(&lines, name).filter(|(_, h, _)| *h == i) else { + let (start, h, end) = span_at(&lines, i); + let dup = lines + .iter() + .filter(|o| header_name(o) == Some(name)) + .count() + > 1; + if dup && lines[h..end].iter().any(|b| b.contains("flake.guix")) { + drop[start..end].iter_mut().for_each(|d| *d = true); + repaired = true; continue; - }; + } if !lines .get(h + 1) .is_some_and(|b| b.trim_start().starts_with("#!")) @@ -100,13 +123,19 @@ pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { for j in h + 1..end { if !lines[j].is_empty() && !lines[j].starts_with([' ', '\t']) { indent[j] = true; + repaired = true; } } } + for (i, l) in lines.iter().enumerate() { + if l.starts_with("//") && !indent[i] { + slashes[i] = true; + repaired = true; + } + } } if let Err(e) = &before - && replaced.is_empty() - && !indent.contains(&true) + && !repaired { return Ok(Act::Skipped(format!( "{name} does not parse, and no mechanical repair applies: {e}" @@ -129,7 +158,13 @@ pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { if indent[i] { out.push_str(" "); } - out.push_str(line); + match line.strip_prefix("//").filter(|_| slashes[i]) { + Some(rest) => { + out.push('#'); + out.push_str(rest); + } + None => out.push_str(line), + } } } out.push('\n'); @@ -330,6 +365,11 @@ fn starts_item(line: &str) -> bool { /// `just` rejects, is still taken whole. fn span(lines: &[&str], name: &str) -> Option<(usize, usize, usize)> { let header = lines.iter().position(|l| header_name(l) == Some(name))?; + Some(span_at(lines, header)) +} + +/// [`span`] of the recipe whose header is line `header`. +fn span_at(lines: &[&str], header: usize) -> (usize, usize, usize) { let mut start = header; while start > 0 && (lines[start - 1].starts_with('#') || lines[start - 1].starts_with('[')) { start -= 1; @@ -347,7 +387,7 @@ fn span(lines: &[&str], name: &str) -> Option<(usize, usize, usize)> { break; } } - Some((start, header, end)) + (start, header, end) } fn recipe_names(text: &str) -> Vec { @@ -449,6 +489,29 @@ mod tests { )); } + #[test] + #[ignore = "needs just >= 1.42 on PATH"] + fn sweep_damage_is_repaired_and_a_provision_recipe_refused() { + let src = "// SPDX-License-Identifier: MPL-2.0\n\nguix-shell:\n guix shell -D -f guix.scm\n\n# fallback\nguix-shell:\n @if [ -f \"flake.guix\" ]; then guix develop; fi\n"; + let d = repo(src); + assert!( + summary(&d, "Justfile").is_err(), + "the control must not parse" + ); + assert!(matches!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Replaced(_) + )); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(text.starts_with("# SPDX") && !text.contains("flake.guix")); + assert!(text.contains("guix shell -D -f guix.scm")); + let clash = repo("provision:\n @echo mine\n"); + assert!(matches!( + merge(&clash, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + } + #[test] #[ignore = "needs just >= 1.42 on PATH"] fn an_unrepairable_file_is_left_byte_identical() { From f9b755d3721495b12c4c2fde226ae756413df6dc Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:15:55 +0100 Subject: [PATCH 06/26] fix(provision-set): replace a banned-tool mise.toml that is not TOML boj-server-mk2's mise.toml repeats a key, so the carry-over parse aborted the whole mint. mise cannot read such a file either, so none of its pins were in effect: it is now replaced like any banned-tool file, nothing is carried, and the replace reason says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../launcher-common/src/provisioning/mint.rs | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 8c6ef80..a05290f 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -190,9 +190,9 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { // mise.toml: minted, but a banned tool is a replace condition. let banned = lib.predicate(&["mise-banned"])?; - let mut carried = Vec::new(); + let (mut carried, mut unread) = (Vec::new(), String::new()); if let Some(hits) = &banned { - carried = carry_over_tools(target, hits)?; + (carried, unread) = carry_over_tools(target, hits)?; } vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried)); let act = match banned { @@ -200,7 +200,7 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { "mise.toml", "mise.toml.tmpl", &vars, - &format!("pinned banned tool(s): {hits}"), + &format!("pinned banned tool(s): {hits}{unread}"), )?, _ => m.minted("mise.toml", "mise.toml.tmpl", &vars)?, }; @@ -513,10 +513,21 @@ fn toml_key(k: &str) -> String { } } -/// The non-banned `[tools]` entries of an existing `mise.toml`, values as TOML. -fn carry_over_tools(target: &Path, banned: &str) -> Result> { +/// The non-banned `[tools]` entries of an existing `mise.toml`, values as TOML, +/// and a note for the replace reason when the file is not TOML at all: mise +/// cannot read such a file either, so nothing in it was in effect to carry. +fn carry_over_tools(target: &Path, banned: &str) -> Result<(Vec<(String, String)>, String)> { let text = std::fs::read_to_string(target.join("mise.toml"))?; - let table: toml::Table = toml::from_str(&text).context("parsing mise.toml")?; + let table: toml::Table = match toml::from_str(&text) { + Ok(t) => t, + Err(e) => { + let why = e.message().trim().to_string(); + return Ok(( + Vec::new(), + format!("; it was not valid TOML ({why}), so no tool was carried over"), + )); + } + }; let banned: Vec<&str> = banned.split_whitespace().collect(); let mut out = Vec::new(); if let Some(tools) = table.get("tools").and_then(|t| t.as_table()) { @@ -526,7 +537,7 @@ fn carry_over_tools(target: &Path, banned: &str) -> Result } } } - Ok(out) + Ok((out, String::new())) } /// One root delegation per contract verb the root Justfile does not define, @@ -921,6 +932,22 @@ mod tests { ); } + #[test] + fn carry_over_drops_banned_and_survives_a_file_that_is_not_toml() { + let d = std::env::temp_dir().join(format!("carry-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let mise = d.join("mise.toml"); + std::fs::write(&mise, "[tools]\npython = \"3\"\nzig = \"0.14\"\n").unwrap(); + let (carried, note) = carry_over_tools(&d, "python").unwrap(); + assert_eq!(carried, vec![("zig".to_string(), "\"0.14\"".to_string())]); + assert!(note.is_empty()); + std::fs::write(&mise, "[tools]\nbun = \"1\"\nbun = \"1\"\n").unwrap(); + let (carried, note) = carry_over_tools(&d, "python").unwrap(); + assert!(carried.is_empty()); + assert!(note.contains("not valid TOML") && note.contains("duplicate key")); + std::fs::remove_dir_all(&d).unwrap(); + } + #[test] fn mechanical_residue_ignores_spec_slots() { assert_eq!(mechanical_residue("x __SPEC_USAGE__ y"), None); From 92f37623b1754311bf8df6fdfbd8bb37c7a98304 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:45:15 +0100 Subject: [PATCH 07/26] fix(provision-set): track vendored engine; resync to standards@e323e0a The four build/just engine files under standards/provisioning/templates/ and their copies in the check/ fixture were never committed: the repository's `build/` ignore rule swallowed them, so canon.rs's include_bytes! only compiled in a working tree that already had them. A fresh clone could not build. Negate the rule for those two trees and add the files. Re-vendor the canon from standards@e323e0ad (the [[ai-install]] prose escape) and move PINNED_DIGEST to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .gitignore | 4 + .../launcher-common/src/provisioning/canon.rs | 2 +- .../check/build/just/provision-check.sh | 112 +++ .../check/build/just/provision-lib.sh | 832 ++++++++++++++++++ .../check/build/just/provision-modes.sh | 158 ++++ .../check/build/just/provision.just | 108 +++ standards/provisioning/CANON | 2 +- .../provisioning/PROVISIONING-STANDARD.adoc | 11 +- .../provisioning-standard_praxis.deed | 2 +- .../templates/build/just/provision-check.sh | 112 +++ .../templates/build/just/provision-lib.sh | 832 ++++++++++++++++++ .../templates/build/just/provision-modes.sh | 158 ++++ .../templates/build/just/provision.just | 108 +++ .../templates/docs/SETUP.adoc.tmpl | 1 + .../provisioning/templates/launcher.sh.tmpl | 4 +- 15 files changed, 2436 insertions(+), 10 deletions(-) create mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh create mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh create mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just create mode 100755 standards/provisioning/templates/build/just/provision-check.sh create mode 100755 standards/provisioning/templates/build/just/provision-lib.sh create mode 100755 standards/provisioning/templates/build/just/provision-modes.sh create mode 100644 standards/provisioning/templates/build/just/provision.just diff --git a/.gitignore b/.gitignore index b423cbb..c72956a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,8 @@ deps/ .elixir_ls/ .cache/ build/ +# The provisioning canon vendors build/just/ engine files, and its fixtures +# mirror a repo root; both are source here, not build output. +!standards/provisioning/templates/build/ +!crates/launcher-common/tests/fixtures/provisioning/**/build/ dist/ diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs index abd8f1e..e6e6ffb 100644 --- a/crates/launcher-common/src/provisioning/canon.rs +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -230,5 +230,5 @@ mod tests { assert!(BAKED_CANON_REF.trim().starts_with("standards@")); } - const PINNED_DIGEST: &str = "72ae31ad63adf81f2e2bf8a27c16bf1921b0426cf6942ef097ab55f5e7e84b0a"; + const PINNED_DIGEST: &str = "f74848223841daf04db1c32ccd445f293750429f3331f4c65125e70a79f11328"; } diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh new file mode 100755 index 0000000..2666842 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision-check.sh — offline conformance check for the provisioning set +# (PROVISIONING-STANDARD.adoc §8, items 1–5). No network, no installs. +# +# provision-check.sh [--dev] [REPO_DIR] +# +# Exit 0 = conforms, 1 = does not. Every failure is printed; none is skipped. +# --dev downgrades repository-specific slot residue (__SPEC_X__) to a warning, +# for a checkout mid-specialisation. Mechanical residue (__X__) always fails. +set -uo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +LIB="$HERE/provision-lib.sh" +DEV=0 +[ "${1:-}" = "--dev" ] && { DEV=1; shift; } +REPO="${1:-.}" +cd "$REPO" || { echo "provision-check: no such directory: $REPO" >&2; exit 1; } + +MIN_JUST="1.42.0" +VERBS="setup doctor heal dev-shell toolchain-refresh ai-setup ai-warmup eval config-show opsm build test bench lint fmt fmt-check run deps" +# Files the provisioning set owns; residue anywhere else is not ours to judge. +# The engine scripts under build/just/ are excluded: they name slot syntax in their +# own comments and code, and realign owns them. README.adoc is scanned only inside +# its [[ai-install]] section. +# Their locations come from provision-lib.sh (guix-dir, set-files): the engine and +# this check must never disagree about where the Guix files or warm-ups live. +[ -f "$LIB" ] || { echo "provision-check: engine missing: $LIB" >&2; exit 1; } +lib() { PROVISION_ROOT="$PWD" bash "$LIB" "$@"; } +GDIR=$(lib guix-dir) +SET_FILES=$(lib set-files) +gp() { [ "$GDIR" = . ] && echo "$1" || echo "$GDIR/$1"; } + +fails=0 warns=0 +ok() { printf ' ok %s\n' "$*"; } +bad() { printf ' FAIL %s\n' "$*"; fails=$((fails + 1)); } +warn() { printf ' warn %s\n' "$*"; warns=$((warns + 1)); } + +version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } + +echo "[1] launcher" +if [ ! -f launcher.sh ]; then bad "launcher.sh missing" +elif [ ! -x launcher.sh ]; then bad "launcher.sh is not executable (chmod +x; committed mode must be 100755)" +else + for m in --help --version; do + if ./launcher.sh "$m" >/dev/null 2>&1; then ok "launcher.sh $m exits 0" + else bad "launcher.sh $m exits non-zero"; fi + done +fi + +echo "[2] just" +f0=$fails +if ! command -v just >/dev/null 2>&1; then bad "just is not installed; cannot check the recipe contract" +else + jv=$(just --version 2>/dev/null | awk '{print $2}') + if version_ge "$jv" "$MIN_JUST"; then ok "just $jv >= $MIN_JUST" + else bad "just $jv < $MIN_JUST (module-recipe dependencies do not resolve)"; fi + if ! summary=$(just --summary 2>&1); then + bad "the Justfile does not parse: $(printf '%s' "$summary" | head -1)" + else + have=" $summary " + for v in $VERBS; do + case "$have" in *" $v "*) ;; *) bad "root recipe missing: $v" ;; esac + case "$have" in *" provision::$v "*) ;; *) bad "module recipe missing: provision::$v (engine drift)" ;; esac + done + [ "$fails" -eq "$f0" ] && ok "all ${VERBS// /, } present at root and in provision::" + fi +fi + +echo "[3] mise" +if [ ! -f mise.toml ]; then bad "mise.toml missing" +else + if hit=$(lib mise-banned); then ok "mise.toml names no banned tool"; else bad "mise.toml names banned tools: $hit"; fi + [ -f .mise.toml ] && bad "both mise.toml and .mise.toml exist" + if lg=$(lib mise-lock-gaps); then ok "mise.lock pins every mise.toml tool"; else bad "$lg (latest is not concrete; run: mise lock)"; fi +fi + +echo "[4] guix" +for g in "$(gp guix.scm)" "$(gp manifest.scm)" "$(gp channels.scm)"; do + if r=$(lib guix-stub "$g"); then ok "$g is not a stub"; else bad "$g: $r"; fi +done + +echo "[5] template residue" +f0=$fails +residue() { # $1 label; stdin = the text to judge + local text mech spec + text=$(cat) + # Mechanical slots (__X__ not starting SPEC_) are the generator's job: always fatal. + mech=$(printf '%s\n' "$text" | grep -noE '__[A-Z][A-Z_]*__' | grep -v ':__SPEC_' | head -3 | tr '\n' ' ') + spec=$(printf '%s\n' "$text" | grep -noE '__SPEC_[A-Z_]*__' | head -3 | tr '\n' ' ') + [ -n "$mech" ] && bad "$1: unfilled mechanical slots: $mech" + if [ -n "$spec" ]; then + if [ "$DEV" = 1 ]; then warn "$1: unfilled repository slots: $spec" + else bad "$1: unfilled repository slots: $spec"; fi + fi +} +for f in $SET_FILES; do + # shellcheck disable=SC2094 # $f is only the label; nothing writes it + [ -f "$f" ] && residue "$f" < "$f" +done +for r in README.adoc README.md; do + [ -f "$r" ] || continue + # From the [[ai-install]] anchor to the next level-2 heading after its own. + residue "$r [[ai-install]]" < <(awk '/^\[\[ai-install\]\]/{on=1; n=0} on && /^== /{n++; if (n>1) exit} on' "$r") +done +[ "$fails" -eq "$f0" ] && ok "no residue that fails this mode" + +echo +echo "provision-check: $fails FAIL, $warns WARN" +[ "$fails" -eq 0 ] diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh new file mode 100755 index 0000000..fb1d942 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh @@ -0,0 +1,832 @@ +#!/usr/bin/env bash +# SC2015: `A && pass || fail` is safe here — pass/warn/fail/info always return 0. +# shellcheck disable=SC2015 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision-lib.sh — the shared provisioning engine behind build/just/provision.just +# +# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +# Vendored into every repo at build/just/provision-lib.sh by `provision-set mint`. +# DO NOT hand-edit a vendored copy: repo-specific facts live in +# .machine_readable/descriptiles/provisioning_praxis.deed, and `provision-set realign` +# overwrites this file from canon. +# +# Usage: provision-lib.sh [args] +# verbs: langs doctor setup heal dev-shell toolchain-refresh crates-scm ai-setup +# ai-warmup eval config-show opsm lang-run +# search version +# facts: guix-specs mise-tools langs guix-dir set-files guix-gaps tool-table +# system-deps +# predicates (print why and exit 1, or exit 0 silently): +# guix-stub FILE, mise-lock-gaps, mise-banned +# +# Exit: 0 ok | 1 a FAIL was found / a step failed | 2 usage error +set -uo pipefail + +PROVISION_LIB_VERSION="0.5.0" +ROOT="${PROVISION_ROOT:-$(pwd)}" +cd "$ROOT" || exit 2 +DEED=".machine_readable/descriptiles/provisioning_praxis.deed" +MIN_JUST="1.42.0" # root→module recipe deps (`doctor: provision::doctor`); 1.41 rejects them +# How to get mise: package managers first; the installer only as download, read, run. +MISE_INSTALL_HINT="brew install mise | Fedora: dnf copr enable jdxcode/mise, dnf install mise | winget install jdx.mise | others: https://mise.jdx.dev/installing-mise.html — or: curl -fsSLo mise-install.sh https://mise.run, read it, sh mise-install.sh" +T="timeout 15" # some --version probes hang (observed 2026-09-30); never probe unbounded + +if [ -t 1 ]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[34m'; Z=$'\033[0m'; else R=; G=; Y=; B=; Z=; fi +PASS=0; WARN=0; FAIL=0 +pass() { PASS=$((PASS+1)); printf ' %sPASS%s %s\n' "$G" "$Z" "$*"; } +warn() { WARN=$((WARN+1)); printf ' %sWARN%s %s\n' "$Y" "$Z" "$*"; } +fail() { FAIL=$((FAIL+1)); printf ' %sFAIL%s %s\n' "$R" "$Z" "$*"; } +info() { printf ' %sinfo%s %s\n' "$B" "$Z" "$*"; } +hdr() { printf '\n%s== %s ==%s\n' "$B" "$*" "$Z"; } +have() { command -v "$1" >/dev/null 2>&1; } + +# Where this repository keeps each part of the set. A file sits at the root only +# when something needs it there (launcher.sh, Justfile, mise.toml, mise.lock); +# the Guix trio and the warm-ups follow the layout the repository already has +# (rsr-template-repo keeps build/guix.scm and docs/onboarding/llm-warmup-*.adoc). +# This is the ONE answer: provision-check.sh asks it through `guix-dir` and +# `set-files` rather than keeping its own copy. +guix_dir() { if [ -f build/guix.scm ]; then echo build; else echo .; fi; } +warmup_dir() { + local d + for d in . docs/onboarding docs; do + compgen -G "$d/llm-warmup-*.adoc" >/dev/null && { echo "$d"; return; } + done + echo . +} +gpath() { local d; d=$(guix_dir); [ "$d" = . ] && echo "$1" || echo "$d/$1"; } +wpath() { local d; d=$(warmup_dir); [ "$d" = . ] && echo "llm-warmup-$1.adoc" || echo "$d/llm-warmup-$1.adoc"; } +set_files() { + printf '%s\n' launcher.sh Justfile justfile mise.toml \ + "$(gpath guix.scm)" "$(gpath manifest.scm)" "$(gpath channels.scm)" \ + "$DEED" docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc \ + "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)" +} + +# --------------------------------------------------------------------------- +# Descriptor: flat `:key "value"` reads from provisioning_praxis.deed (s-expression). +deed() { # $1 key, $2 default + local v="" + [ -f "$DEED" ] && v=$(grep -oE "\(:?$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') + [ -z "$v" ] && v=$(grep -oE ":$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') + printf '%s' "${v:-$2}" +} +repo_slug() { + local u; u=$(git config --get remote.origin.url 2>/dev/null || true) + u=${u%.git}; u=${u#*github.com[:/]} + [ -n "$u" ] && printf '%s' "$u" || printf 'hyperpolymath/%s' "$(basename "$ROOT")" +} +REPO_SLUG="$(deed repo "$(repo_slug)")" +REPO_NAME="${REPO_SLUG#*/}" +ARCHETYPE="$(deed archetype "")" + +# --------------------------------------------------------------------------- +# Language detection. Markers at the root or one level down (workspaces). The +# estate ABI/FFI pattern nests deeper: Idris2 to two levels down (src/abi/*.ipkg), +# Zig to three (ffi/zig/build.zig, and rsr-template-repo's src/interface/ffi/build.zig: +# 74 repos have their only build.zig at that depth, measured 2026-09-30). +# Order matters only for display. `docs` is reported when nothing else is. +first() { find . -maxdepth "${2:-2}" -not -path './.git/*' -not -path '*/node_modules/*' -not -path './target/*' -name "$1" -print -quit 2>/dev/null; } +detect_langs() { + local out=() + [ -n "$(first Cargo.toml)" ] && out+=(rust) + [ -n "$(first '*.ipkg' 3)" ] && out+=(idris2) + [ -n "$(first Project.toml 1)" ] && out+=(julia) + [ -n "$(first build.zig 4)" ] && out+=(zig) + [ -n "$(first mix.exs)" ] && out+=(elixir) + [ -n "$(first gleam.toml)" ] && out+=(gleam) + [ -n "$(first dune-project)" ] && out+=(ocaml) + { [ -n "$(first '*.cabal')" ] || [ -n "$(first stack.yaml 1)" ]; } && out+=(haskell) + [ -n "$(first package.json)" ] && out+=(bun) + [ ${#out[@]} -eq 0 ] && out+=(docs) + printf '%s\n' "${out[@]}" +} +mapfile -t LANGS < <(detect_langs) +[ -z "$ARCHETYPE" ] && { [ "${LANGS[*]}" = docs ] && ARCHETYPE=docs || ARCHETYPE=library; } + +# Binaries each language needs, and how each is obtained. +# mise registry gaps (verified 2026-09-30 against mise 2026.7.5): ocaml, haskell, +# idris2 and guile are NOT mise tools. They come from opam / ghcup / pack / the +# system package manager (or Guix), and the doctor probes the binary itself. +lang_tools() { + case "$1" in + rust) echo "cargo rustc" ;; + idris2) echo "idris2" ;; + julia) echo "julia" ;; + zig) echo "zig" ;; + elixir) echo "elixir mix erl" ;; + gleam) echo "gleam erl" ;; + ocaml) echo "opam dune ocaml" ;; + haskell) echo "ghc cabal" ;; + bun) echo "bun" ;; + docs) echo "" ;; + esac +} +# Guix package specs per language (verified 2026-09-30 against guix ae77aeb in +# docker.io/metacall/guix). Guix has NO idris2, gleam, bun or lychee, and its +# julia is 1.8.5: those come from mise, which Guix itself ships ("mise"). +GUIX_BASE="git bash coreutils nss-certs just mise shellcheck" +lang_guix() { + case "$1" in + rust) echo "rust rust:cargo gcc-toolchain pkg-config" ;; + idris2) echo "chez-scheme gmp gcc-toolchain" ;; + zig) echo "zig" ;; + elixir) echo "elixir erlang" ;; + gleam) echo "erlang" ;; + ocaml) echo "ocaml dune opam ocaml-findlib" ;; + haskell) echo "ghc cabal-install" ;; + docs) echo "ruby-asciidoctor" ;; + julia|bun) echo "" ;; + esac +} +# Tools a Guix shell cannot supply, so mise (inside that shell) does. +lang_guix_gap() { + case "$1" in + idris2) echo "idris2 (via pack)" ;; julia) echo julia ;; gleam) echo gleam ;; bun) echo bun ;; docs) echo lychee ;; + esac +} +guix_specs() { + local l s=" $GUIX_BASE " + for l in "${LANGS[@]}"; do for p in $(lang_guix "$l"); do case "$s" in *" $p "*) ;; *) s="$s$p ";; esac; done; done + echo "$s" | xargs +} + +# mise tools per language (registry-checked 2026-09-30, mise 2026.7.5). idris2 comes +# from pack and haskell from ghcup or Guix: neither is a mise tool. The recipes +# themselves need just and shellcheck, so those two are always declared. +MISE_BASE="just shellcheck" +# Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. +RECIPE_TOOLS="trivy" +lang_mise() { case "$1" in + rust) echo "rust" ;; zig) echo "zig" ;; julia) echo "julia" ;; + elixir) echo "erlang elixir" ;; gleam) echo "erlang gleam" ;; ocaml) echo "opam" ;; + bun) echo "bun" ;; docs) echo "lychee" ;; esac; } +recipe_tools() { + local t f body="" + for f in Justfile justfile build/just/*.just; do + [ -f "$f" ] && body+=$(grep -v '^[[:space:]]*#' "$f")$'\n' + done + for t in $RECIPE_TOOLS; do + [[ "$body" =~ (^|[^[:alnum:]_-])$t([^[:alnum:]_-]|$) ]] && echo "$t" + done +} + +mise_tools() { + local l t seen=" " out=() + for t in $MISE_BASE $(for l in "${LANGS[@]}"; do lang_mise "$l"; done) $(recipe_tools); do + case "$seen" in *" $t "*) ;; *) out+=("$t"); seen="$seen$t " ;; esac + done + printf "%s\n" "${out[*]}" +} + +lang_remedy() { + case "$1" in + rust) echo "mise use rust@latest (or rustup: https://rustup.rs)" ;; + idris2) echo "install pack: https://github.com/stefan-hoeck/idris2-pack#installation, then: pack install-app idris2" ;; + julia) echo "mise use julia@latest (or: https://julialang.org/install/ — juliaup)" ;; + zig) echo "mise use zig@latest" ;; + elixir) echo "mise use erlang@latest elixir@latest (erlang builds from source: allow ~10 min)" ;; + gleam) echo "mise use gleam@latest erlang@latest" ;; + ocaml) echo "mise use opam@latest && opam init -y && opam switch create . --deps-only -y" ;; + haskell) echo "ghcup: https://www.haskell.org/ghcup/ (or: just dev-shell, which uses Guix)" ;; + bun) echo "mise use bun@latest" ;; + esac +} + +# The per-language default for each contract verb. A repo overrides any of +# these simply by defining the root recipe itself; `provision-set` only adds +# `: provision::` where the root Justfile has no such recipe. +lang_cmd() { # $1 lang, $2 verb -> prints a shell command, or nothing (= N/A) + local l=$1 v=$2 ipkg + case "$l:$v" in + rust:deps) echo "cargo fetch" ;; + rust:build) echo "cargo build --all-targets" ;; + rust:test) echo "cargo test --all-targets" ;; + rust:bench) grep -rqs '\[\[bench\]\]\|criterion\|divan' --include=Cargo.toml . && echo "cargo bench" ;; + rust:lint) echo "cargo clippy --all-targets -- -D warnings" ;; + rust:fmt) echo "cargo fmt --all" ;; + rust:fmt-check) echo "cargo fmt --all -- --check" ;; + rust:run) echo "cargo run --release" ;; + + idris2:*) + ipkg=$(first '*.ipkg' 1); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg'); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg' 3); ipkg=${ipkg#./} + case "$v" in + deps) have pack && echo "pack install-deps $ipkg" ;; + build) have pack && echo "pack build $ipkg" || echo "idris2 --build $ipkg" ;; + test) local t; t=$(find . -maxdepth 3 -name 'test*.ipkg' -print -quit 2>/dev/null) + if [ -n "$t" ]; then have pack && echo "pack test ${t#./}" || echo "idris2 --build ${t#./}" + else have pack && echo "pack typecheck $ipkg" || echo "idris2 --typecheck $ipkg"; fi ;; + run) grep -qs '^[[:space:]]*executable' "$ipkg" && { have pack && echo "pack run $ipkg" || echo "idris2 --build $ipkg && ./build/exec/*"; } ;; + esac ;; + + julia:deps) echo "julia --project=. -e 'using Pkg; Pkg.instantiate()'" ;; + julia:build) echo "julia --project=. -e 'using Pkg; Pkg.precompile()'" ;; + julia:test) echo "julia --project=. -e 'using Pkg; Pkg.test()'" ;; + julia:bench) [ -f benchmark/benchmarks.jl ] && echo "julia --project=benchmark -e 'using Pkg; Pkg.develop(path=\".\"); Pkg.instantiate(); include(\"benchmark/benchmarks.jl\")'" ;; + + zig:*) + # build.zig may sit in ffi/zig/; run there, not at the root. + local zb zd; zb=$(first build.zig 1); [ -z "$zb" ] && zb=$(first build.zig); [ -z "$zb" ] && zb=$(first build.zig 3); [ -z "$zb" ] && zb=$(first build.zig 4) + zd=$(dirname "${zb#./}"); local cdz=""; [ "$zd" != . ] && cdz="cd '$zd' && " + case "$v" in + build) echo "${cdz}zig build" ;; + test) grep -qs '"test"' "$zb" && echo "${cdz}zig build test" ;; + bench) grep -qs '"bench"' "$zb" && echo "${cdz}zig build bench -Doptimize=ReleaseFast" ;; + fmt) echo "zig fmt $zd" ;; + fmt-check) echo "zig fmt --check $zd" ;; + lint) echo "zig fmt --check $zd" ;; + run) grep -qs '"run"' "$zb" && echo "${cdz}zig build run" ;; + esac ;; + + elixir:deps) echo "mix local.hex --force --if-missing && mix local.rebar --force --if-missing && mix deps.get" ;; + elixir:build) echo "mix compile --warnings-as-errors" ;; + elixir:test) echo "mix test" ;; + elixir:bench) ls bench/*.exs >/dev/null 2>&1 && echo "for f in bench/*.exs; do mix run \"\$f\"; done" ;; + elixir:lint) grep -qs ':credo' mix.exs && echo "mix credo --strict" || echo "mix compile --warnings-as-errors" ;; + elixir:fmt) echo "mix format" ;; + elixir:fmt-check) echo "mix format --check-formatted" ;; + elixir:run) echo "mix run --no-halt" ;; + + gleam:deps) echo "gleam deps download" ;; + gleam:build) echo "gleam build" ;; + gleam:test) echo "gleam test" ;; + gleam:fmt) echo "gleam format" ;; + gleam:fmt-check) echo "gleam format --check" ;; + gleam:lint) echo "gleam format --check" ;; + gleam:run) echo "gleam run" ;; + + ocaml:deps) echo "opam install . --deps-only --with-test -y" ;; + ocaml:build) echo "opam exec -- dune build" ;; + ocaml:test) echo "opam exec -- dune test" ;; + ocaml:fmt) echo "opam exec -- dune fmt" ;; + ocaml:fmt-check) echo "opam exec -- dune build @fmt" ;; + + haskell:deps) echo "cabal update && cabal build all --only-dependencies" ;; + haskell:build) echo "cabal build all" ;; + haskell:test) echo "cabal test all" ;; + haskell:bench) grep -qs '^benchmark' ./*.cabal && echo "cabal bench all" ;; + haskell:run) echo "cabal run" ;; + + bun:deps) [ -f bun.lock ] || [ -f bun.lockb ] && echo "bun install --frozen-lockfile" || echo "bun install" ;; + bun:build) grep -qs '"build"[[:space:]]*:' package.json && echo "bun run build" ;; + # `bun test` exits 0 when it finds no test files, so it runs only when some exist. + bun:test) if grep -qs '"test"[[:space:]]*:' package.json; then echo "bun run test" + elif git ls-files 2>/dev/null | grep -qE '(\.|_)(test|spec)\.(js|mjs|cjs|jsx)$'; then echo "bun test"; fi ;; + bun:bench) grep -qs '"bench"[[:space:]]*:' package.json && echo "bun run bench" ;; + bun:lint) grep -qs '"lint"[[:space:]]*:' package.json && echo "bun run lint" ;; + bun:fmt) grep -qs '"fmt"[[:space:]]*:' package.json && echo "bun run fmt" ;; + bun:fmt-check) grep -qs '"fmt-check"[[:space:]]*:' package.json && echo "bun run fmt-check" ;; + bun:run) grep -qs '"start"[[:space:]]*:' package.json && echo "bun run start" ;; + + docs:test) have asciidoctor && echo "for f in \$(git ls-files '*.adoc'); do asciidoctor -o /dev/null --failure-level=WARN \"\$f\" || exit 1; done" ;; + docs:lint) have lychee && echo "lychee --offline --no-progress \$(git ls-files '*.adoc' '*.md')" ;; + esac +} + +# Run a contract verb across every detected language. N/A is reported, not +# faked green: a verb with no command for any language exits 0 but SAYS so. +lang_run() { # $1 verb + local verb=$1 ran=0 rc=0 l cmd override + override=$(deed "$verb" "") + if [ -n "$override" ]; then + info "$verb (from provisioning_praxis.deed): $override" + bash -c "$override"; return $? + fi + for l in "${LANGS[@]}"; do + cmd=$(lang_cmd "$l" "$verb") + [ -z "$cmd" ] && continue + ran=1 + info "$verb [$l]: $cmd" + bash -c "$cmd" || { rc=$?; printf ' %sFAIL%s %s [%s] exited %s\n' "$R" "$Z" "$verb" "$l" "$rc" >&2; } + done + [ $ran -eq 0 ] && info "$verb: N/A for ${LANGS[*]} (archetype: $ARCHETYPE) — nothing to run, and nothing was faked" + return $rc +} + +# --------------------------------------------------------------------------- +# A repo keeps its own checks as root recipes named doctor-local / setup-local / +# heal-local (provision-set renames a pre-existing custom doctor/setup/heal to +# these); the canon verbs run them, so `just doctor`, `./launcher.sh --doctor` and +# CI all see the same result. +has_recipe() { have just && just --summary 2>/dev/null | tr " " "\n" | grep -qx "$1"; } + +version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } + +# Tools that must never appear in a repo's toolchain (estate language policy). +BANNED_TOOLS='python|deno|node|nodejs|npm|yarn|pnpm|typescript|rescript|make|black|ruff|pip|poetry|nix|go|golang|java|kotlin' + +# --------------------------------------------------------------------------- +# Shared predicates. doctor and provision-check.sh both call THESE (the check via +# `provision-lib.sh guix-stub|mise-lock-gaps|mise-banned`): a gate with its own +# copy of a test passes what doctor warns about. + +# The keys of mise.toml [tools], quotes stripped ("cargo:foo" stays cargo:foo). +mise_toml_tools() { + [ -f mise.toml ] || return 0 + awk '/^\[tools\]/{t=1;next} /^\[/{t=0} t && /=/{sub(/[ \t]*=.*/,""); gsub(/["\x27 ]/,""); print}' mise.toml +} +# Banned tools named in mise.toml; a backend prefix does not hide one ("aqua:denoland/deno" is deno). +mise_banned() { + local t base hits="" + for t in $(mise_toml_tools); do + base=${t##*:}; base=${base%%@*}; base=${base##*/} + [[ "$base" =~ ^($BANNED_TOOLS)$ ]] && hits="$hits$t " + done + printf '%s' "${hits% }" +} +# Why mise.lock does not pin mise.toml, or nothing when it does. Presence is not +# enough: a zero-byte lock pins nothing. Every [tools] key needs its [[tools.]] +# entry, and the lock must carry checksums. +mise_lock_gaps() { + [ -f mise.toml ] || return 0 + [ -f mise.lock ] || { echo "mise.lock missing"; return; } + [ -s mise.lock ] || { echo "mise.lock is empty"; return; } + local t miss="" + # A tool is pinned when its [[tools.X]] block carries a concrete version line. + for t in $(mise_toml_tools); do + awk -v a="[[tools.$t]]" -v b="[[tools.\"$t\"]]" ' + $0 == a || $0 == b { inb = 1; next } + /^\[/ { inb = 0 } + inb && /^version = "[^"]+"/ { ok = 1 } + END { exit !ok }' mise.lock || miss="$miss$t " + done + [ -n "$miss" ] && { echo "mise.lock does not pin: ${miss% }"; return; } + # Checksums are per artefact: every [tools.X."platforms.P"] table needs its own + # sha256, so one checksummed tool cannot vouch for another. A tool with no + # platform tables has no artefact to checksum (core:rust installs through + # rustup, cargo: builds from source), which is what `mise lock` writes for it. + miss=$(awk ' + # Print the open platform table as tool/platform when it carried no sha256. + function close_table() { if (p != "" && !c) printf "%s ", p; p = "" } + /^\[tools\..*platforms\./ { close_table(); p = $0; c = 0 + gsub(/^\[tools\.|\]$|"/, "", p); sub(/\.platforms\./, "/", p); next } + /^\[/ { close_table() } + /^checksum = "sha256:[0-9a-f]+"/ { c = 1 } + END { close_table() }' mise.lock) + [ -z "$miss" ] || echo "mise.lock has no sha256 for: ${miss% }" +} +# Why a Guix file is a stub, or nothing when it is real. The test is positive: a +# guix.scm must define every field a package needs, not merely avoid known stub +# shapes; `(package (name "x") (source (local-file ".")))` is a stub. +guix_stub_reason() { + local f=$1 k miss="" + [ -f "$f" ] || { echo "missing"; return; } + grep -qE '\{\{|__[A-Z][A-Z_]*__' "$f" && { echo "unfilled template slots"; return; } + grep -qE '\(inputs \(list\)\)|\(source #f\)' "$f" && { echo "empty inputs or no source"; return; } + case "${f##*/}" in + manifest.scm) grep -q 'specifications->manifest' "$f" || echo "lists no specifications"; return ;; + channels.scm) grep -qE '\(commit "[0-9a-f]{40}"\)' "$f" || echo "pins no commit"; return ;; + esac + for k in name version source build-system home-page synopsis description license; do + grep -qE "\\(${k}[[:space:]]" "$f" || miss="$miss$k " + done + [ -n "$miss" ] && { echo "no package field: ${miss% }"; return; } + if grep -q 'crates\.scm' "$f"; then + grep -qs 'define %crate-inputs' build/guix/crates.scm || echo "build/guix/crates.scm is missing or defines no %crate-inputs" + fi +} + +# --------------------------------------------------------------------------- +# Facts the generator writes into docs/SETUP.adoc and the AI guide. They live here, +# beside lang_tools and lang_remedy, so no second per-language table exists. +lang_title() { case "$1" in + rust) echo Rust ;; idris2) echo Idris2 ;; julia) echo Julia ;; zig) echo Zig ;; elixir) echo Elixir ;; + gleam) echo Gleam ;; ocaml) echo OCaml ;; haskell) echo Haskell ;; bun) echo Bun ;; docs) echo Docs ;; esac; } +# What a language's route needs from the OS: "fedora|debian|macos command|why", or nothing. +lang_sysdeps() { case "$1" in + rust) echo "gcc pkgconf-pkg-config|build-essential pkg-config|xcode-select --install|Rust links through the system C toolchain, and pkg-config finds C libraries" ;; + idris2) echo "chez-scheme gmp-devel|chezscheme libgmp-dev|brew install chezscheme gmp|pack builds Idris2 on top of Chez Scheme and GMP" ;; + elixir|gleam) echo "gcc gcc-c++ make autoconf ncurses-devel openssl-devel|build-essential autoconf m4 libncurses-dev libssl-dev|brew install autoconf openssl@3|mise builds Erlang/OTP from source (allow ~10 min); the make here is the system tool that build uses, not a Makefile in this repository" ;; + ocaml) echo "gcc make patch unzip bubblewrap|build-essential patch unzip bubblewrap|xcode-select --install|opam compiles OCaml and sandboxes its builds with bubblewrap" ;; + haskell) echo "gcc gcc-c++ gmp-devel make ncurses-devel xz perl|build-essential curl libffi-dev libgmp-dev libncurses-dev|xcode-select --install|ghcup installs GHC, which links through the C toolchain and GMP" ;; +esac; } +guix_gaps() { + local l g gaps="" + for l in "${LANGS[@]}"; do g=$(lang_guix_gap "$l"); [ -n "$g" ] && gaps="$gaps${gaps:+, }$g"; done + printf '%s\n' "${gaps:-none}" +} +tool_table() { + local l t base + for l in "${LANGS[@]}"; do + if [ "$l" = docs ]; then echo "|docs |\`lychee\` |mise use lychee@latest"; continue; fi + t=$(lang_tools "$l"); echo "|$l |\`${t// /\`, \`}\` |$(lang_remedy "$l")" + done + base="$MISE_BASE $(recipe_tools | xargs)"; base=$(echo "$base" | xargs) + echo "|(recipes) |\`${base// /\`, \`}\` |mise install, or your OS package manager (e.g. \`dnf install just ShellCheck\`)" +} +# shellcheck disable=SC2016 # the backticks are AsciiDoc literals, not command substitution +system_deps() { # $1 adoc|ai + local l d seen="" fed deb mac why any=0 + [ "$1" = adoc ] && printf '=== System packages\n' + for l in "${LANGS[@]}"; do + d=$(lang_sysdeps "$l"); [ -z "$d" ] && continue + case "$seen" in *"|$d|"*) continue ;; esac; seen="$seen|$d|"; any=1 + IFS='|' read -r fed deb mac why <<<"$d" + if [ "$1" = adoc ]; then + printf '\n%s: %s.\n\n* Fedora: `sudo dnf install %s`\n* Debian/Ubuntu: `sudo apt install %s`\n* macOS: `%s`\n' \ + "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" + else + printf '* %s needs OS packages (%s): Fedora `sudo dnf install %s` · Debian/Ubuntu `sudo apt install %s` · macOS `%s`.\n' \ + "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" + fi + done + if [ "$1" = adoc ]; then + if [ $any -eq 1 ]; then printf '\nThe Guix development shell (`%s`) provides these itself, so inside `just dev-shell` none of them is needed.\n' "$(gpath manifest.scm)" + else printf '\nNone beyond git and a shell: every tool this repository needs comes from mise, or from the Guix shell.\n'; fi + elif [ $any -eq 0 ]; then printf '* No other OS packages: every tool comes from mise.\n'; fi +} + +cmd_doctor() { + printf '%s doctor — %s (%s; languages: %s)\n' "$REPO_NAME" "$REPO_SLUG" "$ARCHETYPE" "${LANGS[*]}" + + hdr "Core toolchain" + have git && pass "git $($T git --version 2>/dev/null | awk '{print $3}')" || fail "PV-E01 git not found — install git from your OS package manager" + if have just; then + local jv; jv=$($T just --version 2>/dev/null | awk '{print $2}') + version_ge "$jv" "$MIN_JUST" && pass "just $jv (>= $MIN_JUST)" || fail "PV-E02 just $jv is older than $MIN_JUST — run: mise use just@latest" + else fail "PV-E02 just not found — run: mise use -g just@latest (or see docs/SETUP.adoc)"; fi + if have mise; then + pass "mise $($T mise --version 2>/dev/null | awk '{print $1}')" + if [ -f mise.toml ] || [ -f .mise.toml ]; then + # Only THIS repo's declarations: `mise ls` also lists the user's global config. + local here=${ROOT/#$HOME/\~} missing + missing=$($T mise ls --current --missing 2>/dev/null | grep -F -e "$here/mise.toml" -e "$here/.mise.toml" -e "$ROOT/mise.toml" | awk '{print $1"@"$2}' | tr '\n' ' ') + [ -z "${missing// /}" ] && pass "every mise tool is installed" || fail "PV-E03 mise tools not installed: $missing— run: just setup" + fi + else warn "PV-W01 mise not found — tools must then come from Guix or your OS; install: $MISE_INSTALL_HINT"; fi + if have guix; then pass "guix $($T guix --version 2>/dev/null | head -1 | awk '{print $NF}') (optional reproducible path)" + else info "guix not installed — optional; mise is the default path (docs/SETUP.adoc §Guix)"; fi + + hdr "Language toolchains" + local l t + for l in "${LANGS[@]}"; do + [ "$l" = docs ] && { info "no build language detected — docs/theory archetype"; continue; } + for t in $(lang_tools "$l"); do + if have "$t"; then pass "$l: $t ($(command -v "$t"))" + else fail "PV-E10 $l: '$t' not found — run: just setup (manual: $(lang_remedy "$l"))"; fi + done + done + local extra; extra=$(deed system-deps "") + for t in $extra; do have "$t" && pass "system dep: $t" || fail "PV-E11 system dependency '$t' not found (declared in $DEED) — see docs/SETUP.adoc §Prerequisites"; done + + hdr "Repository provisioning files" + [ -f mise.toml ] && pass "mise.toml" || fail "PV-E20 mise.toml missing — the toolchain is undeclared" + local lg; lg=$(mise_lock_gaps) + [ -z "$lg" ] && pass "mise.lock pins every mise.toml tool (latest → concrete, checksummed)" || warn "PV-W20 $lg — run: just toolchain-refresh" + [ -f .mise.toml ] && [ -f mise.toml ] && warn "PV-W21 both mise.toml and .mise.toml — mise merges them; keep only mise.toml" + [ -f .tool-versions ] && warn "PV-W22 .tool-versions present — a second toolchain source; fold it into mise.toml" + if [ -f mise.toml ]; then + local bad; bad=$(mise_banned) + [ -z "$bad" ] && pass "mise.toml pins no banned tool" || warn "PV-W23 mise.toml pins banned tool(s): $bad (language policy: bun, no python/deno/node/make)" + fi + # hypatia guix_not_stub reads guix.scm AND build/guix.scm; an unfilled __PLACEHOLDER__ is a stub too. + local g r gstub="" + for g in guix.scm build/guix.scm "$(gpath manifest.scm)" "$(gpath channels.scm)"; do + [ -f "$g" ] || continue + r=$(guix_stub_reason "$g"); [ -n "$r" ] && gstub="$gstub$g ($r) " + done + local gs gm; gs=$(gpath guix.scm); gm=$(gpath manifest.scm) + [ -f guix.scm ] && [ -f build/guix.scm ] && warn "PV-W35 both guix.scm and build/guix.scm exist — two Guix sources; keep one (build/ is used)" + if [ -f "$gs" ]; then + if [ -n "$gstub" ]; then warn "PV-W24 template stub in: $gstub— the Guix path does not build this repo (just heal cannot fix this; provision-set realign does)" + else pass "$gs (non-stub)"; fi + else warn "PV-W25 guix.scm missing — no reproducible Guix path"; fi + [ -f "$gm" ] && pass "$gm (guix shell -m $gm)" || warn "PV-W26 $gm missing — 'just dev-shell' falls back to mise" + [ -x launcher.sh ] && pass "launcher.sh (executable)" || { [ -f launcher.sh ] && fail "PV-E27 launcher.sh not executable — run: just heal" || warn "PV-W27 launcher.sh missing"; } + local d + for d in docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)"; do + if [ ! -f "$d" ]; then warn "PV-W28 $d missing" + elif grep -qE "__[A-Z][A-Z_]*__" "$d"; then warn "PV-W29 $d still has unfilled template slots (__SPEC_…__) — replace each with the facts for this repo" + else pass "$d"; fi + done + + hdr "Estate policy drift" + local f any=0 + for f in deno.json deno.jsonc deno.lock import_map.json; do + [ -f "$f" ] && { warn "PV-W30 $f — Deno leftover; the estate runtime is bun (migration issue tracks it)"; any=1; } + done + for f in flake.nix flake.lock shell.nix default.nix; do [ -f "$f" ] && { warn "PV-W31 $f — Nix is not an estate toolchain; Guix is"; any=1; }; done + for f in Makefile GNUmakefile makefile; do [ -f "$f" ] && { warn "PV-W32 $f — Justfile is the only task runner"; any=1; }; done + for f in requirements.txt pyproject.toml setup.py Pipfile; do [ -f "$f" ] && { warn "PV-W33 $f — Python is not an estate language"; any=1; }; done + for f in package-lock.json yarn.lock pnpm-lock.yaml tsconfig.json; do [ -f "$f" ] && { warn "PV-W34 $f — npm/yarn/pnpm/TypeScript leftover; bun only"; any=1; }; done + [ $any -eq 0 ] && pass "no deno / nix / make / python / npm leftovers" + + local hook=build/just/doctor-local.sh + if [ -f "$hook" ]; then + hdr "Repo-specific checks ($hook)" + # Sourced so the hook can call pass/warn/fail, but in a subshell so an `exit` + # or `set -e` in it cannot end the doctor before the summary. The EXIT trap + # hands the hook's tally back; an early exit is itself a FAIL. + local tally qtally hp w f rc + tally=$(mktemp) + printf -v qtally '%q' "$tally" + # shellcheck disable=SC2030,SC2031 # the subshell's counts return via $tally + ( PASS=0 WARN=0 FAIL=0 hook_done=0 + # The path is baked in now: when `set -e` trips, bash unwinds this + # function's locals before the EXIT trap runs, so $tally is gone by then. + # shellcheck disable=SC2064 + trap "printf '%d %d %d %d\n' \"\$PASS\" \"\$WARN\" \"\$FAIL\" \"\$hook_done\" > $qtally" EXIT + # shellcheck source=/dev/null + . "$hook" + hook_done=1 ) + rc=$? + read -r hp w f hook_done < "$tally" || { hp=0 w=0 f=0 hook_done=0; } + rm -f "$tally" + # shellcheck disable=SC2031 + PASS=$((PASS+hp)) WARN=$((WARN+w)) FAIL=$((FAIL+f)) + [ "$hook_done" = 1 ] || fail "PV-E51 $hook exited (status $rc) before it finished; its later checks did not run" + fi + if has_recipe doctor-local; then + hdr "Repo-specific checks (just doctor-local)" + just doctor-local && pass "doctor-local" || fail "PV-E50 the repo-specific doctor-local recipe failed (output above)" + fi + + printf '\n%s: %s%d PASS%s, %s%d WARN%s, %s%d FAIL%s\n' "$REPO_NAME" "$G" "$PASS" "$Z" "$Y" "$WARN" "$Z" "$R" "$FAIL" "$Z" + if [ "$FAIL" -gt 0 ]; then + echo "Next: 'just heal' fixes what is safe to fix automatically; each FAIL code is explained in docs/SETUP.adoc §Troubleshooting." + return 1 + fi + return 0 +} + +cmd_setup() { + printf '%s setup — installing everything this repository needs\n' "$REPO_NAME" + local rc=0 + if have mise; then + hdr "mise toolchain" + $T mise trust -q . 2>/dev/null || true + mise install || rc=1 + else + warn "mise not found. Install it ($MISE_INSTALL_HINT), or enter the Guix shell with 'just dev-shell', then re-run: just setup" + fi + local l + for l in "${LANGS[@]}"; do + case "$l" in + idris2) have pack || warn "pack not found — Idris2 comes from pack, not mise: $(lang_remedy idris2)" ;; + haskell) have ghcup || have ghc || warn "ghc not found — $(lang_remedy haskell)" ;; + ocaml) have opam && { opam switch show >/dev/null 2>&1 || opam init -y --bare; } ;; + esac + done + hdr "Project dependencies" + # Put this repo's mise tools on PATH for the dependency step. + if have mise; then PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH"; export PATH; fi + lang_run deps || rc=1 + local hook=build/just/setup-local.sh + [ -f "$hook" ] && { hdr "Repo-specific setup ($hook)"; bash "$hook" || rc=1; } + has_recipe setup-local && { hdr "Repo-specific setup (just setup-local)"; just setup-local || rc=1; } + [ -f launcher.sh ] && chmod +x launcher.sh + hdr "Verification" + cmd_doctor || rc=1 + [ $rc -eq 0 ] && printf '\n%sReady.%s Try: just --list\n' "$G" "$Z" || printf '\n%sSetup incomplete%s — read the FAIL lines above; docs/SETUP.adoc has the manual route.\n' "$R" "$Z" + return $rc +} + +cmd_heal() { + printf '%s heal — applying safe, reversible fixes, then re-checking\n' "$REPO_NAME" + hdr "Fixes" + [ -f launcher.sh ] && [ ! -x launcher.sh ] && chmod +x launcher.sh && info "made launcher.sh executable" + for f in build/just/*.sh scripts/*.sh; do [ -f "$f" ] && [ ! -x "$f" ] && chmod +x "$f" && info "made $f executable"; done + if have mise; then + $T mise trust -q . 2>/dev/null && info "mise config trusted" + mise install && info "mise tools installed" + [ -f mise.lock ] || { $T mise lock 2>/dev/null && info "mise.lock generated"; } + fi + if printf '%s\n' "${LANGS[@]}" | grep -qx elixir && have mix; then mix deps.get >/dev/null 2>&1 && info "mix deps fetched"; fi + if printf '%s\n' "${LANGS[@]}" | grep -qx bun && have bun; then bun install >/dev/null 2>&1 && info "bun deps installed"; fi + if printf '%s\n' "${LANGS[@]}" | grep -qx julia && have julia; then julia --project=. -e 'using Pkg; Pkg.instantiate()' >/dev/null 2>&1 && info "julia deps instantiated"; fi + local hook=build/just/heal-local.sh + [ -f "$hook" ] && { info "running $hook"; bash "$hook" || true; } + has_recipe heal-local && { info "running just heal-local"; just heal-local || true; } + echo "Not touched (never automatic): source files, git history, Deno/Nix/Make leftovers — those are migrations, tracked as issues." + hdr "Re-check" + cmd_doctor +} + +cmd_dev_shell() { + local gm; gm=$(gpath manifest.scm) + if have guix && [ -f "$gm" ] && ! grep -q '{{' "$gm"; then + info "entering: guix shell -m $gm (exit to leave)" + exec guix shell -m "$gm" + elif have mise; then + info "guix/manifest.scm unavailable — entering the mise environment instead (exit to leave)" + exec mise exec -- "${SHELL:-bash}" + else + fail "PV-E40 neither guix nor mise is installed — see docs/SETUP.adoc"; return 1 + fi +} + +# Weekly toolchain refresh: bump mise pins and the lock, re-pin the Guix channel, +# regenerate build/guix/crates.scm when guix.scm loads it, then show the diff +# for a signed commit. +cmd_toolchain_refresh() { + hdr "mise: bump 'latest' resolutions and re-lock" + have mise || { fail "mise not found"; return 1; } + mise up --bump || return 1 + $T mise lock 2>/dev/null || mise lock || return 1 + local ch; ch=$(gpath channels.scm) + if [ -f "$ch" ]; then + hdr "guix: channel pin" + if have guix; then + guix pull --channels="$ch" --dry-run >/dev/null 2>&1 || true + guix describe --format=channels > "$ch.new" 2>/dev/null && mv "$ch.new" "$ch" && info "$ch re-pinned to the current guix commit" + else info "guix not installed — $ch left as-is (CI re-pins it)"; fi + fi + local cr="" + if [ -f Cargo.lock ] && grep -qs 'crates\.scm' "$(gpath guix.scm)"; then + hdr "guix: crate inputs from Cargo.lock" + cr=build/guix/crates.scm + if have "${GUIX%% *}"; then cmd_crates_scm || return 1 + else info "guix not installed — $cr left as-is (CI regenerates it)"; fi + fi + git --no-pager diff --stat -- mise.toml mise.lock "$ch" $cr 2>/dev/null + echo "Commit the diff above (signed) as: chore(toolchain): weekly refresh" +} + +# The guix command (default: guix), e.g. a wrapper that runs it in a container +# with this directory mounted. +GUIX="${GUIX:-guix}" + +# Write build/guix/crates.scm: every registry crate in Cargo.lock as a Guix +# origin, and %crate-inputs listing them for guix.scm. Written whole or not at +# all. The importer's output is accepted only when it defines exactly one crate +# source per registry package in Cargo.lock: run through a container, guix's exit +# status is lost, so the count is the check. +cmd_crates_scm() { + local dst=build/guix/crates.scm want got tmp spdx + [ -f Cargo.lock ] || { info "no Cargo.lock — no crate inputs"; return 0; } + want=$(grep -c '^source = "registry+' Cargo.lock) + tmp=$(mktemp) || return 1 + if [ "$want" -gt 0 ]; then + # GUIX is split on purpose: it may be a command with arguments. + # shellcheck disable=SC2086 + timeout 1800 $GUIX import crate --lockfile=Cargo.lock "$REPO_NAME" >"$tmp" 2>"$tmp.err" + got=$(grep -c '^(define rust-' "$tmp") + if [ "$got" != "$want" ]; then + fail "PV-E41 guix import crate defined $got of the $want registry crates in Cargo.lock; $dst left as-is: $(grep -m1 -i 'error' "$tmp.err" || grep -v '^+' "$tmp.err" | tail -1)" + rm -f "$tmp" "$tmp.err"; return 1 + fi + fi + # The licence line is guix.scm's own, so the file matches its repository. + spdx=$(grep -m1 'SPDX-License-Identifier' "$(gpath guix.scm)" 2>/dev/null) + mkdir -p build/guix + { + [ -n "$spdx" ] && printf '%s\n' "$spdx" + # shellcheck disable=SC2016 # the backticks are literal text + printf ';; Generated from Cargo.lock by `just toolchain-refresh` (guix import crate\n' + printf ';; --lockfile); never hand-edit it. guix.scm loads it for %%crate-inputs.\n\n' + grep -v '^guix (GNU Guix)' "$tmp" + printf '\n(define %%crate-inputs\n (list' + grep -o '^(define rust-[^ ]*' "$tmp" | awk '{printf "\n %s", $2}' + printf '))\n' + } >"$dst.new" && mv "$dst.new" "$dst" + rm -f "$tmp" "$tmp.err" + info "$dst: $want crate source(s)" +} + +# The sentence people are told to say lives in one place a reader sees: the first +# listing block of the README's [[ai-install]] section. The deed's ai-say-it and +# the generic line are fallbacks for a README without that section. +just_say_it() { + local line="" r + for r in README.adoc README.md; do + [ -f "$r" ] || continue + line=$(awk '/^\[\[ai-install\]\]/{on=1} on && /^----$/{if (inb) exit; inb=1; next} inb && NF{print; exit}' "$r") + [ -n "$line" ] && break + done + [ -z "$line" ] && line=$(deed ai-say-it "") + [ -z "$line" ] && line="Set up $REPO_NAME from https://github.com/$REPO_SLUG — follow docs/AI_INSTALLATION_GUIDE.adoc in that repo." + printf '%s' "$line" +} +clip() { # best-effort; silent when no clipboard exists (CI, SSH) + # wl-copy and xclip fork a daemon that inherits stdout; left attached, it + # holds any pipeline open forever (observed 2026-09-30). Detach and bound it. + local c=() + if have clip.exe; then c=(clip.exe) # WSL: the Windows clipboard + elif have pbcopy; then c=(pbcopy) + elif [ -n "${WAYLAND_DISPLAY:-}" ] && have wl-copy; then c=(wl-copy) + elif [ -n "${DISPLAY:-}" ] && have xclip; then c=(xclip -selection clipboard) + else cat >/dev/null; return 1; fi + timeout 3 "${c[@]}" >/dev/null 2>&1 +} +cmd_ai_setup() { + cat <" >&2; return 2 ;; esac + for f in "$(wpath "$who")" "llm-warmup-$who.adoc" "docs/onboarding/llm-warmup-$who.adoc" "docs/llm-warmup-$who.adoc"; do + if [ -f "$f" ]; then + cat "$f"; clip < "$f" && echo "--- (copied $f to your clipboard — paste it as your first message to any AI)" >&2 || true + return 0 + fi + done + fail "llm-warmup-$who.adoc not found"; return 1 +} + +cmd_eval() { + local logf rc=0 s e v r o st + logf=".eval/$(date -u +%Y%m%dT%H%M%SZ).txt" + mkdir -p .eval + { echo "# $REPO_NAME evaluation — $(date -u +%FT%TZ) — $(git rev-parse --short HEAD 2>/dev/null || echo no-git)" + echo "# languages: ${LANGS[*]} archetype: $ARCHETYPE"; } > "$logf" + for v in test bench; do + # The root recipe if the repo defines one (its own override), else the module default. + if just --summary 2>/dev/null | tr " " "\n" | grep -qx "$v"; then r=$v; else r="provision::$v"; fi + s=$(date +%s); o=$(mktemp) + if just "$r" >"$o" 2>&1; then st=PASS; else st=FAIL; rc=1; fi + e=$(( $(date +%s) - s )); cat "$o" >>"$logf" + # A skip is not a pass: lang_run exits 0 on N/A (users see success), eval says N/A. + [ "$st" = PASS ] && grep -q "nothing was faked" "$o" && st=N/A + rm -f "$o"; echo "$v: $st (${e}s)" | tee -a "$logf" + done + echo "full log: $logf" + return $rc +} + +cmd_config_show() { + echo "repo: $REPO_SLUG" + echo "archetype: $ARCHETYPE" + echo "languages: ${LANGS[*]}" + echo "descriptor: $DEED $([ -f "$DEED" ] && echo '(present)' || echo '(absent — defaults in use)')" + echo "config: $(deed config "none declared")" + echo "run: $(deed run "$(for l in "${LANGS[@]}"; do lang_cmd "$l" run; done | head -1)")" + echo "ports: $(deed ports "none")" + echo "mise: $([ -f mise.toml ] && echo mise.toml) $([ -f mise.lock ] && echo mise.lock)" + echo "guix: $(for f in guix.scm manifest.scm channels.scm; do f=$(gpath "$f"); [ -f "$f" ] && printf "%s " "$f"; done)" + echo "lib: provision-lib $PROVISION_LIB_VERSION" +} + +cmd_opsm() { + cat <} + if have agrep; then git ls-files -z | xargs -0 agrep -n -1 -- "$p" 2>/dev/null + else git grep -n -i -- "$p"; fi +} + +case "${1:-}" in + guix-specs) guix_specs ;; + mise-tools) mise_tools ;; + langs) printf '%s\n' "${LANGS[@]}" ;; + doctor) cmd_doctor ;; + setup) cmd_setup ;; + heal) cmd_heal ;; + dev-shell) cmd_dev_shell ;; + toolchain-refresh) cmd_toolchain_refresh ;; + crates-scm) cmd_crates_scm ;; + ai-setup) cmd_ai_setup ;; + ai-warmup) shift; cmd_ai_warmup "${1:-user}" ;; + eval) cmd_eval ;; + config-show) cmd_config_show ;; + opsm) cmd_opsm ;; + search) shift; cmd_search "${1:-}" ;; + lang-run) shift; lang_run "${1:?verb}" ;; + version) echo "provision-lib $PROVISION_LIB_VERSION" ;; + guix-dir) guix_dir ;; + set-files) set_files ;; + guix-gaps) guix_gaps ;; + tool-table) tool_table ;; + system-deps) shift; system_deps "${1:-adoc}" ;; + # Predicates: print why, exit 1; print nothing, exit 0. + guix-stub) shift; r=$(guix_stub_reason "${1:?usage: guix-stub FILE}"); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + mise-lock-gaps) r=$(mise_lock_gaps); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + mise-banned) r=$(mise_banned); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + *) sed -n '2,20p' "$0"; exit 2 ;; +esac diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh new file mode 100755 index 0000000..35e6033 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# shellcheck shell=bash +# +# provision-modes.sh — the launcher-standard 0.6.0 provisioning mode family, +# as a SOURCEABLE block shared by every launcher in the estate. +# +# New launchers (library/tool/theory/docs): templates/launcher.sh.tmpl sources +# this and calls hp_launcher_main. +# Existing app launchers: add ONE line before their mode switch — +# +# . "$REPO_DIR/build/just/provision-modes.sh" && hp_provision_or_return "$@" +# +# A provisioning mode runs and the launcher exits with its status (a failed +# --setup exits non-zero); any other mode returns, so the app's own switch +# handles it. hp_provision_dispatch itself returns 99 for "not mine". +# +# Canon: hyperpolymath/standards launcher/launcher-standard_praxis.deed +# (archetypes, provisioning-modes) and +# 3-practice/provisioning/PROVISIONING-STANDARD.adoc + +HP_PROVISION_MODES_VERSION="0.6.0" + +hp__deed() { # $1 key, $2 default — flat (key "value") read from provisioning_praxis.deed + local f="$REPO_DIR/.machine_readable/descriptiles/provisioning_praxis.deed" v="" + [ -f "$f" ] && v=$(grep -oE "[(:]$1[[:space:]]+\"[^\"]*\"" "$f" | head -1 | sed -E 's/^[^"]*"//; s/"$//') + printf '%s' "${v:-$2}" +} + +hp_archetype() { hp__deed archetype "library"; } +# Without a deed, the origin remote names the repo (a worktree or renamed clone +# has another directory name); the directory is the last resort. +hp_app_name() { + local u; u=$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true) + u=${u%.git}; u=${u##*/} + hp__deed name "${u:-$(basename "$REPO_DIR")}" +} + +hp_platform() { + case "$(uname -s)" in + Linux*) echo linux ;; + Darwin*) echo macos ;; + CYGWIN*|MINGW*|MSYS*|Windows_NT) echo windows ;; + *) echo unknown ;; + esac +} + +# Make sure `just` is runnable: PATH, then mise, then say exactly what to do. +hp_ensure_just() { + command -v just >/dev/null 2>&1 && return 0 + if command -v mise >/dev/null 2>&1; then + echo "just is not installed — installing it with mise (mise use -g just@latest)..." >&2 + mise use -g just@latest >&2 && PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH" && command -v just >/dev/null 2>&1 && return 0 + mise exec just@latest -- true >/dev/null 2>&1 && { just() { mise exec just@latest -- just "$@"; }; return 0; } + fi + cat >&2 <<'EOF' +Neither `just` nor `mise` is installed. Install mise (it then installs everything else): + + brew install mise # macOS + sudo dnf copr enable jdxcode/mise && sudo dnf install mise # Fedora + winget install jdx.mise # Windows + # anything else (Debian/Ubuntu apt, …): https://mise.jdx.dev/installing-mise.html + # or download the installer, read it, then run it — never pipe it into a shell: + curl -fsSLo mise-install.sh https://mise.run && less mise-install.sh && sh mise-install.sh + +then re-run this command. Manual route without mise: docs/SETUP.adoc +EOF + return 1 +} + +hp_just() { hp_ensure_just || return 1; (cd "$REPO_DIR" && just "$@"); } +# The provisioning modes call the engine directly, not a `just` recipe: a repo may +# define its own root `doctor`/`setup`/`heal`, and the launcher must still run the +# canon (which then runs that repo's *-local recipes). Only needs bash. +hp_lib() { (cd "$REPO_DIR" && bash build/just/provision-lib.sh "$@"); } + +# Returns the mode's exit code, or 99 when "$1" is not a provisioning mode. +# The one-line hook for existing launchers: exit with a provisioning mode's +# status, or return (0) so the caller's own mode switch runs. +hp_provision_or_return() { + hp_provision_dispatch "$@" + local rc=$? + [ "$rc" -eq 99 ] && return 0 + exit "$rc" +} + +hp_provision_dispatch() { + case "${1:-}" in + --setup) hp_lib setup ;; + --doctor) hp_lib doctor ;; + --heal) hp_lib heal ;; + --ai-setup) hp_lib ai-setup ;; + *) return 99 ;; + esac +} + +hp_version_line() { + local sha ver + sha=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown) + ver=$(hp__deed version "") + [ -z "$ver" ] && ver=$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null || echo 0.0.0) + printf '%s-launcher %s (%s) [%s-%s]\n' "$(hp_app_name)" "${ver#v}" "$sha" "$(hp_platform)" "$(uname -m)" +} + +hp_help() { + local name arch; name=$(hp_app_name); arch=$(hp_archetype) + cat < + +Provisioning (every repository): + --setup Install everything this repository needs, then run --doctor + --doctor Check the environment; PASS/WARN/FAIL with fix hints (exit 1 on FAIL) + --heal Apply safe fixes automatically, then re-run --doctor + --ai-setup Print the one line to give any AI assistant to set this up for you + +Meta: + --help This text + --version Machine-readable version line + +EOF + if [ "$arch" = app ]; then + echo "Runtime: --start --stop --status --auto (default) --integ --disinteg" + else + echo "Runtime modes (--start --stop --status --auto --integ --disinteg) do not apply" + echo "to a $arch repository; they are accepted and explain themselves." + fi + cat <&2 + return 1 + fi + echo "$(hp_app_name) is a $arch: there is nothing to ${mode#--}. Try --setup, --doctor or --help." + return 0 ;; + *) + echo "Unknown mode: $mode" >&2; hp_help >&2; return 2 ;; + esac +} diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just new file mode 100644 index 0000000..9f1ac7c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision.just — the estate provisioning contract, as a just MODULE. +# +# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +# Loaded from the root Justfile with: +# +# mod provision 'build/just/provision.just' +# +# A module (not `import`) so these recipes live under `provision::` and never +# collide with a repo's own `doctor`, `heal` or `test`. The root Justfile then +# exposes each contract name ONCE — either its own recipe, or a one-line +# delegation such as `doctor: provision::doctor` that `provision-set mint` +# adds only when the root has no recipe of that name. +# +# requires: just >= 1.42.0 (root recipes depend on module recipes; measured 2026-09-30: 1.41 rejects `x: m::x`, 1.42 accepts) + +root := justfile_directory() +lib := "PROVISION_ROOT=" + quote(root) + " bash " + quote(source_directory() / "provision-lib.sh") + +# List the provisioning recipes +default: + @just --list provision + +# Install every tool and dependency this repo needs, then run doctor +setup: + @{{lib}} setup + +# Diagnose the environment: PASS/WARN/FAIL with codes; exits 1 on any FAIL +doctor: + @{{lib}} doctor + +# Apply safe, reversible fixes, then re-run doctor +heal: + @{{lib}} heal + +# Enter a shell with every tool on PATH (guix shell -m manifest.scm, else mise) +dev-shell: + @{{lib}} dev-shell + +# Weekly: bump mise 'latest' resolutions, re-lock, re-pin guix channels +toolchain-refresh: + @{{lib}} toolchain-refresh + +# Print (and copy) the one line to give any AI to install this repo +ai-setup: + @{{lib}} ai-setup + +# Print (and copy) the warm-up for an AI session: user, dev or maintainer +ai-warmup who="user": + @{{lib}} ai-warmup {{quote(who)}} + +# Run test + bench, time both, and write .eval/.txt +eval: + @{{lib}} eval + +# Show where this repo's configuration and toolchain are declared +config-show: + @{{lib}} config-show + +# How to install this repo with OPSM (odds-and-sods package manager) +opsm: + @{{lib}} opsm + +# Print the languages detected from marker files +langs: + @{{lib}} langs + +# Approximate search across tracked files (agrep, else git grep) +search pattern: + @{{lib}} search {{quote(pattern)}} + +# Per-language defaults for the contract verbs, reached as `just provision::` +# or through a root recipe that delegates (`build: provision::build`). A root +# recipe with its own body is the repo-specific override. + +# Build with every detected language's toolchain +build: + @{{lib}} lang-run build + +# Run every detected language's tests +test: + @{{lib}} lang-run test + +# Run every detected language's benchmarks +bench: + @{{lib}} lang-run bench + +# Lint every detected language +lint: + @{{lib}} lang-run lint + +# Format every detected language's sources +fmt: + @{{lib}} lang-run fmt + +# Check formatting without rewriting anything (the CI form of fmt) +fmt-check: + @{{lib}} lang-run fmt-check + +# Run the project (N/A for libraries) +run: + @{{lib}} lang-run run + +# Fetch every detected language's dependencies +deps: + @{{lib}} lang-run deps diff --git a/standards/provisioning/CANON b/standards/provisioning/CANON index a153a03..dae8c50 100644 --- a/standards/provisioning/CANON +++ b/standards/provisioning/CANON @@ -1 +1 @@ -standards@93342bf6052a5fa5178d7f4bfa3e883f04fa8456 +standards@e323e0adda0397899a4e9963abf3124014310227 diff --git a/standards/provisioning/PROVISIONING-STANDARD.adoc b/standards/provisioning/PROVISIONING-STANDARD.adoc index fab92cb..8f417a2 100644 --- a/standards/provisioning/PROVISIONING-STANDARD.adoc +++ b/standards/provisioning/PROVISIONING-STANDARD.adoc @@ -12,7 +12,7 @@ diagnose or repair it? Machine-readable counterpart: link:provisioning-standard_praxis.deed[provisioning-standard_praxis.deed]. Templates: link:templates/[templates/]. Launcher modes: `docs/UX-standards/launcher-standard.adoc` -§Provisioning modes (v0.5.0). +§Provisioning modes (v0.6.0). == 1. The provisioning set @@ -28,15 +28,16 @@ below, except forks, archived repositories, `007` and the two vaults |`build/just/provision.just` |engine |the `provision::` just module: every verb below |`build/just/provision-lib.sh` |engine |the implementation of every verb (bash only, shellcheck-clean) |`build/just/provision-modes.sh` |engine |the launcher's provisioning dispatch -|`channels.scm` |engine |the pinned Guix commit (placed beside `guix.scm`, see below) +|`channels.scm` |minted |the pinned Guix commit (placed beside `guix.scm`, see below). Copied from the canon at mint, then re-pinned per repository by `toolchain-refresh`, so it is checked for a 40-hex commit pin, never byte-compared with the canon |`Justfile` |merged |`mod provision` plus root delegations; the repo's own recipes are kept |`mise.toml` + `mise.lock` |minted |the toolchain: `latest` in `mise.toml`, made concrete and checksummed in `mise.lock` |`guix.scm`, `manifest.scm` |minted |the Guix package and development shell (at the root, or all three under `build/` where the repository already keeps `build/guix.scm`) +|`build/guix/crates.scm` |generated |Rust repositories: one Guix origin per registry crate in `Cargo.lock`, and `%crate-inputs` for `guix.scm`. Written by `guix import crate --lockfile` through `provision-lib.sh crates-scm`; never hand-edited |`.machine_readable/descriptiles/provisioning_praxis.deed` |minted |this repo's facts: archetype, languages, verb overrides, system deps, ports, config |`docs/SETUP.adoc` |minted |the complete manual route, with the doctor-code troubleshooting table |`docs/AI_INSTALLATION_GUIDE.adoc` |minted |the steps an AI assistant follows |`llm-warmup-{user,dev,maintainer}.adoc` |minted |paste-in context for any AI, one per audience (at the root, or in `docs/onboarding/` or `docs/` where the repository already keeps them) -|README `[[ai-install]]` section |inserted |"Just say it": one sentence to give any AI +|README `\[[ai-install]]` section |inserted |"Just say it": one sentence to give any AI |=== *Placement.* A file sits at the root only when something needs it there: @@ -83,11 +84,11 @@ of the same name can never shadow them. |`doctor` |PASS / WARN / FAIL for every requirement, each with a code and its fix; the last line is `: N PASS, N WARN, N FAIL`; exits non-zero if and only if something FAILed |`heal` |applies every fix marked *auto*, runs `heal-local`, then `doctor` |`dev-shell` |`guix shell -m /manifest.scm` when Guix is present, otherwise the mise environment -|`toolchain-refresh` |`mise up --bump`, re-lock `mise.lock`, re-pin `channels.scm` (the TRUST-DEFAULTS-POLICY recipe) +|`toolchain-refresh` |`mise up --bump`, re-lock `mise.lock`, re-pin `channels.scm`, regenerate `build/guix/crates.scm` from `Cargo.lock` when `guix.scm` loads it (the TRUST-DEFAULTS-POLICY recipe). The crate file is replaced only when the importer defined every registry crate; otherwise PV-E41 and the old file stays |`build`, `test`, `bench`, `lint`, `fmt`, `fmt-check`, `run`, `deps` |the deed's override, else the language default, else an explicit "N/A for this archetype" and exit 0; nothing is faked. A language default runs only when what it needs exists (a `test` step in `build.zig`, test files for `bun test`, which exits 0 on none) |`eval` |`test` + `bench` with timings, saved under `.eval/` |`config-show` |where the toolchain and configuration are declared -|`ai-setup` |prints the "Just say it" sentence, read from the first listing block of the README's `[[ai-install]]` section so it is written in one place, and the guide path +|`ai-setup` |prints the "Just say it" sentence, read from the first listing block of the README's `\[[ai-install]]` section so it is written in one place, and the guide path |`ai-warmup ` |prints that warm-up for pasting into any AI |`opsm` |how to fetch this repository with OPSM, and how to get OPSM |`langs`, `search`, `version` |discovery helpers diff --git a/standards/provisioning/provisioning-standard_praxis.deed b/standards/provisioning/provisioning-standard_praxis.deed index b49f7ed..7f01e1c 100644 --- a/standards/provisioning/provisioning-standard_praxis.deed +++ b/standards/provisioning/provisioning-standard_praxis.deed @@ -122,6 +122,6 @@ (doctor :summary-line "{name}: N PASS, N WARN, N FAIL" :exit "non-zero iff any FAIL" - :fail-codes ("PV-E01" "PV-E02" "PV-E03" "PV-E10" "PV-E11" "PV-E20" "PV-E27" "PV-E40" "PV-E50" "PV-E51") + :fail-codes ("PV-E01" "PV-E02" "PV-E03" "PV-E10" "PV-E11" "PV-E20" "PV-E27" "PV-E40" "PV-E41" "PV-E50" "PV-E51") :warn-codes ("PV-W01" "PV-W20" "PV-W21" "PV-W22" "PV-W23" "PV-W24" "PV-W25" "PV-W26" "PV-W27" "PV-W28" "PV-W29" "PV-W30" "PV-W31" "PV-W32" "PV-W33" "PV-W34" "PV-W35"))) diff --git a/standards/provisioning/templates/build/just/provision-check.sh b/standards/provisioning/templates/build/just/provision-check.sh new file mode 100755 index 0000000..2666842 --- /dev/null +++ b/standards/provisioning/templates/build/just/provision-check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision-check.sh — offline conformance check for the provisioning set +# (PROVISIONING-STANDARD.adoc §8, items 1–5). No network, no installs. +# +# provision-check.sh [--dev] [REPO_DIR] +# +# Exit 0 = conforms, 1 = does not. Every failure is printed; none is skipped. +# --dev downgrades repository-specific slot residue (__SPEC_X__) to a warning, +# for a checkout mid-specialisation. Mechanical residue (__X__) always fails. +set -uo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +LIB="$HERE/provision-lib.sh" +DEV=0 +[ "${1:-}" = "--dev" ] && { DEV=1; shift; } +REPO="${1:-.}" +cd "$REPO" || { echo "provision-check: no such directory: $REPO" >&2; exit 1; } + +MIN_JUST="1.42.0" +VERBS="setup doctor heal dev-shell toolchain-refresh ai-setup ai-warmup eval config-show opsm build test bench lint fmt fmt-check run deps" +# Files the provisioning set owns; residue anywhere else is not ours to judge. +# The engine scripts under build/just/ are excluded: they name slot syntax in their +# own comments and code, and realign owns them. README.adoc is scanned only inside +# its [[ai-install]] section. +# Their locations come from provision-lib.sh (guix-dir, set-files): the engine and +# this check must never disagree about where the Guix files or warm-ups live. +[ -f "$LIB" ] || { echo "provision-check: engine missing: $LIB" >&2; exit 1; } +lib() { PROVISION_ROOT="$PWD" bash "$LIB" "$@"; } +GDIR=$(lib guix-dir) +SET_FILES=$(lib set-files) +gp() { [ "$GDIR" = . ] && echo "$1" || echo "$GDIR/$1"; } + +fails=0 warns=0 +ok() { printf ' ok %s\n' "$*"; } +bad() { printf ' FAIL %s\n' "$*"; fails=$((fails + 1)); } +warn() { printf ' warn %s\n' "$*"; warns=$((warns + 1)); } + +version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } + +echo "[1] launcher" +if [ ! -f launcher.sh ]; then bad "launcher.sh missing" +elif [ ! -x launcher.sh ]; then bad "launcher.sh is not executable (chmod +x; committed mode must be 100755)" +else + for m in --help --version; do + if ./launcher.sh "$m" >/dev/null 2>&1; then ok "launcher.sh $m exits 0" + else bad "launcher.sh $m exits non-zero"; fi + done +fi + +echo "[2] just" +f0=$fails +if ! command -v just >/dev/null 2>&1; then bad "just is not installed; cannot check the recipe contract" +else + jv=$(just --version 2>/dev/null | awk '{print $2}') + if version_ge "$jv" "$MIN_JUST"; then ok "just $jv >= $MIN_JUST" + else bad "just $jv < $MIN_JUST (module-recipe dependencies do not resolve)"; fi + if ! summary=$(just --summary 2>&1); then + bad "the Justfile does not parse: $(printf '%s' "$summary" | head -1)" + else + have=" $summary " + for v in $VERBS; do + case "$have" in *" $v "*) ;; *) bad "root recipe missing: $v" ;; esac + case "$have" in *" provision::$v "*) ;; *) bad "module recipe missing: provision::$v (engine drift)" ;; esac + done + [ "$fails" -eq "$f0" ] && ok "all ${VERBS// /, } present at root and in provision::" + fi +fi + +echo "[3] mise" +if [ ! -f mise.toml ]; then bad "mise.toml missing" +else + if hit=$(lib mise-banned); then ok "mise.toml names no banned tool"; else bad "mise.toml names banned tools: $hit"; fi + [ -f .mise.toml ] && bad "both mise.toml and .mise.toml exist" + if lg=$(lib mise-lock-gaps); then ok "mise.lock pins every mise.toml tool"; else bad "$lg (latest is not concrete; run: mise lock)"; fi +fi + +echo "[4] guix" +for g in "$(gp guix.scm)" "$(gp manifest.scm)" "$(gp channels.scm)"; do + if r=$(lib guix-stub "$g"); then ok "$g is not a stub"; else bad "$g: $r"; fi +done + +echo "[5] template residue" +f0=$fails +residue() { # $1 label; stdin = the text to judge + local text mech spec + text=$(cat) + # Mechanical slots (__X__ not starting SPEC_) are the generator's job: always fatal. + mech=$(printf '%s\n' "$text" | grep -noE '__[A-Z][A-Z_]*__' | grep -v ':__SPEC_' | head -3 | tr '\n' ' ') + spec=$(printf '%s\n' "$text" | grep -noE '__SPEC_[A-Z_]*__' | head -3 | tr '\n' ' ') + [ -n "$mech" ] && bad "$1: unfilled mechanical slots: $mech" + if [ -n "$spec" ]; then + if [ "$DEV" = 1 ]; then warn "$1: unfilled repository slots: $spec" + else bad "$1: unfilled repository slots: $spec"; fi + fi +} +for f in $SET_FILES; do + # shellcheck disable=SC2094 # $f is only the label; nothing writes it + [ -f "$f" ] && residue "$f" < "$f" +done +for r in README.adoc README.md; do + [ -f "$r" ] || continue + # From the [[ai-install]] anchor to the next level-2 heading after its own. + residue "$r [[ai-install]]" < <(awk '/^\[\[ai-install\]\]/{on=1; n=0} on && /^== /{n++; if (n>1) exit} on' "$r") +done +[ "$fails" -eq "$f0" ] && ok "no residue that fails this mode" + +echo +echo "provision-check: $fails FAIL, $warns WARN" +[ "$fails" -eq 0 ] diff --git a/standards/provisioning/templates/build/just/provision-lib.sh b/standards/provisioning/templates/build/just/provision-lib.sh new file mode 100755 index 0000000..fb1d942 --- /dev/null +++ b/standards/provisioning/templates/build/just/provision-lib.sh @@ -0,0 +1,832 @@ +#!/usr/bin/env bash +# SC2015: `A && pass || fail` is safe here — pass/warn/fail/info always return 0. +# shellcheck disable=SC2015 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision-lib.sh — the shared provisioning engine behind build/just/provision.just +# +# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +# Vendored into every repo at build/just/provision-lib.sh by `provision-set mint`. +# DO NOT hand-edit a vendored copy: repo-specific facts live in +# .machine_readable/descriptiles/provisioning_praxis.deed, and `provision-set realign` +# overwrites this file from canon. +# +# Usage: provision-lib.sh [args] +# verbs: langs doctor setup heal dev-shell toolchain-refresh crates-scm ai-setup +# ai-warmup eval config-show opsm lang-run +# search version +# facts: guix-specs mise-tools langs guix-dir set-files guix-gaps tool-table +# system-deps +# predicates (print why and exit 1, or exit 0 silently): +# guix-stub FILE, mise-lock-gaps, mise-banned +# +# Exit: 0 ok | 1 a FAIL was found / a step failed | 2 usage error +set -uo pipefail + +PROVISION_LIB_VERSION="0.5.0" +ROOT="${PROVISION_ROOT:-$(pwd)}" +cd "$ROOT" || exit 2 +DEED=".machine_readable/descriptiles/provisioning_praxis.deed" +MIN_JUST="1.42.0" # root→module recipe deps (`doctor: provision::doctor`); 1.41 rejects them +# How to get mise: package managers first; the installer only as download, read, run. +MISE_INSTALL_HINT="brew install mise | Fedora: dnf copr enable jdxcode/mise, dnf install mise | winget install jdx.mise | others: https://mise.jdx.dev/installing-mise.html — or: curl -fsSLo mise-install.sh https://mise.run, read it, sh mise-install.sh" +T="timeout 15" # some --version probes hang (observed 2026-09-30); never probe unbounded + +if [ -t 1 ]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[34m'; Z=$'\033[0m'; else R=; G=; Y=; B=; Z=; fi +PASS=0; WARN=0; FAIL=0 +pass() { PASS=$((PASS+1)); printf ' %sPASS%s %s\n' "$G" "$Z" "$*"; } +warn() { WARN=$((WARN+1)); printf ' %sWARN%s %s\n' "$Y" "$Z" "$*"; } +fail() { FAIL=$((FAIL+1)); printf ' %sFAIL%s %s\n' "$R" "$Z" "$*"; } +info() { printf ' %sinfo%s %s\n' "$B" "$Z" "$*"; } +hdr() { printf '\n%s== %s ==%s\n' "$B" "$*" "$Z"; } +have() { command -v "$1" >/dev/null 2>&1; } + +# Where this repository keeps each part of the set. A file sits at the root only +# when something needs it there (launcher.sh, Justfile, mise.toml, mise.lock); +# the Guix trio and the warm-ups follow the layout the repository already has +# (rsr-template-repo keeps build/guix.scm and docs/onboarding/llm-warmup-*.adoc). +# This is the ONE answer: provision-check.sh asks it through `guix-dir` and +# `set-files` rather than keeping its own copy. +guix_dir() { if [ -f build/guix.scm ]; then echo build; else echo .; fi; } +warmup_dir() { + local d + for d in . docs/onboarding docs; do + compgen -G "$d/llm-warmup-*.adoc" >/dev/null && { echo "$d"; return; } + done + echo . +} +gpath() { local d; d=$(guix_dir); [ "$d" = . ] && echo "$1" || echo "$d/$1"; } +wpath() { local d; d=$(warmup_dir); [ "$d" = . ] && echo "llm-warmup-$1.adoc" || echo "$d/llm-warmup-$1.adoc"; } +set_files() { + printf '%s\n' launcher.sh Justfile justfile mise.toml \ + "$(gpath guix.scm)" "$(gpath manifest.scm)" "$(gpath channels.scm)" \ + "$DEED" docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc \ + "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)" +} + +# --------------------------------------------------------------------------- +# Descriptor: flat `:key "value"` reads from provisioning_praxis.deed (s-expression). +deed() { # $1 key, $2 default + local v="" + [ -f "$DEED" ] && v=$(grep -oE "\(:?$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') + [ -z "$v" ] && v=$(grep -oE ":$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') + printf '%s' "${v:-$2}" +} +repo_slug() { + local u; u=$(git config --get remote.origin.url 2>/dev/null || true) + u=${u%.git}; u=${u#*github.com[:/]} + [ -n "$u" ] && printf '%s' "$u" || printf 'hyperpolymath/%s' "$(basename "$ROOT")" +} +REPO_SLUG="$(deed repo "$(repo_slug)")" +REPO_NAME="${REPO_SLUG#*/}" +ARCHETYPE="$(deed archetype "")" + +# --------------------------------------------------------------------------- +# Language detection. Markers at the root or one level down (workspaces). The +# estate ABI/FFI pattern nests deeper: Idris2 to two levels down (src/abi/*.ipkg), +# Zig to three (ffi/zig/build.zig, and rsr-template-repo's src/interface/ffi/build.zig: +# 74 repos have their only build.zig at that depth, measured 2026-09-30). +# Order matters only for display. `docs` is reported when nothing else is. +first() { find . -maxdepth "${2:-2}" -not -path './.git/*' -not -path '*/node_modules/*' -not -path './target/*' -name "$1" -print -quit 2>/dev/null; } +detect_langs() { + local out=() + [ -n "$(first Cargo.toml)" ] && out+=(rust) + [ -n "$(first '*.ipkg' 3)" ] && out+=(idris2) + [ -n "$(first Project.toml 1)" ] && out+=(julia) + [ -n "$(first build.zig 4)" ] && out+=(zig) + [ -n "$(first mix.exs)" ] && out+=(elixir) + [ -n "$(first gleam.toml)" ] && out+=(gleam) + [ -n "$(first dune-project)" ] && out+=(ocaml) + { [ -n "$(first '*.cabal')" ] || [ -n "$(first stack.yaml 1)" ]; } && out+=(haskell) + [ -n "$(first package.json)" ] && out+=(bun) + [ ${#out[@]} -eq 0 ] && out+=(docs) + printf '%s\n' "${out[@]}" +} +mapfile -t LANGS < <(detect_langs) +[ -z "$ARCHETYPE" ] && { [ "${LANGS[*]}" = docs ] && ARCHETYPE=docs || ARCHETYPE=library; } + +# Binaries each language needs, and how each is obtained. +# mise registry gaps (verified 2026-09-30 against mise 2026.7.5): ocaml, haskell, +# idris2 and guile are NOT mise tools. They come from opam / ghcup / pack / the +# system package manager (or Guix), and the doctor probes the binary itself. +lang_tools() { + case "$1" in + rust) echo "cargo rustc" ;; + idris2) echo "idris2" ;; + julia) echo "julia" ;; + zig) echo "zig" ;; + elixir) echo "elixir mix erl" ;; + gleam) echo "gleam erl" ;; + ocaml) echo "opam dune ocaml" ;; + haskell) echo "ghc cabal" ;; + bun) echo "bun" ;; + docs) echo "" ;; + esac +} +# Guix package specs per language (verified 2026-09-30 against guix ae77aeb in +# docker.io/metacall/guix). Guix has NO idris2, gleam, bun or lychee, and its +# julia is 1.8.5: those come from mise, which Guix itself ships ("mise"). +GUIX_BASE="git bash coreutils nss-certs just mise shellcheck" +lang_guix() { + case "$1" in + rust) echo "rust rust:cargo gcc-toolchain pkg-config" ;; + idris2) echo "chez-scheme gmp gcc-toolchain" ;; + zig) echo "zig" ;; + elixir) echo "elixir erlang" ;; + gleam) echo "erlang" ;; + ocaml) echo "ocaml dune opam ocaml-findlib" ;; + haskell) echo "ghc cabal-install" ;; + docs) echo "ruby-asciidoctor" ;; + julia|bun) echo "" ;; + esac +} +# Tools a Guix shell cannot supply, so mise (inside that shell) does. +lang_guix_gap() { + case "$1" in + idris2) echo "idris2 (via pack)" ;; julia) echo julia ;; gleam) echo gleam ;; bun) echo bun ;; docs) echo lychee ;; + esac +} +guix_specs() { + local l s=" $GUIX_BASE " + for l in "${LANGS[@]}"; do for p in $(lang_guix "$l"); do case "$s" in *" $p "*) ;; *) s="$s$p ";; esac; done; done + echo "$s" | xargs +} + +# mise tools per language (registry-checked 2026-09-30, mise 2026.7.5). idris2 comes +# from pack and haskell from ghcup or Guix: neither is a mise tool. The recipes +# themselves need just and shellcheck, so those two are always declared. +MISE_BASE="just shellcheck" +# Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. +RECIPE_TOOLS="trivy" +lang_mise() { case "$1" in + rust) echo "rust" ;; zig) echo "zig" ;; julia) echo "julia" ;; + elixir) echo "erlang elixir" ;; gleam) echo "erlang gleam" ;; ocaml) echo "opam" ;; + bun) echo "bun" ;; docs) echo "lychee" ;; esac; } +recipe_tools() { + local t f body="" + for f in Justfile justfile build/just/*.just; do + [ -f "$f" ] && body+=$(grep -v '^[[:space:]]*#' "$f")$'\n' + done + for t in $RECIPE_TOOLS; do + [[ "$body" =~ (^|[^[:alnum:]_-])$t([^[:alnum:]_-]|$) ]] && echo "$t" + done +} + +mise_tools() { + local l t seen=" " out=() + for t in $MISE_BASE $(for l in "${LANGS[@]}"; do lang_mise "$l"; done) $(recipe_tools); do + case "$seen" in *" $t "*) ;; *) out+=("$t"); seen="$seen$t " ;; esac + done + printf "%s\n" "${out[*]}" +} + +lang_remedy() { + case "$1" in + rust) echo "mise use rust@latest (or rustup: https://rustup.rs)" ;; + idris2) echo "install pack: https://github.com/stefan-hoeck/idris2-pack#installation, then: pack install-app idris2" ;; + julia) echo "mise use julia@latest (or: https://julialang.org/install/ — juliaup)" ;; + zig) echo "mise use zig@latest" ;; + elixir) echo "mise use erlang@latest elixir@latest (erlang builds from source: allow ~10 min)" ;; + gleam) echo "mise use gleam@latest erlang@latest" ;; + ocaml) echo "mise use opam@latest && opam init -y && opam switch create . --deps-only -y" ;; + haskell) echo "ghcup: https://www.haskell.org/ghcup/ (or: just dev-shell, which uses Guix)" ;; + bun) echo "mise use bun@latest" ;; + esac +} + +# The per-language default for each contract verb. A repo overrides any of +# these simply by defining the root recipe itself; `provision-set` only adds +# `: provision::` where the root Justfile has no such recipe. +lang_cmd() { # $1 lang, $2 verb -> prints a shell command, or nothing (= N/A) + local l=$1 v=$2 ipkg + case "$l:$v" in + rust:deps) echo "cargo fetch" ;; + rust:build) echo "cargo build --all-targets" ;; + rust:test) echo "cargo test --all-targets" ;; + rust:bench) grep -rqs '\[\[bench\]\]\|criterion\|divan' --include=Cargo.toml . && echo "cargo bench" ;; + rust:lint) echo "cargo clippy --all-targets -- -D warnings" ;; + rust:fmt) echo "cargo fmt --all" ;; + rust:fmt-check) echo "cargo fmt --all -- --check" ;; + rust:run) echo "cargo run --release" ;; + + idris2:*) + ipkg=$(first '*.ipkg' 1); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg'); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg' 3); ipkg=${ipkg#./} + case "$v" in + deps) have pack && echo "pack install-deps $ipkg" ;; + build) have pack && echo "pack build $ipkg" || echo "idris2 --build $ipkg" ;; + test) local t; t=$(find . -maxdepth 3 -name 'test*.ipkg' -print -quit 2>/dev/null) + if [ -n "$t" ]; then have pack && echo "pack test ${t#./}" || echo "idris2 --build ${t#./}" + else have pack && echo "pack typecheck $ipkg" || echo "idris2 --typecheck $ipkg"; fi ;; + run) grep -qs '^[[:space:]]*executable' "$ipkg" && { have pack && echo "pack run $ipkg" || echo "idris2 --build $ipkg && ./build/exec/*"; } ;; + esac ;; + + julia:deps) echo "julia --project=. -e 'using Pkg; Pkg.instantiate()'" ;; + julia:build) echo "julia --project=. -e 'using Pkg; Pkg.precompile()'" ;; + julia:test) echo "julia --project=. -e 'using Pkg; Pkg.test()'" ;; + julia:bench) [ -f benchmark/benchmarks.jl ] && echo "julia --project=benchmark -e 'using Pkg; Pkg.develop(path=\".\"); Pkg.instantiate(); include(\"benchmark/benchmarks.jl\")'" ;; + + zig:*) + # build.zig may sit in ffi/zig/; run there, not at the root. + local zb zd; zb=$(first build.zig 1); [ -z "$zb" ] && zb=$(first build.zig); [ -z "$zb" ] && zb=$(first build.zig 3); [ -z "$zb" ] && zb=$(first build.zig 4) + zd=$(dirname "${zb#./}"); local cdz=""; [ "$zd" != . ] && cdz="cd '$zd' && " + case "$v" in + build) echo "${cdz}zig build" ;; + test) grep -qs '"test"' "$zb" && echo "${cdz}zig build test" ;; + bench) grep -qs '"bench"' "$zb" && echo "${cdz}zig build bench -Doptimize=ReleaseFast" ;; + fmt) echo "zig fmt $zd" ;; + fmt-check) echo "zig fmt --check $zd" ;; + lint) echo "zig fmt --check $zd" ;; + run) grep -qs '"run"' "$zb" && echo "${cdz}zig build run" ;; + esac ;; + + elixir:deps) echo "mix local.hex --force --if-missing && mix local.rebar --force --if-missing && mix deps.get" ;; + elixir:build) echo "mix compile --warnings-as-errors" ;; + elixir:test) echo "mix test" ;; + elixir:bench) ls bench/*.exs >/dev/null 2>&1 && echo "for f in bench/*.exs; do mix run \"\$f\"; done" ;; + elixir:lint) grep -qs ':credo' mix.exs && echo "mix credo --strict" || echo "mix compile --warnings-as-errors" ;; + elixir:fmt) echo "mix format" ;; + elixir:fmt-check) echo "mix format --check-formatted" ;; + elixir:run) echo "mix run --no-halt" ;; + + gleam:deps) echo "gleam deps download" ;; + gleam:build) echo "gleam build" ;; + gleam:test) echo "gleam test" ;; + gleam:fmt) echo "gleam format" ;; + gleam:fmt-check) echo "gleam format --check" ;; + gleam:lint) echo "gleam format --check" ;; + gleam:run) echo "gleam run" ;; + + ocaml:deps) echo "opam install . --deps-only --with-test -y" ;; + ocaml:build) echo "opam exec -- dune build" ;; + ocaml:test) echo "opam exec -- dune test" ;; + ocaml:fmt) echo "opam exec -- dune fmt" ;; + ocaml:fmt-check) echo "opam exec -- dune build @fmt" ;; + + haskell:deps) echo "cabal update && cabal build all --only-dependencies" ;; + haskell:build) echo "cabal build all" ;; + haskell:test) echo "cabal test all" ;; + haskell:bench) grep -qs '^benchmark' ./*.cabal && echo "cabal bench all" ;; + haskell:run) echo "cabal run" ;; + + bun:deps) [ -f bun.lock ] || [ -f bun.lockb ] && echo "bun install --frozen-lockfile" || echo "bun install" ;; + bun:build) grep -qs '"build"[[:space:]]*:' package.json && echo "bun run build" ;; + # `bun test` exits 0 when it finds no test files, so it runs only when some exist. + bun:test) if grep -qs '"test"[[:space:]]*:' package.json; then echo "bun run test" + elif git ls-files 2>/dev/null | grep -qE '(\.|_)(test|spec)\.(js|mjs|cjs|jsx)$'; then echo "bun test"; fi ;; + bun:bench) grep -qs '"bench"[[:space:]]*:' package.json && echo "bun run bench" ;; + bun:lint) grep -qs '"lint"[[:space:]]*:' package.json && echo "bun run lint" ;; + bun:fmt) grep -qs '"fmt"[[:space:]]*:' package.json && echo "bun run fmt" ;; + bun:fmt-check) grep -qs '"fmt-check"[[:space:]]*:' package.json && echo "bun run fmt-check" ;; + bun:run) grep -qs '"start"[[:space:]]*:' package.json && echo "bun run start" ;; + + docs:test) have asciidoctor && echo "for f in \$(git ls-files '*.adoc'); do asciidoctor -o /dev/null --failure-level=WARN \"\$f\" || exit 1; done" ;; + docs:lint) have lychee && echo "lychee --offline --no-progress \$(git ls-files '*.adoc' '*.md')" ;; + esac +} + +# Run a contract verb across every detected language. N/A is reported, not +# faked green: a verb with no command for any language exits 0 but SAYS so. +lang_run() { # $1 verb + local verb=$1 ran=0 rc=0 l cmd override + override=$(deed "$verb" "") + if [ -n "$override" ]; then + info "$verb (from provisioning_praxis.deed): $override" + bash -c "$override"; return $? + fi + for l in "${LANGS[@]}"; do + cmd=$(lang_cmd "$l" "$verb") + [ -z "$cmd" ] && continue + ran=1 + info "$verb [$l]: $cmd" + bash -c "$cmd" || { rc=$?; printf ' %sFAIL%s %s [%s] exited %s\n' "$R" "$Z" "$verb" "$l" "$rc" >&2; } + done + [ $ran -eq 0 ] && info "$verb: N/A for ${LANGS[*]} (archetype: $ARCHETYPE) — nothing to run, and nothing was faked" + return $rc +} + +# --------------------------------------------------------------------------- +# A repo keeps its own checks as root recipes named doctor-local / setup-local / +# heal-local (provision-set renames a pre-existing custom doctor/setup/heal to +# these); the canon verbs run them, so `just doctor`, `./launcher.sh --doctor` and +# CI all see the same result. +has_recipe() { have just && just --summary 2>/dev/null | tr " " "\n" | grep -qx "$1"; } + +version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } + +# Tools that must never appear in a repo's toolchain (estate language policy). +BANNED_TOOLS='python|deno|node|nodejs|npm|yarn|pnpm|typescript|rescript|make|black|ruff|pip|poetry|nix|go|golang|java|kotlin' + +# --------------------------------------------------------------------------- +# Shared predicates. doctor and provision-check.sh both call THESE (the check via +# `provision-lib.sh guix-stub|mise-lock-gaps|mise-banned`): a gate with its own +# copy of a test passes what doctor warns about. + +# The keys of mise.toml [tools], quotes stripped ("cargo:foo" stays cargo:foo). +mise_toml_tools() { + [ -f mise.toml ] || return 0 + awk '/^\[tools\]/{t=1;next} /^\[/{t=0} t && /=/{sub(/[ \t]*=.*/,""); gsub(/["\x27 ]/,""); print}' mise.toml +} +# Banned tools named in mise.toml; a backend prefix does not hide one ("aqua:denoland/deno" is deno). +mise_banned() { + local t base hits="" + for t in $(mise_toml_tools); do + base=${t##*:}; base=${base%%@*}; base=${base##*/} + [[ "$base" =~ ^($BANNED_TOOLS)$ ]] && hits="$hits$t " + done + printf '%s' "${hits% }" +} +# Why mise.lock does not pin mise.toml, or nothing when it does. Presence is not +# enough: a zero-byte lock pins nothing. Every [tools] key needs its [[tools.]] +# entry, and the lock must carry checksums. +mise_lock_gaps() { + [ -f mise.toml ] || return 0 + [ -f mise.lock ] || { echo "mise.lock missing"; return; } + [ -s mise.lock ] || { echo "mise.lock is empty"; return; } + local t miss="" + # A tool is pinned when its [[tools.X]] block carries a concrete version line. + for t in $(mise_toml_tools); do + awk -v a="[[tools.$t]]" -v b="[[tools.\"$t\"]]" ' + $0 == a || $0 == b { inb = 1; next } + /^\[/ { inb = 0 } + inb && /^version = "[^"]+"/ { ok = 1 } + END { exit !ok }' mise.lock || miss="$miss$t " + done + [ -n "$miss" ] && { echo "mise.lock does not pin: ${miss% }"; return; } + # Checksums are per artefact: every [tools.X."platforms.P"] table needs its own + # sha256, so one checksummed tool cannot vouch for another. A tool with no + # platform tables has no artefact to checksum (core:rust installs through + # rustup, cargo: builds from source), which is what `mise lock` writes for it. + miss=$(awk ' + # Print the open platform table as tool/platform when it carried no sha256. + function close_table() { if (p != "" && !c) printf "%s ", p; p = "" } + /^\[tools\..*platforms\./ { close_table(); p = $0; c = 0 + gsub(/^\[tools\.|\]$|"/, "", p); sub(/\.platforms\./, "/", p); next } + /^\[/ { close_table() } + /^checksum = "sha256:[0-9a-f]+"/ { c = 1 } + END { close_table() }' mise.lock) + [ -z "$miss" ] || echo "mise.lock has no sha256 for: ${miss% }" +} +# Why a Guix file is a stub, or nothing when it is real. The test is positive: a +# guix.scm must define every field a package needs, not merely avoid known stub +# shapes; `(package (name "x") (source (local-file ".")))` is a stub. +guix_stub_reason() { + local f=$1 k miss="" + [ -f "$f" ] || { echo "missing"; return; } + grep -qE '\{\{|__[A-Z][A-Z_]*__' "$f" && { echo "unfilled template slots"; return; } + grep -qE '\(inputs \(list\)\)|\(source #f\)' "$f" && { echo "empty inputs or no source"; return; } + case "${f##*/}" in + manifest.scm) grep -q 'specifications->manifest' "$f" || echo "lists no specifications"; return ;; + channels.scm) grep -qE '\(commit "[0-9a-f]{40}"\)' "$f" || echo "pins no commit"; return ;; + esac + for k in name version source build-system home-page synopsis description license; do + grep -qE "\\(${k}[[:space:]]" "$f" || miss="$miss$k " + done + [ -n "$miss" ] && { echo "no package field: ${miss% }"; return; } + if grep -q 'crates\.scm' "$f"; then + grep -qs 'define %crate-inputs' build/guix/crates.scm || echo "build/guix/crates.scm is missing or defines no %crate-inputs" + fi +} + +# --------------------------------------------------------------------------- +# Facts the generator writes into docs/SETUP.adoc and the AI guide. They live here, +# beside lang_tools and lang_remedy, so no second per-language table exists. +lang_title() { case "$1" in + rust) echo Rust ;; idris2) echo Idris2 ;; julia) echo Julia ;; zig) echo Zig ;; elixir) echo Elixir ;; + gleam) echo Gleam ;; ocaml) echo OCaml ;; haskell) echo Haskell ;; bun) echo Bun ;; docs) echo Docs ;; esac; } +# What a language's route needs from the OS: "fedora|debian|macos command|why", or nothing. +lang_sysdeps() { case "$1" in + rust) echo "gcc pkgconf-pkg-config|build-essential pkg-config|xcode-select --install|Rust links through the system C toolchain, and pkg-config finds C libraries" ;; + idris2) echo "chez-scheme gmp-devel|chezscheme libgmp-dev|brew install chezscheme gmp|pack builds Idris2 on top of Chez Scheme and GMP" ;; + elixir|gleam) echo "gcc gcc-c++ make autoconf ncurses-devel openssl-devel|build-essential autoconf m4 libncurses-dev libssl-dev|brew install autoconf openssl@3|mise builds Erlang/OTP from source (allow ~10 min); the make here is the system tool that build uses, not a Makefile in this repository" ;; + ocaml) echo "gcc make patch unzip bubblewrap|build-essential patch unzip bubblewrap|xcode-select --install|opam compiles OCaml and sandboxes its builds with bubblewrap" ;; + haskell) echo "gcc gcc-c++ gmp-devel make ncurses-devel xz perl|build-essential curl libffi-dev libgmp-dev libncurses-dev|xcode-select --install|ghcup installs GHC, which links through the C toolchain and GMP" ;; +esac; } +guix_gaps() { + local l g gaps="" + for l in "${LANGS[@]}"; do g=$(lang_guix_gap "$l"); [ -n "$g" ] && gaps="$gaps${gaps:+, }$g"; done + printf '%s\n' "${gaps:-none}" +} +tool_table() { + local l t base + for l in "${LANGS[@]}"; do + if [ "$l" = docs ]; then echo "|docs |\`lychee\` |mise use lychee@latest"; continue; fi + t=$(lang_tools "$l"); echo "|$l |\`${t// /\`, \`}\` |$(lang_remedy "$l")" + done + base="$MISE_BASE $(recipe_tools | xargs)"; base=$(echo "$base" | xargs) + echo "|(recipes) |\`${base// /\`, \`}\` |mise install, or your OS package manager (e.g. \`dnf install just ShellCheck\`)" +} +# shellcheck disable=SC2016 # the backticks are AsciiDoc literals, not command substitution +system_deps() { # $1 adoc|ai + local l d seen="" fed deb mac why any=0 + [ "$1" = adoc ] && printf '=== System packages\n' + for l in "${LANGS[@]}"; do + d=$(lang_sysdeps "$l"); [ -z "$d" ] && continue + case "$seen" in *"|$d|"*) continue ;; esac; seen="$seen|$d|"; any=1 + IFS='|' read -r fed deb mac why <<<"$d" + if [ "$1" = adoc ]; then + printf '\n%s: %s.\n\n* Fedora: `sudo dnf install %s`\n* Debian/Ubuntu: `sudo apt install %s`\n* macOS: `%s`\n' \ + "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" + else + printf '* %s needs OS packages (%s): Fedora `sudo dnf install %s` · Debian/Ubuntu `sudo apt install %s` · macOS `%s`.\n' \ + "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" + fi + done + if [ "$1" = adoc ]; then + if [ $any -eq 1 ]; then printf '\nThe Guix development shell (`%s`) provides these itself, so inside `just dev-shell` none of them is needed.\n' "$(gpath manifest.scm)" + else printf '\nNone beyond git and a shell: every tool this repository needs comes from mise, or from the Guix shell.\n'; fi + elif [ $any -eq 0 ]; then printf '* No other OS packages: every tool comes from mise.\n'; fi +} + +cmd_doctor() { + printf '%s doctor — %s (%s; languages: %s)\n' "$REPO_NAME" "$REPO_SLUG" "$ARCHETYPE" "${LANGS[*]}" + + hdr "Core toolchain" + have git && pass "git $($T git --version 2>/dev/null | awk '{print $3}')" || fail "PV-E01 git not found — install git from your OS package manager" + if have just; then + local jv; jv=$($T just --version 2>/dev/null | awk '{print $2}') + version_ge "$jv" "$MIN_JUST" && pass "just $jv (>= $MIN_JUST)" || fail "PV-E02 just $jv is older than $MIN_JUST — run: mise use just@latest" + else fail "PV-E02 just not found — run: mise use -g just@latest (or see docs/SETUP.adoc)"; fi + if have mise; then + pass "mise $($T mise --version 2>/dev/null | awk '{print $1}')" + if [ -f mise.toml ] || [ -f .mise.toml ]; then + # Only THIS repo's declarations: `mise ls` also lists the user's global config. + local here=${ROOT/#$HOME/\~} missing + missing=$($T mise ls --current --missing 2>/dev/null | grep -F -e "$here/mise.toml" -e "$here/.mise.toml" -e "$ROOT/mise.toml" | awk '{print $1"@"$2}' | tr '\n' ' ') + [ -z "${missing// /}" ] && pass "every mise tool is installed" || fail "PV-E03 mise tools not installed: $missing— run: just setup" + fi + else warn "PV-W01 mise not found — tools must then come from Guix or your OS; install: $MISE_INSTALL_HINT"; fi + if have guix; then pass "guix $($T guix --version 2>/dev/null | head -1 | awk '{print $NF}') (optional reproducible path)" + else info "guix not installed — optional; mise is the default path (docs/SETUP.adoc §Guix)"; fi + + hdr "Language toolchains" + local l t + for l in "${LANGS[@]}"; do + [ "$l" = docs ] && { info "no build language detected — docs/theory archetype"; continue; } + for t in $(lang_tools "$l"); do + if have "$t"; then pass "$l: $t ($(command -v "$t"))" + else fail "PV-E10 $l: '$t' not found — run: just setup (manual: $(lang_remedy "$l"))"; fi + done + done + local extra; extra=$(deed system-deps "") + for t in $extra; do have "$t" && pass "system dep: $t" || fail "PV-E11 system dependency '$t' not found (declared in $DEED) — see docs/SETUP.adoc §Prerequisites"; done + + hdr "Repository provisioning files" + [ -f mise.toml ] && pass "mise.toml" || fail "PV-E20 mise.toml missing — the toolchain is undeclared" + local lg; lg=$(mise_lock_gaps) + [ -z "$lg" ] && pass "mise.lock pins every mise.toml tool (latest → concrete, checksummed)" || warn "PV-W20 $lg — run: just toolchain-refresh" + [ -f .mise.toml ] && [ -f mise.toml ] && warn "PV-W21 both mise.toml and .mise.toml — mise merges them; keep only mise.toml" + [ -f .tool-versions ] && warn "PV-W22 .tool-versions present — a second toolchain source; fold it into mise.toml" + if [ -f mise.toml ]; then + local bad; bad=$(mise_banned) + [ -z "$bad" ] && pass "mise.toml pins no banned tool" || warn "PV-W23 mise.toml pins banned tool(s): $bad (language policy: bun, no python/deno/node/make)" + fi + # hypatia guix_not_stub reads guix.scm AND build/guix.scm; an unfilled __PLACEHOLDER__ is a stub too. + local g r gstub="" + for g in guix.scm build/guix.scm "$(gpath manifest.scm)" "$(gpath channels.scm)"; do + [ -f "$g" ] || continue + r=$(guix_stub_reason "$g"); [ -n "$r" ] && gstub="$gstub$g ($r) " + done + local gs gm; gs=$(gpath guix.scm); gm=$(gpath manifest.scm) + [ -f guix.scm ] && [ -f build/guix.scm ] && warn "PV-W35 both guix.scm and build/guix.scm exist — two Guix sources; keep one (build/ is used)" + if [ -f "$gs" ]; then + if [ -n "$gstub" ]; then warn "PV-W24 template stub in: $gstub— the Guix path does not build this repo (just heal cannot fix this; provision-set realign does)" + else pass "$gs (non-stub)"; fi + else warn "PV-W25 guix.scm missing — no reproducible Guix path"; fi + [ -f "$gm" ] && pass "$gm (guix shell -m $gm)" || warn "PV-W26 $gm missing — 'just dev-shell' falls back to mise" + [ -x launcher.sh ] && pass "launcher.sh (executable)" || { [ -f launcher.sh ] && fail "PV-E27 launcher.sh not executable — run: just heal" || warn "PV-W27 launcher.sh missing"; } + local d + for d in docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)"; do + if [ ! -f "$d" ]; then warn "PV-W28 $d missing" + elif grep -qE "__[A-Z][A-Z_]*__" "$d"; then warn "PV-W29 $d still has unfilled template slots (__SPEC_…__) — replace each with the facts for this repo" + else pass "$d"; fi + done + + hdr "Estate policy drift" + local f any=0 + for f in deno.json deno.jsonc deno.lock import_map.json; do + [ -f "$f" ] && { warn "PV-W30 $f — Deno leftover; the estate runtime is bun (migration issue tracks it)"; any=1; } + done + for f in flake.nix flake.lock shell.nix default.nix; do [ -f "$f" ] && { warn "PV-W31 $f — Nix is not an estate toolchain; Guix is"; any=1; }; done + for f in Makefile GNUmakefile makefile; do [ -f "$f" ] && { warn "PV-W32 $f — Justfile is the only task runner"; any=1; }; done + for f in requirements.txt pyproject.toml setup.py Pipfile; do [ -f "$f" ] && { warn "PV-W33 $f — Python is not an estate language"; any=1; }; done + for f in package-lock.json yarn.lock pnpm-lock.yaml tsconfig.json; do [ -f "$f" ] && { warn "PV-W34 $f — npm/yarn/pnpm/TypeScript leftover; bun only"; any=1; }; done + [ $any -eq 0 ] && pass "no deno / nix / make / python / npm leftovers" + + local hook=build/just/doctor-local.sh + if [ -f "$hook" ]; then + hdr "Repo-specific checks ($hook)" + # Sourced so the hook can call pass/warn/fail, but in a subshell so an `exit` + # or `set -e` in it cannot end the doctor before the summary. The EXIT trap + # hands the hook's tally back; an early exit is itself a FAIL. + local tally qtally hp w f rc + tally=$(mktemp) + printf -v qtally '%q' "$tally" + # shellcheck disable=SC2030,SC2031 # the subshell's counts return via $tally + ( PASS=0 WARN=0 FAIL=0 hook_done=0 + # The path is baked in now: when `set -e` trips, bash unwinds this + # function's locals before the EXIT trap runs, so $tally is gone by then. + # shellcheck disable=SC2064 + trap "printf '%d %d %d %d\n' \"\$PASS\" \"\$WARN\" \"\$FAIL\" \"\$hook_done\" > $qtally" EXIT + # shellcheck source=/dev/null + . "$hook" + hook_done=1 ) + rc=$? + read -r hp w f hook_done < "$tally" || { hp=0 w=0 f=0 hook_done=0; } + rm -f "$tally" + # shellcheck disable=SC2031 + PASS=$((PASS+hp)) WARN=$((WARN+w)) FAIL=$((FAIL+f)) + [ "$hook_done" = 1 ] || fail "PV-E51 $hook exited (status $rc) before it finished; its later checks did not run" + fi + if has_recipe doctor-local; then + hdr "Repo-specific checks (just doctor-local)" + just doctor-local && pass "doctor-local" || fail "PV-E50 the repo-specific doctor-local recipe failed (output above)" + fi + + printf '\n%s: %s%d PASS%s, %s%d WARN%s, %s%d FAIL%s\n' "$REPO_NAME" "$G" "$PASS" "$Z" "$Y" "$WARN" "$Z" "$R" "$FAIL" "$Z" + if [ "$FAIL" -gt 0 ]; then + echo "Next: 'just heal' fixes what is safe to fix automatically; each FAIL code is explained in docs/SETUP.adoc §Troubleshooting." + return 1 + fi + return 0 +} + +cmd_setup() { + printf '%s setup — installing everything this repository needs\n' "$REPO_NAME" + local rc=0 + if have mise; then + hdr "mise toolchain" + $T mise trust -q . 2>/dev/null || true + mise install || rc=1 + else + warn "mise not found. Install it ($MISE_INSTALL_HINT), or enter the Guix shell with 'just dev-shell', then re-run: just setup" + fi + local l + for l in "${LANGS[@]}"; do + case "$l" in + idris2) have pack || warn "pack not found — Idris2 comes from pack, not mise: $(lang_remedy idris2)" ;; + haskell) have ghcup || have ghc || warn "ghc not found — $(lang_remedy haskell)" ;; + ocaml) have opam && { opam switch show >/dev/null 2>&1 || opam init -y --bare; } ;; + esac + done + hdr "Project dependencies" + # Put this repo's mise tools on PATH for the dependency step. + if have mise; then PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH"; export PATH; fi + lang_run deps || rc=1 + local hook=build/just/setup-local.sh + [ -f "$hook" ] && { hdr "Repo-specific setup ($hook)"; bash "$hook" || rc=1; } + has_recipe setup-local && { hdr "Repo-specific setup (just setup-local)"; just setup-local || rc=1; } + [ -f launcher.sh ] && chmod +x launcher.sh + hdr "Verification" + cmd_doctor || rc=1 + [ $rc -eq 0 ] && printf '\n%sReady.%s Try: just --list\n' "$G" "$Z" || printf '\n%sSetup incomplete%s — read the FAIL lines above; docs/SETUP.adoc has the manual route.\n' "$R" "$Z" + return $rc +} + +cmd_heal() { + printf '%s heal — applying safe, reversible fixes, then re-checking\n' "$REPO_NAME" + hdr "Fixes" + [ -f launcher.sh ] && [ ! -x launcher.sh ] && chmod +x launcher.sh && info "made launcher.sh executable" + for f in build/just/*.sh scripts/*.sh; do [ -f "$f" ] && [ ! -x "$f" ] && chmod +x "$f" && info "made $f executable"; done + if have mise; then + $T mise trust -q . 2>/dev/null && info "mise config trusted" + mise install && info "mise tools installed" + [ -f mise.lock ] || { $T mise lock 2>/dev/null && info "mise.lock generated"; } + fi + if printf '%s\n' "${LANGS[@]}" | grep -qx elixir && have mix; then mix deps.get >/dev/null 2>&1 && info "mix deps fetched"; fi + if printf '%s\n' "${LANGS[@]}" | grep -qx bun && have bun; then bun install >/dev/null 2>&1 && info "bun deps installed"; fi + if printf '%s\n' "${LANGS[@]}" | grep -qx julia && have julia; then julia --project=. -e 'using Pkg; Pkg.instantiate()' >/dev/null 2>&1 && info "julia deps instantiated"; fi + local hook=build/just/heal-local.sh + [ -f "$hook" ] && { info "running $hook"; bash "$hook" || true; } + has_recipe heal-local && { info "running just heal-local"; just heal-local || true; } + echo "Not touched (never automatic): source files, git history, Deno/Nix/Make leftovers — those are migrations, tracked as issues." + hdr "Re-check" + cmd_doctor +} + +cmd_dev_shell() { + local gm; gm=$(gpath manifest.scm) + if have guix && [ -f "$gm" ] && ! grep -q '{{' "$gm"; then + info "entering: guix shell -m $gm (exit to leave)" + exec guix shell -m "$gm" + elif have mise; then + info "guix/manifest.scm unavailable — entering the mise environment instead (exit to leave)" + exec mise exec -- "${SHELL:-bash}" + else + fail "PV-E40 neither guix nor mise is installed — see docs/SETUP.adoc"; return 1 + fi +} + +# Weekly toolchain refresh: bump mise pins and the lock, re-pin the Guix channel, +# regenerate build/guix/crates.scm when guix.scm loads it, then show the diff +# for a signed commit. +cmd_toolchain_refresh() { + hdr "mise: bump 'latest' resolutions and re-lock" + have mise || { fail "mise not found"; return 1; } + mise up --bump || return 1 + $T mise lock 2>/dev/null || mise lock || return 1 + local ch; ch=$(gpath channels.scm) + if [ -f "$ch" ]; then + hdr "guix: channel pin" + if have guix; then + guix pull --channels="$ch" --dry-run >/dev/null 2>&1 || true + guix describe --format=channels > "$ch.new" 2>/dev/null && mv "$ch.new" "$ch" && info "$ch re-pinned to the current guix commit" + else info "guix not installed — $ch left as-is (CI re-pins it)"; fi + fi + local cr="" + if [ -f Cargo.lock ] && grep -qs 'crates\.scm' "$(gpath guix.scm)"; then + hdr "guix: crate inputs from Cargo.lock" + cr=build/guix/crates.scm + if have "${GUIX%% *}"; then cmd_crates_scm || return 1 + else info "guix not installed — $cr left as-is (CI regenerates it)"; fi + fi + git --no-pager diff --stat -- mise.toml mise.lock "$ch" $cr 2>/dev/null + echo "Commit the diff above (signed) as: chore(toolchain): weekly refresh" +} + +# The guix command (default: guix), e.g. a wrapper that runs it in a container +# with this directory mounted. +GUIX="${GUIX:-guix}" + +# Write build/guix/crates.scm: every registry crate in Cargo.lock as a Guix +# origin, and %crate-inputs listing them for guix.scm. Written whole or not at +# all. The importer's output is accepted only when it defines exactly one crate +# source per registry package in Cargo.lock: run through a container, guix's exit +# status is lost, so the count is the check. +cmd_crates_scm() { + local dst=build/guix/crates.scm want got tmp spdx + [ -f Cargo.lock ] || { info "no Cargo.lock — no crate inputs"; return 0; } + want=$(grep -c '^source = "registry+' Cargo.lock) + tmp=$(mktemp) || return 1 + if [ "$want" -gt 0 ]; then + # GUIX is split on purpose: it may be a command with arguments. + # shellcheck disable=SC2086 + timeout 1800 $GUIX import crate --lockfile=Cargo.lock "$REPO_NAME" >"$tmp" 2>"$tmp.err" + got=$(grep -c '^(define rust-' "$tmp") + if [ "$got" != "$want" ]; then + fail "PV-E41 guix import crate defined $got of the $want registry crates in Cargo.lock; $dst left as-is: $(grep -m1 -i 'error' "$tmp.err" || grep -v '^+' "$tmp.err" | tail -1)" + rm -f "$tmp" "$tmp.err"; return 1 + fi + fi + # The licence line is guix.scm's own, so the file matches its repository. + spdx=$(grep -m1 'SPDX-License-Identifier' "$(gpath guix.scm)" 2>/dev/null) + mkdir -p build/guix + { + [ -n "$spdx" ] && printf '%s\n' "$spdx" + # shellcheck disable=SC2016 # the backticks are literal text + printf ';; Generated from Cargo.lock by `just toolchain-refresh` (guix import crate\n' + printf ';; --lockfile); never hand-edit it. guix.scm loads it for %%crate-inputs.\n\n' + grep -v '^guix (GNU Guix)' "$tmp" + printf '\n(define %%crate-inputs\n (list' + grep -o '^(define rust-[^ ]*' "$tmp" | awk '{printf "\n %s", $2}' + printf '))\n' + } >"$dst.new" && mv "$dst.new" "$dst" + rm -f "$tmp" "$tmp.err" + info "$dst: $want crate source(s)" +} + +# The sentence people are told to say lives in one place a reader sees: the first +# listing block of the README's [[ai-install]] section. The deed's ai-say-it and +# the generic line are fallbacks for a README without that section. +just_say_it() { + local line="" r + for r in README.adoc README.md; do + [ -f "$r" ] || continue + line=$(awk '/^\[\[ai-install\]\]/{on=1} on && /^----$/{if (inb) exit; inb=1; next} inb && NF{print; exit}' "$r") + [ -n "$line" ] && break + done + [ -z "$line" ] && line=$(deed ai-say-it "") + [ -z "$line" ] && line="Set up $REPO_NAME from https://github.com/$REPO_SLUG — follow docs/AI_INSTALLATION_GUIDE.adoc in that repo." + printf '%s' "$line" +} +clip() { # best-effort; silent when no clipboard exists (CI, SSH) + # wl-copy and xclip fork a daemon that inherits stdout; left attached, it + # holds any pipeline open forever (observed 2026-09-30). Detach and bound it. + local c=() + if have clip.exe; then c=(clip.exe) # WSL: the Windows clipboard + elif have pbcopy; then c=(pbcopy) + elif [ -n "${WAYLAND_DISPLAY:-}" ] && have wl-copy; then c=(wl-copy) + elif [ -n "${DISPLAY:-}" ] && have xclip; then c=(xclip -selection clipboard) + else cat >/dev/null; return 1; fi + timeout 3 "${c[@]}" >/dev/null 2>&1 +} +cmd_ai_setup() { + cat <" >&2; return 2 ;; esac + for f in "$(wpath "$who")" "llm-warmup-$who.adoc" "docs/onboarding/llm-warmup-$who.adoc" "docs/llm-warmup-$who.adoc"; do + if [ -f "$f" ]; then + cat "$f"; clip < "$f" && echo "--- (copied $f to your clipboard — paste it as your first message to any AI)" >&2 || true + return 0 + fi + done + fail "llm-warmup-$who.adoc not found"; return 1 +} + +cmd_eval() { + local logf rc=0 s e v r o st + logf=".eval/$(date -u +%Y%m%dT%H%M%SZ).txt" + mkdir -p .eval + { echo "# $REPO_NAME evaluation — $(date -u +%FT%TZ) — $(git rev-parse --short HEAD 2>/dev/null || echo no-git)" + echo "# languages: ${LANGS[*]} archetype: $ARCHETYPE"; } > "$logf" + for v in test bench; do + # The root recipe if the repo defines one (its own override), else the module default. + if just --summary 2>/dev/null | tr " " "\n" | grep -qx "$v"; then r=$v; else r="provision::$v"; fi + s=$(date +%s); o=$(mktemp) + if just "$r" >"$o" 2>&1; then st=PASS; else st=FAIL; rc=1; fi + e=$(( $(date +%s) - s )); cat "$o" >>"$logf" + # A skip is not a pass: lang_run exits 0 on N/A (users see success), eval says N/A. + [ "$st" = PASS ] && grep -q "nothing was faked" "$o" && st=N/A + rm -f "$o"; echo "$v: $st (${e}s)" | tee -a "$logf" + done + echo "full log: $logf" + return $rc +} + +cmd_config_show() { + echo "repo: $REPO_SLUG" + echo "archetype: $ARCHETYPE" + echo "languages: ${LANGS[*]}" + echo "descriptor: $DEED $([ -f "$DEED" ] && echo '(present)' || echo '(absent — defaults in use)')" + echo "config: $(deed config "none declared")" + echo "run: $(deed run "$(for l in "${LANGS[@]}"; do lang_cmd "$l" run; done | head -1)")" + echo "ports: $(deed ports "none")" + echo "mise: $([ -f mise.toml ] && echo mise.toml) $([ -f mise.lock ] && echo mise.lock)" + echo "guix: $(for f in guix.scm manifest.scm channels.scm; do f=$(gpath "$f"); [ -f "$f" ] && printf "%s " "$f"; done)" + echo "lib: provision-lib $PROVISION_LIB_VERSION" +} + +cmd_opsm() { + cat <} + if have agrep; then git ls-files -z | xargs -0 agrep -n -1 -- "$p" 2>/dev/null + else git grep -n -i -- "$p"; fi +} + +case "${1:-}" in + guix-specs) guix_specs ;; + mise-tools) mise_tools ;; + langs) printf '%s\n' "${LANGS[@]}" ;; + doctor) cmd_doctor ;; + setup) cmd_setup ;; + heal) cmd_heal ;; + dev-shell) cmd_dev_shell ;; + toolchain-refresh) cmd_toolchain_refresh ;; + crates-scm) cmd_crates_scm ;; + ai-setup) cmd_ai_setup ;; + ai-warmup) shift; cmd_ai_warmup "${1:-user}" ;; + eval) cmd_eval ;; + config-show) cmd_config_show ;; + opsm) cmd_opsm ;; + search) shift; cmd_search "${1:-}" ;; + lang-run) shift; lang_run "${1:?verb}" ;; + version) echo "provision-lib $PROVISION_LIB_VERSION" ;; + guix-dir) guix_dir ;; + set-files) set_files ;; + guix-gaps) guix_gaps ;; + tool-table) tool_table ;; + system-deps) shift; system_deps "${1:-adoc}" ;; + # Predicates: print why, exit 1; print nothing, exit 0. + guix-stub) shift; r=$(guix_stub_reason "${1:?usage: guix-stub FILE}"); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + mise-lock-gaps) r=$(mise_lock_gaps); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + mise-banned) r=$(mise_banned); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + *) sed -n '2,20p' "$0"; exit 2 ;; +esac diff --git a/standards/provisioning/templates/build/just/provision-modes.sh b/standards/provisioning/templates/build/just/provision-modes.sh new file mode 100755 index 0000000..35e6033 --- /dev/null +++ b/standards/provisioning/templates/build/just/provision-modes.sh @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# shellcheck shell=bash +# +# provision-modes.sh — the launcher-standard 0.6.0 provisioning mode family, +# as a SOURCEABLE block shared by every launcher in the estate. +# +# New launchers (library/tool/theory/docs): templates/launcher.sh.tmpl sources +# this and calls hp_launcher_main. +# Existing app launchers: add ONE line before their mode switch — +# +# . "$REPO_DIR/build/just/provision-modes.sh" && hp_provision_or_return "$@" +# +# A provisioning mode runs and the launcher exits with its status (a failed +# --setup exits non-zero); any other mode returns, so the app's own switch +# handles it. hp_provision_dispatch itself returns 99 for "not mine". +# +# Canon: hyperpolymath/standards launcher/launcher-standard_praxis.deed +# (archetypes, provisioning-modes) and +# 3-practice/provisioning/PROVISIONING-STANDARD.adoc + +HP_PROVISION_MODES_VERSION="0.6.0" + +hp__deed() { # $1 key, $2 default — flat (key "value") read from provisioning_praxis.deed + local f="$REPO_DIR/.machine_readable/descriptiles/provisioning_praxis.deed" v="" + [ -f "$f" ] && v=$(grep -oE "[(:]$1[[:space:]]+\"[^\"]*\"" "$f" | head -1 | sed -E 's/^[^"]*"//; s/"$//') + printf '%s' "${v:-$2}" +} + +hp_archetype() { hp__deed archetype "library"; } +# Without a deed, the origin remote names the repo (a worktree or renamed clone +# has another directory name); the directory is the last resort. +hp_app_name() { + local u; u=$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true) + u=${u%.git}; u=${u##*/} + hp__deed name "${u:-$(basename "$REPO_DIR")}" +} + +hp_platform() { + case "$(uname -s)" in + Linux*) echo linux ;; + Darwin*) echo macos ;; + CYGWIN*|MINGW*|MSYS*|Windows_NT) echo windows ;; + *) echo unknown ;; + esac +} + +# Make sure `just` is runnable: PATH, then mise, then say exactly what to do. +hp_ensure_just() { + command -v just >/dev/null 2>&1 && return 0 + if command -v mise >/dev/null 2>&1; then + echo "just is not installed — installing it with mise (mise use -g just@latest)..." >&2 + mise use -g just@latest >&2 && PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH" && command -v just >/dev/null 2>&1 && return 0 + mise exec just@latest -- true >/dev/null 2>&1 && { just() { mise exec just@latest -- just "$@"; }; return 0; } + fi + cat >&2 <<'EOF' +Neither `just` nor `mise` is installed. Install mise (it then installs everything else): + + brew install mise # macOS + sudo dnf copr enable jdxcode/mise && sudo dnf install mise # Fedora + winget install jdx.mise # Windows + # anything else (Debian/Ubuntu apt, …): https://mise.jdx.dev/installing-mise.html + # or download the installer, read it, then run it — never pipe it into a shell: + curl -fsSLo mise-install.sh https://mise.run && less mise-install.sh && sh mise-install.sh + +then re-run this command. Manual route without mise: docs/SETUP.adoc +EOF + return 1 +} + +hp_just() { hp_ensure_just || return 1; (cd "$REPO_DIR" && just "$@"); } +# The provisioning modes call the engine directly, not a `just` recipe: a repo may +# define its own root `doctor`/`setup`/`heal`, and the launcher must still run the +# canon (which then runs that repo's *-local recipes). Only needs bash. +hp_lib() { (cd "$REPO_DIR" && bash build/just/provision-lib.sh "$@"); } + +# Returns the mode's exit code, or 99 when "$1" is not a provisioning mode. +# The one-line hook for existing launchers: exit with a provisioning mode's +# status, or return (0) so the caller's own mode switch runs. +hp_provision_or_return() { + hp_provision_dispatch "$@" + local rc=$? + [ "$rc" -eq 99 ] && return 0 + exit "$rc" +} + +hp_provision_dispatch() { + case "${1:-}" in + --setup) hp_lib setup ;; + --doctor) hp_lib doctor ;; + --heal) hp_lib heal ;; + --ai-setup) hp_lib ai-setup ;; + *) return 99 ;; + esac +} + +hp_version_line() { + local sha ver + sha=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown) + ver=$(hp__deed version "") + [ -z "$ver" ] && ver=$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null || echo 0.0.0) + printf '%s-launcher %s (%s) [%s-%s]\n' "$(hp_app_name)" "${ver#v}" "$sha" "$(hp_platform)" "$(uname -m)" +} + +hp_help() { + local name arch; name=$(hp_app_name); arch=$(hp_archetype) + cat < + +Provisioning (every repository): + --setup Install everything this repository needs, then run --doctor + --doctor Check the environment; PASS/WARN/FAIL with fix hints (exit 1 on FAIL) + --heal Apply safe fixes automatically, then re-run --doctor + --ai-setup Print the one line to give any AI assistant to set this up for you + +Meta: + --help This text + --version Machine-readable version line + +EOF + if [ "$arch" = app ]; then + echo "Runtime: --start --stop --status --auto (default) --integ --disinteg" + else + echo "Runtime modes (--start --stop --status --auto --integ --disinteg) do not apply" + echo "to a $arch repository; they are accepted and explain themselves." + fi + cat <&2 + return 1 + fi + echo "$(hp_app_name) is a $arch: there is nothing to ${mode#--}. Try --setup, --doctor or --help." + return 0 ;; + *) + echo "Unknown mode: $mode" >&2; hp_help >&2; return 2 ;; + esac +} diff --git a/standards/provisioning/templates/build/just/provision.just b/standards/provisioning/templates/build/just/provision.just new file mode 100644 index 0000000..9f1ac7c --- /dev/null +++ b/standards/provisioning/templates/build/just/provision.just @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision.just — the estate provisioning contract, as a just MODULE. +# +# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +# Loaded from the root Justfile with: +# +# mod provision 'build/just/provision.just' +# +# A module (not `import`) so these recipes live under `provision::` and never +# collide with a repo's own `doctor`, `heal` or `test`. The root Justfile then +# exposes each contract name ONCE — either its own recipe, or a one-line +# delegation such as `doctor: provision::doctor` that `provision-set mint` +# adds only when the root has no recipe of that name. +# +# requires: just >= 1.42.0 (root recipes depend on module recipes; measured 2026-09-30: 1.41 rejects `x: m::x`, 1.42 accepts) + +root := justfile_directory() +lib := "PROVISION_ROOT=" + quote(root) + " bash " + quote(source_directory() / "provision-lib.sh") + +# List the provisioning recipes +default: + @just --list provision + +# Install every tool and dependency this repo needs, then run doctor +setup: + @{{lib}} setup + +# Diagnose the environment: PASS/WARN/FAIL with codes; exits 1 on any FAIL +doctor: + @{{lib}} doctor + +# Apply safe, reversible fixes, then re-run doctor +heal: + @{{lib}} heal + +# Enter a shell with every tool on PATH (guix shell -m manifest.scm, else mise) +dev-shell: + @{{lib}} dev-shell + +# Weekly: bump mise 'latest' resolutions, re-lock, re-pin guix channels +toolchain-refresh: + @{{lib}} toolchain-refresh + +# Print (and copy) the one line to give any AI to install this repo +ai-setup: + @{{lib}} ai-setup + +# Print (and copy) the warm-up for an AI session: user, dev or maintainer +ai-warmup who="user": + @{{lib}} ai-warmup {{quote(who)}} + +# Run test + bench, time both, and write .eval/.txt +eval: + @{{lib}} eval + +# Show where this repo's configuration and toolchain are declared +config-show: + @{{lib}} config-show + +# How to install this repo with OPSM (odds-and-sods package manager) +opsm: + @{{lib}} opsm + +# Print the languages detected from marker files +langs: + @{{lib}} langs + +# Approximate search across tracked files (agrep, else git grep) +search pattern: + @{{lib}} search {{quote(pattern)}} + +# Per-language defaults for the contract verbs, reached as `just provision::` +# or through a root recipe that delegates (`build: provision::build`). A root +# recipe with its own body is the repo-specific override. + +# Build with every detected language's toolchain +build: + @{{lib}} lang-run build + +# Run every detected language's tests +test: + @{{lib}} lang-run test + +# Run every detected language's benchmarks +bench: + @{{lib}} lang-run bench + +# Lint every detected language +lint: + @{{lib}} lang-run lint + +# Format every detected language's sources +fmt: + @{{lib}} lang-run fmt + +# Check formatting without rewriting anything (the CI form of fmt) +fmt-check: + @{{lib}} lang-run fmt-check + +# Run the project (N/A for libraries) +run: + @{{lib}} lang-run run + +# Fetch every detected language's dependencies +deps: + @{{lib}} lang-run deps diff --git a/standards/provisioning/templates/docs/SETUP.adoc.tmpl b/standards/provisioning/templates/docs/SETUP.adoc.tmpl index 769a47a..3825722 100644 --- a/standards/provisioning/templates/docs/SETUP.adoc.tmpl +++ b/standards/provisioning/templates/docs/SETUP.adoc.tmpl @@ -175,6 +175,7 @@ every fix marked *auto* for you. |PV-E20 |mise.toml is missing |restore it: `git checkout -- mise.toml` |no |PV-E27 |launcher.sh is not executable |`chmod +x launcher.sh` |yes |PV-E40 |neither Guix nor mise is installed, so there is no dev shell |install mise (step 1) |no +|PV-E41 |`just toolchain-refresh` could not regenerate `build/guix/crates.scm`: the Guix crate importer defined fewer crates than `Cargo.lock` lists (often a network failure or a crate not yet on crates.io) |re-run `just toolchain-refresh`; the error is printed after the code. The old file is kept, so the build is unaffected |no |PV-E50 |this repository's own `doctor-local` checks failed |read the output above the summary line; the recipe is in the Justfile |no |PV-E51 |`build/just/doctor-local.sh` exited before its end (an `exit` or a failing command under `set -e`) |the doctor names the exit status; fix the script so it reports with pass/warn/fail instead of exiting |no |PV-W01 |mise is not installed |install mise (step 1) |no diff --git a/standards/provisioning/templates/launcher.sh.tmpl b/standards/provisioning/templates/launcher.sh.tmpl index 1777fde..32e0243 100755 --- a/standards/provisioning/templates/launcher.sh.tmpl +++ b/standards/provisioning/templates/launcher.sh.tmpl @@ -12,7 +12,7 @@ # :generator "provision-set") # (app :name "__APP_NAME__" :display "__APP_NAME__" # :url "https://github.com/__REPO_SLUG__" :archetype "__ARCHETYPE__") -# (compliance :standard-version "0.5.0" +# (compliance :standard-version "0.6.0" # :standards ("launcher-standard.adoc" # "PROVISIONING-STANDARD.adoc")) # (modes :accepted ("--setup" "--doctor" "--heal" "--ai-setup" "--help" "--version" @@ -23,7 +23,7 @@ # @launcher-deed end # # The launcher for a library / tool / theory / docs repository -# (launcher-standard 0.5.0, archetype profile). Every mode is implemented in +# (launcher-standard 0.6.0, archetype profile). Every mode is implemented in # build/just/provision-modes.sh, and every provisioning mode delegates to the # Justfile. This file is minted by `provision-set`; do not hand-edit it. # Repo-specific facts belong in .machine_readable/descriptiles/provisioning_praxis.deed. From b59be1ba2101e657c0d51f92a593002b66c30afe Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:52:22 +0100 Subject: [PATCH 08/26] feat(provision-set): mint locks mise and crates; fixtures as tests - mint runs `mise lock` and `crates-scm` (guix import crate, through LAUNCH_SCAFFOLDER_GUIX); a failure is Act::Failed and exits 4. --offline skips both and says so per file. - tests/provisioning_fixtures.rs: check/ fails on exactly its three faults, each repair removes only its own, all three pass (positive control); langs per lang fixture; PV-W30 on deno leftovers and only then; offline mint keeps custom recipes as -local twins. - drop the render/ prototype fixtures. - mint-all takes ROOT instead of /var/mnt/eclipse and refuses a missing or duplicated config rather than guessing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .machine_readable/contractiles/Justfile | 41 ++-- Justfile | 41 ++-- .../launcher-common/src/provisioning/mint.rs | 108 ++++++++- .../provisioning/check-repairs/guix.scm | 45 ++++ .../provisioning/check-repairs/mise.lock | 79 ++++++ .../fixtures/provisioning/render/all.vars | 19 -- .../fixtures/provisioning/render/render.sh | 14 -- .../provisioning/render/rsr-spec.vars | 3 - .../provisioning/render/rsr-warm.vars | 8 - .../fixtures/provisioning/render/rsr.vars | 11 - .../tests/provisioning_fixtures.rs | 228 ++++++++++++++++++ crates/launcher/src/cmd_provision_set.rs | 11 + 12 files changed, 521 insertions(+), 87 deletions(-) create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm create mode 100644 crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock delete mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/all.vars delete mode 100755 crates/launcher-common/tests/fixtures/provisioning/render/render.sh delete mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars delete mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars delete mode 100644 crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars create mode 100644 crates/launcher-common/tests/provisioning_fixtures.rs diff --git a/.machine_readable/contractiles/Justfile b/.machine_readable/contractiles/Justfile index adfb82e..8f06123 100644 --- a/.machine_readable/contractiles/Justfile +++ b/.machine_readable/contractiles/Justfile @@ -73,29 +73,42 @@ smoke-mint: @echo "Smoke test: mint produced /tmp/stapeln-launcher.sh" # Mint a launcher in-place. Pass the path to an .launcher.a2ml file. -# Example: just mint /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml +# Example: just mint ../aerie/aerie.launcher.a2ml mint config: cargo run --locked --release -p launch-scaffolder -- mint {{config}} -# Re-mint every scaffolder-managed launcher in the estate. Edit the list -# when adding/removing managed repos. Exceptions live in +# Re-mint every scaffolder-managed launcher found under ROOT (the directory +# holding your clones; nothing machine-specific is assumed). Each managed +# config must resolve to exactly one file: a missing or duplicated clone is +# an error naming the candidates, never a guess. Edit the list when +# adding/removing managed repos. Exceptions live in # docs/launcher-exceptions-2026-04-10.adoc. -mint-all: +mint-all root: #!/usr/bin/env bash set -euo pipefail BIN="./target/release/launch-scaffolder" + [ -d "{{root}}" ] || { echo "✗ {{root}} is not a directory" >&2; exit 2; } [ -x "$BIN" ] || cargo build --locked --release - for cfg in \ - /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/burble/burble.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/game-server-admin/game-server-admin.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/nqc.launcher.a2ml \ - /var/mnt/eclipse/repos/verification-ecosystem/panll/panll.launcher.a2ml \ - /var/mnt/eclipse/repos/project-wharf/project-wharf.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/stapeln/stapeln.launcher.a2ml ; do - "$BIN" mint "$cfg" + status=0 n=0 + for rel in \ + aerie/aerie.launcher.a2ml \ + burble/burble.launcher.a2ml \ + game-server-admin/game-server-admin.launcher.a2ml \ + nqc/nqc.launcher.a2ml \ + panll/panll.launcher.a2ml \ + project-wharf/project-wharf.launcher.a2ml \ + stapeln/stapeln.launcher.a2ml ; do + mapfile -d '' hits < <(find "{{root}}" -path '*/worktrees' -prune -o -path '*/archive' -prune \ + -o -path "*/$rel" -type f -print0) + if [ "${#hits[@]}" -ne 1 ]; then + echo "✗ $rel: ${#hits[@]} matches under {{root}} (need exactly one)" >&2 + for h in "${hits[@]}"; do echo " $h" >&2; done + status=1; continue + fi + "$BIN" mint "${hits[0]}"; n=$((n + 1)) done - echo "✓ Estate re-mint complete (7 launchers)" + echo "Estate re-mint: $n of 7 launchers" + exit "$status" # Generate cargo docs doc: diff --git a/Justfile b/Justfile index adfb82e..8f06123 100644 --- a/Justfile +++ b/Justfile @@ -73,29 +73,42 @@ smoke-mint: @echo "Smoke test: mint produced /tmp/stapeln-launcher.sh" # Mint a launcher in-place. Pass the path to an .launcher.a2ml file. -# Example: just mint /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml +# Example: just mint ../aerie/aerie.launcher.a2ml mint config: cargo run --locked --release -p launch-scaffolder -- mint {{config}} -# Re-mint every scaffolder-managed launcher in the estate. Edit the list -# when adding/removing managed repos. Exceptions live in +# Re-mint every scaffolder-managed launcher found under ROOT (the directory +# holding your clones; nothing machine-specific is assumed). Each managed +# config must resolve to exactly one file: a missing or duplicated clone is +# an error naming the candidates, never a guess. Edit the list when +# adding/removing managed repos. Exceptions live in # docs/launcher-exceptions-2026-04-10.adoc. -mint-all: +mint-all root: #!/usr/bin/env bash set -euo pipefail BIN="./target/release/launch-scaffolder" + [ -d "{{root}}" ] || { echo "✗ {{root}} is not a directory" >&2; exit 2; } [ -x "$BIN" ] || cargo build --locked --release - for cfg in \ - /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/burble/burble.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/game-server-admin/game-server-admin.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/nqc.launcher.a2ml \ - /var/mnt/eclipse/repos/verification-ecosystem/panll/panll.launcher.a2ml \ - /var/mnt/eclipse/repos/project-wharf/project-wharf.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/stapeln/stapeln.launcher.a2ml ; do - "$BIN" mint "$cfg" + status=0 n=0 + for rel in \ + aerie/aerie.launcher.a2ml \ + burble/burble.launcher.a2ml \ + game-server-admin/game-server-admin.launcher.a2ml \ + nqc/nqc.launcher.a2ml \ + panll/panll.launcher.a2ml \ + project-wharf/project-wharf.launcher.a2ml \ + stapeln/stapeln.launcher.a2ml ; do + mapfile -d '' hits < <(find "{{root}}" -path '*/worktrees' -prune -o -path '*/archive' -prune \ + -o -path "*/$rel" -type f -print0) + if [ "${#hits[@]}" -ne 1 ]; then + echo "✗ $rel: ${#hits[@]} matches under {{root}} (need exactly one)" >&2 + for h in "${hits[@]}"; do echo " $h" >&2; done + status=1; continue + fi + "$BIN" mint "${hits[0]}"; n=$((n + 1)) done - echo "✓ Estate re-mint complete (7 launchers)" + echo "Estate re-mint: $n of 7 launchers" + exit "$status" # Generate cargo docs doc: diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index a05290f..16729f3 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -62,6 +62,10 @@ pub struct Options { pub archetype: Option, /// `__YEAR__`; default: `SOURCE_DATE_EPOCH`, else the current year. pub year: Option, + /// Skip the steps that need the network or Guix (`mise lock`, + /// `build/guix/crates.scm`); the files they write are then left for + /// `just toolchain-refresh`. + pub offline: bool, } /// What happened to one file. @@ -71,8 +75,14 @@ pub enum Act { Replaced(String), Kept(String), Skipped(String), + /// An external step (`mise lock`, `guix import crate`) did not produce a + /// valid file: a ledger line, and the CLI exits [`EXIT_EXTERNAL`]. + Failed(String), } +/// Exit code when the set was written but an external step failed. +pub const EXIT_EXTERNAL: i32 = 4; + impl std::fmt::Display for Act { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -80,6 +90,7 @@ impl std::fmt::Display for Act { Act::Replaced(why) => write!(f, "replaced ({why})"), Act::Kept(why) => write!(f, "kept ({why})"), Act::Skipped(why) => write!(f, "skipped ({why})"), + Act::Failed(why) => write!(f, "FAILED ({why})"), } } } @@ -220,10 +231,15 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { let gc = format!("{gpre}channels.scm"); files.push((gc.clone(), m.minted_bytes(&gc, "guix/channels.scm")?)); if cargo && !target.join("build/guix/crates.scm").is_file() { - files.push(( - "build/guix/crates.scm".into(), - Act::Skipped("generate with `guix import crate --lockfile=Cargo.lock`; doctor reports PV-W24 until then".into()), - )); + let act = if opts.offline { + Act::Skipped( + "offline: generate with `just toolchain-refresh`; doctor reports PV-W24 until then" + .into(), + ) + } else { + crates_scm(&lib, &gs)? + }; + files.push(("build/guix/crates.scm".into(), act)); } // Docs and warm-ups go where set-files puts them (root, docs/ or docs/onboarding/). @@ -294,6 +310,14 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { }; files.push((justfile.unwrap_or("Justfile").into(), act)); + // Last, because it reads the mise.toml written above. + let act = if opts.offline { + Act::Skipped("offline: run `mise lock`; provision-check fails until then".into()) + } else { + mise_lock(&lib)? + }; + files.push(("mise.lock".into(), act)); + Ok(Report { slug, licence, @@ -762,14 +786,90 @@ fn set_exec(path: &Path, rel: &str) -> Result<()> { /// The engine's `provision-lib.sh`, run against the target. A verb that fails /// is an error: a fact the generator cannot establish is never guessed. +/// The Guix command for `crates-scm`, e.g. a wrapper that runs guix in a +/// container with the target mounted; the engine reads it as `GUIX`. +pub const GUIX_ENV: &str = "LAUNCH_SCAFFOLDER_GUIX"; + +/// Write `build/guix/crates.scm` through the engine's `crates-scm` verb. The +/// result is accepted only when `guix-stub` then passes on `guix_scm`: a +/// containerised guix loses its exit status, so the file is the evidence. +fn crates_scm(lib: &Lib, guix_scm: &str) -> Result { + let guix = std::env::var(GUIX_ENV).unwrap_or_else(|_| "guix".into()); + let o = lib.run_env(&["crates-scm"], &[("GUIX", &guix)])?; + match lib.predicate(&["guix-stub", guix_scm])? { + None if o.status.success() => Ok(Act::Created), + why => Ok(Act::Failed(format!( + "{}crates-scm: {}", + why.map(|w| format!("{guix_scm}: {w}; ")) + .unwrap_or_default(), + last_line(&[&o.stdout, &o.stderr], &o.status) + ))), + } +} + +/// Pin `mise.toml` in `mise.lock` with `mise lock`, unless the engine's +/// `mise-lock-gaps` already finds every tool pinned and checksummed: bumping +/// versions is `toolchain-refresh`'s job, not mint's. The target is trusted +/// for this one process through the environment, not mise's trust database. +fn mise_lock(lib: &Lib) -> Result { + if lib.predicate(&["mise-lock-gaps"])?.is_none() { + return Ok(Act::Kept("pinned and checksummed".into())); + } + let existed = lib.0.join("mise.lock").is_file(); + let o = Command::new("timeout") + .args(["600", "mise", "lock"]) + .env("MISE_TRUSTED_CONFIG_PATHS", &lib.0) + .current_dir(&lib.0) + .output() + .context("running timeout 600 mise lock")?; + Ok(match lib.predicate(&["mise-lock-gaps"])? { + None if existed => Act::Replaced("re-locked: the old lock had gaps".into()), + None => Act::Created, + Some(gap) => Act::Failed(format!( + "{gap}; mise lock: {}", + last_line(&[&o.stderr, &o.stdout], &o.status) + )), + }) +} + +/// The first error a failed command reported (mise ends with a version and a +/// "Run with --verbose" trailer, which name nothing), else its last line, else +/// its exit status when it said nothing. +fn last_line(streams: &[&[u8]], status: &std::process::ExitStatus) -> String { + streams + .iter() + .find_map(|s| { + let text = String::from_utf8_lossy(s); + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + lines + .iter() + .find(|l| l.contains("ERROR") || l.starts_with("error:")) + .or(lines.last()) + .map(|l| l.to_string()) + }) + .unwrap_or_else(|| format!("no output, {status}")) +} + +/// The target repository's engine library, `build/just/provision-lib.sh`. struct Lib(PathBuf); impl Lib { + /// Run a verb of the target's `provision-lib.sh`. fn run(&self, args: &[&str]) -> Result { + self.run_env(args, &[]) + } + + /// Run a verb of the target's `provision-lib.sh` with extra environment. + fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Result { Command::new("bash") .arg(self.0.join(LIB)) .args(args) .env("PROVISION_ROOT", &self.0) + .envs(env.iter().copied()) .current_dir(&self.0) .output() .with_context(|| format!("running {LIB} {}", args.join(" "))) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm new file mode 100644 index 0000000..4ff25e3 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm @@ -0,0 +1,45 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +;; +;; guix.scm — docsr as a Guix package. +;; +;; guix build -f guix.scm # installs the source tree to share/docsr +;; guix shell -D -f guix.scm # the full development toolchain +;; +;; This is a SOURCE package, and says so: a hermetic compiled build needs this +;; repository's docs dependencies packaged in Guix, which they are not +;; (Guix builds offline). The toolchain below is real — `guix shell -D -f guix.scm` +;; then `just setup` gives a working environment. See docs/SETUP.adoc §Guix. +(use-modules (guix packages) (guix gexp) + (guix build-system copy) + (gnu packages) + ((guix licenses) #:prefix license:)) + +;; The repository root: this file sits at the root or in build/ (PROVISIONING-STANDARD §1). +(define %source-dir + (let ((d (dirname (current-filename)))) + (if (string=? (basename d) "build") (dirname d) d))) +;; A spec may name an output ("rust:cargo"); plain specification->package cannot. +(define (spec->input spec) + (call-with-values (lambda () (specification->package+output spec)) + (lambda (pkg out) (if (string=? out "out") pkg (list pkg out))))) + +(define %ignored + '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) + +(package + (name "docsr") + (version "0.1.0") + (source (local-file %source-dir "docsr-checkout" + #:recursive? #t + #:select? (lambda (file stat) + (not (member (basename file) %ignored))))) + (build-system copy-build-system) + (arguments (list #:install-plan #~'(("." "share/docsr/")))) + (native-inputs (map spec->input (list "git" "bash" "coreutils" "nss-certs" + "just" "mise" "shellcheck" + "ruby-asciidoctor"))) + (home-page "https://github.com/hyperpolymath/docsr") + (synopsis "docsr, a hyperpolymath repository") + (description "docsr, a hyperpolymath repository.") + (license license:mpl2.0)) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock new file mode 100644 index 0000000..7c777d0 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock @@ -0,0 +1,79 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.just]] +version = "1.56.0" +backend = "aqua:casey/just" + +[tools.just."platforms.linux-arm64"] +checksum = "sha256:c8c1d656e9f47569ec1ae2bf8779af2621cdeea6bbbba3b0cacd64f951d25e2b" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074960" + +[tools.just."platforms.linux-arm64-musl"] +checksum = "sha256:c8c1d656e9f47569ec1ae2bf8779af2621cdeea6bbbba3b0cacd64f951d25e2b" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074960" + +[tools.just."platforms.linux-x64"] +checksum = "sha256:fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074624" + +[tools.just."platforms.linux-x64-musl"] +checksum = "sha256:fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074624" + +[tools.just."platforms.macos-arm64"] +checksum = "sha256:f35798d4bcdc4db020eef7d2853ad98bbfb97a4d29ee695ba042f18e7fedcc11" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074635" + +[tools.just."platforms.macos-x64"] +checksum = "sha256:09b35ff6d17023ffae37ce408d1a78a976d9e001cae54b88e238f7f40db9b783" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074650" + +[tools.just."platforms.windows-x64"] +checksum = "sha256:804f5b2fe94291d0df38fd8dfc5620afbf3496f8f11c1915b42a87323234f0ba" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472075917" + +[[tools.shellcheck]] +version = "0.11.0" +backend = "aqua:koalaman/shellcheck" + +[tools.shellcheck."platforms.linux-arm64"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-arm64-musl"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-x64"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.linux-x64-musl"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.macos-arm64"] +checksum = "sha256:56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056932" + +[tools.shellcheck."platforms.macos-x64"] +checksum = "sha256:3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056930" + +[tools.shellcheck."platforms.windows-x64"] +checksum = "sha256:8a4e35ab0b331c85d73567b12f2a444df187f483e5079ceffa6bda1faa2e740e" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.zip" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056944" diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/all.vars b/crates/launcher-common/tests/fixtures/provisioning/render/all.vars deleted file mode 100644 index e16ba16..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/render/all.vars +++ /dev/null @@ -1,19 +0,0 @@ -DOC_LICENSE CC-BY-SA-4.0 -YEAR 2026 -COPYRIGHT_HOLDER Jonathan D.A. Jewell (hyperpolymath) -APP_NAME rsr-template-repo -REPO_SLUG hyperpolymath/rsr-template-repo -ARCHETYPE library -LANGS idris2, zig -GUIX_PREFIX build/ -GUIX_GAPS idris2 -TOOL_TABLE |idris2 |`idris2` |not a mise tool: install pack (https://github.com/stefan-hoeck/idris2-pack#installation), then `pack install-app idris2`\n|zig |`zig` |https://ziglang.org/download/ or Guix `zig`\n|(recipes) |`just`, `shellcheck` |your OS package manager, e.g. `dnf install just ShellCheck` -SYSTEM_DEPS_SECTION === System packages for Idris2\n\nIdris2 is built by pack on top of Chez Scheme and GMP, which come from your OS:\n\n* Fedora: `sudo dnf install chez-scheme gmp-devel`\n* Debian/Ubuntu: `sudo apt install chezscheme libgmp-dev`\n* macOS: `brew install chezscheme gmp`\n\nThe Guix development shell (`build/manifest.scm`) already includes both. -LICENSE MPL-2.0 -SPEC_WHAT_IT_IS It is not a project in its own right: it is the template every new hyperpolymath\nrepository is created from. It ships CI/CD, machine-readable metadata\n(`.machine_readable/descriptiles/`), an AI gatekeeper deed\n(`rsr-template-repo_chora.deed`), a typed ABI/FFI seam (Idris2 over Zig), Guix and\nmise toolchains, and governance files, all passing the RSR validators before you\nadd a line of your own. -SPEC_USAGE A person uses it to start a new repository:\n\n. On GitHub, *Use this template* (or clone it).\n. In the new checkout, run `just repo-init`. It asks for the project name, owner,\n author and licence contact (listed in `.machine_readable/ai/PLACEHOLDERS.adoc`),\n replaces every placeholder token, then validates the result. `just repo-init\n julia-library` applies the one archetype overlay shipped in `archetypes/`.\n. Run `just validate` (structure + metadata) and `just doctor` (toolchain).\n\nAn AI running `repo-init` must pipe answers to stdin and must never set\n`RSR_NON_INTERACTIVE=1`: that stubs `read`, and the substitution loops then never end. -SPEC_PRIVACY Nothing leaves the machine. `mise install` and `just setup` install tools per-user\nunder the mise data directory; `just repo-init` rewrites files only inside the\ncheckout. No remote repository is created and nothing is pushed until you do it. -SPEC_ARCHITECTURE The template's own code is the ABI/FFI seam that every minted repo inherits:\n\n* `src/interface/Abi/` + `src/interface/abi.ipkg`: Idris2 types and layout proofs\n (the ABI owns the contract).\n* `src/interface/ffi/`: the Zig implementation (`build.zig`, `src/main.zig`,\n `test/integration_test.zig`), exporting C symbols named `_*`.\n* `src/interface/generated/`: C headers generated from the ABI.\n\nEverything else is scaffolding: `.machine_readable/` (descriptiles, contractiles,\npolicies), `build/just/*.just` (the task phases the root `Justfile` imports), and\n`.github/workflows/` (the CI a minted repo starts with). -SPEC_DEV_COMMANDS Template-specific commands:\n\n[source,bash]\n----\njust repo-init # instantiate: fills every placeholder token, then validates\njust validate # RSR shape + metadata + AI-install completeness\njust quality # fmt-check, lint, test\njust test-all # test, e2e, aspect, bench, readiness\njust repo-map # regenerate docs/architecture/REPOSITORY-MAP.adoc (CI checks it)\n----\n\nThe golden path is `just test && just quality`. In the template itself `test` and\n`lint` fail on the placeholder tokens in `src/interface/ffi/src/main.zig`; that is\ndeliberate. Test a change to the FFI in a scratch copy after `repo-init`. -SPEC_LAYOUT * Top level: `archetypes/ benches/ build/ ci/ docs/ examples/ features/ LICENSES/\n scripts/ session/ src/ tests/ verification/ www/`. The root file list is fenced by\n `.machine_readable/root-allow.txt`; a new root file needs a line there.\n* `build/just/`: provisioning (`provision.just`, `provision-lib.sh`), `repo-init.just`,\n `validate.just`, and the other imported phases.\n* `build/guix.scm`, `build/manifest.scm`, `build/channels.scm`: the Guix route.\n* `tests/`: e2e, aspect, shape and workflow checks. `benches/`: benchmark harness.\n* `docs/`: SETUP, AI installation guide, onboarding warm-ups, architecture map. -SPEC_GOTCHAS * `.machine_readable/contractiles/Justfile` must stay byte-identical to the root\n `Justfile`; copy it after every edit.\n* Never put literal placeholder tokens in prose that `repo-init` will process: it\n substitutes them. Write "placeholder token" instead.\n* `build/` is not an artefact directory here: `just clean` deliberately leaves it,\n because `build/just/repo-init.just` lives there.\n* Descriptile and policy files are A2ML, which is retired: do not extend them or\n build anything new on them. diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/render.sh b/crates/launcher-common/tests/fixtures/provisioning/render/render.sh deleted file mode 100755 index 25efe0c..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/render/render.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# render.sh TEMPLATE VARSFILE — replace __KEY__ with values from VARSFILE (KEYvalue, -# value may contain \n for newlines). Unknown slots are left for provision-check to catch. -set -euo pipefail -# bash 5.2 expands & in a ${var//pat/rep} replacement to the match; values are literal. -shopt -u patsub_replacement 2>/dev/null || true -tmpl=$1 vars=$2 -content=$(cat "$tmpl") -while IFS=$'\t' read -r k v; do - [ -z "$k" ] && continue - v=$(printf '%b' "$v") - content=${content//"__${k}__"/$v} -done < "$vars" -printf '%s\n' "$content" diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars b/crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars deleted file mode 100644 index e4206c9..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/render/rsr-spec.vars +++ /dev/null @@ -1,3 +0,0 @@ -SPEC_RELEASE A release is a signed tag `v` on `main`; pushing it runs `.github/workflows/release.yml`, which builds the release notes, publishes the GitHub release (`softprops/action-gh-release`) and attests build provenance. Bump `version` in the Justfile and `CITATION.cff` first, in the same PR. This repository is a GitHub *template*: a release changes what every new repository is born with, so re-run `./launcher.sh --doctor` and `bash build/just/provision-check.sh .` on a fresh clone before tagging. -SPEC_CI `main` is protected by a ruleset with these rule types: `required_signatures`, `required_linear_history`, `non_fast_forward`, `deletion`, `code_scanning`, `code_coverage` and `copilot_code_review` (read 2026-09-30; re-read with `gh api repos/hyperpolymath/rsr-template-repo/rules/branches/main`). Only squash merges are allowed and auto-merge is on. Every commit must be SSH-signed or the PR cannot land. The workflows under `.github/workflows/` are themselves the estate's reference copies: a change here propagates to every repository minted from the template, so change the canonical reusable in `hyperpolymath/standards` first and pin to it here. -SPEC_DECISIONS Rulings that bind this repository in particular: bun is the only JavaScript runtime (deno, TypeScript, ReScript, Python are banned), A2ML is retired (leave existing `.a2ml` files alone, build nothing new on them), and Justfile changes are mirrored in `.machine_readable/contractiles/Justfile`, which is kept byte-identical. diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars b/crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars deleted file mode 100644 index a7ddb73..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/render/rsr-warm.vars +++ /dev/null @@ -1,8 +0,0 @@ -LICENSE MPL-2.0 -SPEC_WHAT_IT_IS It is not a project in its own right: it is the template every new hyperpolymath\nrepository is created from. It ships CI/CD, machine-readable metadata\n(`.machine_readable/descriptiles/`), an AI gatekeeper deed\n(`rsr-template-repo_chora.deed`), a typed ABI/FFI seam (Idris2 over Zig), Guix and\nmise toolchains, and governance files, all passing the RSR validators before you\nadd a line of your own. -SPEC_USAGE A person uses it to start a new repository:\n\n. On GitHub, *Use this template* (or clone it).\n. In the new checkout, run `just repo-init`. It asks for the project name, owner,\n author and licence contact (listed in `.machine_readable/ai/PLACEHOLDERS.adoc`),\n replaces every placeholder token, then validates the result. `just repo-init\n julia-library` applies the one archetype overlay shipped in `archetypes/`.\n. Run `just validate` (structure + metadata) and `just doctor` (toolchain).\n\nAn AI running `repo-init` must pipe answers to stdin and must never set\n`RSR_NON_INTERACTIVE=1`: that stubs `read`, and the substitution loops then never end. -SPEC_PRIVACY Nothing leaves the machine. `mise install` and `just setup` install tools per-user\nunder the mise data directory; `just repo-init` rewrites files only inside the\ncheckout. No remote repository is created and nothing is pushed until you do it. -SPEC_ARCHITECTURE The template's own code is the ABI/FFI seam that every minted repo inherits:\n\n* `src/interface/Abi/` + `src/interface/abi.ipkg`: Idris2 types and layout proofs\n (the ABI owns the contract).\n* `src/interface/ffi/`: the Zig implementation (`build.zig`, `src/main.zig`,\n `test/integration_test.zig`), exporting C symbols named `_*`.\n* `src/interface/generated/`: C headers generated from the ABI.\n\nEverything else is scaffolding: `.machine_readable/` (descriptiles, contractiles,\npolicies), `build/just/*.just` (the task phases the root `Justfile` imports), and\n`.github/workflows/` (the CI a minted repo starts with). -SPEC_DEV_COMMANDS Template-specific commands:\n\n[source,bash]\n----\njust repo-init # instantiate: fills every placeholder token, then validates\njust validate # RSR shape + metadata + AI-install completeness\njust quality # fmt-check, lint, test\njust test-all # test, e2e, aspect, bench, readiness\njust repo-map # regenerate docs/architecture/REPOSITORY-MAP.adoc (CI checks it)\n----\n\nThe golden path is `just test && just quality`. In the template itself `test` and\n`lint` fail on the placeholder tokens in `src/interface/ffi/src/main.zig`; that is\ndeliberate. Test a change to the FFI in a scratch copy after `repo-init`. -SPEC_LAYOUT * Top level: `archetypes/ benches/ build/ ci/ docs/ examples/ features/ LICENSES/\n scripts/ session/ src/ tests/ verification/ www/`. The root file list is fenced by\n `.machine_readable/root-allow.txt`; a new root file needs a line there.\n* `build/just/`: provisioning (`provision.just`, `provision-lib.sh`), `repo-init.just`,\n `validate.just`, and the other imported phases.\n* `build/guix.scm`, `build/manifest.scm`, `build/channels.scm`: the Guix route.\n* `tests/`: e2e, aspect, shape and workflow checks. `benches/`: benchmark harness.\n* `docs/`: SETUP, AI installation guide, onboarding warm-ups, architecture map. -SPEC_GOTCHAS * `.machine_readable/contractiles/Justfile` must stay byte-identical to the root\n `Justfile`; copy it after every edit.\n* Never put literal placeholder tokens in prose that `repo-init` will process: it\n substitutes them. Write "placeholder token" instead.\n* `build/` is not an artefact directory here: `just clean` deliberately leaves it,\n because `build/just/repo-init.just` lives there.\n* Descriptile and policy files are A2ML, which is retired: do not extend them or\n build anything new on them. diff --git a/crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars b/crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars deleted file mode 100644 index 4de4e98..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/render/rsr.vars +++ /dev/null @@ -1,11 +0,0 @@ -DOC_LICENSE CC-BY-SA-4.0 -YEAR 2026 -COPYRIGHT_HOLDER Jonathan D.A. Jewell (hyperpolymath) -APP_NAME rsr-template-repo -REPO_SLUG hyperpolymath/rsr-template-repo -ARCHETYPE library -LANGS idris2, zig -GUIX_PREFIX build/ -GUIX_GAPS idris2 -TOOL_TABLE |idris2 |`idris2` |not a mise tool: install pack (https://github.com/stefan-hoeck/idris2-pack#installation), then `pack install-app idris2`\n|zig |`zig` |https://ziglang.org/download/ or Guix `zig`\n|(recipes) |`just`, `shellcheck` |your OS package manager, e.g. `dnf install just ShellCheck` -SYSTEM_DEPS_SECTION === System packages for Idris2\n\nIdris2 is built by pack on top of Chez Scheme and GMP, which come from your OS:\n\n* Fedora: `sudo dnf install chez-scheme gmp-devel`\n* Debian/Ubuntu: `sudo apt install chezscheme libgmp-dev`\n* macOS: `brew install chezscheme gmp`\n\nThe Guix development shell (`build/manifest.scm`) already includes both. diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs new file mode 100644 index 0000000..a124f6a --- /dev/null +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The provisioning fixtures, driven through the real engine. +//! +//! `check/` is a broken repository with exactly three faults. Each repair in +//! `check-repairs/` must remove its own FAIL and no other (kill the mutant), +//! and all three together must bring `provision-check.sh` to rc 0 — the +//! positive control, without which "exactly three FAILs" could be a checker +//! that fails everything. +//! +//! `lang/` holds one small repository per detection case: `langs` must name +//! the right language, the deno leftovers must raise PV-W30 (and stop raising +//! it once removed), and an offline mint must merge each custom Justfile +//! recipe into its `-local` twin. +//! +//! The engine is bash and needs `just` and `git`: a missing tool fails these +//! tests loudly, because a skipped conformance test is not a pass. + +use launch_scaffolder_common::provisioning::{ + canon::Canon, + mint::{self, Act, Options}, +}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/provisioning"); + +/// The canon engine library, which the `lang/` fixtures do not carry. +const LIB: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/templates/build/just/provision-lib.sh" +); + +/// The three faults planted in `check/`, as `provision-check.sh` words them. +const CHECK_FAILS: [&str; 3] = [ + "root recipe missing: fmt-check", + "mise.lock is empty (latest is not concrete; run: mise lock)", + "guix.scm: no package field: version build-system home-page synopsis description license", +]; + +/// Copy fixture `rel` to a fresh directory named `tag` under cargo's test +/// tmpdir, and make it a git repository (the engine takes the repo name from +/// git, so an un-initialised copy would report the enclosing checkout). +fn scratch(rel: &str, tag: &str) -> PathBuf { + let dst = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("provisioning-{tag}")); + let _ = std::fs::remove_dir_all(&dst); + let ok = Command::new("cp") + .arg("-r") + .arg(Path::new(FIXTURES).join(rel)) + .arg(&dst) + .status() + .expect("cp") + .success(); + assert!(ok, "copying fixture {rel}"); + let ok = Command::new("git") + .args(["init", "-q"]) + .current_dir(&dst) + .status() + .expect("git is required by the provisioning engine") + .success(); + assert!(ok, "git init in {}", dst.display()); + dst +} + +/// Run the repository's own `provision-check.sh --dev`; return its exit code +/// and the set of FAIL messages it printed. +fn provision_check(repo: &Path) -> (i32, BTreeSet) { + let out = Command::new("bash") + .args(["build/just/provision-check.sh", "--dev", "."]) + .current_dir(repo) + .output() + .expect("bash"); + let stdout = String::from_utf8_lossy(&out.stdout); + let fails = stdout + .lines() + .filter_map(|l| l.trim().strip_prefix("FAIL")) + .map(|l| l.trim().to_string()) + .collect(); + let code = out + .status + .code() + .expect("provision-check.sh killed by a signal"); + (code, fails) +} + +/// Run a verb of the canon `provision-lib.sh` against `repo`; return stdout +/// and stderr together. +fn lib(repo: &Path, verb: &str) -> String { + let out = Command::new("bash") + .arg(LIB) + .arg(verb) + .env("PROVISION_ROOT", repo) + .current_dir(repo) + .output() + .expect("bash"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +/// The FAIL set expected once the faults in `fixed` have been repaired. +fn expected_without(fixed: &[usize]) -> BTreeSet { + CHECK_FAILS + .iter() + .enumerate() + .filter(|(i, _)| !fixed.contains(i)) + .map(|(_, f)| f.to_string()) + .collect() +} + +/// Apply repair `i` (an index into [`CHECK_FAILS`]) to `repo`. +fn repair(repo: &Path, i: usize) { + let repairs = Path::new(FIXTURES).join("check-repairs"); + match i { + 0 => { + let jf = repo.join("Justfile"); + let mut s = std::fs::read_to_string(&jf).unwrap(); + s.push_str("fmt-check: provision::fmt-check\n"); + std::fs::write(jf, s).unwrap(); + } + 1 => { + std::fs::copy(repairs.join("mise.lock"), repo.join("mise.lock")).unwrap(); + } + 2 => { + std::fs::copy(repairs.join("guix.scm"), repo.join("guix.scm")).unwrap(); + } + _ => unreachable!(), + } +} + +#[test] +fn check_fixture_fails_on_exactly_its_three_faults() { + let repo = scratch("check", "check"); + let (code, fails) = provision_check(&repo); + assert_eq!(code, 1); + assert_eq!(fails, expected_without(&[])); +} + +#[test] +fn each_repair_removes_only_its_own_fail() { + for i in 0..CHECK_FAILS.len() { + let repo = scratch("check", &format!("check-repair-{i}")); + repair(&repo, i); + let (code, fails) = provision_check(&repo); + assert_eq!(code, 1, "repair {i}: two faults remain"); + assert_eq!(fails, expected_without(&[i]), "repair {i}"); + } +} + +#[test] +fn all_repairs_together_pass() { + let repo = scratch("check", "check-repaired"); + for i in 0..CHECK_FAILS.len() { + repair(&repo, i); + } + let (code, fails) = provision_check(&repo); + assert!(fails.is_empty(), "{fails:?}"); + assert_eq!(code, 0); +} + +#[test] +fn langs_names_each_fixture_language() { + for (fixture, lang) in [ + ("docsr", "docs"), + ("idr", "idris2"), + ("rustd", "rust"), + ("rustr", "rust"), + ("srcc", "docs"), + ] { + let repo = scratch(&format!("lang/{fixture}"), &format!("langs-{fixture}")); + assert_eq!(lib(&repo, "langs").trim(), lang, "{fixture}"); + } +} + +#[test] +fn doctor_warns_on_deno_leftovers_and_only_then() { + for fixture in ["rustd", "rustr"] { + let repo = scratch(&format!("lang/{fixture}"), &format!("deno-{fixture}")); + assert!( + lib(&repo, "doctor").contains("PV-W30 deno.json"), + "{fixture}" + ); + std::fs::remove_file(repo.join("deno.json")).unwrap(); + assert!( + !lib(&repo, "doctor").contains("PV-W30"), + "{fixture} without deno.json" + ); + } +} + +#[test] +fn offline_mint_keeps_custom_recipes_as_local_twins() { + let canon = Canon::resolve(None).unwrap(); + let licence = concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"); + for (fixture, verb) in [("idr", "doctor"), ("rustd", "setup"), ("rustr", "doctor")] { + let repo = scratch(&format!("lang/{fixture}"), &format!("mint-{fixture}")); + std::fs::copy(licence, repo.join("LICENSE")).unwrap(); + let opts = Options { + repo: Some(format!("hyperpolymath/{fixture}")), + year: Some(2026), + offline: true, + ..Options::default() + }; + let report = mint::mint(&repo, &canon, &opts).unwrap(); + let act = |path: &str| { + report + .files + .iter() + .find(|(p, _)| p == path) + .map(|(_, a)| a.clone()) + .unwrap_or_else(|| panic!("{fixture}: no report line for {path}")) + }; + assert!( + matches!(act("Justfile"), Act::Replaced(ref w) if w.contains(&format!("{verb}-local"))), + "{fixture}: {}", + act("Justfile") + ); + assert!(matches!(act("mise.lock"), Act::Skipped(_)), "{fixture}"); + let jf = std::fs::read_to_string(repo.join("Justfile")).unwrap(); + assert!(jf.contains(&format!("{verb}-local")), "{fixture}"); + assert!( + jf.contains(&format!("{verb}: provision::{verb}")), + "{fixture}" + ); + } +} diff --git a/crates/launcher/src/cmd_provision_set.rs b/crates/launcher/src/cmd_provision_set.rs index 12cc9d2..3ec0dbe 100644 --- a/crates/launcher/src/cmd_provision_set.rs +++ b/crates/launcher/src/cmd_provision_set.rs @@ -54,6 +54,9 @@ struct MintArgs { /// Copyright year (default: SOURCE_DATE_EPOCH, else this year). #[arg(long)] year: Option, + /// Skip `mise lock` and `guix import crate` (both need the network). + #[arg(long)] + offline: bool, /// Repository to provision. #[arg(default_value = ".")] target: PathBuf, @@ -62,6 +65,9 @@ struct MintArgs { /// Exit code for a licence refusal (standard §6): a ledger line, not a crash. pub const EXIT_REFUSED: i32 = 3; +/// Run `provision-set`: check a repository, or mint/realign its provisioning +/// set. Exits 1 on drift or a FAIL, 3 on a licence refusal, 4 when an +/// external step (`mise lock`, `guix import crate`) failed. pub fn run(args: Args) -> Result<()> { let canon = Canon::resolve(args.canon.as_deref())?; match args.action { @@ -85,6 +91,7 @@ pub fn run(args: Args) -> Result<()> { repo: m.repo, archetype: m.archetype, year: m.year, + offline: m.offline, }; match mint::mint(&m.target, &canon, &opts) { Ok(r) => { @@ -108,9 +115,13 @@ pub fn run(args: Args) -> Result<()> { Act::Replaced(_) => "replace", Act::Kept(_) => "keep", Act::Skipped(_) => "SKIP", + Act::Failed(_) => "FAIL", }; println!(" {tag:<8} {path}: {act}"); } + if r.files.iter().any(|(_, a)| matches!(a, Act::Failed(_))) { + std::process::exit(mint::EXIT_EXTERNAL); + } Ok(()) } Err(e) => match e.downcast_ref::() { From 0cc4be0c71f7017c2efe60fc71fb0db4c592f445 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:54:18 +0100 Subject: [PATCH 09/26] ci: install just for the provisioning fixture tests The fixture tests drive the real engine, which needs just >= 1.42; the runner has none, so four tests failed loudly as designed. Install the 1.56.0 musl release, pinned by its SHA256SUMS digest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .github/workflows/launcher-artefacts.yml | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index d59f513..85beeb6 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -122,6 +122,33 @@ jobs: fi exit 0 + - name: Install just (the provisioning engine's runner) + # tests/provisioning_fixtures.rs drives the real engine, which needs + # just >= 1.42 and fails loudly without it. A release tarball pinned by + # sha256 (casey/just SHA256SUMS) adds no action to pin. + shell: bash + env: + JUST_VERSION: "1.56.0" + JUST_SHA256: fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 + run: | + set +e + . "$RUNNER_TEMP/annotate.sh" + tgz="$RUNNER_TEMP/just.tar.gz" + curl -fsSL -o "$tgz" \ + "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + > "$RUNNER_TEMP/just.log" 2>&1 \ + && echo "${JUST_SHA256} $tgz" | sha256sum -c - >> "$RUNNER_TEMP/just.log" 2>&1 \ + && mkdir -p "$RUNNER_TEMP/bin" \ + && tar -xzf "$tgz" -C "$RUNNER_TEMP/bin" just >> "$RUNNER_TEMP/just.log" 2>&1 + STATUS=$? + if [ "$STATUS" -ne 0 ]; then + annotate "installing just ${JUST_VERSION} failed" "$RUNNER_TEMP/just.log" 20 + exit "$STATUS" + fi + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/bin/just" --version + exit $? + - name: Build every target shell: bash run: | From 7092ab1b5f904bec37ccc5d081a8dda361b27356 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:58:40 +0100 Subject: [PATCH 10/26] ci: run the just-dependent tests only where just exists The estate rust-ci reusable has no just, so the four engine tests that call provision-check.sh failed there. Group them in `needs_just`, skip that module in rust-ci by name, and make launcher-artefacts (which installs just) fail unless all four ran and passed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .github/workflows/launcher-artefacts.yml | 7 + .github/workflows/rust-ci.yml | 7 +- .../tests/provisioning_fixtures.rs | 136 +++++++++--------- 3 files changed, 85 insertions(+), 65 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 85beeb6..6457c24 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -205,6 +205,13 @@ jobs: echo "::error::$FAILED test(s) failed" exit 1 fi + # rust-ci.yml skips `needs_just::` (its runner has no just); this job + # is the only place they run, so their absence here is a failure. + NEEDS_JUST=$(grep -c '^test needs_just::.* \.\.\. ok$' "$LOG") + if [ "$NEEDS_JUST" -lt 4 ]; then + echo "::error title=needs_just tests::only $NEEDS_JUST of 4 needs_just:: tests passed here, and rust-ci skips them, so nothing else runs them" + exit 1 + fi if [ "$PASSED" -lt "$TEST_COUNT_FLOOR" ]; then echo "::error title=test-count floor::only $PASSED tests ran; the floor is $TEST_COUNT_FLOOR. A suite that collects nothing exits 0, so this gate is what makes green mean ran (#45 AC6)." exit 1 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index e8baf25..bf5f23f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -19,7 +19,8 @@ # check → cargo check --locked --all-targets # cargo fmt --all -- --check # cargo clippy --locked --all-targets -- -D warnings -# test → cargo test --locked --all-targets +# test → cargo test --locked --all-targets, skipping `needs_just::` +# (run with just installed by launcher-artefacts.yml) # # The job names below are static and unmatrixed on purpose (#45 AC3): the # rendered check names ("rust-ci / Cargo test" and friends) have to be stable @@ -42,3 +43,7 @@ permissions: jobs: rust-ci: uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 + with: + # The provisioning engine tests (module `needs_just`) need `just`, which + # this runner lacks; launcher-artefacts.yml installs it and runs them. + test_args: "--all-targets -- --skip needs_just::" diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs index a124f6a..d62c631 100644 --- a/crates/launcher-common/tests/provisioning_fixtures.rs +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -130,36 +130,6 @@ fn repair(repo: &Path, i: usize) { } } -#[test] -fn check_fixture_fails_on_exactly_its_three_faults() { - let repo = scratch("check", "check"); - let (code, fails) = provision_check(&repo); - assert_eq!(code, 1); - assert_eq!(fails, expected_without(&[])); -} - -#[test] -fn each_repair_removes_only_its_own_fail() { - for i in 0..CHECK_FAILS.len() { - let repo = scratch("check", &format!("check-repair-{i}")); - repair(&repo, i); - let (code, fails) = provision_check(&repo); - assert_eq!(code, 1, "repair {i}: two faults remain"); - assert_eq!(fails, expected_without(&[i]), "repair {i}"); - } -} - -#[test] -fn all_repairs_together_pass() { - let repo = scratch("check", "check-repaired"); - for i in 0..CHECK_FAILS.len() { - repair(&repo, i); - } - let (code, fails) = provision_check(&repo); - assert!(fails.is_empty(), "{fails:?}"); - assert_eq!(code, 0); -} - #[test] fn langs_names_each_fixture_language() { for (fixture, lang) in [ @@ -190,39 +160,77 @@ fn doctor_warns_on_deno_leftovers_and_only_then() { } } -#[test] -fn offline_mint_keeps_custom_recipes_as_local_twins() { - let canon = Canon::resolve(None).unwrap(); - let licence = concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"); - for (fixture, verb) in [("idr", "doctor"), ("rustd", "setup"), ("rustr", "doctor")] { - let repo = scratch(&format!("lang/{fixture}"), &format!("mint-{fixture}")); - std::fs::copy(licence, repo.join("LICENSE")).unwrap(); - let opts = Options { - repo: Some(format!("hyperpolymath/{fixture}")), - year: Some(2026), - offline: true, - ..Options::default() - }; - let report = mint::mint(&repo, &canon, &opts).unwrap(); - let act = |path: &str| { - report - .files - .iter() - .find(|(p, _)| p == path) - .map(|(_, a)| a.clone()) - .unwrap_or_else(|| panic!("{fixture}: no report line for {path}")) - }; - assert!( - matches!(act("Justfile"), Act::Replaced(ref w) if w.contains(&format!("{verb}-local"))), - "{fixture}: {}", - act("Justfile") - ); - assert!(matches!(act("mise.lock"), Act::Skipped(_)), "{fixture}"); - let jf = std::fs::read_to_string(repo.join("Justfile")).unwrap(); - assert!(jf.contains(&format!("{verb}-local")), "{fixture}"); - assert!( - jf.contains(&format!("{verb}: provision::{verb}")), - "{fixture}" - ); +/// The tests that run `provision-check.sh` or `just --summary`, so need +/// `just` >= 1.42 on PATH. The estate `rust-ci` reusable has no `just` and +/// skips this module by name (`rust-ci.yml`); `launcher-artefacts.yml` +/// installs `just` and runs it. +mod needs_just { + use super::*; + + #[test] + fn check_fixture_fails_on_exactly_its_three_faults() { + let repo = scratch("check", "check"); + let (code, fails) = provision_check(&repo); + assert_eq!(code, 1); + assert_eq!(fails, expected_without(&[])); + } + + #[test] + fn each_repair_removes_only_its_own_fail() { + for i in 0..CHECK_FAILS.len() { + let repo = scratch("check", &format!("check-repair-{i}")); + repair(&repo, i); + let (code, fails) = provision_check(&repo); + assert_eq!(code, 1, "repair {i}: two faults remain"); + assert_eq!(fails, expected_without(&[i]), "repair {i}"); + } + } + + #[test] + fn all_repairs_together_pass() { + let repo = scratch("check", "check-repaired"); + for i in 0..CHECK_FAILS.len() { + repair(&repo, i); + } + let (code, fails) = provision_check(&repo); + assert!(fails.is_empty(), "{fails:?}"); + assert_eq!(code, 0); + } + + #[test] + fn offline_mint_keeps_custom_recipes_as_local_twins() { + let canon = Canon::resolve(None).unwrap(); + let licence = concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"); + for (fixture, verb) in [("idr", "doctor"), ("rustd", "setup"), ("rustr", "doctor")] { + let repo = scratch(&format!("lang/{fixture}"), &format!("mint-{fixture}")); + std::fs::copy(licence, repo.join("LICENSE")).unwrap(); + let opts = Options { + repo: Some(format!("hyperpolymath/{fixture}")), + year: Some(2026), + offline: true, + ..Options::default() + }; + let report = mint::mint(&repo, &canon, &opts).unwrap(); + let act = |path: &str| { + report + .files + .iter() + .find(|(p, _)| p == path) + .map(|(_, a)| a.clone()) + .unwrap_or_else(|| panic!("{fixture}: no report line for {path}")) + }; + assert!( + matches!(act("Justfile"), Act::Replaced(ref w) if w.contains(&format!("{verb}-local"))), + "{fixture}: {}", + act("Justfile") + ); + assert!(matches!(act("mise.lock"), Act::Skipped(_)), "{fixture}"); + let jf = std::fs::read_to_string(repo.join("Justfile")).unwrap(); + assert!(jf.contains(&format!("{verb}-local")), "{fixture}"); + assert!( + jf.contains(&format!("{verb}: provision::{verb}")), + "{fixture}" + ); + } } } From 4b10b8aa4330f5201e652b6fef63b6e1a0d0f0d9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:26:18 +0100 Subject: [PATCH 11/26] fix(provision-set): drop carried mise tools that mise cannot pin The Phase 4 pilot replaced five 07-18-sweep mise.toml files and carried over 12 names mise's registry does not resolve (cargo denojs git gnu-grep gnu-sed gnu-tar go-task gofmt isort jest pytest vitest). mise lock skips them silently, so mise.lock could never pin them and provision-check would fail for ever. When mise-lock-gaps names carried tools, mint now drops exactly those, rewrites mise.toml once, re-locks, and names the drops in the replace reason. A canon tool in the gap is still a FAIL (a canon defect, not repo residue). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../launcher-common/src/provisioning/mint.rs | 69 ++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 16729f3..f27728a 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -206,13 +206,12 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { (carried, unread) = carry_over_tools(target, hits)?; } vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried)); - let act = match banned { - Some(hits) if target.join("mise.toml").exists() => m.force( - "mise.toml", - "mise.toml.tmpl", - &vars, - &format!("pinned banned tool(s): {hits}{unread}"), - )?, + let replace_why = banned + .as_ref() + .filter(|_| target.join("mise.toml").exists()) + .map(|hits| format!("pinned banned tool(s): {hits}{unread}")); + let act = match &replace_why { + Some(why) => m.force("mise.toml", "mise.toml.tmpl", &vars, why)?, _ => m.minted("mise.toml", "mise.toml.tmpl", &vars)?, }; files.push(("mise.toml".into(), act)); @@ -314,7 +313,34 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { let act = if opts.offline { Act::Skipped("offline: run `mise lock`; provision-check fails until then".into()) } else { - mise_lock(&lib)? + let act = mise_lock(&lib)?; + let dropped = match &act { + Act::Failed(why) => unpinnable(why, &carried), + _ => Vec::new(), + }; + match &replace_why { + Some(why) if !dropped.is_empty() => { + // A carried tool mise cannot pin (a name its registry does not + // know, such as the 07-18 sweep's `gnu-sed`) would fail + // provision-check for ever: drop it, once, and say so. + carried.retain(|(k, _)| !dropped.contains(k)); + vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried)); + let why = format!( + "{why}; dropped {} carried tool(s) mise cannot pin: {}", + dropped.len(), + dropped.join(" ") + ); + let toml = m.force("mise.toml", "mise.toml.tmpl", &vars, &why)?; + if let Some(f) = files.iter_mut().find(|(p, _)| p == "mise.toml") { + f.1 = toml; + } + match mise_lock(&lib)? { + Act::Kept(_) => Act::Created, + relocked => relocked, + } + } + _ => act, + } }; files.push(("mise.lock".into(), act)); @@ -832,6 +858,21 @@ fn mise_lock(lib: &Lib) -> Result { }) } +/// The carried-over tools named in a `mise-lock-gaps` "does not pin" verdict: +/// the ones mint itself brought in and may therefore take out again. A canon +/// tool in the same verdict is not returned, because that is a canon defect. +fn unpinnable(gap: &str, carried: &[(String, String)]) -> Vec { + let Some(rest) = gap.strip_prefix("mise.lock does not pin: ") else { + return Vec::new(); + }; + let named = rest.split(';').next().unwrap_or(""); + named + .split_whitespace() + .filter(|t| carried.iter().any(|(k, _)| k == t)) + .map(str::to_string) + .collect() +} + /// The first error a failed command reported (mise ends with a version and a /// "Run with --verbose" trailer, which name nothing), else its last line, else /// its exit status when it said nothing. @@ -1019,6 +1060,18 @@ mod tests { assert_eq!(year_of_days(11_016), 2000); // 2000-02-29 } + #[test] + fn unpinnable_names_only_carried_tools() { + let carried = vec![ + ("gnu-sed".to_string(), "\"latest\"".to_string()), + ("zig".to_string(), "\"0.14\"".to_string()), + ]; + let gap = "mise.lock does not pin: bun gnu-sed; mise lock: failed"; + assert_eq!(unpinnable(gap, &carried), vec!["gnu-sed".to_string()]); + assert!(unpinnable("mise.lock is empty", &carried).is_empty()); + assert!(unpinnable("mise.lock has no sha256 for: zig/linux-x64", &carried).is_empty()); + } + #[test] fn carried_pins_survive_and_extras_follow() { let tools = vec!["just".to_string(), "rust".to_string()]; From 2b75d42b8c4664f8ec09130d454e87b7307c1f46 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:42:20 +0100 Subject: [PATCH 12/26] fix(provision-set): replace template-placeholder verbs; run merge tests A contract verb whose body is the unedited RSR template placeholder (`# TODO: Replace with your ...` then `@echo "Tests passed!"`) shadowed the canon verb with a fake pass: the idris2 and julia pilots reported `just test` / `just bench` rc 0 while running nothing. merge() now drops such a verb so `provision::` takes over; a real override is kept. The five merge tests move from #[ignore] into `mod needs_just`, which rust-ci skips by name and launcher-artefacts runs and counts: the count regex now matches nested paths (`(.*::)?needs_just::`) and the floor is 9 (5 here + 4 fixture tests), so an ignored test can no longer pass unseen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .github/workflows/launcher-artefacts.yml | 6 +- .../src/provisioning/justfile.rs | 181 ++-- .../check/build/just/provision-check.sh | 112 --- .../check/build/just/provision-lib.sh | 832 ------------------ .../check/build/just/provision-modes.sh | 158 ---- .../check/build/just/provision.just | 108 --- 6 files changed, 117 insertions(+), 1280 deletions(-) delete mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh delete mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh delete mode 100755 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh delete mode 100644 crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 6457c24..8a24adf 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -207,9 +207,9 @@ jobs: fi # rust-ci.yml skips `needs_just::` (its runner has no just); this job # is the only place they run, so their absence here is a failure. - NEEDS_JUST=$(grep -c '^test needs_just::.* \.\.\. ok$' "$LOG") - if [ "$NEEDS_JUST" -lt 4 ]; then - echo "::error title=needs_just tests::only $NEEDS_JUST of 4 needs_just:: tests passed here, and rust-ci skips them, so nothing else runs them" + NEEDS_JUST=$(grep -cE '^test (.*::)?needs_just::.* \.\.\. ok$' "$LOG") + if [ "$NEEDS_JUST" -lt 9 ]; then + echo "::error title=needs_just tests::only $NEEDS_JUST of 9 needs_just:: tests passed here, and rust-ci skips them, so nothing else runs them" exit 1 fi if [ "$PASSED" -lt "$TEST_COUNT_FLOOR" ]; then diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index 93b5323..b77c158 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -10,6 +10,9 @@ //! "Running diagnostics for" family) is removed: the canon verb does that job //! properly. A custom one is renamed `doctor-local` / `setup-local` / //! `heal-local`, which the canon verb runs. +//! * Any other contract verb whose body is the unedited RSR template's +//! placeholder (`# TODO: Replace with your ...`) is removed, so the canon +//! verb runs instead of a fake pass. //! //! `just` itself is the judge of the result: the merge is kept only when //! `just --summary` afterwards lists every contract verb and every recipe the @@ -30,6 +33,12 @@ pub const BOILERPLATE: &[&str] = &[ "Heal — Automatic Tool Installation", ]; +/// Text found only in the unedited RSR template's recipe bodies: a contract +/// verb carrying it (`test:` ... `# TODO: Replace with your test command` ... +/// `@echo "Tests passed!"`) is a placeholder that would shadow the canon verb +/// with a fake pass, not the repository's own override. +pub const PLACEHOLDER: &[&str] = &["# TODO: Replace with your"]; + /// The verbs a repository may already implement in its own way. const LOCAL: &[&str] = &["setup", "doctor", "heal"]; @@ -77,6 +86,17 @@ pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { } } + for verb in VERBS.iter().filter(|v| !LOCAL.contains(v)) { + let Some((start, header, end)) = span(&lines, verb) else { + continue; + }; + let body = lines[header..end].join("\n"); + if PLACEHOLDER.iter().any(|m| body.contains(m)) { + drop[start..end].iter_mut().for_each(|d| *d = true); + replaced.push(*verb); + } + } + if lines.iter().any(|l| header_name(l) == Some("provision")) { return Ok(Act::Skipped(format!( "{name} has its own `provision` recipe, which clashes with `mod provision`: rename it by hand" @@ -451,76 +471,103 @@ mod tests { const BROKEN: &str = "# Build\nbuild:\n cargo build\n\n# Self-diagnostic\ndoctor:\n #!/usr/bin/env bash\n echo \"Running diagnostics for x\"\nif command -v y >/dev/null; then\n echo ok\nfi\n\n# Help\nhelp-me:\n #!/usr/bin/env bash\n echo \"\"\necho \"FIRST TIME SETUP:\"\n"; - #[test] - #[ignore = "needs just >= 1.42 on PATH"] - fn boilerplate_is_replaced_and_a_broken_file_repaired() { - let d = repo(BROKEN); - assert!( - summary(&d, "Justfile").is_err(), - "the control must not parse" - ); - let act = merge(&d, "Justfile", &provision_just()).unwrap(); - assert!(matches!(act, Act::Replaced(_)), "{act}"); - let after = summary(&d, "Justfile").unwrap(); - for v in VERBS { - assert!(after.iter().any(|r| r == v), "{v} missing"); + /// The merge tests run `just --summary`, so need `just` >= 1.42 on PATH. + /// rust-ci skips this module by name; launcher-artefacts runs and counts it. + mod needs_just { + use super::*; + + #[test] + fn boilerplate_is_replaced_and_a_broken_file_repaired() { + let d = repo(BROKEN); + assert!( + summary(&d, "Justfile").is_err(), + "the control must not parse" + ); + let act = merge(&d, "Justfile", &provision_just()).unwrap(); + assert!(matches!(act, Act::Replaced(_)), "{act}"); + let after = summary(&d, "Justfile").unwrap(); + for v in VERBS { + assert!(after.iter().any(|r| r == v), "{v} missing"); + } + assert!(after.iter().any(|r| r == "help-me") && after.iter().any(|r| r == "build")); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(!text.contains("Running diagnostics for")); + assert!(text.contains(" echo \"FIRST TIME SETUP:\"")); + assert_eq!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Kept("already merged".into()) + ); } - assert!(after.iter().any(|r| r == "help-me") && after.iter().any(|r| r == "build")); - let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); - assert!(!text.contains("Running diagnostics for")); - assert!(text.contains(" echo \"FIRST TIME SETUP:\"")); - assert_eq!( - merge(&d, "Justfile", &provision_just()).unwrap(), - Act::Kept("already merged".into()) - ); - } - #[test] - #[ignore = "needs just >= 1.42 on PATH"] - fn a_custom_doctor_becomes_doctor_local() { - let d = repo("doctor:\n @echo mine\n"); - merge(&d, "Justfile", &provision_just()).unwrap(); - let after = summary(&d, "Justfile").unwrap(); - assert!(after.iter().any(|r| r == "doctor-local") && after.iter().any(|r| r == "doctor")); - let clash = repo("doctor:\n @echo a\ndoctor-local:\n @echo b\n"); - assert!(matches!( - merge(&clash, "Justfile", &provision_just()).unwrap(), - Act::Skipped(_) - )); - } + #[test] + fn a_custom_doctor_becomes_doctor_local() { + let d = repo("doctor:\n @echo mine\n"); + merge(&d, "Justfile", &provision_just()).unwrap(); + let after = summary(&d, "Justfile").unwrap(); + assert!( + after.iter().any(|r| r == "doctor-local") && after.iter().any(|r| r == "doctor") + ); + let clash = repo("doctor:\n @echo a\ndoctor-local:\n @echo b\n"); + assert!(matches!( + merge(&clash, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + } - #[test] - #[ignore = "needs just >= 1.42 on PATH"] - fn sweep_damage_is_repaired_and_a_provision_recipe_refused() { - let src = "// SPDX-License-Identifier: MPL-2.0\n\nguix-shell:\n guix shell -D -f guix.scm\n\n# fallback\nguix-shell:\n @if [ -f \"flake.guix\" ]; then guix develop; fi\n"; - let d = repo(src); - assert!( - summary(&d, "Justfile").is_err(), - "the control must not parse" - ); - assert!(matches!( - merge(&d, "Justfile", &provision_just()).unwrap(), - Act::Replaced(_) - )); - let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); - assert!(text.starts_with("# SPDX") && !text.contains("flake.guix")); - assert!(text.contains("guix shell -D -f guix.scm")); - let clash = repo("provision:\n @echo mine\n"); - assert!(matches!( - merge(&clash, "Justfile", &provision_just()).unwrap(), - Act::Skipped(_) - )); - } + #[test] + fn a_template_placeholder_verb_is_replaced_and_a_real_one_kept() { + let d = repo( + "test *args:\n @echo \"Running tests...\"\n # TODO: Replace with your test command\n @echo \"Tests passed!\"\n\nbench:\n cargo bench\n", + ); + let act = merge(&d, "Justfile", &provision_just()).unwrap(); + assert!( + matches!(act, Act::Replaced(ref w) if w.contains("test")), + "{act}" + ); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(!text.contains("Tests passed!"), "the placeholder must go"); + assert!( + text.contains("test: provision::test"), + "the canon verb must take over" + ); + assert!( + text.contains(" cargo bench"), + "a real override must stay" + ); + assert!(!text.contains("bench: provision::bench")); + } - #[test] - #[ignore = "needs just >= 1.42 on PATH"] - fn an_unrepairable_file_is_left_byte_identical() { - let src = "build:\n cargo build\nthis is not just syntax\n"; - let d = repo(src); - assert!(matches!( - merge(&d, "Justfile", &provision_just()).unwrap(), - Act::Skipped(_) - )); - assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + #[test] + fn sweep_damage_is_repaired_and_a_provision_recipe_refused() { + let src = "// SPDX-License-Identifier: MPL-2.0\n\nguix-shell:\n guix shell -D -f guix.scm\n\n# fallback\nguix-shell:\n @if [ -f \"flake.guix\" ]; then guix develop; fi\n"; + let d = repo(src); + assert!( + summary(&d, "Justfile").is_err(), + "the control must not parse" + ); + assert!(matches!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Replaced(_) + )); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(text.starts_with("# SPDX") && !text.contains("flake.guix")); + assert!(text.contains("guix shell -D -f guix.scm")); + let clash = repo("provision:\n @echo mine\n"); + assert!(matches!( + merge(&clash, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + } + + #[test] + fn an_unrepairable_file_is_left_byte_identical() { + let src = "build:\n cargo build\nthis is not just syntax\n"; + let d = repo(src); + assert!(matches!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + } } } diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh deleted file mode 100755 index 2666842..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-check.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# provision-check.sh — offline conformance check for the provisioning set -# (PROVISIONING-STANDARD.adoc §8, items 1–5). No network, no installs. -# -# provision-check.sh [--dev] [REPO_DIR] -# -# Exit 0 = conforms, 1 = does not. Every failure is printed; none is skipped. -# --dev downgrades repository-specific slot residue (__SPEC_X__) to a warning, -# for a checkout mid-specialisation. Mechanical residue (__X__) always fails. -set -uo pipefail - -HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -LIB="$HERE/provision-lib.sh" -DEV=0 -[ "${1:-}" = "--dev" ] && { DEV=1; shift; } -REPO="${1:-.}" -cd "$REPO" || { echo "provision-check: no such directory: $REPO" >&2; exit 1; } - -MIN_JUST="1.42.0" -VERBS="setup doctor heal dev-shell toolchain-refresh ai-setup ai-warmup eval config-show opsm build test bench lint fmt fmt-check run deps" -# Files the provisioning set owns; residue anywhere else is not ours to judge. -# The engine scripts under build/just/ are excluded: they name slot syntax in their -# own comments and code, and realign owns them. README.adoc is scanned only inside -# its [[ai-install]] section. -# Their locations come from provision-lib.sh (guix-dir, set-files): the engine and -# this check must never disagree about where the Guix files or warm-ups live. -[ -f "$LIB" ] || { echo "provision-check: engine missing: $LIB" >&2; exit 1; } -lib() { PROVISION_ROOT="$PWD" bash "$LIB" "$@"; } -GDIR=$(lib guix-dir) -SET_FILES=$(lib set-files) -gp() { [ "$GDIR" = . ] && echo "$1" || echo "$GDIR/$1"; } - -fails=0 warns=0 -ok() { printf ' ok %s\n' "$*"; } -bad() { printf ' FAIL %s\n' "$*"; fails=$((fails + 1)); } -warn() { printf ' warn %s\n' "$*"; warns=$((warns + 1)); } - -version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } - -echo "[1] launcher" -if [ ! -f launcher.sh ]; then bad "launcher.sh missing" -elif [ ! -x launcher.sh ]; then bad "launcher.sh is not executable (chmod +x; committed mode must be 100755)" -else - for m in --help --version; do - if ./launcher.sh "$m" >/dev/null 2>&1; then ok "launcher.sh $m exits 0" - else bad "launcher.sh $m exits non-zero"; fi - done -fi - -echo "[2] just" -f0=$fails -if ! command -v just >/dev/null 2>&1; then bad "just is not installed; cannot check the recipe contract" -else - jv=$(just --version 2>/dev/null | awk '{print $2}') - if version_ge "$jv" "$MIN_JUST"; then ok "just $jv >= $MIN_JUST" - else bad "just $jv < $MIN_JUST (module-recipe dependencies do not resolve)"; fi - if ! summary=$(just --summary 2>&1); then - bad "the Justfile does not parse: $(printf '%s' "$summary" | head -1)" - else - have=" $summary " - for v in $VERBS; do - case "$have" in *" $v "*) ;; *) bad "root recipe missing: $v" ;; esac - case "$have" in *" provision::$v "*) ;; *) bad "module recipe missing: provision::$v (engine drift)" ;; esac - done - [ "$fails" -eq "$f0" ] && ok "all ${VERBS// /, } present at root and in provision::" - fi -fi - -echo "[3] mise" -if [ ! -f mise.toml ]; then bad "mise.toml missing" -else - if hit=$(lib mise-banned); then ok "mise.toml names no banned tool"; else bad "mise.toml names banned tools: $hit"; fi - [ -f .mise.toml ] && bad "both mise.toml and .mise.toml exist" - if lg=$(lib mise-lock-gaps); then ok "mise.lock pins every mise.toml tool"; else bad "$lg (latest is not concrete; run: mise lock)"; fi -fi - -echo "[4] guix" -for g in "$(gp guix.scm)" "$(gp manifest.scm)" "$(gp channels.scm)"; do - if r=$(lib guix-stub "$g"); then ok "$g is not a stub"; else bad "$g: $r"; fi -done - -echo "[5] template residue" -f0=$fails -residue() { # $1 label; stdin = the text to judge - local text mech spec - text=$(cat) - # Mechanical slots (__X__ not starting SPEC_) are the generator's job: always fatal. - mech=$(printf '%s\n' "$text" | grep -noE '__[A-Z][A-Z_]*__' | grep -v ':__SPEC_' | head -3 | tr '\n' ' ') - spec=$(printf '%s\n' "$text" | grep -noE '__SPEC_[A-Z_]*__' | head -3 | tr '\n' ' ') - [ -n "$mech" ] && bad "$1: unfilled mechanical slots: $mech" - if [ -n "$spec" ]; then - if [ "$DEV" = 1 ]; then warn "$1: unfilled repository slots: $spec" - else bad "$1: unfilled repository slots: $spec"; fi - fi -} -for f in $SET_FILES; do - # shellcheck disable=SC2094 # $f is only the label; nothing writes it - [ -f "$f" ] && residue "$f" < "$f" -done -for r in README.adoc README.md; do - [ -f "$r" ] || continue - # From the [[ai-install]] anchor to the next level-2 heading after its own. - residue "$r [[ai-install]]" < <(awk '/^\[\[ai-install\]\]/{on=1; n=0} on && /^== /{n++; if (n>1) exit} on' "$r") -done -[ "$fails" -eq "$f0" ] && ok "no residue that fails this mode" - -echo -echo "provision-check: $fails FAIL, $warns WARN" -[ "$fails" -eq 0 ] diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh deleted file mode 100755 index fb1d942..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-lib.sh +++ /dev/null @@ -1,832 +0,0 @@ -#!/usr/bin/env bash -# SC2015: `A && pass || fail` is safe here — pass/warn/fail/info always return 0. -# shellcheck disable=SC2015 -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# provision-lib.sh — the shared provisioning engine behind build/just/provision.just -# -# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc -# Vendored into every repo at build/just/provision-lib.sh by `provision-set mint`. -# DO NOT hand-edit a vendored copy: repo-specific facts live in -# .machine_readable/descriptiles/provisioning_praxis.deed, and `provision-set realign` -# overwrites this file from canon. -# -# Usage: provision-lib.sh [args] -# verbs: langs doctor setup heal dev-shell toolchain-refresh crates-scm ai-setup -# ai-warmup eval config-show opsm lang-run -# search version -# facts: guix-specs mise-tools langs guix-dir set-files guix-gaps tool-table -# system-deps -# predicates (print why and exit 1, or exit 0 silently): -# guix-stub FILE, mise-lock-gaps, mise-banned -# -# Exit: 0 ok | 1 a FAIL was found / a step failed | 2 usage error -set -uo pipefail - -PROVISION_LIB_VERSION="0.5.0" -ROOT="${PROVISION_ROOT:-$(pwd)}" -cd "$ROOT" || exit 2 -DEED=".machine_readable/descriptiles/provisioning_praxis.deed" -MIN_JUST="1.42.0" # root→module recipe deps (`doctor: provision::doctor`); 1.41 rejects them -# How to get mise: package managers first; the installer only as download, read, run. -MISE_INSTALL_HINT="brew install mise | Fedora: dnf copr enable jdxcode/mise, dnf install mise | winget install jdx.mise | others: https://mise.jdx.dev/installing-mise.html — or: curl -fsSLo mise-install.sh https://mise.run, read it, sh mise-install.sh" -T="timeout 15" # some --version probes hang (observed 2026-09-30); never probe unbounded - -if [ -t 1 ]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[34m'; Z=$'\033[0m'; else R=; G=; Y=; B=; Z=; fi -PASS=0; WARN=0; FAIL=0 -pass() { PASS=$((PASS+1)); printf ' %sPASS%s %s\n' "$G" "$Z" "$*"; } -warn() { WARN=$((WARN+1)); printf ' %sWARN%s %s\n' "$Y" "$Z" "$*"; } -fail() { FAIL=$((FAIL+1)); printf ' %sFAIL%s %s\n' "$R" "$Z" "$*"; } -info() { printf ' %sinfo%s %s\n' "$B" "$Z" "$*"; } -hdr() { printf '\n%s== %s ==%s\n' "$B" "$*" "$Z"; } -have() { command -v "$1" >/dev/null 2>&1; } - -# Where this repository keeps each part of the set. A file sits at the root only -# when something needs it there (launcher.sh, Justfile, mise.toml, mise.lock); -# the Guix trio and the warm-ups follow the layout the repository already has -# (rsr-template-repo keeps build/guix.scm and docs/onboarding/llm-warmup-*.adoc). -# This is the ONE answer: provision-check.sh asks it through `guix-dir` and -# `set-files` rather than keeping its own copy. -guix_dir() { if [ -f build/guix.scm ]; then echo build; else echo .; fi; } -warmup_dir() { - local d - for d in . docs/onboarding docs; do - compgen -G "$d/llm-warmup-*.adoc" >/dev/null && { echo "$d"; return; } - done - echo . -} -gpath() { local d; d=$(guix_dir); [ "$d" = . ] && echo "$1" || echo "$d/$1"; } -wpath() { local d; d=$(warmup_dir); [ "$d" = . ] && echo "llm-warmup-$1.adoc" || echo "$d/llm-warmup-$1.adoc"; } -set_files() { - printf '%s\n' launcher.sh Justfile justfile mise.toml \ - "$(gpath guix.scm)" "$(gpath manifest.scm)" "$(gpath channels.scm)" \ - "$DEED" docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc \ - "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)" -} - -# --------------------------------------------------------------------------- -# Descriptor: flat `:key "value"` reads from provisioning_praxis.deed (s-expression). -deed() { # $1 key, $2 default - local v="" - [ -f "$DEED" ] && v=$(grep -oE "\(:?$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') - [ -z "$v" ] && v=$(grep -oE ":$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') - printf '%s' "${v:-$2}" -} -repo_slug() { - local u; u=$(git config --get remote.origin.url 2>/dev/null || true) - u=${u%.git}; u=${u#*github.com[:/]} - [ -n "$u" ] && printf '%s' "$u" || printf 'hyperpolymath/%s' "$(basename "$ROOT")" -} -REPO_SLUG="$(deed repo "$(repo_slug)")" -REPO_NAME="${REPO_SLUG#*/}" -ARCHETYPE="$(deed archetype "")" - -# --------------------------------------------------------------------------- -# Language detection. Markers at the root or one level down (workspaces). The -# estate ABI/FFI pattern nests deeper: Idris2 to two levels down (src/abi/*.ipkg), -# Zig to three (ffi/zig/build.zig, and rsr-template-repo's src/interface/ffi/build.zig: -# 74 repos have their only build.zig at that depth, measured 2026-09-30). -# Order matters only for display. `docs` is reported when nothing else is. -first() { find . -maxdepth "${2:-2}" -not -path './.git/*' -not -path '*/node_modules/*' -not -path './target/*' -name "$1" -print -quit 2>/dev/null; } -detect_langs() { - local out=() - [ -n "$(first Cargo.toml)" ] && out+=(rust) - [ -n "$(first '*.ipkg' 3)" ] && out+=(idris2) - [ -n "$(first Project.toml 1)" ] && out+=(julia) - [ -n "$(first build.zig 4)" ] && out+=(zig) - [ -n "$(first mix.exs)" ] && out+=(elixir) - [ -n "$(first gleam.toml)" ] && out+=(gleam) - [ -n "$(first dune-project)" ] && out+=(ocaml) - { [ -n "$(first '*.cabal')" ] || [ -n "$(first stack.yaml 1)" ]; } && out+=(haskell) - [ -n "$(first package.json)" ] && out+=(bun) - [ ${#out[@]} -eq 0 ] && out+=(docs) - printf '%s\n' "${out[@]}" -} -mapfile -t LANGS < <(detect_langs) -[ -z "$ARCHETYPE" ] && { [ "${LANGS[*]}" = docs ] && ARCHETYPE=docs || ARCHETYPE=library; } - -# Binaries each language needs, and how each is obtained. -# mise registry gaps (verified 2026-09-30 against mise 2026.7.5): ocaml, haskell, -# idris2 and guile are NOT mise tools. They come from opam / ghcup / pack / the -# system package manager (or Guix), and the doctor probes the binary itself. -lang_tools() { - case "$1" in - rust) echo "cargo rustc" ;; - idris2) echo "idris2" ;; - julia) echo "julia" ;; - zig) echo "zig" ;; - elixir) echo "elixir mix erl" ;; - gleam) echo "gleam erl" ;; - ocaml) echo "opam dune ocaml" ;; - haskell) echo "ghc cabal" ;; - bun) echo "bun" ;; - docs) echo "" ;; - esac -} -# Guix package specs per language (verified 2026-09-30 against guix ae77aeb in -# docker.io/metacall/guix). Guix has NO idris2, gleam, bun or lychee, and its -# julia is 1.8.5: those come from mise, which Guix itself ships ("mise"). -GUIX_BASE="git bash coreutils nss-certs just mise shellcheck" -lang_guix() { - case "$1" in - rust) echo "rust rust:cargo gcc-toolchain pkg-config" ;; - idris2) echo "chez-scheme gmp gcc-toolchain" ;; - zig) echo "zig" ;; - elixir) echo "elixir erlang" ;; - gleam) echo "erlang" ;; - ocaml) echo "ocaml dune opam ocaml-findlib" ;; - haskell) echo "ghc cabal-install" ;; - docs) echo "ruby-asciidoctor" ;; - julia|bun) echo "" ;; - esac -} -# Tools a Guix shell cannot supply, so mise (inside that shell) does. -lang_guix_gap() { - case "$1" in - idris2) echo "idris2 (via pack)" ;; julia) echo julia ;; gleam) echo gleam ;; bun) echo bun ;; docs) echo lychee ;; - esac -} -guix_specs() { - local l s=" $GUIX_BASE " - for l in "${LANGS[@]}"; do for p in $(lang_guix "$l"); do case "$s" in *" $p "*) ;; *) s="$s$p ";; esac; done; done - echo "$s" | xargs -} - -# mise tools per language (registry-checked 2026-09-30, mise 2026.7.5). idris2 comes -# from pack and haskell from ghcup or Guix: neither is a mise tool. The recipes -# themselves need just and shellcheck, so those two are always declared. -MISE_BASE="just shellcheck" -# Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. -RECIPE_TOOLS="trivy" -lang_mise() { case "$1" in - rust) echo "rust" ;; zig) echo "zig" ;; julia) echo "julia" ;; - elixir) echo "erlang elixir" ;; gleam) echo "erlang gleam" ;; ocaml) echo "opam" ;; - bun) echo "bun" ;; docs) echo "lychee" ;; esac; } -recipe_tools() { - local t f body="" - for f in Justfile justfile build/just/*.just; do - [ -f "$f" ] && body+=$(grep -v '^[[:space:]]*#' "$f")$'\n' - done - for t in $RECIPE_TOOLS; do - [[ "$body" =~ (^|[^[:alnum:]_-])$t([^[:alnum:]_-]|$) ]] && echo "$t" - done -} - -mise_tools() { - local l t seen=" " out=() - for t in $MISE_BASE $(for l in "${LANGS[@]}"; do lang_mise "$l"; done) $(recipe_tools); do - case "$seen" in *" $t "*) ;; *) out+=("$t"); seen="$seen$t " ;; esac - done - printf "%s\n" "${out[*]}" -} - -lang_remedy() { - case "$1" in - rust) echo "mise use rust@latest (or rustup: https://rustup.rs)" ;; - idris2) echo "install pack: https://github.com/stefan-hoeck/idris2-pack#installation, then: pack install-app idris2" ;; - julia) echo "mise use julia@latest (or: https://julialang.org/install/ — juliaup)" ;; - zig) echo "mise use zig@latest" ;; - elixir) echo "mise use erlang@latest elixir@latest (erlang builds from source: allow ~10 min)" ;; - gleam) echo "mise use gleam@latest erlang@latest" ;; - ocaml) echo "mise use opam@latest && opam init -y && opam switch create . --deps-only -y" ;; - haskell) echo "ghcup: https://www.haskell.org/ghcup/ (or: just dev-shell, which uses Guix)" ;; - bun) echo "mise use bun@latest" ;; - esac -} - -# The per-language default for each contract verb. A repo overrides any of -# these simply by defining the root recipe itself; `provision-set` only adds -# `: provision::` where the root Justfile has no such recipe. -lang_cmd() { # $1 lang, $2 verb -> prints a shell command, or nothing (= N/A) - local l=$1 v=$2 ipkg - case "$l:$v" in - rust:deps) echo "cargo fetch" ;; - rust:build) echo "cargo build --all-targets" ;; - rust:test) echo "cargo test --all-targets" ;; - rust:bench) grep -rqs '\[\[bench\]\]\|criterion\|divan' --include=Cargo.toml . && echo "cargo bench" ;; - rust:lint) echo "cargo clippy --all-targets -- -D warnings" ;; - rust:fmt) echo "cargo fmt --all" ;; - rust:fmt-check) echo "cargo fmt --all -- --check" ;; - rust:run) echo "cargo run --release" ;; - - idris2:*) - ipkg=$(first '*.ipkg' 1); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg'); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg' 3); ipkg=${ipkg#./} - case "$v" in - deps) have pack && echo "pack install-deps $ipkg" ;; - build) have pack && echo "pack build $ipkg" || echo "idris2 --build $ipkg" ;; - test) local t; t=$(find . -maxdepth 3 -name 'test*.ipkg' -print -quit 2>/dev/null) - if [ -n "$t" ]; then have pack && echo "pack test ${t#./}" || echo "idris2 --build ${t#./}" - else have pack && echo "pack typecheck $ipkg" || echo "idris2 --typecheck $ipkg"; fi ;; - run) grep -qs '^[[:space:]]*executable' "$ipkg" && { have pack && echo "pack run $ipkg" || echo "idris2 --build $ipkg && ./build/exec/*"; } ;; - esac ;; - - julia:deps) echo "julia --project=. -e 'using Pkg; Pkg.instantiate()'" ;; - julia:build) echo "julia --project=. -e 'using Pkg; Pkg.precompile()'" ;; - julia:test) echo "julia --project=. -e 'using Pkg; Pkg.test()'" ;; - julia:bench) [ -f benchmark/benchmarks.jl ] && echo "julia --project=benchmark -e 'using Pkg; Pkg.develop(path=\".\"); Pkg.instantiate(); include(\"benchmark/benchmarks.jl\")'" ;; - - zig:*) - # build.zig may sit in ffi/zig/; run there, not at the root. - local zb zd; zb=$(first build.zig 1); [ -z "$zb" ] && zb=$(first build.zig); [ -z "$zb" ] && zb=$(first build.zig 3); [ -z "$zb" ] && zb=$(first build.zig 4) - zd=$(dirname "${zb#./}"); local cdz=""; [ "$zd" != . ] && cdz="cd '$zd' && " - case "$v" in - build) echo "${cdz}zig build" ;; - test) grep -qs '"test"' "$zb" && echo "${cdz}zig build test" ;; - bench) grep -qs '"bench"' "$zb" && echo "${cdz}zig build bench -Doptimize=ReleaseFast" ;; - fmt) echo "zig fmt $zd" ;; - fmt-check) echo "zig fmt --check $zd" ;; - lint) echo "zig fmt --check $zd" ;; - run) grep -qs '"run"' "$zb" && echo "${cdz}zig build run" ;; - esac ;; - - elixir:deps) echo "mix local.hex --force --if-missing && mix local.rebar --force --if-missing && mix deps.get" ;; - elixir:build) echo "mix compile --warnings-as-errors" ;; - elixir:test) echo "mix test" ;; - elixir:bench) ls bench/*.exs >/dev/null 2>&1 && echo "for f in bench/*.exs; do mix run \"\$f\"; done" ;; - elixir:lint) grep -qs ':credo' mix.exs && echo "mix credo --strict" || echo "mix compile --warnings-as-errors" ;; - elixir:fmt) echo "mix format" ;; - elixir:fmt-check) echo "mix format --check-formatted" ;; - elixir:run) echo "mix run --no-halt" ;; - - gleam:deps) echo "gleam deps download" ;; - gleam:build) echo "gleam build" ;; - gleam:test) echo "gleam test" ;; - gleam:fmt) echo "gleam format" ;; - gleam:fmt-check) echo "gleam format --check" ;; - gleam:lint) echo "gleam format --check" ;; - gleam:run) echo "gleam run" ;; - - ocaml:deps) echo "opam install . --deps-only --with-test -y" ;; - ocaml:build) echo "opam exec -- dune build" ;; - ocaml:test) echo "opam exec -- dune test" ;; - ocaml:fmt) echo "opam exec -- dune fmt" ;; - ocaml:fmt-check) echo "opam exec -- dune build @fmt" ;; - - haskell:deps) echo "cabal update && cabal build all --only-dependencies" ;; - haskell:build) echo "cabal build all" ;; - haskell:test) echo "cabal test all" ;; - haskell:bench) grep -qs '^benchmark' ./*.cabal && echo "cabal bench all" ;; - haskell:run) echo "cabal run" ;; - - bun:deps) [ -f bun.lock ] || [ -f bun.lockb ] && echo "bun install --frozen-lockfile" || echo "bun install" ;; - bun:build) grep -qs '"build"[[:space:]]*:' package.json && echo "bun run build" ;; - # `bun test` exits 0 when it finds no test files, so it runs only when some exist. - bun:test) if grep -qs '"test"[[:space:]]*:' package.json; then echo "bun run test" - elif git ls-files 2>/dev/null | grep -qE '(\.|_)(test|spec)\.(js|mjs|cjs|jsx)$'; then echo "bun test"; fi ;; - bun:bench) grep -qs '"bench"[[:space:]]*:' package.json && echo "bun run bench" ;; - bun:lint) grep -qs '"lint"[[:space:]]*:' package.json && echo "bun run lint" ;; - bun:fmt) grep -qs '"fmt"[[:space:]]*:' package.json && echo "bun run fmt" ;; - bun:fmt-check) grep -qs '"fmt-check"[[:space:]]*:' package.json && echo "bun run fmt-check" ;; - bun:run) grep -qs '"start"[[:space:]]*:' package.json && echo "bun run start" ;; - - docs:test) have asciidoctor && echo "for f in \$(git ls-files '*.adoc'); do asciidoctor -o /dev/null --failure-level=WARN \"\$f\" || exit 1; done" ;; - docs:lint) have lychee && echo "lychee --offline --no-progress \$(git ls-files '*.adoc' '*.md')" ;; - esac -} - -# Run a contract verb across every detected language. N/A is reported, not -# faked green: a verb with no command for any language exits 0 but SAYS so. -lang_run() { # $1 verb - local verb=$1 ran=0 rc=0 l cmd override - override=$(deed "$verb" "") - if [ -n "$override" ]; then - info "$verb (from provisioning_praxis.deed): $override" - bash -c "$override"; return $? - fi - for l in "${LANGS[@]}"; do - cmd=$(lang_cmd "$l" "$verb") - [ -z "$cmd" ] && continue - ran=1 - info "$verb [$l]: $cmd" - bash -c "$cmd" || { rc=$?; printf ' %sFAIL%s %s [%s] exited %s\n' "$R" "$Z" "$verb" "$l" "$rc" >&2; } - done - [ $ran -eq 0 ] && info "$verb: N/A for ${LANGS[*]} (archetype: $ARCHETYPE) — nothing to run, and nothing was faked" - return $rc -} - -# --------------------------------------------------------------------------- -# A repo keeps its own checks as root recipes named doctor-local / setup-local / -# heal-local (provision-set renames a pre-existing custom doctor/setup/heal to -# these); the canon verbs run them, so `just doctor`, `./launcher.sh --doctor` and -# CI all see the same result. -has_recipe() { have just && just --summary 2>/dev/null | tr " " "\n" | grep -qx "$1"; } - -version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } - -# Tools that must never appear in a repo's toolchain (estate language policy). -BANNED_TOOLS='python|deno|node|nodejs|npm|yarn|pnpm|typescript|rescript|make|black|ruff|pip|poetry|nix|go|golang|java|kotlin' - -# --------------------------------------------------------------------------- -# Shared predicates. doctor and provision-check.sh both call THESE (the check via -# `provision-lib.sh guix-stub|mise-lock-gaps|mise-banned`): a gate with its own -# copy of a test passes what doctor warns about. - -# The keys of mise.toml [tools], quotes stripped ("cargo:foo" stays cargo:foo). -mise_toml_tools() { - [ -f mise.toml ] || return 0 - awk '/^\[tools\]/{t=1;next} /^\[/{t=0} t && /=/{sub(/[ \t]*=.*/,""); gsub(/["\x27 ]/,""); print}' mise.toml -} -# Banned tools named in mise.toml; a backend prefix does not hide one ("aqua:denoland/deno" is deno). -mise_banned() { - local t base hits="" - for t in $(mise_toml_tools); do - base=${t##*:}; base=${base%%@*}; base=${base##*/} - [[ "$base" =~ ^($BANNED_TOOLS)$ ]] && hits="$hits$t " - done - printf '%s' "${hits% }" -} -# Why mise.lock does not pin mise.toml, or nothing when it does. Presence is not -# enough: a zero-byte lock pins nothing. Every [tools] key needs its [[tools.]] -# entry, and the lock must carry checksums. -mise_lock_gaps() { - [ -f mise.toml ] || return 0 - [ -f mise.lock ] || { echo "mise.lock missing"; return; } - [ -s mise.lock ] || { echo "mise.lock is empty"; return; } - local t miss="" - # A tool is pinned when its [[tools.X]] block carries a concrete version line. - for t in $(mise_toml_tools); do - awk -v a="[[tools.$t]]" -v b="[[tools.\"$t\"]]" ' - $0 == a || $0 == b { inb = 1; next } - /^\[/ { inb = 0 } - inb && /^version = "[^"]+"/ { ok = 1 } - END { exit !ok }' mise.lock || miss="$miss$t " - done - [ -n "$miss" ] && { echo "mise.lock does not pin: ${miss% }"; return; } - # Checksums are per artefact: every [tools.X."platforms.P"] table needs its own - # sha256, so one checksummed tool cannot vouch for another. A tool with no - # platform tables has no artefact to checksum (core:rust installs through - # rustup, cargo: builds from source), which is what `mise lock` writes for it. - miss=$(awk ' - # Print the open platform table as tool/platform when it carried no sha256. - function close_table() { if (p != "" && !c) printf "%s ", p; p = "" } - /^\[tools\..*platforms\./ { close_table(); p = $0; c = 0 - gsub(/^\[tools\.|\]$|"/, "", p); sub(/\.platforms\./, "/", p); next } - /^\[/ { close_table() } - /^checksum = "sha256:[0-9a-f]+"/ { c = 1 } - END { close_table() }' mise.lock) - [ -z "$miss" ] || echo "mise.lock has no sha256 for: ${miss% }" -} -# Why a Guix file is a stub, or nothing when it is real. The test is positive: a -# guix.scm must define every field a package needs, not merely avoid known stub -# shapes; `(package (name "x") (source (local-file ".")))` is a stub. -guix_stub_reason() { - local f=$1 k miss="" - [ -f "$f" ] || { echo "missing"; return; } - grep -qE '\{\{|__[A-Z][A-Z_]*__' "$f" && { echo "unfilled template slots"; return; } - grep -qE '\(inputs \(list\)\)|\(source #f\)' "$f" && { echo "empty inputs or no source"; return; } - case "${f##*/}" in - manifest.scm) grep -q 'specifications->manifest' "$f" || echo "lists no specifications"; return ;; - channels.scm) grep -qE '\(commit "[0-9a-f]{40}"\)' "$f" || echo "pins no commit"; return ;; - esac - for k in name version source build-system home-page synopsis description license; do - grep -qE "\\(${k}[[:space:]]" "$f" || miss="$miss$k " - done - [ -n "$miss" ] && { echo "no package field: ${miss% }"; return; } - if grep -q 'crates\.scm' "$f"; then - grep -qs 'define %crate-inputs' build/guix/crates.scm || echo "build/guix/crates.scm is missing or defines no %crate-inputs" - fi -} - -# --------------------------------------------------------------------------- -# Facts the generator writes into docs/SETUP.adoc and the AI guide. They live here, -# beside lang_tools and lang_remedy, so no second per-language table exists. -lang_title() { case "$1" in - rust) echo Rust ;; idris2) echo Idris2 ;; julia) echo Julia ;; zig) echo Zig ;; elixir) echo Elixir ;; - gleam) echo Gleam ;; ocaml) echo OCaml ;; haskell) echo Haskell ;; bun) echo Bun ;; docs) echo Docs ;; esac; } -# What a language's route needs from the OS: "fedora|debian|macos command|why", or nothing. -lang_sysdeps() { case "$1" in - rust) echo "gcc pkgconf-pkg-config|build-essential pkg-config|xcode-select --install|Rust links through the system C toolchain, and pkg-config finds C libraries" ;; - idris2) echo "chez-scheme gmp-devel|chezscheme libgmp-dev|brew install chezscheme gmp|pack builds Idris2 on top of Chez Scheme and GMP" ;; - elixir|gleam) echo "gcc gcc-c++ make autoconf ncurses-devel openssl-devel|build-essential autoconf m4 libncurses-dev libssl-dev|brew install autoconf openssl@3|mise builds Erlang/OTP from source (allow ~10 min); the make here is the system tool that build uses, not a Makefile in this repository" ;; - ocaml) echo "gcc make patch unzip bubblewrap|build-essential patch unzip bubblewrap|xcode-select --install|opam compiles OCaml and sandboxes its builds with bubblewrap" ;; - haskell) echo "gcc gcc-c++ gmp-devel make ncurses-devel xz perl|build-essential curl libffi-dev libgmp-dev libncurses-dev|xcode-select --install|ghcup installs GHC, which links through the C toolchain and GMP" ;; -esac; } -guix_gaps() { - local l g gaps="" - for l in "${LANGS[@]}"; do g=$(lang_guix_gap "$l"); [ -n "$g" ] && gaps="$gaps${gaps:+, }$g"; done - printf '%s\n' "${gaps:-none}" -} -tool_table() { - local l t base - for l in "${LANGS[@]}"; do - if [ "$l" = docs ]; then echo "|docs |\`lychee\` |mise use lychee@latest"; continue; fi - t=$(lang_tools "$l"); echo "|$l |\`${t// /\`, \`}\` |$(lang_remedy "$l")" - done - base="$MISE_BASE $(recipe_tools | xargs)"; base=$(echo "$base" | xargs) - echo "|(recipes) |\`${base// /\`, \`}\` |mise install, or your OS package manager (e.g. \`dnf install just ShellCheck\`)" -} -# shellcheck disable=SC2016 # the backticks are AsciiDoc literals, not command substitution -system_deps() { # $1 adoc|ai - local l d seen="" fed deb mac why any=0 - [ "$1" = adoc ] && printf '=== System packages\n' - for l in "${LANGS[@]}"; do - d=$(lang_sysdeps "$l"); [ -z "$d" ] && continue - case "$seen" in *"|$d|"*) continue ;; esac; seen="$seen|$d|"; any=1 - IFS='|' read -r fed deb mac why <<<"$d" - if [ "$1" = adoc ]; then - printf '\n%s: %s.\n\n* Fedora: `sudo dnf install %s`\n* Debian/Ubuntu: `sudo apt install %s`\n* macOS: `%s`\n' \ - "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" - else - printf '* %s needs OS packages (%s): Fedora `sudo dnf install %s` · Debian/Ubuntu `sudo apt install %s` · macOS `%s`.\n' \ - "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" - fi - done - if [ "$1" = adoc ]; then - if [ $any -eq 1 ]; then printf '\nThe Guix development shell (`%s`) provides these itself, so inside `just dev-shell` none of them is needed.\n' "$(gpath manifest.scm)" - else printf '\nNone beyond git and a shell: every tool this repository needs comes from mise, or from the Guix shell.\n'; fi - elif [ $any -eq 0 ]; then printf '* No other OS packages: every tool comes from mise.\n'; fi -} - -cmd_doctor() { - printf '%s doctor — %s (%s; languages: %s)\n' "$REPO_NAME" "$REPO_SLUG" "$ARCHETYPE" "${LANGS[*]}" - - hdr "Core toolchain" - have git && pass "git $($T git --version 2>/dev/null | awk '{print $3}')" || fail "PV-E01 git not found — install git from your OS package manager" - if have just; then - local jv; jv=$($T just --version 2>/dev/null | awk '{print $2}') - version_ge "$jv" "$MIN_JUST" && pass "just $jv (>= $MIN_JUST)" || fail "PV-E02 just $jv is older than $MIN_JUST — run: mise use just@latest" - else fail "PV-E02 just not found — run: mise use -g just@latest (or see docs/SETUP.adoc)"; fi - if have mise; then - pass "mise $($T mise --version 2>/dev/null | awk '{print $1}')" - if [ -f mise.toml ] || [ -f .mise.toml ]; then - # Only THIS repo's declarations: `mise ls` also lists the user's global config. - local here=${ROOT/#$HOME/\~} missing - missing=$($T mise ls --current --missing 2>/dev/null | grep -F -e "$here/mise.toml" -e "$here/.mise.toml" -e "$ROOT/mise.toml" | awk '{print $1"@"$2}' | tr '\n' ' ') - [ -z "${missing// /}" ] && pass "every mise tool is installed" || fail "PV-E03 mise tools not installed: $missing— run: just setup" - fi - else warn "PV-W01 mise not found — tools must then come from Guix or your OS; install: $MISE_INSTALL_HINT"; fi - if have guix; then pass "guix $($T guix --version 2>/dev/null | head -1 | awk '{print $NF}') (optional reproducible path)" - else info "guix not installed — optional; mise is the default path (docs/SETUP.adoc §Guix)"; fi - - hdr "Language toolchains" - local l t - for l in "${LANGS[@]}"; do - [ "$l" = docs ] && { info "no build language detected — docs/theory archetype"; continue; } - for t in $(lang_tools "$l"); do - if have "$t"; then pass "$l: $t ($(command -v "$t"))" - else fail "PV-E10 $l: '$t' not found — run: just setup (manual: $(lang_remedy "$l"))"; fi - done - done - local extra; extra=$(deed system-deps "") - for t in $extra; do have "$t" && pass "system dep: $t" || fail "PV-E11 system dependency '$t' not found (declared in $DEED) — see docs/SETUP.adoc §Prerequisites"; done - - hdr "Repository provisioning files" - [ -f mise.toml ] && pass "mise.toml" || fail "PV-E20 mise.toml missing — the toolchain is undeclared" - local lg; lg=$(mise_lock_gaps) - [ -z "$lg" ] && pass "mise.lock pins every mise.toml tool (latest → concrete, checksummed)" || warn "PV-W20 $lg — run: just toolchain-refresh" - [ -f .mise.toml ] && [ -f mise.toml ] && warn "PV-W21 both mise.toml and .mise.toml — mise merges them; keep only mise.toml" - [ -f .tool-versions ] && warn "PV-W22 .tool-versions present — a second toolchain source; fold it into mise.toml" - if [ -f mise.toml ]; then - local bad; bad=$(mise_banned) - [ -z "$bad" ] && pass "mise.toml pins no banned tool" || warn "PV-W23 mise.toml pins banned tool(s): $bad (language policy: bun, no python/deno/node/make)" - fi - # hypatia guix_not_stub reads guix.scm AND build/guix.scm; an unfilled __PLACEHOLDER__ is a stub too. - local g r gstub="" - for g in guix.scm build/guix.scm "$(gpath manifest.scm)" "$(gpath channels.scm)"; do - [ -f "$g" ] || continue - r=$(guix_stub_reason "$g"); [ -n "$r" ] && gstub="$gstub$g ($r) " - done - local gs gm; gs=$(gpath guix.scm); gm=$(gpath manifest.scm) - [ -f guix.scm ] && [ -f build/guix.scm ] && warn "PV-W35 both guix.scm and build/guix.scm exist — two Guix sources; keep one (build/ is used)" - if [ -f "$gs" ]; then - if [ -n "$gstub" ]; then warn "PV-W24 template stub in: $gstub— the Guix path does not build this repo (just heal cannot fix this; provision-set realign does)" - else pass "$gs (non-stub)"; fi - else warn "PV-W25 guix.scm missing — no reproducible Guix path"; fi - [ -f "$gm" ] && pass "$gm (guix shell -m $gm)" || warn "PV-W26 $gm missing — 'just dev-shell' falls back to mise" - [ -x launcher.sh ] && pass "launcher.sh (executable)" || { [ -f launcher.sh ] && fail "PV-E27 launcher.sh not executable — run: just heal" || warn "PV-W27 launcher.sh missing"; } - local d - for d in docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)"; do - if [ ! -f "$d" ]; then warn "PV-W28 $d missing" - elif grep -qE "__[A-Z][A-Z_]*__" "$d"; then warn "PV-W29 $d still has unfilled template slots (__SPEC_…__) — replace each with the facts for this repo" - else pass "$d"; fi - done - - hdr "Estate policy drift" - local f any=0 - for f in deno.json deno.jsonc deno.lock import_map.json; do - [ -f "$f" ] && { warn "PV-W30 $f — Deno leftover; the estate runtime is bun (migration issue tracks it)"; any=1; } - done - for f in flake.nix flake.lock shell.nix default.nix; do [ -f "$f" ] && { warn "PV-W31 $f — Nix is not an estate toolchain; Guix is"; any=1; }; done - for f in Makefile GNUmakefile makefile; do [ -f "$f" ] && { warn "PV-W32 $f — Justfile is the only task runner"; any=1; }; done - for f in requirements.txt pyproject.toml setup.py Pipfile; do [ -f "$f" ] && { warn "PV-W33 $f — Python is not an estate language"; any=1; }; done - for f in package-lock.json yarn.lock pnpm-lock.yaml tsconfig.json; do [ -f "$f" ] && { warn "PV-W34 $f — npm/yarn/pnpm/TypeScript leftover; bun only"; any=1; }; done - [ $any -eq 0 ] && pass "no deno / nix / make / python / npm leftovers" - - local hook=build/just/doctor-local.sh - if [ -f "$hook" ]; then - hdr "Repo-specific checks ($hook)" - # Sourced so the hook can call pass/warn/fail, but in a subshell so an `exit` - # or `set -e` in it cannot end the doctor before the summary. The EXIT trap - # hands the hook's tally back; an early exit is itself a FAIL. - local tally qtally hp w f rc - tally=$(mktemp) - printf -v qtally '%q' "$tally" - # shellcheck disable=SC2030,SC2031 # the subshell's counts return via $tally - ( PASS=0 WARN=0 FAIL=0 hook_done=0 - # The path is baked in now: when `set -e` trips, bash unwinds this - # function's locals before the EXIT trap runs, so $tally is gone by then. - # shellcheck disable=SC2064 - trap "printf '%d %d %d %d\n' \"\$PASS\" \"\$WARN\" \"\$FAIL\" \"\$hook_done\" > $qtally" EXIT - # shellcheck source=/dev/null - . "$hook" - hook_done=1 ) - rc=$? - read -r hp w f hook_done < "$tally" || { hp=0 w=0 f=0 hook_done=0; } - rm -f "$tally" - # shellcheck disable=SC2031 - PASS=$((PASS+hp)) WARN=$((WARN+w)) FAIL=$((FAIL+f)) - [ "$hook_done" = 1 ] || fail "PV-E51 $hook exited (status $rc) before it finished; its later checks did not run" - fi - if has_recipe doctor-local; then - hdr "Repo-specific checks (just doctor-local)" - just doctor-local && pass "doctor-local" || fail "PV-E50 the repo-specific doctor-local recipe failed (output above)" - fi - - printf '\n%s: %s%d PASS%s, %s%d WARN%s, %s%d FAIL%s\n' "$REPO_NAME" "$G" "$PASS" "$Z" "$Y" "$WARN" "$Z" "$R" "$FAIL" "$Z" - if [ "$FAIL" -gt 0 ]; then - echo "Next: 'just heal' fixes what is safe to fix automatically; each FAIL code is explained in docs/SETUP.adoc §Troubleshooting." - return 1 - fi - return 0 -} - -cmd_setup() { - printf '%s setup — installing everything this repository needs\n' "$REPO_NAME" - local rc=0 - if have mise; then - hdr "mise toolchain" - $T mise trust -q . 2>/dev/null || true - mise install || rc=1 - else - warn "mise not found. Install it ($MISE_INSTALL_HINT), or enter the Guix shell with 'just dev-shell', then re-run: just setup" - fi - local l - for l in "${LANGS[@]}"; do - case "$l" in - idris2) have pack || warn "pack not found — Idris2 comes from pack, not mise: $(lang_remedy idris2)" ;; - haskell) have ghcup || have ghc || warn "ghc not found — $(lang_remedy haskell)" ;; - ocaml) have opam && { opam switch show >/dev/null 2>&1 || opam init -y --bare; } ;; - esac - done - hdr "Project dependencies" - # Put this repo's mise tools on PATH for the dependency step. - if have mise; then PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH"; export PATH; fi - lang_run deps || rc=1 - local hook=build/just/setup-local.sh - [ -f "$hook" ] && { hdr "Repo-specific setup ($hook)"; bash "$hook" || rc=1; } - has_recipe setup-local && { hdr "Repo-specific setup (just setup-local)"; just setup-local || rc=1; } - [ -f launcher.sh ] && chmod +x launcher.sh - hdr "Verification" - cmd_doctor || rc=1 - [ $rc -eq 0 ] && printf '\n%sReady.%s Try: just --list\n' "$G" "$Z" || printf '\n%sSetup incomplete%s — read the FAIL lines above; docs/SETUP.adoc has the manual route.\n' "$R" "$Z" - return $rc -} - -cmd_heal() { - printf '%s heal — applying safe, reversible fixes, then re-checking\n' "$REPO_NAME" - hdr "Fixes" - [ -f launcher.sh ] && [ ! -x launcher.sh ] && chmod +x launcher.sh && info "made launcher.sh executable" - for f in build/just/*.sh scripts/*.sh; do [ -f "$f" ] && [ ! -x "$f" ] && chmod +x "$f" && info "made $f executable"; done - if have mise; then - $T mise trust -q . 2>/dev/null && info "mise config trusted" - mise install && info "mise tools installed" - [ -f mise.lock ] || { $T mise lock 2>/dev/null && info "mise.lock generated"; } - fi - if printf '%s\n' "${LANGS[@]}" | grep -qx elixir && have mix; then mix deps.get >/dev/null 2>&1 && info "mix deps fetched"; fi - if printf '%s\n' "${LANGS[@]}" | grep -qx bun && have bun; then bun install >/dev/null 2>&1 && info "bun deps installed"; fi - if printf '%s\n' "${LANGS[@]}" | grep -qx julia && have julia; then julia --project=. -e 'using Pkg; Pkg.instantiate()' >/dev/null 2>&1 && info "julia deps instantiated"; fi - local hook=build/just/heal-local.sh - [ -f "$hook" ] && { info "running $hook"; bash "$hook" || true; } - has_recipe heal-local && { info "running just heal-local"; just heal-local || true; } - echo "Not touched (never automatic): source files, git history, Deno/Nix/Make leftovers — those are migrations, tracked as issues." - hdr "Re-check" - cmd_doctor -} - -cmd_dev_shell() { - local gm; gm=$(gpath manifest.scm) - if have guix && [ -f "$gm" ] && ! grep -q '{{' "$gm"; then - info "entering: guix shell -m $gm (exit to leave)" - exec guix shell -m "$gm" - elif have mise; then - info "guix/manifest.scm unavailable — entering the mise environment instead (exit to leave)" - exec mise exec -- "${SHELL:-bash}" - else - fail "PV-E40 neither guix nor mise is installed — see docs/SETUP.adoc"; return 1 - fi -} - -# Weekly toolchain refresh: bump mise pins and the lock, re-pin the Guix channel, -# regenerate build/guix/crates.scm when guix.scm loads it, then show the diff -# for a signed commit. -cmd_toolchain_refresh() { - hdr "mise: bump 'latest' resolutions and re-lock" - have mise || { fail "mise not found"; return 1; } - mise up --bump || return 1 - $T mise lock 2>/dev/null || mise lock || return 1 - local ch; ch=$(gpath channels.scm) - if [ -f "$ch" ]; then - hdr "guix: channel pin" - if have guix; then - guix pull --channels="$ch" --dry-run >/dev/null 2>&1 || true - guix describe --format=channels > "$ch.new" 2>/dev/null && mv "$ch.new" "$ch" && info "$ch re-pinned to the current guix commit" - else info "guix not installed — $ch left as-is (CI re-pins it)"; fi - fi - local cr="" - if [ -f Cargo.lock ] && grep -qs 'crates\.scm' "$(gpath guix.scm)"; then - hdr "guix: crate inputs from Cargo.lock" - cr=build/guix/crates.scm - if have "${GUIX%% *}"; then cmd_crates_scm || return 1 - else info "guix not installed — $cr left as-is (CI regenerates it)"; fi - fi - git --no-pager diff --stat -- mise.toml mise.lock "$ch" $cr 2>/dev/null - echo "Commit the diff above (signed) as: chore(toolchain): weekly refresh" -} - -# The guix command (default: guix), e.g. a wrapper that runs it in a container -# with this directory mounted. -GUIX="${GUIX:-guix}" - -# Write build/guix/crates.scm: every registry crate in Cargo.lock as a Guix -# origin, and %crate-inputs listing them for guix.scm. Written whole or not at -# all. The importer's output is accepted only when it defines exactly one crate -# source per registry package in Cargo.lock: run through a container, guix's exit -# status is lost, so the count is the check. -cmd_crates_scm() { - local dst=build/guix/crates.scm want got tmp spdx - [ -f Cargo.lock ] || { info "no Cargo.lock — no crate inputs"; return 0; } - want=$(grep -c '^source = "registry+' Cargo.lock) - tmp=$(mktemp) || return 1 - if [ "$want" -gt 0 ]; then - # GUIX is split on purpose: it may be a command with arguments. - # shellcheck disable=SC2086 - timeout 1800 $GUIX import crate --lockfile=Cargo.lock "$REPO_NAME" >"$tmp" 2>"$tmp.err" - got=$(grep -c '^(define rust-' "$tmp") - if [ "$got" != "$want" ]; then - fail "PV-E41 guix import crate defined $got of the $want registry crates in Cargo.lock; $dst left as-is: $(grep -m1 -i 'error' "$tmp.err" || grep -v '^+' "$tmp.err" | tail -1)" - rm -f "$tmp" "$tmp.err"; return 1 - fi - fi - # The licence line is guix.scm's own, so the file matches its repository. - spdx=$(grep -m1 'SPDX-License-Identifier' "$(gpath guix.scm)" 2>/dev/null) - mkdir -p build/guix - { - [ -n "$spdx" ] && printf '%s\n' "$spdx" - # shellcheck disable=SC2016 # the backticks are literal text - printf ';; Generated from Cargo.lock by `just toolchain-refresh` (guix import crate\n' - printf ';; --lockfile); never hand-edit it. guix.scm loads it for %%crate-inputs.\n\n' - grep -v '^guix (GNU Guix)' "$tmp" - printf '\n(define %%crate-inputs\n (list' - grep -o '^(define rust-[^ ]*' "$tmp" | awk '{printf "\n %s", $2}' - printf '))\n' - } >"$dst.new" && mv "$dst.new" "$dst" - rm -f "$tmp" "$tmp.err" - info "$dst: $want crate source(s)" -} - -# The sentence people are told to say lives in one place a reader sees: the first -# listing block of the README's [[ai-install]] section. The deed's ai-say-it and -# the generic line are fallbacks for a README without that section. -just_say_it() { - local line="" r - for r in README.adoc README.md; do - [ -f "$r" ] || continue - line=$(awk '/^\[\[ai-install\]\]/{on=1} on && /^----$/{if (inb) exit; inb=1; next} inb && NF{print; exit}' "$r") - [ -n "$line" ] && break - done - [ -z "$line" ] && line=$(deed ai-say-it "") - [ -z "$line" ] && line="Set up $REPO_NAME from https://github.com/$REPO_SLUG — follow docs/AI_INSTALLATION_GUIDE.adoc in that repo." - printf '%s' "$line" -} -clip() { # best-effort; silent when no clipboard exists (CI, SSH) - # wl-copy and xclip fork a daemon that inherits stdout; left attached, it - # holds any pipeline open forever (observed 2026-09-30). Detach and bound it. - local c=() - if have clip.exe; then c=(clip.exe) # WSL: the Windows clipboard - elif have pbcopy; then c=(pbcopy) - elif [ -n "${WAYLAND_DISPLAY:-}" ] && have wl-copy; then c=(wl-copy) - elif [ -n "${DISPLAY:-}" ] && have xclip; then c=(xclip -selection clipboard) - else cat >/dev/null; return 1; fi - timeout 3 "${c[@]}" >/dev/null 2>&1 -} -cmd_ai_setup() { - cat <" >&2; return 2 ;; esac - for f in "$(wpath "$who")" "llm-warmup-$who.adoc" "docs/onboarding/llm-warmup-$who.adoc" "docs/llm-warmup-$who.adoc"; do - if [ -f "$f" ]; then - cat "$f"; clip < "$f" && echo "--- (copied $f to your clipboard — paste it as your first message to any AI)" >&2 || true - return 0 - fi - done - fail "llm-warmup-$who.adoc not found"; return 1 -} - -cmd_eval() { - local logf rc=0 s e v r o st - logf=".eval/$(date -u +%Y%m%dT%H%M%SZ).txt" - mkdir -p .eval - { echo "# $REPO_NAME evaluation — $(date -u +%FT%TZ) — $(git rev-parse --short HEAD 2>/dev/null || echo no-git)" - echo "# languages: ${LANGS[*]} archetype: $ARCHETYPE"; } > "$logf" - for v in test bench; do - # The root recipe if the repo defines one (its own override), else the module default. - if just --summary 2>/dev/null | tr " " "\n" | grep -qx "$v"; then r=$v; else r="provision::$v"; fi - s=$(date +%s); o=$(mktemp) - if just "$r" >"$o" 2>&1; then st=PASS; else st=FAIL; rc=1; fi - e=$(( $(date +%s) - s )); cat "$o" >>"$logf" - # A skip is not a pass: lang_run exits 0 on N/A (users see success), eval says N/A. - [ "$st" = PASS ] && grep -q "nothing was faked" "$o" && st=N/A - rm -f "$o"; echo "$v: $st (${e}s)" | tee -a "$logf" - done - echo "full log: $logf" - return $rc -} - -cmd_config_show() { - echo "repo: $REPO_SLUG" - echo "archetype: $ARCHETYPE" - echo "languages: ${LANGS[*]}" - echo "descriptor: $DEED $([ -f "$DEED" ] && echo '(present)' || echo '(absent — defaults in use)')" - echo "config: $(deed config "none declared")" - echo "run: $(deed run "$(for l in "${LANGS[@]}"; do lang_cmd "$l" run; done | head -1)")" - echo "ports: $(deed ports "none")" - echo "mise: $([ -f mise.toml ] && echo mise.toml) $([ -f mise.lock ] && echo mise.lock)" - echo "guix: $(for f in guix.scm manifest.scm channels.scm; do f=$(gpath "$f"); [ -f "$f" ] && printf "%s " "$f"; done)" - echo "lib: provision-lib $PROVISION_LIB_VERSION" -} - -cmd_opsm() { - cat <} - if have agrep; then git ls-files -z | xargs -0 agrep -n -1 -- "$p" 2>/dev/null - else git grep -n -i -- "$p"; fi -} - -case "${1:-}" in - guix-specs) guix_specs ;; - mise-tools) mise_tools ;; - langs) printf '%s\n' "${LANGS[@]}" ;; - doctor) cmd_doctor ;; - setup) cmd_setup ;; - heal) cmd_heal ;; - dev-shell) cmd_dev_shell ;; - toolchain-refresh) cmd_toolchain_refresh ;; - crates-scm) cmd_crates_scm ;; - ai-setup) cmd_ai_setup ;; - ai-warmup) shift; cmd_ai_warmup "${1:-user}" ;; - eval) cmd_eval ;; - config-show) cmd_config_show ;; - opsm) cmd_opsm ;; - search) shift; cmd_search "${1:-}" ;; - lang-run) shift; lang_run "${1:?verb}" ;; - version) echo "provision-lib $PROVISION_LIB_VERSION" ;; - guix-dir) guix_dir ;; - set-files) set_files ;; - guix-gaps) guix_gaps ;; - tool-table) tool_table ;; - system-deps) shift; system_deps "${1:-adoc}" ;; - # Predicates: print why, exit 1; print nothing, exit 0. - guix-stub) shift; r=$(guix_stub_reason "${1:?usage: guix-stub FILE}"); [ -z "$r" ] || { echo "$r"; exit 1; } ;; - mise-lock-gaps) r=$(mise_lock_gaps); [ -z "$r" ] || { echo "$r"; exit 1; } ;; - mise-banned) r=$(mise_banned); [ -z "$r" ] || { echo "$r"; exit 1; } ;; - *) sed -n '2,20p' "$0"; exit 2 ;; -esac diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh deleted file mode 100755 index 35e6033..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision-modes.sh +++ /dev/null @@ -1,158 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# shellcheck shell=bash -# -# provision-modes.sh — the launcher-standard 0.6.0 provisioning mode family, -# as a SOURCEABLE block shared by every launcher in the estate. -# -# New launchers (library/tool/theory/docs): templates/launcher.sh.tmpl sources -# this and calls hp_launcher_main. -# Existing app launchers: add ONE line before their mode switch — -# -# . "$REPO_DIR/build/just/provision-modes.sh" && hp_provision_or_return "$@" -# -# A provisioning mode runs and the launcher exits with its status (a failed -# --setup exits non-zero); any other mode returns, so the app's own switch -# handles it. hp_provision_dispatch itself returns 99 for "not mine". -# -# Canon: hyperpolymath/standards launcher/launcher-standard_praxis.deed -# (archetypes, provisioning-modes) and -# 3-practice/provisioning/PROVISIONING-STANDARD.adoc - -HP_PROVISION_MODES_VERSION="0.6.0" - -hp__deed() { # $1 key, $2 default — flat (key "value") read from provisioning_praxis.deed - local f="$REPO_DIR/.machine_readable/descriptiles/provisioning_praxis.deed" v="" - [ -f "$f" ] && v=$(grep -oE "[(:]$1[[:space:]]+\"[^\"]*\"" "$f" | head -1 | sed -E 's/^[^"]*"//; s/"$//') - printf '%s' "${v:-$2}" -} - -hp_archetype() { hp__deed archetype "library"; } -# Without a deed, the origin remote names the repo (a worktree or renamed clone -# has another directory name); the directory is the last resort. -hp_app_name() { - local u; u=$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true) - u=${u%.git}; u=${u##*/} - hp__deed name "${u:-$(basename "$REPO_DIR")}" -} - -hp_platform() { - case "$(uname -s)" in - Linux*) echo linux ;; - Darwin*) echo macos ;; - CYGWIN*|MINGW*|MSYS*|Windows_NT) echo windows ;; - *) echo unknown ;; - esac -} - -# Make sure `just` is runnable: PATH, then mise, then say exactly what to do. -hp_ensure_just() { - command -v just >/dev/null 2>&1 && return 0 - if command -v mise >/dev/null 2>&1; then - echo "just is not installed — installing it with mise (mise use -g just@latest)..." >&2 - mise use -g just@latest >&2 && PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH" && command -v just >/dev/null 2>&1 && return 0 - mise exec just@latest -- true >/dev/null 2>&1 && { just() { mise exec just@latest -- just "$@"; }; return 0; } - fi - cat >&2 <<'EOF' -Neither `just` nor `mise` is installed. Install mise (it then installs everything else): - - brew install mise # macOS - sudo dnf copr enable jdxcode/mise && sudo dnf install mise # Fedora - winget install jdx.mise # Windows - # anything else (Debian/Ubuntu apt, …): https://mise.jdx.dev/installing-mise.html - # or download the installer, read it, then run it — never pipe it into a shell: - curl -fsSLo mise-install.sh https://mise.run && less mise-install.sh && sh mise-install.sh - -then re-run this command. Manual route without mise: docs/SETUP.adoc -EOF - return 1 -} - -hp_just() { hp_ensure_just || return 1; (cd "$REPO_DIR" && just "$@"); } -# The provisioning modes call the engine directly, not a `just` recipe: a repo may -# define its own root `doctor`/`setup`/`heal`, and the launcher must still run the -# canon (which then runs that repo's *-local recipes). Only needs bash. -hp_lib() { (cd "$REPO_DIR" && bash build/just/provision-lib.sh "$@"); } - -# Returns the mode's exit code, or 99 when "$1" is not a provisioning mode. -# The one-line hook for existing launchers: exit with a provisioning mode's -# status, or return (0) so the caller's own mode switch runs. -hp_provision_or_return() { - hp_provision_dispatch "$@" - local rc=$? - [ "$rc" -eq 99 ] && return 0 - exit "$rc" -} - -hp_provision_dispatch() { - case "${1:-}" in - --setup) hp_lib setup ;; - --doctor) hp_lib doctor ;; - --heal) hp_lib heal ;; - --ai-setup) hp_lib ai-setup ;; - *) return 99 ;; - esac -} - -hp_version_line() { - local sha ver - sha=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown) - ver=$(hp__deed version "") - [ -z "$ver" ] && ver=$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null || echo 0.0.0) - printf '%s-launcher %s (%s) [%s-%s]\n' "$(hp_app_name)" "${ver#v}" "$sha" "$(hp_platform)" "$(uname -m)" -} - -hp_help() { - local name arch; name=$(hp_app_name); arch=$(hp_archetype) - cat < - -Provisioning (every repository): - --setup Install everything this repository needs, then run --doctor - --doctor Check the environment; PASS/WARN/FAIL with fix hints (exit 1 on FAIL) - --heal Apply safe fixes automatically, then re-run --doctor - --ai-setup Print the one line to give any AI assistant to set this up for you - -Meta: - --help This text - --version Machine-readable version line - -EOF - if [ "$arch" = app ]; then - echo "Runtime: --start --stop --status --auto (default) --integ --disinteg" - else - echo "Runtime modes (--start --stop --status --auto --integ --disinteg) do not apply" - echo "to a $arch repository; they are accepted and explain themselves." - fi - cat <&2 - return 1 - fi - echo "$(hp_app_name) is a $arch: there is nothing to ${mode#--}. Try --setup, --doctor or --help." - return 0 ;; - *) - echo "Unknown mode: $mode" >&2; hp_help >&2; return 2 ;; - esac -} diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just b/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just deleted file mode 100644 index 9f1ac7c..0000000 --- a/crates/launcher-common/tests/fixtures/provisioning/check/build/just/provision.just +++ /dev/null @@ -1,108 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# provision.just — the estate provisioning contract, as a just MODULE. -# -# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc -# Loaded from the root Justfile with: -# -# mod provision 'build/just/provision.just' -# -# A module (not `import`) so these recipes live under `provision::` and never -# collide with a repo's own `doctor`, `heal` or `test`. The root Justfile then -# exposes each contract name ONCE — either its own recipe, or a one-line -# delegation such as `doctor: provision::doctor` that `provision-set mint` -# adds only when the root has no recipe of that name. -# -# requires: just >= 1.42.0 (root recipes depend on module recipes; measured 2026-09-30: 1.41 rejects `x: m::x`, 1.42 accepts) - -root := justfile_directory() -lib := "PROVISION_ROOT=" + quote(root) + " bash " + quote(source_directory() / "provision-lib.sh") - -# List the provisioning recipes -default: - @just --list provision - -# Install every tool and dependency this repo needs, then run doctor -setup: - @{{lib}} setup - -# Diagnose the environment: PASS/WARN/FAIL with codes; exits 1 on any FAIL -doctor: - @{{lib}} doctor - -# Apply safe, reversible fixes, then re-run doctor -heal: - @{{lib}} heal - -# Enter a shell with every tool on PATH (guix shell -m manifest.scm, else mise) -dev-shell: - @{{lib}} dev-shell - -# Weekly: bump mise 'latest' resolutions, re-lock, re-pin guix channels -toolchain-refresh: - @{{lib}} toolchain-refresh - -# Print (and copy) the one line to give any AI to install this repo -ai-setup: - @{{lib}} ai-setup - -# Print (and copy) the warm-up for an AI session: user, dev or maintainer -ai-warmup who="user": - @{{lib}} ai-warmup {{quote(who)}} - -# Run test + bench, time both, and write .eval/.txt -eval: - @{{lib}} eval - -# Show where this repo's configuration and toolchain are declared -config-show: - @{{lib}} config-show - -# How to install this repo with OPSM (odds-and-sods package manager) -opsm: - @{{lib}} opsm - -# Print the languages detected from marker files -langs: - @{{lib}} langs - -# Approximate search across tracked files (agrep, else git grep) -search pattern: - @{{lib}} search {{quote(pattern)}} - -# Per-language defaults for the contract verbs, reached as `just provision::` -# or through a root recipe that delegates (`build: provision::build`). A root -# recipe with its own body is the repo-specific override. - -# Build with every detected language's toolchain -build: - @{{lib}} lang-run build - -# Run every detected language's tests -test: - @{{lib}} lang-run test - -# Run every detected language's benchmarks -bench: - @{{lib}} lang-run bench - -# Lint every detected language -lint: - @{{lib}} lang-run lint - -# Format every detected language's sources -fmt: - @{{lib}} lang-run fmt - -# Check formatting without rewriting anything (the CI form of fmt) -fmt-check: - @{{lib}} lang-run fmt-check - -# Run the project (N/A for libraries) -run: - @{{lib}} lang-run run - -# Fetch every detected language's dependencies -deps: - @{{lib}} lang-run deps From 2cd4690e037fac01c79078d37da6d1975e609e98 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:42:21 +0100 Subject: [PATCH 13/26] fix(provision-set): MPL before AGPL, baked fixture engine, canon 42b66c3 - licence: the full MPL-2.0 text names the GNU Affero GPL among its Secondary Licenses, so MPL is tested before AGPL; a test feeds the real LICENSES/MPL-2.0.txt (killed-mutant checked: reversing the order fails it). - fixtures: `check/` no longer commits engine copies; scratch() writes the baked canon engine, so the test exercises what the binary ships. - mint-all: a failed mint sets status=1 and continues instead of ending the loop under `set -e` (Justfile and its contractiles copy). - re-vendor the canon at standards@42b66c3 (guix-only channel re-pin, doctor tally last on stdout, honest deed artefact kinds) and re-bless the digest pin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .machine_readable/contractiles/Justfile | 7 +++- Justfile | 7 +++- .../launcher-common/src/provisioning/canon.rs | 2 +- .../src/provisioning/licence.rs | 24 +++++++++-- .../tests/provisioning_fixtures.rs | 13 +++++- standards/provisioning/CANON | 2 +- .../provisioning/PROVISIONING-STANDARD.adoc | 2 +- .../provisioning-standard_praxis.deed | 11 +++-- .../templates/build/just/provision-lib.sh | 41 +++++++++++++++++-- .../templates/llm-warmup-maintainer.adoc.tmpl | 15 ++++--- 10 files changed, 102 insertions(+), 22 deletions(-) diff --git a/.machine_readable/contractiles/Justfile b/.machine_readable/contractiles/Justfile index 8f06123..68f39c1 100644 --- a/.machine_readable/contractiles/Justfile +++ b/.machine_readable/contractiles/Justfile @@ -105,7 +105,12 @@ mint-all root: for h in "${hits[@]}"; do echo " $h" >&2; done status=1; continue fi - "$BIN" mint "${hits[0]}"; n=$((n + 1)) + if "$BIN" mint "${hits[0]}"; then + n=$((n + 1)) + else + echo "✗ $rel: mint failed" >&2 + status=1 + fi done echo "Estate re-mint: $n of 7 launchers" exit "$status" diff --git a/Justfile b/Justfile index 8f06123..68f39c1 100644 --- a/Justfile +++ b/Justfile @@ -105,7 +105,12 @@ mint-all root: for h in "${hits[@]}"; do echo " $h" >&2; done status=1; continue fi - "$BIN" mint "${hits[0]}"; n=$((n + 1)) + if "$BIN" mint "${hits[0]}"; then + n=$((n + 1)) + else + echo "✗ $rel: mint failed" >&2 + status=1 + fi done echo "Estate re-mint: $n of 7 launchers" exit "$status" diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs index e6e6ffb..91891d5 100644 --- a/crates/launcher-common/src/provisioning/canon.rs +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -230,5 +230,5 @@ mod tests { assert!(BAKED_CANON_REF.trim().starts_with("standards@")); } - const PINNED_DIGEST: &str = "f74848223841daf04db1c32ccd445f293750429f3331f4c65125e70a79f11328"; + const PINNED_DIGEST: &str = "5f7d2a0044486bf0946e2266b44d9ff7144d1b6991ccf5a326ee1c3220bf0c38"; } diff --git a/crates/launcher-common/src/provisioning/licence.rs b/crates/launcher-common/src/provisioning/licence.rs index 2da3d4b..a272939 100644 --- a/crates/launcher-common/src/provisioning/licence.rs +++ b/crates/launcher-common/src/provisioning/licence.rs @@ -116,15 +116,17 @@ impl Signal { } /// What one licence text declares. PMPL is derived from MPL and names it, so a -/// Palimpsest text is PMPL however often it mentions Mozilla. +/// Palimpsest text is PMPL however often it mentions Mozilla. MPL-2.0 in turn +/// names the GNU Affero GPL among its Secondary Licenses (§1.12), so MPL is +/// tested before AGPL; the AGPL text never names Mozilla. fn signal(text: &str) -> Option { let t = text.to_ascii_lowercase(); if t.contains("palimpsest") || t.contains("pmpl-1.0") { Some(Signal::Pmpl) - } else if t.contains("gnu affero general public license") || t.contains("agpl-3.0") { - Some(Signal::Agpl) } else if (t.contains("mozilla public license") && t.contains("2.0")) || t.contains("mpl-2.0") { Some(Signal::Mpl) + } else if t.contains("gnu affero general public license") || t.contains("agpl-3.0") { + Some(Signal::Agpl) } else { None } @@ -228,6 +230,22 @@ mod tests { assert_eq!(classify(&d, "aerie"), Ok(MPL)); } + #[test] + fn full_mpl_text_naming_the_affero_gpl_is_still_mpl() { + // Re-wrapped onto single spaces, as a reflowed copy would be, so the + // AGPL name is no longer split across a line break. + let full = include_str!("../../../../LICENSES/MPL-2.0.txt") + .split_whitespace() + .collect::>() + .join(" "); + assert!( + full.contains("GNU Affero General Public License"), + "the control must name AGPL" + ); + let d = repo(&[("LICENSE", full.as_str())]); + assert_eq!(classify(&d, "aerie"), Ok(MPL)); + } + #[test] fn agpl_control_is_classified_agpl_and_flagged() { let d = repo(&[("LICENSE.txt", AGPL_TEXT)]); diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs index d62c631..6ee6fd3 100644 --- a/crates/launcher-common/tests/provisioning_fixtures.rs +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -16,7 +16,7 @@ //! tests loudly, because a skipped conformance test is not a pass. use launch_scaffolder_common::provisioning::{ - canon::Canon, + canon::{Canon, ENGINE_FILES}, mint::{self, Act, Options}, }; use std::collections::BTreeSet; @@ -41,6 +41,10 @@ const CHECK_FAILS: [&str; 3] = [ /// Copy fixture `rel` to a fresh directory named `tag` under cargo's test /// tmpdir, and make it a git repository (the engine takes the repo name from /// git, so an un-initialised copy would report the enclosing checkout). +/// +/// The `check` fixture carries no engine of its own: the baked canon engine is +/// written in here, so the test always exercises the engine the binary ships +/// rather than a committed copy that could drift from it. fn scratch(rel: &str, tag: &str) -> PathBuf { let dst = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("provisioning-{tag}")); let _ = std::fs::remove_dir_all(&dst); @@ -52,6 +56,13 @@ fn scratch(rel: &str, tag: &str) -> PathBuf { .expect("cp") .success(); assert!(ok, "copying fixture {rel}"); + if rel == "check" { + for e in ENGINE_FILES { + let f = dst.join(e); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(&f, Canon::Baked.file(e).unwrap()).unwrap(); + } + } let ok = Command::new("git") .args(["init", "-q"]) .current_dir(&dst) diff --git a/standards/provisioning/CANON b/standards/provisioning/CANON index dae8c50..4b52473 100644 --- a/standards/provisioning/CANON +++ b/standards/provisioning/CANON @@ -1 +1 @@ -standards@e323e0adda0397899a4e9963abf3124014310227 +standards@42b66c3669bcd3e5816aca313fea20d8ea8fb4db diff --git a/standards/provisioning/PROVISIONING-STANDARD.adoc b/standards/provisioning/PROVISIONING-STANDARD.adoc index 8f417a2..8975116 100644 --- a/standards/provisioning/PROVISIONING-STANDARD.adoc +++ b/standards/provisioning/PROVISIONING-STANDARD.adoc @@ -84,7 +84,7 @@ of the same name can never shadow them. |`doctor` |PASS / WARN / FAIL for every requirement, each with a code and its fix; the last line is `: N PASS, N WARN, N FAIL`; exits non-zero if and only if something FAILed |`heal` |applies every fix marked *auto*, runs `heal-local`, then `doctor` |`dev-shell` |`guix shell -m /manifest.scm` when Guix is present, otherwise the mise environment -|`toolchain-refresh` |`mise up --bump`, re-lock `mise.lock`, re-pin `channels.scm`, regenerate `build/guix/crates.scm` from `Cargo.lock` when `guix.scm` loads it (the TRUST-DEFAULTS-POLICY recipe). The crate file is replaced only when the importer defined every registry crate; otherwise PV-E41 and the old file stays +|`toolchain-refresh` |`mise up --bump`, re-lock `mise.lock`, re-pin only the `guix` channel commit in `channels.scm` (left unchanged, with a WARN, when the current commit cannot be read), regenerate `build/guix/crates.scm` from `Cargo.lock` when `guix.scm` loads it (the TRUST-DEFAULTS-POLICY recipe). The crate file is replaced only when the importer defined every registry crate; otherwise PV-E41 and the old file stays |`build`, `test`, `bench`, `lint`, `fmt`, `fmt-check`, `run`, `deps` |the deed's override, else the language default, else an explicit "N/A for this archetype" and exit 0; nothing is faked. A language default runs only when what it needs exists (a `test` step in `build.zig`, test files for `bun test`, which exits 0 on none) |`eval` |`test` + `bench` with timings, saved under `.eval/` |`config-show` |where the toolchain and configuration are declared diff --git a/standards/provisioning/provisioning-standard_praxis.deed b/standards/provisioning/provisioning-standard_praxis.deed index 7f01e1c..e348893 100644 --- a/standards/provisioning/provisioning-standard_praxis.deed +++ b/standards/provisioning/provisioning-standard_praxis.deed @@ -25,9 +25,11 @@ ;; ------------------------------------------------------------ artefact set ;; engine = identical estate-wide; `provision-set realign` overwrites it. - ;; generated = rendered from the template + deed; realign re-renders it only - ;; when it carries the @launcher-deed block (hand-written ones are - ;; kept and source build/just/provision-modes.sh). + ;; generated = produced, never hand-edited. launcher.sh is rendered from the + ;; template + deed and re-rendered by realign only when it carries + ;; the @launcher-deed block (hand-written ones are kept and source + ;; build/just/provision-modes.sh); build/guix/crates.scm is + ;; written from Cargo.lock by mint and toolchain-refresh. ;; minted = written once, then owned by the repository; realign creates it ;; when missing and replaces it only while it is still a stub, or ;; when the deed's :repo is another repository's (inherited). @@ -38,7 +40,8 @@ (file :path "build/just/provision-lib.sh" :kind engine :mode "100755") (file :path "build/just/provision-modes.sh" :kind engine :mode "100755") (file :path "build/just/provision-check.sh" :kind engine :mode "100755") - (file :path "channels.scm" :kind engine) + (file :path "channels.scm" :kind minted) ; re-pinned by toolchain-refresh + (file :path "build/guix/crates.scm" :kind generated) ; from Cargo.lock, when guix.scm loads it (file :path "Justfile" :kind merged) (file :path "mise.toml" :kind minted) (file :path "mise.lock" :kind minted) diff --git a/standards/provisioning/templates/build/just/provision-lib.sh b/standards/provisioning/templates/build/just/provision-lib.sh index fb1d942..a76ef33 100755 --- a/standards/provisioning/templates/build/just/provision-lib.sh +++ b/standards/provisioning/templates/build/just/provision-lib.sh @@ -438,6 +438,9 @@ system_deps() { # $1 adoc|ai elif [ $any -eq 0 ]; then printf '* No other OS packages: every tool comes from mise.\n'; fi } +# Diagnose the provisioning set and toolchain, then the repository's own +# doctor-local checks. The PASS/WARN/FAIL tally is the last line of stdout; +# returns non-zero when anything FAILed. cmd_doctor() { printf '%s doctor — %s (%s; languages: %s)\n' "$REPO_NAME" "$REPO_SLUG" "$ARCHETYPE" "${LANGS[*]}" @@ -545,7 +548,8 @@ cmd_doctor() { printf '\n%s: %s%d PASS%s, %s%d WARN%s, %s%d FAIL%s\n' "$REPO_NAME" "$G" "$PASS" "$Z" "$Y" "$WARN" "$Z" "$R" "$FAIL" "$Z" if [ "$FAIL" -gt 0 ]; then - echo "Next: 'just heal' fixes what is safe to fix automatically; each FAIL code is explained in docs/SETUP.adoc §Troubleshooting." + # stderr, so the tally above stays the last line of stdout on every outcome. + echo "Next: 'just heal' fixes what is safe to fix automatically; each FAIL code is explained in docs/SETUP.adoc §Troubleshooting." >&2 return 1 fi return 0 @@ -617,6 +621,30 @@ cmd_dev_shell() { fi } +# Print the 40-hex commit of the `guix` channel in a channels list read from +# stdin (`guix describe --format=channels`), or nothing when there is none. +guix_channel_commit() { + awk '/\(name .guix\)/ { g = 1 } + g && match($0, /\(commit "[0-9a-f]{40}"\)/) { print substr($0, RSTART + 9, 40); exit }' +} + +# Re-pin only the `guix` channel's commit in channels file $1 to $2, keeping +# every other line (comments, other channels, introduction) as it is. Returns +# non-zero, leaving the file untouched, when it has no guix channel commit. +repin_guix_channel() { + local ch=$1 pin=$2 tmp + tmp=$(mktemp) || return 1 + if awk -v pin="$pin" ' + /\(name .guix\)/ { g = 1 } + g && !done && sub(/\(commit "[0-9a-f]{40}"\)/, "(commit \"" pin "\")") { done = 1 } + { print } + END { exit !done }' "$ch" > "$tmp"; then + cat "$tmp" > "$ch"; rm -f "$tmp" + else + rm -f "$tmp"; return 1 + fi +} + # Weekly toolchain refresh: bump mise pins and the lock, re-pin the Guix channel, # regenerate build/guix/crates.scm when guix.scm loads it, then show the diff # for a signed commit. @@ -629,8 +657,15 @@ cmd_toolchain_refresh() { if [ -f "$ch" ]; then hdr "guix: channel pin" if have guix; then - guix pull --channels="$ch" --dry-run >/dev/null 2>&1 || true - guix describe --format=channels > "$ch.new" 2>/dev/null && mv "$ch.new" "$ch" && info "$ch re-pinned to the current guix commit" + local pin + pin=$(guix describe --format=channels 2>/dev/null | guix_channel_commit) + if [ -z "$pin" ]; then + warn "could not read the current guix channel commit — $ch left unchanged" + elif repin_guix_channel "$ch" "$pin"; then + info "$ch: guix channel re-pinned to $pin" + else + warn "$ch has no (name 'guix) channel with a commit — left unchanged" + fi else info "guix not installed — $ch left as-is (CI re-pins it)"; fi fi local cr="" diff --git a/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl b/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl index 62085cb..4295ea3 100644 --- a/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl +++ b/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl @@ -10,15 +10,18 @@ __APP_NAME__. Read `llm-warmup-dev.adoc` first; this adds what a maintainer owns This repository is provisioned from the estate canon (https://github.com/hyperpolymath/standards, `3-practice/provisioning/`). Repo-specific facts live in `.machine_readable/descriptiles/provisioning_praxis.deed`. -`provision-set realign` overwrites only the engine: `launcher.sh`, -`build/just/provision*.{just,sh}` and `channels.scm`; change behaviour through the -deed, never by hand-editing those. Everything else (`guix.scm` once real, -`manifest.scm`, `mise.toml`, the Justfile, `docs/` and these warm-ups) is minted -once and then belongs to this repository: keep it true as the code changes. +`provision-set realign` overwrites only the engine, `build/just/provision*`; +change behaviour through the deed, never by hand-editing those. It re-renders +`launcher.sh` only when that file carries the `@launcher-deed` block. +`build/guix/crates.scm` is generated from `Cargo.lock`: regenerate it, never +edit it. Everything else (`channels.scm`, re-pinned by `toolchain-refresh`; +`guix.scm` once real, `manifest.scm`, `mise.toml`, the Justfile, `docs/` and +these warm-ups) is minted once and then belongs to this repository: keep it +true as the code changes. == Keeping it current -* `just toolchain-refresh` — `mise up --bump`, `mise lock`, re-pin `channels.scm`. +* `just toolchain-refresh` — `mise up --bump`, `mise lock`, re-pin the guix commit in `channels.scm`. Commit the lock changes on their own. * `just doctor` must be clean before a release; WARN codes are tracked, not ignored. * The `provisioning-check` workflow reports drift from the canon. From 17016db3001e29af095e6eab1409714fcd5d27fd Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:07:39 +0100 Subject: [PATCH 14/26] fix(provision-set): fold duplicate configs, raise floors, canon bf7c97a Found by re-minting the rust pilot (action-trust-layers), which tracks mise.toml, .mise.toml, Justfile and justfile at once. - Two or more justfiles make `just` refuse to run. mint now folds them: the file with the most recipes is kept, the others' missing recipes join it, a template placeholder or boilerplate body yields to a real one of the same name, and the fold is undone if it would break a file that parsed before. The folded files are reported as removed. - Carried-over tools are read from .tool-versions, mise.toml and .mise.toml in mise's precedence order; the secondary config is folded in and removed. A carried pin below a canon floor (just < 1.42.0) is raised and the raise is said. - Canon re-vendored at standards bf7c97a: the banned-tool list is the deed's, npm:/pipx:/pip:/go: backends are banned, and a bare name whose only backends are those (prettier) is banned too. A test asserts the deed's lists equal the engine's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../launcher-common/src/provisioning/canon.rs | 2 +- .../src/provisioning/justfile.rs | 170 ++++++++++ .../launcher-common/src/provisioning/mint.rs | 304 +++++++++++++++--- crates/launcher/src/cmd_provision_set.rs | 1 + standards/provisioning/CANON | 2 +- .../provisioning-standard_praxis.deed | 9 +- .../templates/build/just/provision-lib.sh | 47 ++- .../templates/docs/SETUP.adoc.tmpl | 2 +- 8 files changed, 479 insertions(+), 58 deletions(-) diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs index 91891d5..8118737 100644 --- a/crates/launcher-common/src/provisioning/canon.rs +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -230,5 +230,5 @@ mod tests { assert!(BAKED_CANON_REF.trim().starts_with("standards@")); } - const PINNED_DIGEST: &str = "5f7d2a0044486bf0946e2266b44d9ff7144d1b6991ccf5a326ee1c3220bf0c38"; + const PINNED_DIGEST: &str = "f4337dd6d042fd028fde2f6c7d970edd223225ad29d033135dc3128065a50e83"; } diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index b77c158..4c6ccc6 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -44,6 +44,92 @@ const LOCAL: &[&str] = &["setup", "doctor", "heal"]; const MOD_LINE: &str = "mod provision 'build/just/provision.just'"; +/// Fold several justfiles in `target` into one, which `just` needs before it +/// will run at all ("Multiple candidate justfiles found"). The file with the +/// most recipes is kept (`Justfile` on a tie); each other file's recipes that +/// it lacks are appended to it, and a recipe both define keeps the kept file's +/// body unless that body is a template placeholder or boilerplate and the +/// other's is not. The other file is then removed. The result must +/// parse whenever the kept file parsed before, or it is restored, nothing is +/// removed, and the fold is reported as skipped. Returns the kept file's name +/// and one report line per other file. +pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)>)> { + let mut texts = Vec::new(); + for n in names { + let p = target.join(n); + texts + .push(std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?); + } + let keep = (0..names.len()) + .max_by_key(|&i| (recipe_names(&texts[i]).len(), names[i] == "Justfile")) + .unwrap_or(0); + let kept = names[keep].to_string(); + let parsed_before = summary(target, &kept).is_ok(); + let mut merged = texts[keep].clone(); + let mut notes = Vec::new(); + for (i, n) in names.iter().enumerate().filter(|&(i, _)| i != keep) { + let lines: Vec<&str> = texts[i].lines().collect(); + let (mut added, mut shadowed) = (Vec::new(), Vec::new()); + for (h, l) in lines.iter().enumerate() { + let Some(r) = header_name(l) else { continue }; + let have: Vec<&str> = merged.lines().collect(); + let (s, _, e) = span_at(&lines, h); + if let Some((hs, _, he)) = span(&have, r) { + // A template placeholder never beats the repository's own body. + let stub = |b: &str| PLACEHOLDER.iter().chain(BOILERPLATE).any(|m| b.contains(m)); + let theirs = lines[s..e].join("\n"); + if stub(&have[hs..he].join("\n")) && !stub(&theirs) { + let mut out: Vec<&str> = have[..hs].to_vec(); + out.extend(theirs.lines()); + out.extend(&have[he..]); + merged = out.join("\n") + "\n"; + added.push(r.to_string()); + } else { + shadowed.push(r.to_string()); + } + continue; + } + if !merged.ends_with('\n') { + merged.push('\n'); + } + merged.push('\n'); + merged.push_str(&lines[s..e].join("\n")); + merged.push('\n'); + added.push(r.to_string()); + } + let why = match (added.is_empty(), shadowed.is_empty()) { + (true, true) => format!("no recipes; {kept} is the one `just` runs"), + (true, false) => format!("every recipe is already in {kept}: {}", shadowed.join(" ")), + (false, true) => format!("folded into {kept}: {}", added.join(" ")), + (false, false) => format!( + "folded into {kept}: {}; {kept}'s own kept for: {}", + added.join(" "), + shadowed.join(" ") + ), + }; + notes.push(((*n).to_string(), why)); + } + let path = target.join(&kept); + std::fs::write(&path, &merged).with_context(|| format!("writing {}", path.display()))?; + if parsed_before { + if let Err(e) = summary(target, &kept) { + std::fs::write(&path, &texts[keep])?; + let why = format!("folding them into {kept} breaks it ({e}): fold them by hand"); + let skipped = notes + .into_iter() + .map(|(n, _)| (n, Act::Skipped(why.clone()))) + .collect(); + return Ok((kept, skipped)); + } + } + let mut out = Vec::new(); + for (n, why) in notes { + std::fs::remove_file(target.join(&n)).with_context(|| format!("removing {n}"))?; + out.push((n, Act::Removed(why))); + } + Ok((kept, out)) +} + /// Merge into `target/name`. `provision_just` is the canon module's text. pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { let path = target.join(name); @@ -570,4 +656,88 @@ mod tests { assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); } } + + #[test] + fn identical_justfiles_fold_to_one_and_the_copy_is_removed() { + let src = "# Build\nbuild:\n echo b\n"; + let d = repo(src); + std::fs::write(d.join("justfile"), src).unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "Justfile"); + assert_eq!(acts.len(), 1); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.contains("already in Justfile")), + "{acts:?}" + ); + assert!(!d.join("justfile").exists()); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn a_real_body_replaces_a_template_placeholder_of_the_same_name() { + // The kept file is the bigger, unedited RSR template; the other file holds + // the recipe the author actually wrote. + let d = repo( + "# Build\nbuild:\n # TODO: Replace with your build command\n @echo built\n\nci:\n echo ci\n\ndocs:\n echo d\n", + ); + std::fs::write(d.join("justfile"), "build:\n cargo build --release\n").unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "Justfile"); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!( + text.contains("cargo build --release") && !text.contains("TODO"), + "{text}" + ); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.starts_with("folded into Justfile: build")), + "{acts:?}" + ); + assert_eq!(summary(&d, "Justfile").unwrap(), ["build", "ci", "docs"]); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn the_richer_justfile_is_kept_and_the_others_recipes_join_it() { + // action-trust-layers' shape: the real recipes are in the lowercase file. + let d = repo("# Help\nhelp:\n echo h\n"); + std::fs::write( + d.join("justfile"), + "# Build\nbuild:\n cargo build\n\n# Test\ntest:\n cargo test\n\nhelp:\n echo other\n", + ) + .unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "justfile"); + let text = std::fs::read_to_string(d.join("justfile")).unwrap(); + assert_eq!(text.matches("help:").count(), 1, "{text}"); + assert!( + text.contains("cargo build") && text.contains("echo other"), + "{text}" + ); + assert!(!d.join("Justfile").exists()); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.contains("help")), + "{acts:?}" + ); + assert_eq!(summary(&d, "justfile").unwrap(), ["build", "help", "test"]); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn a_fold_that_would_break_the_kept_file_is_undone() { + // `x` is a variable in the kept file; appending a recipe that reassigns it + // is a parse error, so nothing may be removed. + let kept = "x := \"1\"\n\nbuild:\n echo {{x}}\n\ntest:\n echo t\n"; + let d = repo(kept); + std::fs::write(d.join(".justfile"), "lint:\n echo {{y}}\n").unwrap(); + let (k, acts) = fold(&d, &["Justfile", ".justfile"]).unwrap(); + assert_eq!(k, "Justfile"); + assert!( + matches!(&acts[0].1, Act::Skipped(w) if w.contains("by hand")), + "{acts:?}" + ); + assert!(d.join(".justfile").exists()); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), kept); + std::fs::remove_dir_all(&d).unwrap(); + } } diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index f27728a..3ba8bf8 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -54,6 +54,17 @@ const DEED: &str = ".machine_readable/descriptiles/provisioning_praxis.deed"; const LIB: &str = "build/just/provision-lib.sh"; const WRAP: usize = 80; +/// The repository mise configs, lowest precedence first: mise merges them and +/// a later file's pin wins (measured with `mise ls --current`). These are the +/// only mise config paths tracked anywhere in the estate's local clones +/// (2026-10-01); `provision-lib.sh` `mise_toml_tools` reads the same three. +const MISE_PRECEDENCE: [&str; 3] = [".tool-versions", "mise.toml", ".mise.toml"]; +/// The configs mint folds into `mise.toml` and removes. +const SECONDARY_MISE: [&str; 2] = [".tool-versions", ".mise.toml"]; +/// Version floors for canon tools: a carried pin below one is raised to +/// `latest`. Mirrors the deed's `:just-floor`; a test keeps the two equal. +const TOOL_FLOORS: &[(&str, &str)] = &[("just", "1.42.0")]; + #[derive(Debug, Default, Clone)] pub struct Options { /// `owner/name`; default: the `origin` remote. @@ -75,6 +86,9 @@ pub enum Act { Replaced(String), Kept(String), Skipped(String), + /// A secondary file the canon folds into another (`.mise.toml` and + /// `.tool-versions` into `mise.toml`, `justfile` into `Justfile`). + Removed(String), /// An external step (`mise lock`, `guix import crate`) did not produce a /// valid file: a ledger line, and the CLI exits [`EXIT_EXTERNAL`]. Failed(String), @@ -90,6 +104,7 @@ impl std::fmt::Display for Act { Act::Replaced(why) => write!(f, "replaced ({why})"), Act::Kept(why) => write!(f, "kept ({why})"), Act::Skipped(why) => write!(f, "skipped ({why})"), + Act::Removed(why) => write!(f, "removed ({why})"), Act::Failed(why) => write!(f, "FAILED ({why})"), } } @@ -199,22 +214,44 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { vars.insert("GUIX_SPECS", quoted_atoms(&guix_specs)); vars.insert("GUIX_PKG_SPECS", quoted_atoms(&guix_specs)); - // mise.toml: minted, but a banned tool is a replace condition. + // mise.toml: minted, but a banned tool or a second mise config is a replace + // condition. mise merges every config it finds and the higher-precedence + // file wins, so a minted mise.toml beside a .mise.toml pins nothing. let banned = lib.predicate(&["mise-banned"])?; - let (mut carried, mut unread) = (Vec::new(), String::new()); + let secondary: Vec<&str> = SECONDARY_MISE + .into_iter() + .filter(|f| target.join(f).is_file()) + .collect(); + let (mut carried, mut notes) = (Vec::new(), Vec::new()); + if banned.is_some() || !secondary.is_empty() { + (carried, notes) = carry_over_tools(target, banned.as_deref().unwrap_or(""))?; + } + let (toml_lines, floor_notes) = mise_tools_toml(&mise_tools, &carried); + notes.extend(floor_notes); + vars.insert("MISE_TOOLS_TOML", toml_lines); + let mut reasons = Vec::new(); if let Some(hits) = &banned { - (carried, unread) = carry_over_tools(target, hits)?; + reasons.push(format!("pinned banned tool(s): {hits}")); } - vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried)); - let replace_why = banned - .as_ref() - .filter(|_| target.join("mise.toml").exists()) - .map(|hits| format!("pinned banned tool(s): {hits}{unread}")); + if !secondary.is_empty() { + reasons.push(format!("folded in {}", secondary.join(", "))); + } + reasons.extend(notes); + let replace_why = (banned.is_some() && target.join("mise.toml").exists() + || !secondary.is_empty()) + .then(|| reasons.join("; ")); let act = match &replace_why { Some(why) => m.force("mise.toml", "mise.toml.tmpl", &vars, why)?, _ => m.minted("mise.toml", "mise.toml.tmpl", &vars)?, }; files.push(("mise.toml".into(), act)); + for f in &secondary { + std::fs::remove_file(target.join(f)).with_context(|| format!("removing {f}"))?; + files.push(( + (*f).to_string(), + Act::Removed("its tools were folded into mise.toml".into()), + )); + } // The Guix trio, beside whichever guix.scm the repository already keeps. let cargo = langs.iter().any(|l| l == "rust") && target.join("Cargo.toml").is_file(); @@ -290,11 +327,21 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { )); // Justfile: created whole when absent, otherwise merged (justfile.rs), which - // `just --summary` must accept or the original is restored. - let justfile = ["Justfile", "justfile", ".justfile"] + // `just --summary` must accept or the original is restored. Several + // justfiles are folded into one first: `just` refuses to pick between them. + let present: Vec<&str> = ["Justfile", "justfile", ".justfile"] .into_iter() - .find(|j| target.join(j).is_file()); - let act = match justfile { + .filter(|j| target.join(j).is_file()) + .collect(); + let (justfile, folded) = match present.len() { + 0 => (None, Vec::new()), + 1 => (Some(present[0].to_string()), Vec::new()), + _ => { + let (kept, acts) = super::justfile::fold(target, &present)?; + (Some(kept), acts) + } + }; + let act = match justfile.as_deref() { Some(j) => { super::justfile::merge(target, j, &canon_text(canon, "build/just/provision.just")?)? } @@ -307,7 +354,8 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { write_file(target, "Justfile", body.as_bytes(), "")? } }; - files.push((justfile.unwrap_or("Justfile").into(), act)); + files.push((justfile.unwrap_or_else(|| "Justfile".into()), act)); + files.extend(folded); // Last, because it reads the mise.toml written above. let act = if opts.offline { @@ -324,7 +372,7 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { // know, such as the 07-18 sweep's `gnu-sed`) would fail // provision-check for ever: drop it, once, and say so. carried.retain(|(k, _)| !dropped.contains(k)); - vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried)); + vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried).0); let why = format!( "{why}; dropped {} carried tool(s) mise cannot pin: {}", dropped.len(), @@ -532,14 +580,21 @@ fn or_none(s: String) -> String { } /// `[tools]` lines: the canon's tools, then the other entries carried over from -/// a replaced `mise.toml`. A carried entry keeps its own value: the -/// repository's pin of a tool the canon also lists is a decision, not drift. -fn mise_tools_toml(tools: &[String], carried: &[(String, String)]) -> String { - let value = |t: &String| { - carried - .iter() - .find(|(k, _)| k == t) - .map_or_else(|| "\"latest\"".to_string(), |(_, v)| v.clone()) +/// a replaced `mise.toml`, plus one note per carried pin that was raised. A +/// carried entry keeps its own value (the repository's pin of a tool the canon +/// also lists is a decision, not drift) unless it is below a floor in +/// [`TOOL_FLOORS`], which is raised to `latest`. +fn mise_tools_toml(tools: &[String], carried: &[(String, String)]) -> (String, Vec) { + let mut notes = Vec::new(); + let mut value = |t: &String| match carried.iter().find(|(k, _)| k == t) { + None => "\"latest\"".to_string(), + Some((_, v)) => match below_floor(t, v) { + Some(floor) => { + notes.push(format!("raised {t} {v} to latest (floor {floor})")); + "\"latest\"".to_string() + } + None => v.clone(), + }, }; let mut lines: Vec = tools .iter() @@ -550,7 +605,26 @@ fn mise_tools_toml(tools: &[String], carried: &[(String, String)]) -> String { lines.push(format!("{} = {v}", toml_key(k))); } } - lines.join("\n") + (lines.join("\n"), notes) +} + +/// The floor `tool`'s pin `value` (a TOML value) is below, if any. A prefix pin +/// such as `"1"` is below only when no version it selects can meet the floor. +fn below_floor(tool: &str, value: &str) -> Option<&'static str> { + let (_, floor) = TOOL_FLOORS.iter().find(|(t, _)| *t == tool)?; + let pin: Vec = value + .trim_matches('"') + .split('.') + .map(str::parse) + .collect::>() + .ok()?; + let want: Vec = floor.split('.').map(|p| p.parse().unwrap_or(0)).collect(); + for (p, w) in pin.iter().zip(&want) { + if p != w { + return (p < w).then_some(*floor); + } + } + None } fn toml_key(k: &str) -> String { @@ -562,32 +636,59 @@ fn toml_key(k: &str) -> String { format!("\"{}\"", k.replace('\\', "\\\\").replace('"', "\\\"")) } } - -/// The non-banned `[tools]` entries of an existing `mise.toml`, values as TOML, -/// and a note for the replace reason when the file is not TOML at all: mise -/// cannot read such a file either, so nothing in it was in effect to carry. -fn carry_over_tools(target: &Path, banned: &str) -> Result<(Vec<(String, String)>, String)> { - let text = std::fs::read_to_string(target.join("mise.toml"))?; - let table: toml::Table = match toml::from_str(&text) { - Ok(t) => t, - Err(e) => { - let why = e.message().trim().to_string(); - return Ok(( - Vec::new(), - format!("; it was not valid TOML ({why}), so no tool was carried over"), - )); +/// A `(tool, TOML value)` pin carried from an existing mise config. +type Pin = (String, String); + +/// The non-banned tools of every repository mise config, values as TOML, and a +/// note per file that is not TOML at all: mise cannot read such a file either, +/// so nothing in it was in effect to carry. Files are read lowest precedence +/// first, so a later file's pin of the same tool replaces an earlier one, which +/// is the pin `mise ls --current` reports as in effect. +fn carry_over_tools(target: &Path, banned: &str) -> Result<(Vec, Vec)> { + let banned: Vec<&str> = banned.split_whitespace().collect(); + let (mut out, mut notes): (Vec<(String, String)>, Vec) = (Vec::new(), Vec::new()); + let mut carry = |k: &str, v: String| { + if banned.contains(&k) { + return; + } + match out.iter_mut().find(|(o, _)| o == k) { + Some(slot) => slot.1 = v, + None => out.push((k.to_string(), v)), } }; - let banned: Vec<&str> = banned.split_whitespace().collect(); - let mut out = Vec::new(); - if let Some(tools) = table.get("tools").and_then(|t| t.as_table()) { - for (k, v) in tools { - if !banned.contains(&k.as_str()) { - out.push((k.clone(), v.to_string())); + for f in MISE_PRECEDENCE { + let Ok(text) = std::fs::read_to_string(target.join(f)) else { + continue; + }; + if f == ".tool-versions" { + // `tool version [fallback…]`: the first version is the one in effect. + for line in text.lines().map(str::trim) { + let mut w = line.split_whitespace(); + if let (Some(k), Some(v)) = (w.next(), w.next()) { + if !k.starts_with('#') { + carry(k, toml::Value::String(v.to_string()).to_string()); + } + } + } + continue; + } + let table: toml::Table = match toml::from_str(&text) { + Ok(t) => t, + Err(e) => { + let why = e.message().trim().to_string(); + notes.push(format!( + "{f} was not valid TOML ({why}), so no tool was carried from it" + )); + continue; + } + }; + if let Some(tools) = table.get("tools").and_then(|t| t.as_table()) { + for (k, v) in tools { + carry(k, v.to_string()); } } } - Ok((out, String::new())) + Ok((out, notes)) } /// One root delegation per contract verb the root Justfile does not define, @@ -1080,7 +1181,7 @@ mod tests { ("cargo:cargo-nextest".to_string(), "\"latest\"".to_string()), ]; assert_eq!( - mise_tools_toml(&tools, &carried), + mise_tools_toml(&tools, &carried).0, "just = \"latest\"\nrust = \"1.85\"\n\"cargo:cargo-nextest\" = \"latest\"" ); } @@ -1097,10 +1198,121 @@ mod tests { std::fs::write(&mise, "[tools]\nbun = \"1\"\nbun = \"1\"\n").unwrap(); let (carried, note) = carry_over_tools(&d, "python").unwrap(); assert!(carried.is_empty()); - assert!(note.contains("not valid TOML") && note.contains("duplicate key")); + assert!( + note.iter() + .any(|n| n.contains("not valid TOML") && n.contains("duplicate key")) + ); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn carry_over_reads_every_config_and_the_winning_pin_wins() { + let d = std::env::temp_dir().join(format!("carry-prec-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write( + d.join(".tool-versions"), + "# pins\njust 1.30.0 1.29.0\nzig 0.13\npython 3.12\n", + ) + .unwrap(); + std::fs::write(d.join("mise.toml"), "[tools]\njust = \"1.40.0\"\n").unwrap(); + std::fs::write( + d.join(".mise.toml"), + "[tools]\nrust = \"1.95.0\"\njust = \"1.43.0\"\n", + ) + .unwrap(); + let (carried, notes) = carry_over_tools(&d, "python").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + // .mise.toml beats mise.toml beats .tool-versions, as `mise ls --current` reports. + assert_eq!( + carried, + vec![ + ("just".to_string(), "\"1.43.0\"".to_string()), + ("zig".to_string(), "\"0.13\"".to_string()), + ("rust".to_string(), "\"1.95.0\"".to_string()), + ] + ); std::fs::remove_dir_all(&d).unwrap(); } + #[test] + fn a_carried_pin_below_a_floor_is_raised_and_said() { + let tools = vec!["just".to_string()]; + for (pin, raised) in [ + ("\"1.36.0\"", true), + ("\"1.41\"", true), + ("\"0.9\"", true), + ("\"1.42.0\"", false), + ("\"1.58.0\"", false), + ("\"2\"", false), + ("\"1\"", false), // a prefix that can select 1.42+ + ("\"latest\"", false), + ] { + let carried = vec![("just".to_string(), pin.to_string())]; + let (toml, notes) = mise_tools_toml(&tools, &carried); + let want = if raised { + "just = \"latest\"".to_string() + } else { + format!("just = {pin}") + }; + assert_eq!(toml, want, "{pin}"); + assert_eq!(notes.len(), usize::from(raised), "{pin}: {notes:?}"); + } + } + + #[test] + fn the_deeds_banned_lists_are_the_engines() { + let deed = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/provisioning-standard_praxis.deed" + )); + let lib = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/templates/build/just/provision-lib.sh" + )); + // The quoted words of a deed list, which may wrap over several lines. + let deed_list = |key: &str| -> Vec { + let at = deed + .find(key) + .unwrap_or_else(|| panic!("deed has no {key}")); + let body = &deed[at..][..deed[at..].find(')').unwrap()]; + let mut v: Vec = body + .split('"') + .skip(1) + .step_by(2) + .map(String::from) + .collect(); + v.sort(); + v + }; + let lib_list = |var: &str| -> Vec { + let line = lib + .lines() + .find(|l| l.starts_with(&format!("{var}='"))) + .unwrap(); + let mut v: Vec = line[var.len() + 2..line.len() - 1] + .split('|') + .map(String::from) + .collect(); + v.sort(); + v + }; + assert_eq!(deed_list(":banned-tools "), lib_list("BANNED_TOOLS")); + assert_eq!(deed_list(":banned-backends "), lib_list("BANNED_BACKENDS")); + } + + #[test] + fn the_just_floor_matches_the_canon_deed() { + let deed = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/provisioning-standard_praxis.deed" + )); + let floor = TOOL_FLOORS.iter().find(|(t, _)| *t == "just").unwrap().1; + assert!( + deed.contains(&format!(":just-floor \"{floor}\"")), + "deed and TOOL_FLOORS disagree" + ); + } + #[test] fn mechanical_residue_ignores_spec_slots() { assert_eq!(mechanical_residue("x __SPEC_USAGE__ y"), None); diff --git a/crates/launcher/src/cmd_provision_set.rs b/crates/launcher/src/cmd_provision_set.rs index 3ec0dbe..af9ebd8 100644 --- a/crates/launcher/src/cmd_provision_set.rs +++ b/crates/launcher/src/cmd_provision_set.rs @@ -115,6 +115,7 @@ pub fn run(args: Args) -> Result<()> { Act::Replaced(_) => "replace", Act::Kept(_) => "keep", Act::Skipped(_) => "SKIP", + Act::Removed(_) => "remove", Act::Failed(_) => "FAIL", }; println!(" {tag:<8} {path}: {act}"); diff --git a/standards/provisioning/CANON b/standards/provisioning/CANON index 4b52473..aa07a30 100644 --- a/standards/provisioning/CANON +++ b/standards/provisioning/CANON @@ -1 +1 @@ -standards@42b66c3669bcd3e5816aca313fea20d8ea8fb4db +standards@bf7c97abfc7240e9c4275c85153e74b50de10eaf diff --git a/standards/provisioning/provisioning-standard_praxis.deed b/standards/provisioning/provisioning-standard_praxis.deed index e348893..14a2e5a 100644 --- a/standards/provisioning/provisioning-standard_praxis.deed +++ b/standards/provisioning/provisioning-standard_praxis.deed @@ -92,7 +92,14 @@ :mise-lockfile #t :mise-base ("just" "shellcheck") :js-runtime "bun" - :banned-tools ("python" "deno" "node" "npm" "yarn" "pnpm" "go" "java" "kotlin" "make") + ;; One list with BANNED_TOOLS in build/just/provision-lib.sh (launch-scaffolder + ;; tests that they agree). A tool installed through an npm:, pipx:, pip: or go: + ;; backend is banned whatever its name; mise.toml, .mise.toml and + ;; .tool-versions are all read (doctor PV-W23). + :banned-tools ("python" "deno" "denojs" "node" "nodejs" "npm" "yarn" "pnpm" + "typescript" "rescript" "make" "black" "ruff" "pip" "poetry" + "nix" "go" "golang" "java" "kotlin") + :banned-backends ("npm" "pipx" "pip" "go") ;; Measured 2026-09-30: a root recipe depending on a module recipe fails on ;; just 1.31, 1.36, 1.40, 1.41 and works from 1.42. :just-floor "1.42.0" diff --git a/standards/provisioning/templates/build/just/provision-lib.sh b/standards/provisioning/templates/build/just/provision-lib.sh index a76ef33..96f4012 100755 --- a/standards/provisioning/templates/build/just/provision-lib.sh +++ b/standards/provisioning/templates/build/just/provision-lib.sh @@ -315,24 +315,55 @@ has_recipe() { have just && just --summary 2>/dev/null | tr " " "\n" | grep -qx version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } # Tools that must never appear in a repo's toolchain (estate language policy). -BANNED_TOOLS='python|deno|node|nodejs|npm|yarn|pnpm|typescript|rescript|make|black|ruff|pip|poetry|nix|go|golang|java|kotlin' +BANNED_TOOLS='python|deno|denojs|node|nodejs|npm|yarn|pnpm|typescript|rescript|make|black|ruff|pip|poetry|nix|go|golang|java|kotlin' +BANNED_BACKENDS='npm|pipx|pip|go' # --------------------------------------------------------------------------- # Shared predicates. doctor and provision-check.sh both call THESE (the check via # `provision-lib.sh guix-stub|mise-lock-gaps|mise-banned`): a gate with its own # copy of a test passes what doctor warns about. -# The keys of mise.toml [tools], quotes stripped ("cargo:foo" stays cargo:foo). +# The tools every repository mise config declares, quotes stripped ("cargo:foo" +# stays cargo:foo): the [tools] keys of mise.toml and .mise.toml and the first +# word of each .tool-versions line. mise merges all three, so a predicate that +# read mise.toml alone would pass a banned tool pinned in the others. mise_toml_tools() { - [ -f mise.toml ] || return 0 - awk '/^\[tools\]/{t=1;next} /^\[/{t=0} t && /=/{sub(/[ \t]*=.*/,""); gsub(/["\x27 ]/,""); print}' mise.toml + local f + for f in .tool-versions mise.toml .mise.toml; do + [ -f "$f" ] || continue + if [ "$f" = .tool-versions ]; then + awk '!/^[ \t]*(#|$)/{print $1}' "$f" + else + awk '/^\[tools\]/{t=1;next} /^\[/{t=0} t && /=/{sub(/[ \t]*=.*/,""); gsub(/["\x27 ]/,""); print}' "$f" + fi + done | awk '!seen[$0]++' +} +# True when a bare registry name resolves only to banned backends ("prettier" +# is only npm:prettier). Needs no network: `mise registry` reads the registry +# baked into mise. False when mise is absent or does not know the name, so an +# unknown tool is left to mise-lock to report, never called banned on a guess. +only_banned_backends() { + local b n=0 + command -v mise >/dev/null 2>&1 || return 1 + for b in $(mise registry "$1" 2>/dev/null); do + n=$((n + 1)) + [[ "$b" =~ ^($BANNED_BACKENDS): ]] || return 1 + done + [ "$n" -gt 0 ] } -# Banned tools named in mise.toml; a backend prefix does not hide one ("aqua:denoland/deno" is deno). +# Banned tools named in the mise configs. A backend prefix does not hide one +# ("aqua:denoland/deno" is deno), an npm:/pipx:/pip:/go: backend installs +# through a banned runtime whatever the package is ("npm:prettier"), and so does +# a bare name with no other backend ("prettier"). mise_banned() { local t base hits="" for t in $(mise_toml_tools); do base=${t##*:}; base=${base%%@*}; base=${base##*/} - [[ "$base" =~ ^($BANNED_TOOLS)$ ]] && hits="$hits$t " + if [[ "$base" =~ ^($BANNED_TOOLS)$ ]] || [[ "$t" =~ ^($BANNED_BACKENDS): ]]; then + hits="$hits$t " + elif [[ "$t" != *:* ]] && only_banned_backends "$t"; then + hits="$hits$t " + fi done printf '%s' "${hits% }" } @@ -480,9 +511,9 @@ cmd_doctor() { [ -z "$lg" ] && pass "mise.lock pins every mise.toml tool (latest → concrete, checksummed)" || warn "PV-W20 $lg — run: just toolchain-refresh" [ -f .mise.toml ] && [ -f mise.toml ] && warn "PV-W21 both mise.toml and .mise.toml — mise merges them; keep only mise.toml" [ -f .tool-versions ] && warn "PV-W22 .tool-versions present — a second toolchain source; fold it into mise.toml" - if [ -f mise.toml ]; then + if [ -f mise.toml ] || [ -f .mise.toml ] || [ -f .tool-versions ]; then local bad; bad=$(mise_banned) - [ -z "$bad" ] && pass "mise.toml pins no banned tool" || warn "PV-W23 mise.toml pins banned tool(s): $bad (language policy: bun, no python/deno/node/make)" + [ -z "$bad" ] && pass "the mise configs pin no banned tool" || warn "PV-W23 a mise config pins banned tool(s): $bad (language policy: bun, no python/deno/node/npm/make)" fi # hypatia guix_not_stub reads guix.scm AND build/guix.scm; an unfilled __PLACEHOLDER__ is a stub too. local g r gstub="" diff --git a/standards/provisioning/templates/docs/SETUP.adoc.tmpl b/standards/provisioning/templates/docs/SETUP.adoc.tmpl index 3825722..71de1d1 100644 --- a/standards/provisioning/templates/docs/SETUP.adoc.tmpl +++ b/standards/provisioning/templates/docs/SETUP.adoc.tmpl @@ -182,7 +182,7 @@ every fix marked *auto* for you. |PV-W20 |mise.lock is missing, so `latest` is not concrete |`just toolchain-refresh` |yes |PV-W21 |both mise.toml and .mise.toml exist |merge into mise.toml, delete .mise.toml |no |PV-W22 |a .tool-versions file competes with mise.toml |fold it into mise.toml |no -|PV-W23 |mise.toml pins a tool the estate does not use |remove that line |no +|PV-W23 |a mise config (mise.toml, .mise.toml, .tool-versions) pins a tool the estate does not use, or an npm:/pipx:/pip:/go: backend |remove that line |no |PV-W24 |a Guix file is an unfilled template |regenerate it (maintainers: `provision-set realign`) |no |PV-W25 |guix.scm is missing |as PV-W24 |no |PV-W26 |manifest.scm is missing |as PV-W24; mise still works |no From 1e95a5625fc07b905ab1ba768e6f78dbd2767265 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:19:47 +0100 Subject: [PATCH 15/26] test(provision-set): run the just-backed fold tests under needs_just rust-ci runs without `just` and skips `needs_just::`. Three fold tests call `just --summary` but sat outside that module, so Cargo test went red on 17016db. Reproduced with `just` off PATH (3 failed), green after the move; the full suite with `just` present still runs all five (168 passed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- .../src/provisioning/justfile.rs | 156 +++++++++--------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index 4c6ccc6..00c6e68 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -557,7 +557,24 @@ mod tests { const BROKEN: &str = "# Build\nbuild:\n cargo build\n\n# Self-diagnostic\ndoctor:\n #!/usr/bin/env bash\n echo \"Running diagnostics for x\"\nif command -v y >/dev/null; then\n echo ok\nfi\n\n# Help\nhelp-me:\n #!/usr/bin/env bash\n echo \"\"\necho \"FIRST TIME SETUP:\"\n"; - /// The merge tests run `just --summary`, so need `just` >= 1.42 on PATH. + #[test] + fn identical_justfiles_fold_to_one_and_the_copy_is_removed() { + let src = "# Build\nbuild:\n echo b\n"; + let d = repo(src); + std::fs::write(d.join("justfile"), src).unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "Justfile"); + assert_eq!(acts.len(), 1); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.contains("already in Justfile")), + "{acts:?}" + ); + assert!(!d.join("justfile").exists()); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// The merge and fold tests run `just --summary`, so need `just` >= 1.42 on PATH. /// rust-ci skips this module by name; launcher-artefacts runs and counts it. mod needs_just { use super::*; @@ -655,89 +672,72 @@ mod tests { )); assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); } - } - - #[test] - fn identical_justfiles_fold_to_one_and_the_copy_is_removed() { - let src = "# Build\nbuild:\n echo b\n"; - let d = repo(src); - std::fs::write(d.join("justfile"), src).unwrap(); - let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); - assert_eq!(kept, "Justfile"); - assert_eq!(acts.len(), 1); - assert!( - matches!(&acts[0].1, Act::Removed(w) if w.contains("already in Justfile")), - "{acts:?}" - ); - assert!(!d.join("justfile").exists()); - assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); - std::fs::remove_dir_all(&d).unwrap(); - } - #[test] - fn a_real_body_replaces_a_template_placeholder_of_the_same_name() { - // The kept file is the bigger, unedited RSR template; the other file holds - // the recipe the author actually wrote. - let d = repo( - "# Build\nbuild:\n # TODO: Replace with your build command\n @echo built\n\nci:\n echo ci\n\ndocs:\n echo d\n", - ); - std::fs::write(d.join("justfile"), "build:\n cargo build --release\n").unwrap(); - let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); - assert_eq!(kept, "Justfile"); - let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); - assert!( - text.contains("cargo build --release") && !text.contains("TODO"), - "{text}" - ); - assert!( - matches!(&acts[0].1, Act::Removed(w) if w.starts_with("folded into Justfile: build")), - "{acts:?}" - ); - assert_eq!(summary(&d, "Justfile").unwrap(), ["build", "ci", "docs"]); - std::fs::remove_dir_all(&d).unwrap(); - } + #[test] + fn a_real_body_replaces_a_template_placeholder_of_the_same_name() { + // The kept file is the bigger, unedited RSR template; the other file holds + // the recipe the author actually wrote. + let d = repo( + "# Build\nbuild:\n # TODO: Replace with your build command\n @echo built\n\nci:\n echo ci\n\ndocs:\n echo d\n", + ); + std::fs::write(d.join("justfile"), "build:\n cargo build --release\n").unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "Justfile"); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!( + text.contains("cargo build --release") && !text.contains("TODO"), + "{text}" + ); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.starts_with("folded into Justfile: build")), + "{acts:?}" + ); + assert_eq!(summary(&d, "Justfile").unwrap(), ["build", "ci", "docs"]); + std::fs::remove_dir_all(&d).unwrap(); + } - #[test] - fn the_richer_justfile_is_kept_and_the_others_recipes_join_it() { - // action-trust-layers' shape: the real recipes are in the lowercase file. - let d = repo("# Help\nhelp:\n echo h\n"); - std::fs::write( + #[test] + fn the_richer_justfile_is_kept_and_the_others_recipes_join_it() { + // action-trust-layers' shape: the real recipes are in the lowercase file. + let d = repo("# Help\nhelp:\n echo h\n"); + std::fs::write( d.join("justfile"), "# Build\nbuild:\n cargo build\n\n# Test\ntest:\n cargo test\n\nhelp:\n echo other\n", ) .unwrap(); - let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); - assert_eq!(kept, "justfile"); - let text = std::fs::read_to_string(d.join("justfile")).unwrap(); - assert_eq!(text.matches("help:").count(), 1, "{text}"); - assert!( - text.contains("cargo build") && text.contains("echo other"), - "{text}" - ); - assert!(!d.join("Justfile").exists()); - assert!( - matches!(&acts[0].1, Act::Removed(w) if w.contains("help")), - "{acts:?}" - ); - assert_eq!(summary(&d, "justfile").unwrap(), ["build", "help", "test"]); - std::fs::remove_dir_all(&d).unwrap(); - } + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "justfile"); + let text = std::fs::read_to_string(d.join("justfile")).unwrap(); + assert_eq!(text.matches("help:").count(), 1, "{text}"); + assert!( + text.contains("cargo build") && text.contains("echo other"), + "{text}" + ); + assert!(!d.join("Justfile").exists()); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.contains("help")), + "{acts:?}" + ); + assert_eq!(summary(&d, "justfile").unwrap(), ["build", "help", "test"]); + std::fs::remove_dir_all(&d).unwrap(); + } - #[test] - fn a_fold_that_would_break_the_kept_file_is_undone() { - // `x` is a variable in the kept file; appending a recipe that reassigns it - // is a parse error, so nothing may be removed. - let kept = "x := \"1\"\n\nbuild:\n echo {{x}}\n\ntest:\n echo t\n"; - let d = repo(kept); - std::fs::write(d.join(".justfile"), "lint:\n echo {{y}}\n").unwrap(); - let (k, acts) = fold(&d, &["Justfile", ".justfile"]).unwrap(); - assert_eq!(k, "Justfile"); - assert!( - matches!(&acts[0].1, Act::Skipped(w) if w.contains("by hand")), - "{acts:?}" - ); - assert!(d.join(".justfile").exists()); - assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), kept); - std::fs::remove_dir_all(&d).unwrap(); + #[test] + fn a_fold_that_would_break_the_kept_file_is_undone() { + // `x` is a variable in the kept file; appending a recipe that reassigns it + // is a parse error, so nothing may be removed. + let kept = "x := \"1\"\n\nbuild:\n echo {{x}}\n\ntest:\n echo t\n"; + let d = repo(kept); + std::fs::write(d.join(".justfile"), "lint:\n echo {{y}}\n").unwrap(); + let (k, acts) = fold(&d, &["Justfile", ".justfile"]).unwrap(); + assert_eq!(k, "Justfile"); + assert!( + matches!(&acts[0].1, Act::Skipped(w) if w.contains("by hand")), + "{acts:?}" + ); + assert!(d.join(".justfile").exists()); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), kept); + std::fs::remove_dir_all(&d).unwrap(); + } } } From 81ab63f4f82b563335c76b953414279e6d1c527a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:19:04 +0100 Subject: [PATCH 16/26] Update crates/launcher-common/src/provisioning/mint.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- crates/launcher-common/src/provisioning/mint.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 3ba8bf8..5db08d8 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -329,9 +329,17 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { // Justfile: created whole when absent, otherwise merged (justfile.rs), which // `just --summary` must accept or the original is restored. Several // justfiles are folded into one first: `just` refuses to pick between them. + // Exact directory entries: on a case-insensitive filesystem `justfile` + // resolves to `Justfile`, and folding a file into itself deletes it. + let entries: Vec = std::fs::read_dir(target) + .with_context(|| format!("listing {}", target.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); let present: Vec<&str> = ["Justfile", "justfile", ".justfile"] .into_iter() - .filter(|j| target.join(j).is_file()) + .filter(|j| entries.iter().any(|e| e == j)) .collect(); let (justfile, folded) = match present.len() { 0 => (None, Vec::new()), From ac9d78b95a0b62a0edcd1c850cceda9681acdf23 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:25:25 +0000 Subject: [PATCH 17/26] docs(provision-set): document provisioning helpers, tests, fixtures, and CLI entry point --- .../launcher-common/src/provisioning/canon.rs | 3 ++ .../launcher-common/src/provisioning/check.rs | 4 +++ .../src/provisioning/justfile.rs | 14 +++++++++ .../src/provisioning/licence.rs | 11 +++++++ .../launcher-common/src/provisioning/mint.rs | 31 +++++++++++++++++++ .../src/provisioning/readme.rs | 4 +++ .../provisioning/lang/rustd/src/main.rs | 1 + .../provisioning/lang/rustr/src/main.rs | 1 + .../tests/provisioning_fixtures.rs | 6 ++++ crates/launcher/src/main.rs | 1 + 10 files changed, 76 insertions(+) diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs index 8118737..68c654c 100644 --- a/crates/launcher-common/src/provisioning/canon.rs +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -175,10 +175,12 @@ impl Canon { mod tests { use super::*; + /// Locate the vendored template tree relative to this crate. fn vendor_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../standards/provisioning/templates") } + /// Ensure the baked table lists every vendored template in sorted order. #[test] fn the_table_lists_exactly_the_vendored_files() { let mut on_disk: Vec = walkdir::WalkDir::new(vendor_dir()) @@ -201,6 +203,7 @@ mod tests { ); } + /// Ensure every engine file is baked and refreshable channel pins are excluded. #[test] fn every_engine_file_is_in_the_table() { for e in ENGINE_FILES { diff --git a/crates/launcher-common/src/provisioning/check.rs b/crates/launcher-common/src/provisioning/check.rs index 7ccc6fb..00d06ba 100644 --- a/crates/launcher-common/src/provisioning/check.rs +++ b/crates/launcher-common/src/provisioning/check.rs @@ -17,6 +17,7 @@ pub enum Drift { } impl std::fmt::Display for Drift { + /// Describe the affected path and whether its engine file is missing or changed. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Drift::Missing(p) => write!(f, "{p}: missing"), @@ -65,6 +66,7 @@ mod tests { use super::*; use crate::provisioning::canon::BAKED; + /// Write the baked engine files into a test directory, creating parent directories. fn mint_engine(dir: &Path) { for rel in ENGINE_FILES { let (_, b) = BAKED.iter().find(|(p, _)| p == rel).unwrap(); @@ -74,6 +76,7 @@ mod tests { } } + /// Recreate a temporary test directory identified by process ID and case name. fn scratch(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("ls-provcheck-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&d); @@ -81,6 +84,7 @@ mod tests { d } + /// Verify that freshly copied canon engine files produce no drift findings. #[test] fn identical_engine_has_no_drift() { let d = scratch("clean"); diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index 00c6e68..c42c3ee 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -496,6 +496,7 @@ fn span_at(lines: &[&str], header: usize) -> (usize, usize, usize) { (start, header, end) } +/// Collect recipe names in source order, ignoring lines without recipe headers. fn recipe_names(text: &str) -> Vec { text.lines() .filter_map(header_name) @@ -507,6 +508,7 @@ fn recipe_names(text: &str) -> Vec { mod tests { use super::*; + /// Verify recipe header parsing, including parameters, while rejecting directives and bodies. #[test] fn headers_are_recognised_and_directives_are_not() { assert_eq!(header_name("build:"), Some("build")); @@ -521,6 +523,7 @@ mod tests { assert_eq!(header_name("# doctor:"), None); } + /// Verify recipe spans include damaged body lines but preserve the next recipe documentation. #[test] fn a_span_takes_stray_unindented_lines_and_leaves_the_next_doc() { let src = "# Diagnose\ndoctor:\n #!/usr/bin/env bash\n a\n# Optional tools\nif x; then\n b\nfi\n c\n\n# Repair\nheal:\n d\n"; @@ -529,6 +532,7 @@ mod tests { assert_eq!(span(&lines, "heal"), Some((10, 11, 13))); } + /// Create a temporary repository containing the baked engine and the supplied Justfile. fn repo(justfile: &str) -> std::path::PathBuf { let n = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -545,6 +549,7 @@ mod tests { d } + /// Decode the baked provisioning Just module for merge and fold tests. fn provision_just() -> String { String::from_utf8( crate::provisioning::canon::Canon::Baked @@ -557,6 +562,7 @@ mod tests { const BROKEN: &str = "# Build\nbuild:\n cargo build\n\n# Self-diagnostic\ndoctor:\n #!/usr/bin/env bash\n echo \"Running diagnostics for x\"\nif command -v y >/dev/null; then\n echo ok\nfi\n\n# Help\nhelp-me:\n #!/usr/bin/env bash\n echo \"\"\necho \"FIRST TIME SETUP:\"\n"; + /// Verify identical Justfiles collapse to one without changing the retained contents. #[test] fn identical_justfiles_fold_to_one_and_the_copy_is_removed() { let src = "# Build\nbuild:\n echo b\n"; @@ -579,6 +585,7 @@ mod tests { mod needs_just { use super::*; + /// Verify merging repairs boilerplate, preserves custom verbs, and is idempotent. #[test] fn boilerplate_is_replaced_and_a_broken_file_repaired() { let d = repo(BROKEN); @@ -602,6 +609,7 @@ mod tests { ); } + /// Verify a custom doctor is preserved as doctor-local and an existing twin prevents merging. #[test] fn a_custom_doctor_becomes_doctor_local() { let d = repo("doctor:\n @echo mine\n"); @@ -617,6 +625,7 @@ mod tests { )); } + /// Verify canon delegation replaces a placeholder test recipe while preserving a real benchmark. #[test] fn a_template_placeholder_verb_is_replaced_and_a_real_one_kept() { let d = repo( @@ -640,6 +649,7 @@ mod tests { assert!(!text.contains("bench: provision::bench")); } + /// Verify known syntax damage is repaired and a conflicting provision recipe blocks merging. #[test] fn sweep_damage_is_repaired_and_a_provision_recipe_refused() { let src = "// SPDX-License-Identifier: MPL-2.0\n\nguix-shell:\n guix shell -D -f guix.scm\n\n# fallback\nguix-shell:\n @if [ -f \"flake.guix\" ]; then guix develop; fi\n"; @@ -662,6 +672,7 @@ mod tests { )); } + /// Verify an unsuccessful repair preserves the original Justfile bytes. #[test] fn an_unrepairable_file_is_left_byte_identical() { let src = "build:\n cargo build\nthis is not just syntax\n"; @@ -673,6 +684,7 @@ mod tests { assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); } + /// Verify folding prefers a real recipe over a placeholder with the same name. #[test] fn a_real_body_replaces_a_template_placeholder_of_the_same_name() { // The kept file is the bigger, unedited RSR template; the other file holds @@ -696,6 +708,7 @@ mod tests { std::fs::remove_dir_all(&d).unwrap(); } + /// Verify folding retains the file with more recipes and resolves duplicate recipe names. #[test] fn the_richer_justfile_is_kept_and_the_others_recipes_join_it() { // action-trust-layers' shape: the real recipes are in the lowercase file. @@ -722,6 +735,7 @@ mod tests { std::fs::remove_dir_all(&d).unwrap(); } + /// Verify a fold that introduces invalid syntax restores the retained file and keeps the source. #[test] fn a_fold_that_would_break_the_kept_file_is_undone() { // `x` is a variable in the kept file; appending a recipe that reassigns it diff --git a/crates/launcher-common/src/provisioning/licence.rs b/crates/launcher-common/src/provisioning/licence.rs index a272939..123d0ce 100644 --- a/crates/launcher-common/src/provisioning/licence.rs +++ b/crates/launcher-common/src/provisioning/licence.rs @@ -74,6 +74,7 @@ pub enum Refusal { } impl std::fmt::Display for Refusal { + /// Format the licence refusal with its repository, evidence, or policy reason. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Refusal::OutOfScope(r) => { @@ -106,6 +107,7 @@ enum Signal { } impl Signal { + /// Return the licence label used when reporting conflicting classification signals. fn name(self) -> &'static str { match self { Signal::Mpl => "MPL-2.0", @@ -132,6 +134,7 @@ fn signal(text: &str) -> Option { } } +/// Recognise root licence filenames by case-insensitive LICENSE, LICENCE, or COPYING prefixes. fn is_licence_name(name: &str) -> bool { let n = name.to_ascii_uppercase(); n.starts_with("LICENSE") || n.starts_with("LICENCE") || n.starts_with("COPYING") @@ -191,6 +194,7 @@ pub fn classify(target: &Path, repo: &str) -> Result { } } +/// Return sorted directory entries, omitting unreadable entries and treating read failures as empty. fn read_dir_sorted(dir: &Path) -> Vec { let mut v: Vec<_> = std::fs::read_dir(dir) .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect()) @@ -203,6 +207,7 @@ fn read_dir_sorted(dir: &Path) -> Vec { mod tests { use super::*; + /// Create a temporary licence fixture with the supplied relative paths and file contents. fn repo(files: &[(&str, &str)]) -> std::path::PathBuf { let n = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -224,12 +229,14 @@ mod tests { const PMPL_TEXT: &str = "Palimpsest-MPL License 1.0\nderived from the Mozilla Public License Version 2.0\n"; + /// Verify MPL code is paired with the required CC-BY-SA documentation licence. #[test] fn mpl_repo_gets_cc_by_sa_docs() { let d = repo(&[("LICENSE", MPL_TEXT)]); assert_eq!(classify(&d, "aerie"), Ok(MPL)); } + /// Verify the MPL secondary-licence reference to AGPL does not change classification. #[test] fn full_mpl_text_naming_the_affero_gpl_is_still_mpl() { // Re-wrapped onto single spaces, as a reflowed copy would be, so the @@ -246,6 +253,7 @@ mod tests { assert_eq!(classify(&d, "aerie"), Ok(MPL)); } + /// Verify AGPL classification includes the Guix identifier and an unratified prose-licence note. #[test] fn agpl_control_is_classified_agpl_and_flagged() { let d = repo(&[("LICENSE.txt", AGPL_TEXT)]); @@ -255,6 +263,7 @@ mod tests { assert!(l.unratified.is_some()); } + /// Verify PMPL is accepted for registered repositories and refused elsewhere. #[test] fn pmpl_only_inside_the_register() { let d = repo(&[("LICENSE", PMPL_TEXT)]); @@ -268,6 +277,7 @@ mod tests { ); } + /// Verify missing, unrecognised, conflicting, and excluded licences yield distinct refusals. #[test] fn refusals_name_their_reason() { assert_eq!(classify(&repo(&[]), "x"), Err(Refusal::NoLicenceFile)); @@ -285,6 +295,7 @@ mod tests { ); } + /// Verify the REUSE layout supplies the code licence without treating prose licensing as a conflict. #[test] fn reuse_layout_is_read_when_the_root_has_no_licence() { let d = repo(&[ diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 5db08d8..8161377 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -98,6 +98,7 @@ pub enum Act { pub const EXIT_EXTERNAL: i32 = 4; impl std::fmt::Display for Act { + /// Format a file action, including the reason for replacement, retention, removal, or failure. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Act::Created => write!(f, "created"), @@ -427,6 +428,7 @@ impl Minter<'_> { } } + /// Copy canon bytes into a missing, stub, or inherited file while preserving repository-owned content. fn minted_bytes(&self, dest: &str, src: &str) -> Result { match self.stub_reason(dest)? { Some(why) => write_file(self.target, dest, &self.canon.file(src)?, &why), @@ -434,6 +436,7 @@ impl Minter<'_> { } } + /// Render and write a template regardless of stub ownership, recording the supplied reason. fn force( &self, dest: &str, @@ -501,6 +504,7 @@ const MECHANICAL: &[&str] = &[ "DELEGATIONS", ]; +/// Return the first known mechanical slot still present in text, ignoring repository-specific slots. fn mechanical_residue(text: &str) -> Option<&'static str> { MECHANICAL .iter() @@ -546,6 +550,7 @@ pub fn render(tmpl: &str, vars: &BTreeMap<&str, String>) -> String { /// Marks a value as space-separated Scheme atoms for [`render`] to wrap. const ATOMS: &str = "\u{0}atoms\u{0}"; +/// Escape and quote Scheme strings, marking the result for atom wrapping during rendering. fn quoted_atoms(specs: &[String]) -> String { let atoms: Vec = specs .iter() @@ -554,6 +559,7 @@ fn quoted_atoms(specs: &[String]) -> String { format!("{ATOMS}{}", atoms.join(" ")) } +/// Wrap space-separated atoms at the template column, reserving room for closing parentheses. fn wrap_atoms(atoms: &str, col: usize) -> String { let mut out = String::new(); let mut width = col; @@ -575,10 +581,12 @@ fn wrap_atoms(atoms: &str, col: usize) -> String { out } +/// Escape backslashes and double quotes for a Scheme string literal. fn scheme_escape(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } +/// Replace whitespace-only text with "none" and otherwise preserve the original string. fn or_none(s: String) -> String { if s.trim().is_empty() { "none".into() @@ -635,6 +643,7 @@ fn below_floor(tool: &str, value: &str) -> Option<&'static str> { None } +/// Emit a bare TOML key when possible, otherwise quote and escape it. fn toml_key(k: &str) -> String { if k.chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') @@ -806,6 +815,7 @@ fn describe(target: &Path, name: &str) -> (String, String, String) { (version, synopsis_of(&description), description) } +/// Extract the first prose paragraph from the first readable README, skipping markup and blocks. fn readme_paragraph(target: &Path) -> Option { let text = ["README.adoc", "README.md", "README"] .iter() @@ -858,6 +868,7 @@ fn synopsis_of(description: &str) -> String { s } +/// Derive the year from SOURCE_DATE_EPOCH, falling back to the system clock. fn current_year() -> i64 { let secs = std::env::var("SOURCE_DATE_EPOCH") .ok() @@ -883,6 +894,7 @@ fn year_of_days(z: i64) -> i64 { yoe + era * 400 + i64::from(m <= 2) } +/// Read a canon file as UTF-8, reporting its path if decoding fails. fn canon_text(canon: &Canon, rel: &str) -> Result { String::from_utf8(canon.file(rel)?.into_owned()).with_context(|| format!("{rel} is not UTF-8")) } @@ -908,6 +920,7 @@ fn write_file(target: &Path, rel: &str, bytes: &[u8], why: &str) -> Result }) } +/// On Unix, set shell-script permissions to 0755; leave other paths and platforms unchanged. fn set_exec(path: &Path, rel: &str) -> Result<()> { #[cfg(unix)] if rel.ends_with(".sh") { @@ -1025,6 +1038,7 @@ impl Lib { .with_context(|| format!("running {LIB} {}", args.join(" "))) } + /// Run an engine verb and return UTF-8 stdout without trailing newlines, failing on nonzero status. fn out(&self, args: &[&str]) -> Result { let o = self.run(args)?; if !o.status.success() { @@ -1040,6 +1054,7 @@ impl Lib { .to_string()) } + /// Return the nonempty output lines of a successful engine verb. fn lines(&self, args: &[&str]) -> Result> { Ok(self .out(args)? @@ -1049,6 +1064,7 @@ impl Lib { .collect()) } + /// Split the output of a successful engine verb into whitespace-delimited words. fn words(&self, args: &[&str]) -> Result> { Ok(self .out(args)? @@ -1077,10 +1093,12 @@ impl Lib { mod tests { use super::*; + /// Build owned template substitution values from borrowed test data. fn vars(kv: &[(&'static str, &str)]) -> BTreeMap<&'static str, String> { kv.iter().map(|(k, v)| (*k, v.to_string())).collect() } + /// Verify substitutions are not expanded recursively and unknown slots remain intact. #[test] fn render_is_single_pass_and_leaves_unknown_slots() { let v = vars(&[("A", "__B__ and __init__"), ("B", "x")]); @@ -1091,6 +1109,7 @@ mod tests { assert_eq!(render("___A__", &vars(&[("A", "v")])), "_v"); } + /// Verify rendered Scheme atoms wrap within the width limit and align with the first atom. #[test] fn atoms_wrap_under_the_first_atom() { let specs: Vec = [ @@ -1122,6 +1141,7 @@ mod tests { assert_eq!(tokens.last(), Some(&"\"zig\"))")); } + /// Verify canon delegations retain contract verbs, parameters, and docs while omitting overrides. #[test] fn delegations_cover_every_verb_with_its_doc() { let canon = Canon::Baked; @@ -1142,6 +1162,7 @@ mod tests { assert!(!some.contains("doctor:") && !some.contains("build:") && some.contains("heal:")); } + /// Verify quoted deed fields are extracted and absent fields return no value. #[test] fn deed_fields_and_slugs() { let d = "(praxis-deed\n :repo \"hyperpolymath/rsr-template-repo\"\n :archetype \"library\" ; app | …\n"; @@ -1153,6 +1174,7 @@ mod tests { assert_eq!(deed_field(d, "ports"), None); } + /// Verify synopsis truncation and Scheme string escaping for generated package metadata. #[test] fn synopsis_is_one_short_sentence() { assert_eq!(synopsis_of("Does a thing. Then more."), "Does a thing"); @@ -1161,6 +1183,7 @@ mod tests { assert_eq!(scheme_escape(r#"a "q" \ b"#), r#"a \"q\" \\ b"#); } + /// Verify Gregorian year conversion at the epoch, a year boundary, and a leap day. #[test] fn years_from_day_counts() { assert_eq!(year_of_days(0), 1970); @@ -1169,6 +1192,7 @@ mod tests { assert_eq!(year_of_days(11_016), 2000); // 2000-02-29 } + /// Verify lock failures identify only explicitly unpinned tools carried from existing config. #[test] fn unpinnable_names_only_carried_tools() { let carried = vec![ @@ -1181,6 +1205,7 @@ mod tests { assert!(unpinnable("mise.lock has no sha256 for: zig/linux-x64", &carried).is_empty()); } + /// Verify existing pins override canon defaults and additional tools follow the canon entries. #[test] fn carried_pins_survive_and_extras_follow() { let tools = vec!["just".to_string(), "rust".to_string()]; @@ -1194,6 +1219,7 @@ mod tests { ); } + /// Verify banned tools are removed and invalid TOML produces a note instead of carried pins. #[test] fn carry_over_drops_banned_and_survives_a_file_that_is_not_toml() { let d = std::env::temp_dir().join(format!("carry-{}", std::process::id())); @@ -1213,6 +1239,7 @@ mod tests { std::fs::remove_dir_all(&d).unwrap(); } + /// Verify all supported mise configs contribute tools in the required precedence order. #[test] fn carry_over_reads_every_config_and_the_winning_pin_wins() { let d = std::env::temp_dir().join(format!("carry-prec-{}", std::process::id())); @@ -1242,6 +1269,7 @@ mod tests { std::fs::remove_dir_all(&d).unwrap(); } + /// Verify pins below the Just floor are raised with a note while compatible selectors are preserved. #[test] fn a_carried_pin_below_a_floor_is_raised_and_said() { let tools = vec!["just".to_string()]; @@ -1267,6 +1295,7 @@ mod tests { } } + /// Verify the deed and shell engine agree on banned tool names and backends. #[test] fn the_deeds_banned_lists_are_the_engines() { let deed = include_str!(concat!( @@ -1308,6 +1337,7 @@ mod tests { assert_eq!(deed_list(":banned-backends "), lib_list("BANNED_BACKENDS")); } + /// Verify the Rust Just version floor matches the value declared by the canon deed. #[test] fn the_just_floor_matches_the_canon_deed() { let deed = include_str!(concat!( @@ -1321,6 +1351,7 @@ mod tests { ); } + /// Verify repository-specific slots are allowed while unfilled mechanical slots are detected. #[test] fn mechanical_residue_ignores_spec_slots() { assert_eq!(mechanical_residue("x __SPEC_USAGE__ y"), None); diff --git a/crates/launcher-common/src/provisioning/readme.rs b/crates/launcher-common/src/provisioning/readme.rs index 6de79a3..ec6c4df 100644 --- a/crates/launcher-common/src/provisioning/readme.rs +++ b/crates/launcher-common/src/provisioning/readme.rs @@ -97,6 +97,7 @@ fn merged(text: &str, rendered: &str) -> Option<(String, &'static str)> { Some((s, "AI-install section inserted")) } +/// Join lines with newlines and append a final newline. fn join(lines: &[&str]) -> String { let mut s = lines.join("\n"); s.push('\n'); @@ -155,6 +156,7 @@ mod tests { const R: &str = "[TIP]\n====\nsay it\n====\n\n[[ai-install]]\n== AI-Assisted Installation (Recommended)\n\nbody\n"; + /// Verify installation content surrounds the introduction correctly and reinsertion is a no-op. #[test] fn tip_after_header_section_before_first_heading() { let src = "// SPDX\n= Title\n:toc:\n\nIntro.\n\n[#usage]\n== Usage\n\n----\n== not a heading\n----\n"; @@ -170,6 +172,7 @@ mod tests { assert!(merged(&out, R).is_none(), "second run must be a no-op"); } + /// Verify an existing installation section gains only its missing anchor. #[test] fn an_existing_section_only_gains_the_anchor() { let src = "= T\n\n== AI-Assisted Installation\n\nSay X.\n"; @@ -181,6 +184,7 @@ mod tests { assert!(what.contains("anchor")); } + /// Verify headings inside literal blocks do not prevent appending the installation section. #[test] fn headings_inside_blocks_are_ignored_and_no_heading_appends() { let src = "= T\n\n....\n== x\n....\n"; diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs index 484251b..8cc876f 100644 --- a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs @@ -1 +1,2 @@ +/// Provide a minimal Rust executable for the mixed-language detection fixture. fn main() { let mut b = itoa::Buffer::new(); println!("{}", b.format(42)); } diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs index e7a11a9..a5aeaae 100644 --- a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs @@ -1,3 +1,4 @@ +/// Run the Rust fixture executable used by provisioning language detection. fn main() { println!("Hello, world!"); } diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs index 6ee6fd3..ed8f9bb 100644 --- a/crates/launcher-common/tests/provisioning_fixtures.rs +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -141,6 +141,7 @@ fn repair(repo: &Path, i: usize) { } } +/// Verify engine language detection for documentation, Idris, and Rust fixtures. #[test] fn langs_names_each_fixture_language() { for (fixture, lang) in [ @@ -155,6 +156,7 @@ fn langs_names_each_fixture_language() { } } +/// Verify the Deno-leftover warning disappears when deno.json is removed from Rust fixtures. #[test] fn doctor_warns_on_deno_leftovers_and_only_then() { for fixture in ["rustd", "rustr"] { @@ -178,6 +180,7 @@ fn doctor_warns_on_deno_leftovers_and_only_then() { mod needs_just { use super::*; + /// Verify the broken fixture fails with exactly the three planted conformance faults. #[test] fn check_fixture_fails_on_exactly_its_three_faults() { let repo = scratch("check", "check"); @@ -186,6 +189,7 @@ mod needs_just { assert_eq!(fails, expected_without(&[])); } + /// Verify each targeted repair removes its own finding while the other faults still fail. #[test] fn each_repair_removes_only_its_own_fail() { for i in 0..CHECK_FAILS.len() { @@ -197,6 +201,7 @@ mod needs_just { } } + /// Verify repairing every planted fault yields a successful check with no failures. #[test] fn all_repairs_together_pass() { let repo = scratch("check", "check-repaired"); @@ -208,6 +213,7 @@ mod needs_just { assert_eq!(code, 0); } + /// Verify offline mint preserves custom recipes as local twins and reports the skipped mise lock. #[test] fn offline_mint_keeps_custom_recipes_as_local_twins() { let canon = Canon::resolve(None).unwrap(); diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index a5b44c2..072799d 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -77,6 +77,7 @@ enum Command { Standard(cmd_standard::Args), } +/// Parse CLI arguments, initialise tracing, and dispatch the selected launcher command. fn main() -> Result<()> { let cli = Cli::parse(); From 482d912b0e843d4e0bea248c714d4c080b255f89 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:27:30 +0000 Subject: [PATCH 18/26] fix(provision-set): skip unsafe justfile and mise config folds to preserve settings --- .../src/provisioning/justfile.rs | 84 +++++++++++- .../launcher-common/src/provisioning/mint.rs | 45 +++++-- .../tests/provisioning_fixtures.rs | 127 ++++++++++++++++++ 3 files changed, 242 insertions(+), 14 deletions(-) diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index c42c3ee..a3181b0 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -49,7 +49,8 @@ const MOD_LINE: &str = "mod provision 'build/just/provision.just'"; /// most recipes is kept (`Justfile` on a tie); each other file's recipes that /// it lacks are appended to it, and a recipe both define keeps the kept file's /// body unless that body is a template placeholder or boilerplate and the -/// other's is not. The other file is then removed. The result must +/// other's is not. Sources with unmatched top-level content are retained for +/// manual folding; successfully folded files are removed. The result must /// parse whenever the kept file parsed before, or it is restored, nothing is /// removed, and the fold is reported as skipped. Returns the kept file's name /// and one report line per other file. @@ -69,6 +70,23 @@ pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)> let mut notes = Vec::new(); for (i, n) in names.iter().enumerate().filter(|&(i, _)| i != keep) { let lines: Vec<&str> = texts[i].lines().collect(); + // Recipes alone cannot preserve settings, variables, imports or aliases. + // Check the whole source before copying any recipes, even if `just` + // could not parse the kept file (or is unavailable). + if lines.iter().any(|l| { + !l.trim().is_empty() + && !l.starts_with([' ', '\t', '#']) + && header_name(l).is_none() + && !merged.lines().any(|have| have == *l) + }) { + notes.push(( + (*n).to_string(), + Act::Skipped(format!( + "top-level content is not in {kept}: fold {n} by hand" + )), + )); + continue; + } let (mut added, mut shadowed) = (Vec::new(), Vec::new()); for (h, l) in lines.iter().enumerate() { let Some(r) = header_name(l) else { continue }; @@ -107,7 +125,7 @@ pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)> shadowed.join(" ") ), }; - notes.push(((*n).to_string(), why)); + notes.push(((*n).to_string(), Act::Removed(why))); } let path = target.join(&kept); std::fs::write(&path, &merged).with_context(|| format!("writing {}", path.display()))?; @@ -117,15 +135,20 @@ pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)> let why = format!("folding them into {kept} breaks it ({e}): fold them by hand"); let skipped = notes .into_iter() - .map(|(n, _)| (n, Act::Skipped(why.clone()))) + .map(|(n, act)| match act { + Act::Skipped(_) => (n, act), + _ => (n, Act::Skipped(why.clone())), + }) .collect(); return Ok((kept, skipped)); } } let mut out = Vec::new(); - for (n, why) in notes { - std::fs::remove_file(target.join(&n)).with_context(|| format!("removing {n}"))?; - out.push((n, Act::Removed(why))); + for (n, act) in notes { + if matches!(act, Act::Removed(_)) { + std::fs::remove_file(target.join(&n)).with_context(|| format!("removing {n}"))?; + } + out.push((n, act)); } Ok((kept, out)) } @@ -586,6 +609,55 @@ mod tests { use super::*; /// Verify merging repairs boilerplate, preserves custom verbs, and is idempotent. + #[test] + fn folding_retains_unmatched_top_level_content_even_when_kept_file_is_broken() { + for kept in ["build:\n echo b\ntest:\n echo t\n", BROKEN] { + for directive in [ + "x := 'value'", + "set dotenv-load", + "alias check := lint", + "import 'extra.just'", + "mod extra 'extra.just'", + ] { + let d = repo(kept); + assert_eq!(summary(&d, "Justfile").is_err(), kept == BROKEN); + // Put the directive after a recipe to catch partial folding. + let source = format!("lint:\n echo lint\n\n{directive}\n"); + std::fs::write(d.join("justfile"), &source).unwrap(); + let (_, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert!( + matches!(&acts[0].1, Act::Skipped(w) if w.contains("by hand")), + "{directive}: {acts:?}" + ); + assert_eq!(std::fs::read_to_string(d.join("justfile")).unwrap(), source); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), kept); + std::fs::remove_dir_all(&d).unwrap(); + } + } + } + + #[test] + fn folding_removes_only_safe_sources_and_accepts_shared_directives() { + let d = repo("set dotenv-load\nbuild:\n echo b\ntest:\n echo t\n"); + let unsafe_source = "set export\n"; + std::fs::write(d.join("justfile"), unsafe_source).unwrap(); + std::fs::write( + d.join(".justfile"), + "# Shared setting\nset dotenv-load\n\nlint:\n echo lint\n", + ) + .unwrap(); + let (_, acts) = fold(&d, &["Justfile", "justfile", ".justfile"]).unwrap(); + assert!(matches!(acts[0].1, Act::Skipped(_)), "{acts:?}"); + assert!(matches!(acts[1].1, Act::Removed(_)), "{acts:?}"); + assert_eq!( + std::fs::read_to_string(d.join("justfile")).unwrap(), + unsafe_source + ); + assert!(!d.join(".justfile").exists()); + assert_eq!(summary(&d, "Justfile").unwrap(), ["build", "lint", "test"]); + std::fs::remove_dir_all(&d).unwrap(); + } + #[test] fn boilerplate_is_replaced_and_a_broken_file_repaired() { let d = repo(BROKEN); diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 8161377..b4eeab3 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -215,16 +215,20 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { vars.insert("GUIX_SPECS", quoted_atoms(&guix_specs)); vars.insert("GUIX_PKG_SPECS", quoted_atoms(&guix_specs)); - // mise.toml: minted, but a banned tool or a second mise config is a replace - // condition. mise merges every config it finds and the higher-precedence - // file wins, so a minted mise.toml beside a .mise.toml pins nothing. + // mise.toml: banned tools are replaced, and tool-only configs can be folded. + // Other settings need a manual merge: the template only carries tools. let banned = lib.predicate(&["mise-banned"])?; let secondary: Vec<&str> = SECONDARY_MISE .into_iter() .filter(|f| target.join(f).is_file()) .collect(); + let fold_skip = if secondary.is_empty() { + None + } else { + mise_fold_skip_reason(target)? + }; let (mut carried, mut notes) = (Vec::new(), Vec::new()); - if banned.is_some() || !secondary.is_empty() { + if fold_skip.is_none() && (banned.is_some() || !secondary.is_empty()) { (carried, notes) = carry_over_tools(target, banned.as_deref().unwrap_or(""))?; } let (toml_lines, floor_notes) = mise_tools_toml(&mise_tools, &carried); @@ -238,15 +242,20 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { reasons.push(format!("folded in {}", secondary.join(", "))); } reasons.extend(notes); - let replace_why = (banned.is_some() && target.join("mise.toml").exists() - || !secondary.is_empty()) + let replace_why = (fold_skip.is_none() + && (banned.is_some() && target.join("mise.toml").exists() || !secondary.is_empty())) .then(|| reasons.join("; ")); - let act = match &replace_why { - Some(why) => m.force("mise.toml", "mise.toml.tmpl", &vars, why)?, + let act = match (&fold_skip, &replace_why) { + (Some(why), _) => Act::Skipped(why.clone()), + (_, Some(why)) => m.force("mise.toml", "mise.toml.tmpl", &vars, why)?, _ => m.minted("mise.toml", "mise.toml.tmpl", &vars)?, }; files.push(("mise.toml".into(), act)); for f in &secondary { + if let Some(why) = &fold_skip { + files.push(((*f).to_string(), Act::Skipped(why.clone()))); + continue; + } std::fs::remove_file(target.join(f)).with_context(|| format!("removing {f}"))?; files.push(( (*f).to_string(), @@ -656,6 +665,26 @@ fn toml_key(k: &str) -> String { /// A `(tool, TOML value)` pin carried from an existing mise config. type Pin = (String, String); +/// Folding through the tools-only template must not discard other settings. +fn mise_fold_skip_reason(target: &Path) -> Result> { + for f in ["mise.toml", ".mise.toml"] { + let path = target.join(f); + if !path.is_file() { + continue; + } + let text = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + if toml::from_str::(&text) + .is_ok_and(|table| table.keys().any(|key| key != "tools")) + { + return Ok(Some(format!( + "{f} has settings outside [tools]: fold the mise configs by hand" + ))); + } + } + Ok(None) +} + /// The non-banned tools of every repository mise config, values as TOML, and a /// note per file that is not TOML at all: mise cannot read such a file either, /// so nothing in it was in effect to carry. Files are read lowest precedence diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs index ed8f9bb..2975f2e 100644 --- a/crates/launcher-common/tests/provisioning_fixtures.rs +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -214,6 +214,133 @@ mod needs_just { } /// Verify offline mint preserves custom recipes as local twins and reports the skipped mise lock. + #[test] + fn offline_mint_retains_mise_configs_with_non_tool_settings() { + for (i, (primary, secondary)) in [ + ( + Some("[tools]\nzig = '0.14'\n[env]\nMODE = 'dev'\n"), + "[tools]\nrust = '1.95'\n", + ), + ( + Some("[tools]\nzig = '0.14'\n"), + "[tools]\nrust = '1.95'\n[tasks.build]\nrun = 'echo build'\n", + ), + (None, "[settings]\nexperimental = true\n"), + ( + Some("[tools]\npython = '3'\n[env]\nMODE = 'dev'\n"), + "[tools]\nrust = '1.95'\n", + ), + ] + .into_iter() + .enumerate() + { + let repo = scratch("lang/idr", &format!("mise-settings-{i}")); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"), + repo.join("LICENSE"), + ) + .unwrap(); + if let Some(text) = primary { + std::fs::write(repo.join("mise.toml"), text).unwrap(); + } + std::fs::write(repo.join(".mise.toml"), secondary).unwrap(); + std::fs::write(repo.join(".tool-versions"), "just 1.56.0\n").unwrap(); + let report = mint::mint( + &repo, + &Canon::Baked, + &Options { + offline: true, + ..Options::default() + }, + ) + .unwrap(); + for file in ["mise.toml", ".mise.toml", ".tool-versions"] { + assert!( + report.files.iter().any(|(p, a)| p == file + && matches!(a, Act::Skipped(w) if w.contains("outside [tools]"))), + "{file}: {:?}", + report.files + ); + } + assert_eq!( + std::fs::read_to_string(repo.join("mise.toml")) + .ok() + .as_deref(), + primary + ); + assert_eq!( + std::fs::read_to_string(repo.join(".mise.toml")).unwrap(), + secondary + ); + assert_eq!( + std::fs::read_to_string(repo.join(".tool-versions")).unwrap(), + "just 1.56.0\n" + ); + std::fs::remove_dir_all(repo).unwrap(); + } + } + + #[test] + fn offline_mint_folds_tool_only_configs_and_still_replaces_banned_tools() { + for (i, (banned, secondary)) in [(false, true), (true, true), (true, false)] + .into_iter() + .enumerate() + { + let repo = scratch("lang/idr", &format!("mise-tools-{i}")); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"), + repo.join("LICENSE"), + ) + .unwrap(); + let primary = if banned { + "[tools]\npython = '3'\nzig = '0.14'\n" + } else { + "[tools]\nzig = '0.14'\n" + }; + std::fs::write(repo.join("mise.toml"), primary).unwrap(); + if secondary { + std::fs::write(repo.join(".mise.toml"), "[tools]\nzig = '0.15'\n").unwrap(); + } + let report = mint::mint( + &repo, + &Canon::Baked, + &Options { + offline: true, + ..Options::default() + }, + ) + .unwrap(); + assert!( + report + .files + .iter() + .any(|(p, a)| p == "mise.toml" && matches!(a, Act::Replaced(_))), + "{:?}", + report.files + ); + let text = std::fs::read_to_string(repo.join("mise.toml")).unwrap(); + let config: toml::Table = toml::from_str(&text).unwrap(); + let tools = config["tools"].as_table().unwrap(); + assert!(!tools.contains_key("python")); + assert_eq!( + tools["zig"].as_str(), + Some(if secondary { "0.15" } else { "0.14" }) + ); + if secondary { + assert!( + report + .files + .iter() + .any(|(p, a)| p == ".mise.toml" && matches!(a, Act::Removed(_))), + "{:?}", + report.files + ); + assert!(!repo.join(".mise.toml").exists()); + } + std::fs::remove_dir_all(repo).unwrap(); + } + } + #[test] fn offline_mint_keeps_custom_recipes_as_local_twins() { let canon = Canon::resolve(None).unwrap(); From 81415f3c0477d9532b4f61f0285d817977ac584a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:07:30 +0000 Subject: [PATCH 19/26] docs(provision-set): clarify provisioning behavior, error handling, and exit codes --- .../launcher-common/src/provisioning/canon.rs | 10 +++- .../launcher-common/src/provisioning/check.rs | 5 ++ .../src/provisioning/justfile.rs | 12 ++++ .../src/provisioning/licence.rs | 10 +++- .../launcher-common/src/provisioning/mint.rs | 56 +++++++++++++++---- .../src/provisioning/readme.rs | 6 +- crates/launcher/src/cmd_provision_set.rs | 7 ++- 7 files changed, 88 insertions(+), 18 deletions(-) diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs index 68c654c..07d37d6 100644 --- a/crates/launcher-common/src/provisioning/canon.rs +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -130,6 +130,8 @@ pub enum Canon { impl Canon { /// The explicit override if given, else the baked table. + /// Returns an error if the override has no `templates/` directory; individual + /// files and the `CANON` reference are read only when requested. pub fn resolve(dir: Option<&Path>) -> Result { match dir { None => Ok(Canon::Baked), @@ -143,7 +145,9 @@ impl Canon { } } - /// The `standards@` this canon names. + /// The trimmed `CANON` reference, conventionally `standards@`. + /// Its format is not validated. Returns an error if an override's `CANON` + /// file cannot be read as UTF-8. pub fn reference(&self) -> Result { let raw = match self { Canon::Baked => BAKED_CANON_REF.to_string(), @@ -153,7 +157,9 @@ impl Canon { Ok(raw.trim().to_string()) } - /// The bytes of one canon file. + /// The bytes of a canon file, with `rel` relative to `templates/`. + /// Returns an error if the baked table has no such entry or reading the + /// override file fails. pub fn file(&self, rel: &str) -> Result> { match self { Canon::Baked => BAKED diff --git a/crates/launcher-common/src/provisioning/check.rs b/crates/launcher-common/src/provisioning/check.rs index 00d06ba..f489678 100644 --- a/crates/launcher-common/src/provisioning/check.rs +++ b/crates/launcher-common/src/provisioning/check.rs @@ -27,6 +27,8 @@ impl std::fmt::Display for Drift { } /// Every engine file under `target` that is absent or not byte-identical to the canon. +/// Returns an empty list when all match. Missing target files are drift; +/// other target read errors and canon lookup/read errors are returned as errors. pub fn engine_drift(target: &Path, canon: &Canon) -> Result> { let mut out = Vec::new(); for rel in ENGINE_FILES { @@ -47,6 +49,9 @@ pub fn engine_drift(target: &Path, canon: &Canon) -> Result> { /// Run the repository's `build/just/provision-check.sh` and return its exit /// code. A child killed by a signal is a failure (1), never a pass. +/// `dev` passes `--dev`, downgrading unfilled repository-specific slots to +/// warnings. Child output is inherited; failure to start or wait for Bash is +/// returned as an error, whereas a nonzero child exit is returned as a code. pub fn conformance(target: &Path, dev: bool) -> Result { let script = target.join("build/just/provision-check.sh"); let mut cmd = Command::new("bash"); diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index a3181b0..ad552e9 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -54,6 +54,14 @@ const MOD_LINE: &str = "mod provision 'build/just/provision.just'"; /// parse whenever the kept file parsed before, or it is restored, nothing is /// removed, and the fold is reported as skipped. Returns the kept file's name /// and one report line per other file. +/// +/// Filesystem errors are propagated and may leave a partial fold. If the kept +/// file cannot be summarised before folding (including when `just` is absent), +/// parsing is not required afterwards. +/// +/// # Panics +/// +/// Panics if `names` is empty. pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)>)> { let mut texts = Vec::new(); for n in names { @@ -154,6 +162,10 @@ pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)> } /// Merge into `target/name`. `provision_just` is the canon module's text. +/// Returns `Kept` when already merged, `Replaced` on an accepted merge, or +/// `Skipped` for conflicts or failed validation by `just --summary`. A rejected +/// write is restored before returning `Skipped`; filesystem errors, including +/// restoration failures, are propagated. pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { let path = target.join(name); let original = diff --git a/crates/launcher-common/src/provisioning/licence.rs b/crates/launcher-common/src/provisioning/licence.rs index 123d0ce..e434a7b 100644 --- a/crates/launcher-common/src/provisioning/licence.rs +++ b/crates/launcher-common/src/provisioning/licence.rs @@ -140,7 +140,15 @@ fn is_licence_name(name: &str) -> bool { n.starts_with("LICENSE") || n.starts_with("LICENCE") || n.starts_with("COPYING") } -/// Classify `target`, whose repository name is `repo`. +/// Classify `target`, whose repository name (without its owner) is `repo`. +/// Root licence files take precedence over the `LICENSES/` fallback. Returns +/// code, prose and Guix licence identifiers, with an unratified prose note +/// where applicable. +/// +/// Refuses excluded repositories, missing or unrecognised licence evidence, +/// conflicting recognised licences, and PMPL outside the register. Unreadable +/// directories contribute no files; unreadable or non-UTF-8 files contribute +/// no recognised signal. Unrecognised files do not veto a recognised licence. pub fn classify(target: &Path, repo: &str) -> Result { if OUT_OF_SCOPE.contains(&repo) { return Err(Refusal::OutOfScope(repo.to_string())); diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index b4eeab3..2955a99 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -121,7 +121,17 @@ pub struct Report { pub files: Vec<(String, Act)>, } -/// Mint (or realign) the provisioning set in `target`. +/// Mint (or realign) the provisioning set in `target`, returning a per-file report. +/// Realigns engine files, fills missing or replaceable templates, merges the +/// Justfile and inserts the README section. Folded secondary configs are removed. +/// Unless `opts.offline` is set, also locks mise tools and generates missing +/// Rust crate definitions through Guix. +/// +/// Licence refusals are returned before any writes. Invalid archetypes, canon +/// access/decoding errors, filesystem errors and engine invocation or output +/// errors are propagated; earlier writes are not rolled back. External steps +/// that run but fail their checks are recorded as `Act::Failed` in an otherwise +/// successful report. Callers must inspect the report for failures and skips. pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { let target = &target .canonicalize() @@ -457,7 +467,9 @@ impl Minter<'_> { write_file(self.target, dest, body.as_bytes(), why) } - /// Why `dest` may be (re)written, or `None` when the repository owns it. + /// Why `dest`, relative to the target, may be (re)written, or `None` when + /// the repository owns it. Any read or UTF-8 error is treated as missing; + /// errors from the Guix stub predicate are propagated. fn stub_reason(&self, dest: &str) -> Result> { let path = self.target.join(dest); let Ok(text) = std::fs::read_to_string(&path) else { @@ -665,7 +677,9 @@ fn toml_key(k: &str) -> String { /// A `(tool, TOML value)` pin carried from an existing mise config. type Pin = (String, String); -/// Folding through the tools-only template must not discard other settings. +/// Why folding would discard settings outside `[tools]`, or `None` if neither +/// mise TOML file has such settings. Missing files and invalid TOML do not +/// block folding here; errors reading an existing file are propagated. fn mise_fold_skip_reason(target: &Path) -> Result> { for f in ["mise.toml", ".mise.toml"] { let path = target.join(f); @@ -690,6 +704,8 @@ fn mise_fold_skip_reason(target: &Path) -> Result> { /// so nothing in it was in effect to carry. Files are read lowest precedence /// first, so a later file's pin of the same tool replaces an earlier one, which /// is the pin `mise ls --current` reports as in effect. +/// `banned` contains whitespace-separated tool names. Unreadable files are +/// ignored; `.tool-versions` contributes only the first version of each tool. fn carry_over_tools(target: &Path, banned: &str) -> Result<(Vec, Vec)> { let banned: Vec<&str> = banned.split_whitespace().collect(); let (mut out, mut notes): (Vec<(String, String)>, Vec) = (Vec::new(), Vec::new()); @@ -816,8 +832,11 @@ fn origin_slug(target: &Path) -> Option { (!o.is_empty() && !r.is_empty() && !r.contains('/')).then(|| format!("{o}/{r}")) } -/// `(version, synopsis, description)`: the Cargo package's, else the README's -/// first prose paragraph, else the name. +/// Return `(version, synopsis, description)` from Cargo package metadata. +/// The version defaults to `0.1.0`; the description falls back to the first +/// README prose paragraph, then a sentence naming the repository. Whitespace +/// is collapsed and the synopsis is derived from the description. Unreadable +/// or invalid metadata is ignored when choosing these fallbacks. fn describe(target: &Path, name: &str) -> (String, String, String) { let cargo: Option = std::fs::read_to_string(target.join("Cargo.toml")) .ok() @@ -874,7 +893,8 @@ fn readme_paragraph(target: &Path) -> Option { (!para.is_empty()).then(|| para.join(" ")) } -/// The first sentence, without its full stop, at most 79 characters. +/// Text before the first `. `, without trailing full stops, limited to 79 UTF-8 +/// bytes. Longer text is shortened at word boundaries and may become empty. fn synopsis_of(description: &str) -> String { let first = description .split(". ") @@ -970,6 +990,9 @@ pub const GUIX_ENV: &str = "LAUNCH_SCAFFOLDER_GUIX"; /// Write `build/guix/crates.scm` through the engine's `crates-scm` verb. The /// result is accepted only when `guix-stub` then passes on `guix_scm`: a /// containerised guix loses its exit status, so the file is the evidence. +/// Returns `Created` only if the command succeeds and the stub check passes; +/// otherwise returns `Failed`. Process I/O errors and unexpected predicate +/// exits are propagated as errors. fn crates_scm(lib: &Lib, guix_scm: &str) -> Result { let guix = std::env::var(GUIX_ENV).unwrap_or_else(|_| "guix".into()); let o = lib.run_env(&["crates-scm"], &[("GUIX", &guix)])?; @@ -988,6 +1011,9 @@ fn crates_scm(lib: &Lib, guix_scm: &str) -> Result { /// `mise-lock-gaps` already finds every tool pinned and checksummed: bumping /// versions is `toolchain-refresh`'s job, not mint's. The target is trusted /// for this one process through the environment, not mise's trust database. +/// Runs with a 600-second timeout. The post-run gap check determines success +/// regardless of the command's exit status; remaining gaps yield `Act::Failed`. +/// Process I/O errors and unexpected predicate exits are propagated. fn mise_lock(lib: &Lib) -> Result { if lib.predicate(&["mise-lock-gaps"])?.is_none() { return Ok(Act::Kept("pinned and checksummed".into())); @@ -1011,7 +1037,8 @@ fn mise_lock(lib: &Lib) -> Result { /// The carried-over tools named in a `mise-lock-gaps` "does not pin" verdict: /// the ones mint itself brought in and may therefore take out again. A canon -/// tool in the same verdict is not returned, because that is a canon defect. +/// tool absent from `carried` is not returned. A canon tool also present in +/// `carried` is eligible for removal. fn unpinnable(gap: &str, carried: &[(String, String)]) -> Vec { let Some(rest) = gap.strip_prefix("mise.lock does not pin: ") else { return Vec::new(); @@ -1024,9 +1051,9 @@ fn unpinnable(gap: &str, carried: &[(String, String)]) -> Vec { .collect() } -/// The first error a failed command reported (mise ends with a version and a -/// "Run with --verbose" trailer, which name nothing), else its last line, else -/// its exit status when it said nothing. +/// From the first stream with a nonblank line, the first error line (mise ends +/// with a version and a "Run with --verbose" trailer, which name nothing), else +/// its last nonblank line. Falls back to the exit status if all streams are blank. fn last_line(streams: &[&[u8]], status: &std::process::ExitStatus) -> String { streams .iter() @@ -1055,7 +1082,10 @@ impl Lib { self.run_env(args, &[]) } - /// Run a verb of the target's `provision-lib.sh` with extra environment. + /// Run a verb of the target's `provision-lib.sh` with extra environment, + /// using the target as the working directory and `PROVISION_ROOT`. + /// Returns captured output even on nonzero exit; process I/O errors are + /// propagated. fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Result { Command::new("bash") .arg(self.0.join(LIB)) @@ -1102,7 +1132,9 @@ impl Lib { .collect()) } - /// A predicate verb: exit 0 is "no", exit 1 is "yes, because ". + /// A predicate verb: exit 0 returns `None`, exit 1 returns trimmed stdout + /// as `Some`, replacing invalid UTF-8. Other exits, signal termination and + /// process I/O failures return errors. fn predicate(&self, args: &[&str]) -> Result> { let o = self.run(args)?; match o.status.code() { diff --git a/crates/launcher-common/src/provisioning/readme.rs b/crates/launcher-common/src/provisioning/readme.rs index ec6c4df..6b7d19e 100644 --- a/crates/launcher-common/src/provisioning/readme.rs +++ b/crates/launcher-common/src/provisioning/readme.rs @@ -16,7 +16,11 @@ use std::path::Path; const ANCHOR: &str = "[[ai-install]]"; -/// Insert `rendered` (the template with the repo's values) into the README. +/// Insert `rendered` (the template with the repo's values) into `README.adoc`. +/// Returns `Skipped` if that file is absent and `Kept` if its anchor is already +/// present. An existing AI-assisted installation section gains only the anchor; +/// otherwise the rendered content is inserted. Successful writes return +/// `Replaced`; read, UTF-8 decoding and write errors are propagated. pub fn insert(target: &Path, rendered: &str) -> Result { let path = target.join("README.adoc"); if !path.is_file() { diff --git a/crates/launcher/src/cmd_provision_set.rs b/crates/launcher/src/cmd_provision_set.rs index af9ebd8..8c11857 100644 --- a/crates/launcher/src/cmd_provision_set.rs +++ b/crates/launcher/src/cmd_provision_set.rs @@ -66,8 +66,11 @@ struct MintArgs { pub const EXIT_REFUSED: i32 = 3; /// Run `provision-set`: check a repository, or mint/realign its provisioning -/// set. Exits 1 on drift or a FAIL, 3 on a licence refusal, 4 when an -/// external step (`mise lock`, `guix import crate`) failed. +/// set. Check mode exits 1 on engine drift; otherwise it exits with the +/// repository checker's code (1 if killed by a signal). Mint/realign exits 3 +/// on a licence refusal and 4 when the report contains a failed external step. +/// Other canon, check or mint errors are returned to the caller; a mint with +/// no reported failures returns successfully, even if some files were skipped. pub fn run(args: Args) -> Result<()> { let canon = Canon::resolve(args.canon.as_deref())?; match args.action { From 073e3bafc1a85d6707882e5530cb4b7cf2463038 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:23:01 +0100 Subject: [PATCH 20/26] Update crates/launcher-common/src/provisioning/mint.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- crates/launcher-common/src/provisioning/mint.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 2955a99..26f64d4 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -1019,12 +1019,15 @@ fn mise_lock(lib: &Lib) -> Result { return Ok(Act::Kept("pinned and checksummed".into())); } let existed = lib.0.join("mise.lock").is_file(); - let o = Command::new("timeout") + let o = match Command::new("timeout") .args(["600", "mise", "lock"]) .env("MISE_TRUSTED_CONFIG_PATHS", &lib.0) .current_dir(&lib.0) .output() - .context("running timeout 600 mise lock")?; + { + Ok(o) => o, + Err(e) => return Ok(Act::Failed(format!("cannot run `timeout 600 mise lock`: {e}"))), + }; Ok(match lib.predicate(&["mise-lock-gaps"])? { None if existed => Act::Replaced("re-locked: the old lock had gaps".into()), None => Act::Created, From b696d40250221f57846954db69a245dcc5cd8df0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:32:47 +0100 Subject: [PATCH 21/26] Update crates/launcher-common/src/provisioning/mint.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .../launcher-common/src/provisioning/mint.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 26f64d4..881c31e 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -136,15 +136,14 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { let target = &target .canonicalize() .with_context(|| format!("resolving {}", target.display()))?; - let slug = match &opts.repo { - Some(s) => s.clone(), - None => origin_slug(target).unwrap_or_else(|| { - format!( - "hyperpolymath/{}", - target.file_name().unwrap_or_default().to_string_lossy() - ) - }), - }; + let known = opts.repo.clone().or_else(|| origin_slug(target)); + let slug_is_guess = known.is_none(); + let slug = known.unwrap_or_else(|| { + format!( + "hyperpolymath/{}", + target.file_name().unwrap_or_default().to_string_lossy() + ) + }); let name = slug.rsplit('/').next().unwrap_or(&slug).to_string(); // Refuse before writing anything (standard §6). let licence = licence::classify(target, &name)?; @@ -159,7 +158,9 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { let langs = lib.lines(&["langs"])?; let old_deed = std::fs::read_to_string(target.join(DEED)).ok(); let deed_repo = old_deed.as_deref().and_then(|d| deed_field(d, "repo")); - let inherited_from = deed_repo.filter(|r| r != &slug && !r.contains("__")); + let inherited_from = deed_repo + .filter(|_| !slug_is_guess) + .filter(|r| r != &slug && !r.contains("__")); let archetype = match &opts.archetype { Some(a) => a.clone(), None => old_deed From c46218b5ff936b5bae1a9e73f51d8017b0892643 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:56:27 +0100 Subject: [PATCH 22/26] Update .github/workflows/launcher-artefacts.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 8a24adf..2ccb982 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -208,8 +208,8 @@ jobs: # rust-ci.yml skips `needs_just::` (its runner has no just); this job # is the only place they run, so their absence here is a failure. NEEDS_JUST=$(grep -cE '^test (.*::)?needs_just::.* \.\.\. ok$' "$LOG") - if [ "$NEEDS_JUST" -lt 9 ]; then - echo "::error title=needs_just tests::only $NEEDS_JUST of 9 needs_just:: tests passed here, and rust-ci skips them, so nothing else runs them" + if [ "$NEEDS_JUST" -lt 16 ]; then + echo "::error title=needs_just tests::only $NEEDS_JUST of 16 needs_just:: tests passed here, and rust-ci skips them, so nothing else runs them" exit 1 fi if [ "$PASSED" -lt "$TEST_COUNT_FLOOR" ]; then From fa428294c52aef6185d2eac0df181d536f9f4a09 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:00:54 +0100 Subject: [PATCH 23/26] Update crates/launcher-common/src/provisioning/mint.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- crates/launcher-common/src/provisioning/mint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index 881c31e..d109a37 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -233,7 +233,7 @@ pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { .into_iter() .filter(|f| target.join(f).is_file()) .collect(); - let fold_skip = if secondary.is_empty() { + let fold_skip = if secondary.is_empty() && banned.is_none() { None } else { mise_fold_skip_reason(target)? From 19c265d61d61fce1019d2ae2a71c2e25728259a2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:10:29 +0100 Subject: [PATCH 24/26] style(provision-set): rustfmt mint.rs after web edit fa42829 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UkSmyapDUmuGyyZSJmvbKy --- crates/launcher-common/src/provisioning/mint.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index d109a37..cb152ff 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -1027,7 +1027,11 @@ fn mise_lock(lib: &Lib) -> Result { .output() { Ok(o) => o, - Err(e) => return Ok(Act::Failed(format!("cannot run `timeout 600 mise lock`: {e}"))), + Err(e) => { + return Ok(Act::Failed(format!( + "cannot run `timeout 600 mise lock`: {e}" + ))); + } }; Ok(match lib.predicate(&["mise-lock-gaps"])? { None if existed => Act::Replaced("re-locked: the old lock had gaps".into()), From e9c54a093dbeff35123315847b5e8563de8f7da5 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:28:47 +0000 Subject: [PATCH 25/26] docs(provisioning): document shell helpers and tests, and refresh the canon provenance and digest --- .../launcher-common/src/provisioning/canon.rs | 6 ++-- .../src/provisioning/justfile.rs | 4 ++- .../tests/provisioning_fixtures.rs | 4 ++- standards/provisioning/CANON | 2 +- .../templates/build/just/provision-check.sh | 7 +++++ .../templates/build/just/provision-lib.sh | 31 +++++++++++++++++++ .../templates/build/just/provision-modes.sh | 7 +++++ 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs index 07d37d6..85526ac 100644 --- a/crates/launcher-common/src/provisioning/canon.rs +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -20,7 +20,9 @@ use std::path::{Path, PathBuf}; /// Environment override for the canon directory. pub const CANON_ENV: &str = "LAUNCH_SCAFFOLDER_PROVISIONING_CANON"; -/// `standards@` the baked table was vendored from. +/// Upstream `standards@` provenance of the baked table. +/// The `+local-docstrings` suffix records local shell function documentation; +/// the content digest below pins the complete snapshot, including those comments. pub const BAKED_CANON_REF: &str = include_str!("../../../../standards/provisioning/CANON"); /// Engine files: copied byte-for-byte, owned by realign, and byte-compared by @@ -239,5 +241,5 @@ mod tests { assert!(BAKED_CANON_REF.trim().starts_with("standards@")); } - const PINNED_DIGEST: &str = "f4337dd6d042fd028fde2f6c7d970edd223225ad29d033135dc3128065a50e83"; + const PINNED_DIGEST: &str = "4df3133905d404d16aeec943fd8be37b601c6037988eb69dc0184ac3167d3dcf"; } diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs index ad552e9..0c8bef2 100644 --- a/crates/launcher-common/src/provisioning/justfile.rs +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -620,7 +620,7 @@ mod tests { mod needs_just { use super::*; - /// Verify merging repairs boilerplate, preserves custom verbs, and is idempotent. + /// Verify folding retains unmatched top-level content even when the kept file cannot parse. #[test] fn folding_retains_unmatched_top_level_content_even_when_kept_file_is_broken() { for kept in ["build:\n echo b\ntest:\n echo t\n", BROKEN] { @@ -648,6 +648,7 @@ mod tests { } } + /// Verify folding removes sources with shared directives but retains unmatched settings. #[test] fn folding_removes_only_safe_sources_and_accepts_shared_directives() { let d = repo("set dotenv-load\nbuild:\n echo b\ntest:\n echo t\n"); @@ -670,6 +671,7 @@ mod tests { std::fs::remove_dir_all(&d).unwrap(); } + /// Verify merging repairs boilerplate, preserves custom verbs, and is idempotent. #[test] fn boilerplate_is_replaced_and_a_broken_file_repaired() { let d = repo(BROKEN); diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs index 2975f2e..19387a4 100644 --- a/crates/launcher-common/tests/provisioning_fixtures.rs +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -213,7 +213,7 @@ mod needs_just { assert_eq!(code, 0); } - /// Verify offline mint preserves custom recipes as local twins and reports the skipped mise lock. + /// Verify offline mint preserves all mise configs when either contains non-tool settings. #[test] fn offline_mint_retains_mise_configs_with_non_tool_settings() { for (i, (primary, secondary)) in [ @@ -280,6 +280,7 @@ mod needs_just { } } + /// Verify offline mint folds tool-only configs, preserves winning pins, and removes banned tools. #[test] fn offline_mint_folds_tool_only_configs_and_still_replaces_banned_tools() { for (i, (banned, secondary)) in [(false, true), (true, true), (true, false)] @@ -341,6 +342,7 @@ mod needs_just { } } + /// Verify offline mint preserves custom recipes as local twins and reports the skipped mise lock. #[test] fn offline_mint_keeps_custom_recipes_as_local_twins() { let canon = Canon::resolve(None).unwrap(); diff --git a/standards/provisioning/CANON b/standards/provisioning/CANON index aa07a30..8ccf41a 100644 --- a/standards/provisioning/CANON +++ b/standards/provisioning/CANON @@ -1 +1 @@ -standards@bf7c97abfc7240e9c4275c85153e74b50de10eaf +standards@bf7c97abfc7240e9c4275c85153e74b50de10eaf+local-docstrings diff --git a/standards/provisioning/templates/build/just/provision-check.sh b/standards/provisioning/templates/build/just/provision-check.sh index 2666842..651aa92 100755 --- a/standards/provisioning/templates/build/just/provision-check.sh +++ b/standards/provisioning/templates/build/just/provision-check.sh @@ -28,16 +28,22 @@ VERBS="setup doctor heal dev-shell toolchain-refresh ai-setup ai-warmup eval con # Their locations come from provision-lib.sh (guix-dir, set-files): the engine and # this check must never disagree about where the Guix files or warm-ups live. [ -f "$LIB" ] || { echo "provision-check: engine missing: $LIB" >&2; exit 1; } +# Run an engine verb with this checkout as PROVISION_ROOT, forwarding its exit status. lib() { PROVISION_ROOT="$PWD" bash "$LIB" "$@"; } GDIR=$(lib guix-dir) SET_FILES=$(lib set-files) +# Print the path to Guix file $1 using the directory reported by the engine. gp() { [ "$GDIR" = . ] && echo "$1" || echo "$GDIR/$1"; } fails=0 warns=0 +# Print a successful conformance diagnostic without changing the failure tally. ok() { printf ' ok %s\n' "$*"; } +# Print a failed conformance diagnostic and increment the failure tally. bad() { printf ' FAIL %s\n' "$*"; fails=$((fails + 1)); } +# Print a conformance warning and increment the warning tally. warn() { printf ' warn %s\n' "$*"; warns=$((warns + 1)); } +# Return success when version $1 sorts at or above version $2 with sort -V. version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } echo "[1] launcher" @@ -84,6 +90,7 @@ done echo "[5] template residue" f0=$fails +# Inspect stdin for template slots labelled by $1; --dev downgrades only repository slots. residue() { # $1 label; stdin = the text to judge local text mech spec text=$(cat) diff --git a/standards/provisioning/templates/build/just/provision-lib.sh b/standards/provisioning/templates/build/just/provision-lib.sh index 96f4012..abca94c 100755 --- a/standards/provisioning/templates/build/just/provision-lib.sh +++ b/standards/provisioning/templates/build/just/provision-lib.sh @@ -35,11 +35,17 @@ T="timeout 15" # some --version probes hang (observed 2026-09-30); never pr if [ -t 1 ]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[34m'; Z=$'\033[0m'; else R=; G=; Y=; B=; Z=; fi PASS=0; WARN=0; FAIL=0 +# Increment the PASS tally and print the supplied diagnostic. pass() { PASS=$((PASS+1)); printf ' %sPASS%s %s\n' "$G" "$Z" "$*"; } +# Increment the WARN tally and print the supplied diagnostic. warn() { WARN=$((WARN+1)); printf ' %sWARN%s %s\n' "$Y" "$Z" "$*"; } +# Increment the FAIL tally and print the supplied diagnostic. fail() { FAIL=$((FAIL+1)); printf ' %sFAIL%s %s\n' "$R" "$Z" "$*"; } +# Print an informational diagnostic without changing the tallies. info() { printf ' %sinfo%s %s\n' "$B" "$Z" "$*"; } +# Print the supplied text as a diagnostic section heading. hdr() { printf '\n%s== %s ==%s\n' "$B" "$*" "$Z"; } +# Return success when the command named by $1 is available. have() { command -v "$1" >/dev/null 2>&1; } # Where this repository keeps each part of the set. A file sits at the root only @@ -49,6 +55,7 @@ have() { command -v "$1" >/dev/null 2>&1; } # This is the ONE answer: provision-check.sh asks it through `guix-dir` and # `set-files` rather than keeping its own copy. guix_dir() { if [ -f build/guix.scm ]; then echo build; else echo .; fi; } +# Print the first existing warm-up directory, falling back to the repository root. warmup_dir() { local d for d in . docs/onboarding docs; do @@ -56,8 +63,11 @@ warmup_dir() { done echo . } +# Print the path to Guix file $1 in the repository-selected Guix directory. gpath() { local d; d=$(guix_dir); [ "$d" = . ] && echo "$1" || echo "$d/$1"; } +# Print the warm-up path for audience $1 in the selected warm-up directory. wpath() { local d; d=$(warmup_dir); [ "$d" = . ] && echo "llm-warmup-$1.adoc" || echo "$d/llm-warmup-$1.adoc"; } +# List the provisioning-owned paths inspected for unfilled template slots. set_files() { printf '%s\n' launcher.sh Justfile justfile mise.toml \ "$(gpath guix.scm)" "$(gpath manifest.scm)" "$(gpath channels.scm)" \ @@ -73,6 +83,7 @@ deed() { # $1 key, $2 default [ -z "$v" ] && v=$(grep -oE ":$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') printf '%s' "${v:-$2}" } +# Derive the repository slug from origin, falling back to hyperpolymath/. repo_slug() { local u; u=$(git config --get remote.origin.url 2>/dev/null || true) u=${u%.git}; u=${u#*github.com[:/]} @@ -89,6 +100,7 @@ ARCHETYPE="$(deed archetype "")" # 74 repos have their only build.zig at that depth, measured 2026-09-30). # Order matters only for display. `docs` is reported when nothing else is. first() { find . -maxdepth "${2:-2}" -not -path './.git/*' -not -path '*/node_modules/*' -not -path './target/*' -name "$1" -print -quit 2>/dev/null; } +# Print languages found through project markers, or docs when none are detected. detect_langs() { local out=() [ -n "$(first Cargo.toml)" ] && out+=(rust) @@ -128,6 +140,7 @@ lang_tools() { # docker.io/metacall/guix). Guix has NO idris2, gleam, bun or lychee, and its # julia is 1.8.5: those come from mise, which Guix itself ships ("mise"). GUIX_BASE="git bash coreutils nss-certs just mise shellcheck" +# Print the Guix package specifications needed for language $1. lang_guix() { case "$1" in rust) echo "rust rust:cargo gcc-toolchain pkg-config" ;; @@ -147,6 +160,7 @@ lang_guix_gap() { idris2) echo "idris2 (via pack)" ;; julia) echo julia ;; gleam) echo gleam ;; bun) echo bun ;; docs) echo lychee ;; esac } +# Print deduplicated base and language-specific Guix package specifications. guix_specs() { local l s=" $GUIX_BASE " for l in "${LANGS[@]}"; do for p in $(lang_guix "$l"); do case "$s" in *" $p "*) ;; *) s="$s$p ";; esac; done; done @@ -159,10 +173,12 @@ guix_specs() { MISE_BASE="just shellcheck" # Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. RECIPE_TOOLS="trivy" +# Print the mise tools available for language $1; unsupported languages emit nothing. lang_mise() { case "$1" in rust) echo "rust" ;; zig) echo "zig" ;; julia) echo "julia" ;; elixir) echo "erlang elixir" ;; gleam) echo "erlang gleam" ;; ocaml) echo "opam" ;; bun) echo "bun" ;; docs) echo "lychee" ;; esac; } +# Print known optional tools referenced by uncommented Justfile recipe text. recipe_tools() { local t f body="" for f in Justfile justfile build/just/*.just; do @@ -173,6 +189,7 @@ recipe_tools() { done } +# Print deduplicated mise tools required by the base, languages and recipes. mise_tools() { local l t seen=" " out=() for t in $MISE_BASE $(for l in "${LANGS[@]}"; do lang_mise "$l"; done) $(recipe_tools); do @@ -181,6 +198,7 @@ mise_tools() { printf "%s\n" "${out[*]}" } +# Print installation guidance for the toolchain of language $1. lang_remedy() { case "$1" in rust) echo "mise use rust@latest (or rustup: https://rustup.rs)" ;; @@ -312,6 +330,7 @@ lang_run() { # $1 verb # CI all see the same result. has_recipe() { have just && just --summary 2>/dev/null | tr " " "\n" | grep -qx "$1"; } +# Return success when version $1 sorts at or above version $2 with sort -V. version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } # Tools that must never appear in a repo's toolchain (estate language policy). @@ -433,11 +452,13 @@ lang_sysdeps() { case "$1" in ocaml) echo "gcc make patch unzip bubblewrap|build-essential patch unzip bubblewrap|xcode-select --install|opam compiles OCaml and sandboxes its builds with bubblewrap" ;; haskell) echo "gcc gcc-c++ gmp-devel make ncurses-devel xz perl|build-essential curl libffi-dev libgmp-dev libncurses-dev|xcode-select --install|ghcup installs GHC, which links through the C toolchain and GMP" ;; esac; } +# Print tools needed outside Guix for detected languages, or none. guix_gaps() { local l g gaps="" for l in "${LANGS[@]}"; do g=$(lang_guix_gap "$l"); [ -n "$g" ] && gaps="$gaps${gaps:+, }$g"; done printf '%s\n' "${gaps:-none}" } +# Print AsciiDoc table rows describing language and recipe tools and installation routes. tool_table() { local l t base for l in "${LANGS[@]}"; do @@ -586,6 +607,7 @@ cmd_doctor() { return 0 } +# Install tools and dependencies, run local setup hooks, and fail if setup or doctor fails. cmd_setup() { printf '%s setup — installing everything this repository needs\n' "$REPO_NAME" local rc=0 @@ -618,6 +640,7 @@ cmd_setup() { return $rc } +# Attempt tool and permission repairs plus local hooks, then return the doctor result. cmd_heal() { printf '%s heal — applying safe, reversible fixes, then re-checking\n' "$REPO_NAME" hdr "Fixes" @@ -639,6 +662,7 @@ cmd_heal() { cmd_doctor } +# Replace this process with a Guix or mise shell; fail if neither route is available. cmd_dev_shell() { local gm; gm=$(gpath manifest.scm) if have guix && [ -f "$gm" ] && ! grep -q '{{' "$gm"; then @@ -765,6 +789,7 @@ just_say_it() { [ -z "$line" ] && line="Set up $REPO_NAME from https://github.com/$REPO_SLUG — follow docs/AI_INSTALLATION_GUIDE.adoc in that repo." printf '%s' "$line" } +# Copy stdin through an available clipboard tool within three seconds; fail silently otherwise. clip() { # best-effort; silent when no clipboard exists (CI, SSH) # wl-copy and xclip fork a daemon that inherits stdout; left attached, it # holds any pipeline open forever (observed 2026-09-30). Detach and bound it. @@ -776,6 +801,7 @@ clip() { # best-effort; silent when no clipboard exists (CI, SSH) else cat >/dev/null; return 1; fi timeout 3 "${c[@]}" >/dev/null 2>&1 } +# Print AI setup guidance and attempt to copy its setup line to the clipboard. cmd_ai_setup() { cat <" >&2; return 2 ;; esac @@ -805,6 +832,7 @@ cmd_ai_warmup() { fail "llm-warmup-$who.adoc not found"; return 1 } +# Run and time the test and bench recipes, save their output, and fail if either fails. cmd_eval() { local logf rc=0 s e v r o st logf=".eval/$(date -u +%Y%m%dT%H%M%SZ).txt" @@ -825,6 +853,7 @@ cmd_eval() { return $rc } +# Print repository identity, declared configuration and detected toolchain file locations. cmd_config_show() { echo "repo: $REPO_SLUG" echo "archetype: $ARCHETYPE" @@ -838,6 +867,7 @@ cmd_config_show() { echo "lib: provision-lib $PROVISION_LIB_VERSION" } +# Print repository-specific OPSM installation instructions and any deed note. cmd_opsm() { cat <} if have agrep; then git ls-files -z | xargs -0 agrep -n -1 -- "$p" 2>/dev/null diff --git a/standards/provisioning/templates/build/just/provision-modes.sh b/standards/provisioning/templates/build/just/provision-modes.sh index 35e6033..50a9fa7 100755 --- a/standards/provisioning/templates/build/just/provision-modes.sh +++ b/standards/provisioning/templates/build/just/provision-modes.sh @@ -21,12 +21,14 @@ HP_PROVISION_MODES_VERSION="0.6.0" +# Print deed field $1, or default $2 when the field or deed is absent. hp__deed() { # $1 key, $2 default — flat (key "value") read from provisioning_praxis.deed local f="$REPO_DIR/.machine_readable/descriptiles/provisioning_praxis.deed" v="" [ -f "$f" ] && v=$(grep -oE "[(:]$1[[:space:]]+\"[^\"]*\"" "$f" | head -1 | sed -E 's/^[^"]*"//; s/"$//') printf '%s' "${v:-$2}" } +# Print the declared archetype, defaulting to library. hp_archetype() { hp__deed archetype "library"; } # Without a deed, the origin remote names the repo (a worktree or renamed clone # has another directory name); the directory is the last resort. @@ -36,6 +38,7 @@ hp_app_name() { hp__deed name "${u:-$(basename "$REPO_DIR")}" } +# Map the host kernel name to linux, macos, windows or unknown. hp_platform() { case "$(uname -s)" in Linux*) echo linux ;; @@ -68,6 +71,7 @@ EOF return 1 } +# Ensure just is available and run its arguments from REPO_DIR, forwarding the status. hp_just() { hp_ensure_just || return 1; (cd "$REPO_DIR" && just "$@"); } # The provisioning modes call the engine directly, not a `just` recipe: a repo may # define its own root `doctor`/`setup`/`heal`, and the launcher must still run the @@ -84,6 +88,7 @@ hp_provision_or_return() { exit "$rc" } +# Run a recognised provisioning mode; return 99 when $1 belongs to another mode family. hp_provision_dispatch() { case "${1:-}" in --setup) hp_lib setup ;; @@ -94,6 +99,7 @@ hp_provision_dispatch() { esac } +# Print the launcher name, version, commit and platform, using fallbacks for missing metadata. hp_version_line() { local sha ver sha=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown) @@ -102,6 +108,7 @@ hp_version_line() { printf '%s-launcher %s (%s) [%s-%s]\n' "$(hp_app_name)" "${ver#v}" "$sha" "$(hp_platform)" "$(uname -m)" } +# Print launcher usage and the runtime modes appropriate to the declared archetype. hp_help() { local name arch; name=$(hp_app_name); arch=$(hp_archetype) cat < Date: Thu, 1 Oct 2026 23:33:43 +0100 Subject: [PATCH 26/26] Clarify provisioning mint helper documentation (#69) Update mint.rs docstrings to explain template key syntax and byte-based atom wrapping, origin slug failure cases, file action reporting and Unix permissions, and error handling for mise locking and engine output. No runtime behavior changes. Validation: git diff --check passed for the pinned diff. Tests were not run (documentation-only changes). [View coding task](https://app.coderabbit.ai/code/tasks/fb7a9e10-fd8f-5a84-aae1-564b90898cf8?source=coding_agent_github_pr_description) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../launcher-common/src/provisioning/mint.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs index cb152ff..cf68f86 100644 --- a/crates/launcher-common/src/provisioning/mint.rs +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -536,8 +536,11 @@ fn mechanical_residue(text: &str) -> Option<&'static str> { /// Replace every `__KEY__` whose KEY is in `vars`, in one left-to-right pass, so /// a value can never be re-substituted (a description that mentions `__init__` -/// stays as written). A value marked as a list of atoms is wrapped at -/// [`WRAP`] columns with its continuation lines aligned under the first atom. +/// stays as written). Keys must start with an ASCII uppercase letter and contain +/// only ASCII uppercase letters, digits or underscores; other slots stay unchanged. +/// A value marked as a list of atoms is wrapped towards [`WRAP`] UTF-8 bytes per +/// line, reserving three bytes for closing parentheses and aligning continuation +/// lines under the first atom. Individual atoms are never split. pub fn render(tmpl: &str, vars: &BTreeMap<&str, String>) -> String { let mut out = String::with_capacity(tmpl.len()); let mut rest = tmpl; @@ -813,6 +816,7 @@ fn deed_field(deed: &str, key: &str) -> Option { } /// `owner/name` from the `origin` remote of a GitHub checkout. +/// Returns `None` if Git fails, its output is not UTF-8, or no slug can be extracted. fn origin_slug(target: &Path) -> Option { let out = Command::new("git") .arg("-C") @@ -949,8 +953,11 @@ fn canon_text(canon: &Canon, rel: &str) -> Result { String::from_utf8(canon.file(rel)?.into_owned()).with_context(|| format!("{rel} is not UTF-8")) } -/// Write `bytes` to `target/rel` unless it already holds them. Shell scripts and -/// the launcher are made executable. +/// Write `bytes` to `target/rel`, creating parent directories as needed. Returns +/// `Kept` for identical content, `Replaced(why)` for changed readable content, +/// or `Created` when the previous content could not be read. +/// On Unix, `.sh` files get mode 0755 even when their content is unchanged. +/// Directory creation, write and permission errors propagate without rollback. fn write_file(target: &Path, rel: &str, bytes: &[u8], why: &str) -> Result { let path = target.join(rel); let old = std::fs::read(&path).ok(); @@ -1014,7 +1021,8 @@ fn crates_scm(lib: &Lib, guix_scm: &str) -> Result { /// for this one process through the environment, not mise's trust database. /// Runs with a 600-second timeout. The post-run gap check determines success /// regardless of the command's exit status; remaining gaps yield `Act::Failed`. -/// Process I/O errors and unexpected predicate exits are propagated. +/// Failure to run or capture the lock command also yields `Act::Failed`; +/// process I/O errors and unexpected exits from the gap predicate are propagated. fn mise_lock(lib: &Lib) -> Result { if lib.predicate(&["mise-lock-gaps"])?.is_none() { return Ok(Act::Kept("pinned and checksummed".into())); @@ -1106,6 +1114,7 @@ impl Lib { } /// Run an engine verb and return UTF-8 stdout without trailing newlines, failing on nonzero status. + /// Process I/O and UTF-8 decoding errors are propagated. fn out(&self, args: &[&str]) -> Result { let o = self.run(args)?; if !o.status.success() {