diff --git a/.flux/README.adoc b/.flux/README.adoc new file mode 100644 index 0000000..31a1c55 --- /dev/null +++ b/.flux/README.adoc @@ -0,0 +1,32 @@ += GitOps Configuration Promotion +:author: Jonathan D.A. Jewell + +// SPDX-License-Identifier: PMPL-1.0-or-later + +This directory contains Flux-compatible GitOps configuration for promoting +contractile-managed policies from staging to production. + +== Structure + +[source] +---- +.flux/ +├── clusters/ +│ ├── staging/ # Staging environment configs +│ └── production/ # Production environment configs +└── README.adoc # This file +---- + +== Promotion Flow + +1. Changes to `policy/policy.ncl` are applied to staging first +2. `must check` and `trust verify` run against staging +3. After verification, promote to production via PR +4. Flux reconciles the production cluster + +== Integration with Contractiles + +- `must check` validates policy before promotion +- `trust verify` ensures artifact integrity +- `dust rollback` provides recovery if promotion fails +- `intend check` tracks whether GitOps adoption is complete diff --git a/.machine_readable/root-allow.txt b/.machine_readable/root-allow.txt index 394e024..b74663e 100644 --- a/.machine_readable/root-allow.txt +++ b/.machine_readable/root-allow.txt @@ -100,3 +100,21 @@ mise.toml # toolchain pin read by mise. Estate canon is .tool-v # never written for it. Read from the estate-rules gate's own output. .github/hooks/ # Top-level component directory of this project. REQUIRES_INITIALISATION.md # Project documentation. + +# ─── Declared 2026-10-09: landed from the vendored copy in reposystem ───────── +# These trees were vendored at reposystem@845679600b29^:contractiles/ and +# reached this repo only on 2026-10-09 (owner ruling: "get that stuff +# out"). Their paths are kept as vendored so the provenance stays +# traceable. Where they finally live is a question for RFC-0001 +# ratification; the owner can move any of them in review. +cli/ # Rust workspace: crates contractile + contractile-core +mustfile/ # Mustfile runner project (Ada/SPARK + shell launchers) +runners/ # per-verb runner projects (must/, trust/, intend/, just/, bust/) +config/ # attestation, canary, policy, TPM and tracing configs +policy/ # policy.ncl + policy.json +keys/ # signing.pub +schema/ # version.txt +dustfile/ # dust-spec.adoc +intentfile/ # intent-spec.adoc +trustfile/ # trust-spec.adoc +.flux/ # README.adoc diff --git a/cli/Cargo.lock b/cli/Cargo.lock new file mode 100644 index 0000000..ce7362c --- /dev/null +++ b/cli/Cargo.lock @@ -0,0 +1,657 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "contractile" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "colored", + "contractile-core", + "serde_json", + "toml", +] + +[[package]] +name = "contractile-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..163a889 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# Contractile CLI — unified runner for Must/Trust/Dust/Intend/K9 contractiles +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +[workspace] +resolver = "2" +members = [ + "crates/contractile-core", + "crates/contractile", +] + +[workspace.package] +version = "0.1.0" +authors = ["Jonathan D.A. Jewell "] +license = "MPL-2.0" +edition = "2024" +rust-version = "1.85" + +[workspace.dependencies] +contractile-core = { version = "0.1.0", path = "crates/contractile-core" } +clap = { version = "4", features = ["derive"] } +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +colored = "3" +toml = "0.8" +clap_complete = "4" diff --git a/cli/crates/contractile-core/Cargo.toml b/cli/crates/contractile-core/Cargo.toml new file mode 100644 index 0000000..66e40f1 --- /dev/null +++ b/cli/crates/contractile-core/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# contractile-core — A2ML parser, K9 bridge, Just emitter +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +[package] +name = "contractile-core" +description = "Core library for parsing A2ML contractile files, bridging K9 Nickel components, and emitting Just recipes" +version.workspace = true +authors.workspace = true +# PMPL-1.0-or-later preferred; MPL-2.0 required for crates.io (no PMPL SPDX ID yet) +license = "MPL-2.0" +edition.workspace = true +rust-version.workspace = true +repository = "https://github.com/hyperpolymath/contractiles" +keywords = ["a2ml", "contractile", "parser"] +categories = ["parsing", "development-tools"] + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/cli/crates/contractile-core/src/a2ml.rs b/cli/crates/contractile-core/src/a2ml.rs new file mode 100644 index 0000000..122b0fb --- /dev/null +++ b/cli/crates/contractile-core/src/a2ml.rs @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// a2ml.rs — Parser for A2ML (Annotated Markup with Machine Logic) contractile files. +// +// A2ML is the canonical format for all contractile types: Mustfile, Trustfile, +// Dustfile, Intentfile, and any future *file contracts. The format is deliberately +// line-oriented and human-readable, with a small set of structural elements: +// +// # comment +// @block-name: ← opens a metadata block +// @end ← closes it +// ## Section ← level-2 heading (section) +// ### Subsection ← level-3 heading (named entry within a section) +// - key: value ← key-value pair within the current section/subsection +// plain text ← prose within @abstract or section bodies +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::Result; + +/// A fully parsed A2ML document, representing one contractile file. +#[derive(Debug, Clone)] +pub struct A2mlDocument { + /// SPDX license identifier extracted from the header comment. + pub spdx_license: Option, + + /// The contractile type name parsed from the header comment + /// (e.g. "Mustfile", "Trustfile", "Dustfile", "Intentfile"). + pub file_type: Option, + + /// Contents of the `@abstract:` block — a human-readable summary of + /// what this contractile file declares. + pub abstract_text: Option, + + /// Section names listed in the `@requires:` block. These declare which + /// sections the document considers mandatory for completeness. + pub requires: Vec, + + /// All top-level sections (`## Heading`) and their contents. + pub sections: Vec
, +} + +/// A top-level section within an A2ML document, introduced by `## Name`. +#[derive(Debug, Clone)] +pub struct Section { + /// Section heading text (the part after `## `). + pub name: String, + + /// Key-value entries directly within this section (not inside a subsection). + pub entries: Vec, + + /// Named subsections introduced by `### Name` within this section. + pub subsections: Vec, + + /// Prose lines that appear before any entries or subsections. + pub prose: Vec, +} + +/// A named subsection within a section, introduced by `### Name`. +/// Subsections are the typical unit of an "item" within a contractile — for +/// example, a single check in a Mustfile, a single verification in a Trustfile, +/// or a single rollback target in a Dustfile. +#[derive(Debug, Clone)] +pub struct Subsection { + /// Subsection heading text (the part after `### `). + pub name: String, + + /// Key-value entries within this subsection. + pub entries: Vec, +} + +/// A single key-value pair parsed from `- key: value` lines. +#[derive(Debug, Clone)] +pub struct Entry { + /// The key portion (trimmed, before the first `:`). + pub key: String, + + /// The value portion (trimmed, after the first `:`). + pub value: String, +} + +/// Well-known executable field keys used across contractile types. +/// Each contractile type uses a subset of these to indicate which entries +/// contain shell commands that can be run by the corresponding CLI tool. +pub mod executable_keys { + /// Mustfile: the command to run for a check. + pub const RUN: &str = "run"; + + /// Trustfile: the command to run for a verification step. + pub const COMMAND: &str = "command"; + + /// Dustfile: the handler command for a recovery action. + pub const HANDLER: &str = "handler"; + + /// Dustfile: the rollback command to undo a change. + pub const ROLLBACK: &str = "rollback"; + + /// Dustfile: the undo command for a deployment failure. + pub const UNDO: &str = "undo"; + + /// Dustfile: the transform command to convert logs into dust events. + pub const TRANSFORM: &str = "transform"; + + /// Returns all keys that represent executable commands, in any contractile type. + pub fn all() -> &'static [&'static str] { + &[RUN, COMMAND, HANDLER, ROLLBACK, UNDO, TRANSFORM] + } +} + +/// Internal parser state — which structural context we're currently inside. +#[derive(Debug, PartialEq)] +enum ParseState { + /// Outside any block or section, at the top of the file. + TopLevel, + /// Inside an `@abstract:` ... `@end` block. + AbstractBlock, + /// Inside an `@requires:` ... `@end` block. + RequiresBlock, + /// Inside a `## Section`, before any `### Subsection`. + InSection, + /// Inside a `### Subsection` within a section. + InSubsection, +} + +/// Parse an A2ML source string into a structured document. +/// +/// The parser is intentionally lenient: it ignores unknown `@block:` types, +/// treats unrecognised lines as prose, and does not require any particular +/// section ordering. This allows the format to evolve without breaking older +/// parsers. +pub fn parse(input: &str) -> Result { + let mut doc = A2mlDocument { + spdx_license: None, + file_type: None, + abstract_text: None, + requires: Vec::new(), + sections: Vec::new(), + }; + + let mut state = ParseState::TopLevel; + let mut abstract_lines: Vec = Vec::new(); + + for (_line_num, raw_line) in input.lines().enumerate() { + let line = raw_line.trim_end(); + // ── Blank lines ── + if line.trim().is_empty() { + if state == ParseState::AbstractBlock { + abstract_lines.push(String::new()); + } + continue; + } + + // ── Comment lines (# ...) ── + if line.starts_with('#') && !line.starts_with("##") { + // Extract SPDX license from comment header. + if let Some(rest) = line.strip_prefix("# SPDX-License-Identifier:") { + doc.spdx_license = Some(rest.trim().to_string()); + } + // Extract file type from comment like "# Mustfile (A2ML Canonical)". + if line.contains("(A2ML") || line.contains("A2ML Canonical") { + let type_part = line.trim_start_matches('#').trim(); + if let Some(name) = type_part.split_whitespace().next() { + doc.file_type = Some(name.to_string()); + } + } + continue; + } + + // ── @block: and @end directives ── + if line.trim() == "@end" { + match state { + ParseState::AbstractBlock => { + doc.abstract_text = Some(abstract_lines.join("\n").trim().to_string()); + abstract_lines.clear(); + } + ParseState::RequiresBlock => { + // requires entries already pushed + } + _ => {} + } + // Return to whatever context makes sense — if we had sections, + // we'd go back to section, but @end only closes metadata blocks + // which always appear before sections. + state = ParseState::TopLevel; + continue; + } + + if line.trim() == "@abstract:" { + state = ParseState::AbstractBlock; + continue; + } + + if line.trim() == "@requires:" { + state = ParseState::RequiresBlock; + continue; + } + + // Skip unknown @block: directives (forward compatibility). + if line.trim().starts_with('@') && line.trim().ends_with(':') { + // Unknown block — consume until @end + state = ParseState::TopLevel; + continue; + } + + // ── State-specific parsing ── + match state { + ParseState::AbstractBlock => { + abstract_lines.push(line.to_string()); + } + + ParseState::RequiresBlock => { + // Lines like "- section: Parameters" + if let Some(entry) = parse_entry(line) { + if entry.key == "section" { + doc.requires.push(entry.value); + } + } + } + + ParseState::TopLevel | ParseState::InSection | ParseState::InSubsection => { + // ── ## Section heading ── + if let Some(heading) = line.strip_prefix("## ") { + let heading = heading.trim(); + if !heading.is_empty() { + doc.sections.push(Section { + name: heading.to_string(), + entries: Vec::new(), + subsections: Vec::new(), + prose: Vec::new(), + }); + state = ParseState::InSection; + continue; + } + } + + // ── ### Subsection heading ── + if let Some(heading) = line.strip_prefix("### ") { + let heading = heading.trim(); + // Strip brackets for extended A2ML dialects (e.g., `### [META]` → `META`). + let heading = heading + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(heading); + if !heading.is_empty() { + // If no section exists yet, create a default one. + if doc.sections.is_empty() { + doc.sections.push(Section { + name: "Default".to_string(), + entries: Vec::new(), + subsections: Vec::new(), + prose: Vec::new(), + }); + } + let section = doc.sections.last_mut().unwrap(); + section.subsections.push(Subsection { + name: heading.to_string(), + entries: Vec::new(), + }); + state = ParseState::InSubsection; + continue; + } + } + + // ── - key: value entry ── + if let Some(entry) = parse_entry(line) { + match state { + ParseState::InSubsection => { + if let Some(section) = doc.sections.last_mut() { + if let Some(sub) = section.subsections.last_mut() { + sub.entries.push(entry); + } + } + } + ParseState::InSection => { + if let Some(section) = doc.sections.last_mut() { + section.entries.push(entry); + } + } + _ => { + // Entry at top level (unusual but tolerated) + } + } + continue; + } + + // ── Prose lines (plain text within a section) ── + if state == ParseState::InSection { + if let Some(section) = doc.sections.last_mut() { + section.prose.push(line.to_string()); + } + } + } + } + } + + Ok(doc) +} + +/// Try to parse a line as `- key: value`. Returns `None` if the line doesn't +/// match the expected format. +fn parse_entry(line: &str) -> Option { + let trimmed = line.trim(); + let body = trimmed.strip_prefix("- ")?; + + // Split on the first `:` only — values may contain colons (e.g. URLs, + // sha256sum output, openssl commands). + let colon_pos = body.find(':')?; + let key = body[..colon_pos].trim().to_string(); + let value = body[colon_pos + 1..].trim().to_string(); + + if key.is_empty() { + return None; + } + + Some(Entry { key, value }) +} + +// ── Convenience accessors ── + +impl A2mlDocument { + /// Find a section by name (case-sensitive). + pub fn section(&self, name: &str) -> Option<&Section> { + self.sections.iter().find(|s| s.name == name) + } + + /// Iterate over all subsections across all sections that contain an + /// executable command entry (run, command, handler, rollback, undo, transform). + pub fn executable_items(&self) -> Vec> { + let mut items = Vec::new(); + for section in &self.sections { + for sub in §ion.subsections { + for entry in &sub.entries { + if executable_keys::all().contains(&entry.key.as_str()) { + items.push(ExecutableItem { + section: §ion.name, + subsection: &sub.name, + key: &entry.key, + command: &entry.value, + description: sub + .entries + .iter() + .find(|e| e.key == "description") + .map(|e| e.value.as_str()), + }); + } + } + } + } + items + } +} + +/// A reference to a single executable item found within an A2ML document. +/// Produced by [`A2mlDocument::executable_items`]. +#[derive(Debug)] +pub struct ExecutableItem<'a> { + /// The section this item belongs to (e.g. "Checks", "Verifications"). + pub section: &'a str, + + /// The subsection (entry name) this item belongs to (e.g. "policy-config-valid"). + pub subsection: &'a str, + + /// The key type that makes this executable (e.g. "run", "command", "rollback"). + pub key: &'a str, + + /// The shell command to execute. + pub command: &'a str, + + /// An optional human-readable description from the same subsection. + pub description: Option<&'a str>, +} + +impl Section { + /// Look up a direct entry by key within this section. + pub fn get(&self, key: &str) -> Option<&str> { + self.entries + .iter() + .find(|e| e.key == key) + .map(|e| e.value.as_str()) + } + + /// Find a subsection by name. + pub fn subsection(&self, name: &str) -> Option<&Subsection> { + self.subsections.iter().find(|s| s.name == name) + } +} + +impl Subsection { + /// Look up an entry by key within this subsection. + pub fn get(&self, key: &str) -> Option<&str> { + self.entries + .iter() + .find(|e| e.key == key) + .map(|e| e.value.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verify the parser handles a minimal Mustfile-style A2ML document. + #[test] + fn parse_mustfile_style() { + let input = r#" +# SPDX-License-Identifier: PMPL-1.0-or-later +# Mustfile (A2ML Canonical) + +@abstract: +Declarative state contract describing what must be true. +@end + +@requires: +- section: Parameters +- section: Checks +@end + +## Parameters + +- gateway_port: 8080 +- schema_version: v1.0.0 + +## Checks + +### policy-config-valid +- description: config/policy.yaml must be valid +- run: just validate-policy + +### gateway-exposes-port +- description: gateway must expose the configured port +- run: ss -lnt | rg ":8080" +"#; + + let doc = parse(input).unwrap(); + assert_eq!(doc.spdx_license.as_deref(), Some("PMPL-1.0-or-later")); + assert_eq!(doc.file_type.as_deref(), Some("Mustfile")); + assert!(doc.abstract_text.as_ref().unwrap().contains("must be true")); + assert_eq!(doc.requires, vec!["Parameters", "Checks"]); + assert_eq!(doc.sections.len(), 2); + + // Parameters section has direct entries + let params = doc.section("Parameters").unwrap(); + assert_eq!(params.get("gateway_port"), Some("8080")); + + // Checks section has subsections with executable items + let checks = doc.section("Checks").unwrap(); + assert_eq!(checks.subsections.len(), 2); + let policy = checks.subsection("policy-config-valid").unwrap(); + assert_eq!(policy.get("run"), Some("just validate-policy")); + + // executable_items should find 2 items (both `run` entries) + let execs = doc.executable_items(); + assert_eq!(execs.len(), 2); + assert_eq!(execs[0].subsection, "policy-config-valid"); + assert_eq!(execs[0].key, "run"); + } + + /// Verify the parser handles Trustfile-style `command:` entries. + #[test] + fn parse_trustfile_style() { + let input = r#" +# Trustfile (A2ML Canonical) + +## Verifications + +### policy-hash +- description: SHA-256 of policy matches +- command: sha256sum policy/policy.ncl +"#; + + let doc = parse(input).unwrap(); + let execs = doc.executable_items(); + assert_eq!(execs.len(), 1); + assert_eq!(execs[0].key, "command"); + assert_eq!(execs[0].command, "sha256sum policy/policy.ncl"); + } + + /// Verify the parser handles Dustfile-style multi-key executables. + #[test] + fn parse_dustfile_style() { + let input = r#" +# Dustfile (A2ML Canonical) + +## Policy + +### policy-rollback +- path: policy/policy.ncl +- rollback: git checkout HEAD~1 -- policy/policy.ncl + +## Gateway + +### bad-deployment +- event: deploy.failure +- undo: gatewayctl rollback --last +"#; + + let doc = parse(input).unwrap(); + let execs = doc.executable_items(); + assert_eq!(execs.len(), 2); + assert_eq!(execs[0].key, "rollback"); + assert_eq!(execs[1].key, "undo"); + } + + /// Verify the parser handles Intentfile-style structured intents + /// with metadata fields (status, priority, evidence, depends_on). + #[test] + fn parse_intentfile_structured() { + let input = r#" +# Intentfile (A2ML Canonical) + +@abstract: +Declared future intent. +@end + +@requires: +- section: Tooling +@end + +## Tooling + +### cli-integration +- description: Build the CLI +- status: realised +- priority: critical +- evidence: command: contractile --version +- notes: Done 2026-03-14 + +### k9-validators +- description: Write K9 validators +- status: in-progress +- priority: medium +- evidence: contractiles/k9/validators/ exists +- depends_on: cli-integration +"#; + + let doc = parse(input).unwrap(); + assert_eq!(doc.file_type.as_deref(), Some("Intentfile")); + + let tooling = doc.section("Tooling").unwrap(); + assert_eq!(tooling.subsections.len(), 2); + + // Structured intent has metadata fields accessible via get(). + let cli = tooling.subsection("cli-integration").unwrap(); + assert_eq!(cli.get("status"), Some("realised")); + assert_eq!(cli.get("priority"), Some("critical")); + assert_eq!(cli.get("evidence"), Some("command: contractile --version")); + + let k9 = tooling.subsection("k9-validators").unwrap(); + assert_eq!(k9.get("status"), Some("in-progress")); + assert_eq!(k9.get("depends_on"), Some("cli-integration")); + + // Intentfile has no executable items (no run/command/handler/etc.) + let execs = doc.executable_items(); + assert_eq!(execs.len(), 0); + } + + /// Verify colon-containing values are preserved (URLs, openssl commands). + #[test] + fn parse_values_with_colons() { + let input = r#" +# Trustfile (A2ML Canonical) + +## Verifications + +### tls-check +- description: TLS cert valid +- command: openssl x509 -in certs/server.pem -checkend 2592000 -noout +"#; + + let doc = parse(input).unwrap(); + let execs = doc.executable_items(); + assert_eq!(execs.len(), 1); + // The full command including colons must be preserved. + assert_eq!( + execs[0].command, + "openssl x509 -in certs/server.pem -checkend 2592000 -noout" + ); + } + + /// Verify the parser handles the severity field alongside executable keys. + #[test] + fn parse_severity_field() { + let input = r#" +# Mustfile (A2ML Canonical) + +## Checks + +### critical-check +- description: Must pass +- run: test -f LICENSE +- severity: critical + +### warn-check +- description: Should pass +- run: test -f CHANGELOG +- severity: warning +"#; + + let doc = parse(input).unwrap(); + let checks = doc.section("Checks").unwrap(); + + let critical = checks.subsection("critical-check").unwrap(); + assert_eq!(critical.get("severity"), Some("critical")); + + let warning = checks.subsection("warn-check").unwrap(); + assert_eq!(warning.get("severity"), Some("warning")); + } +} diff --git a/cli/crates/contractile-core/src/just_emitter.rs b/cli/crates/contractile-core/src/just_emitter.rs new file mode 100644 index 0000000..6b63c7b --- /dev/null +++ b/cli/crates/contractile-core/src/just_emitter.rs @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// just_emitter.rs — Generates Just recipes from parsed A2ML documents and K9 components. +// +// The emitter produces a `contractile.just` file that can be imported into any +// repo's Justfile via `import "contractile.just"`. Each contractile type gets +// a namespaced set of recipes: +// +// must-check, must- — from Mustfile.a2ml +// trust-verify, trust- — from Trustfile.a2ml +// dust-, dust-status — from Dustfile.a2ml +// intend-list — from Intentfile.a2ml +// k9- — from *.k9.ncl Hunt-level components +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use crate::a2ml::{self, A2mlDocument}; +use crate::k9::K9Component; +use anyhow::{Context, Result}; +use std::fmt::Write; +use std::fs; +use std::path::Path; + +/// Generate a complete `contractile.just` file from all A2ML and K9 sources +/// found under `contractiles_dir`. Scans for `*.a2ml` and `*.k9.ncl` files. +pub fn emit_all(contractiles_dir: &Path) -> Result { + let mut output = String::new(); + writeln!(output, "# Auto-generated by: contractile gen-just").unwrap(); + writeln!( + output, + "# Source directory: {}", + contractiles_dir.display() + ) + .unwrap(); + writeln!( + output, + "# Re-generate with: contractile gen-just --dir {}", + contractiles_dir.display() + ) + .unwrap(); + writeln!(output, "#").unwrap(); + writeln!(output, "# SPDX-License-Identifier: PMPL-1.0-or-later").unwrap(); + writeln!(output).unwrap(); + + // ── Scan for A2ML files ── + let a2ml_files = find_files_with_extension(contractiles_dir, "a2ml")?; + + for path in &a2ml_files { + let content = fs::read_to_string(path) + .with_context(|| format!("reading A2ML file: {}", path.display()))?; + let doc = a2ml::parse(&content) + .with_context(|| format!("parsing A2ML file: {}", path.display()))?; + + let file_type = doc + .file_type + .as_deref() + .unwrap_or("Unknown") + .to_lowercase(); + + match file_type.as_str() { + "mustfile" => { + writeln!(output, "{}", emit_must(&doc)).unwrap(); + } + "trustfile" => { + writeln!(output, "{}", emit_trust(&doc)).unwrap(); + } + "dustfile" => { + writeln!(output, "{}", emit_dust(&doc)).unwrap(); + } + "intentfile" => { + writeln!(output, "{}", emit_intend(&doc)).unwrap(); + } + _ => { + // Future contractile types: emit generic executable recipes. + writeln!(output, "{}", emit_generic(&file_type, &doc)).unwrap(); + } + } + } + + // ── Scan for K9 Hunt-level files ── + // K9 evaluation requires nickel, so we only emit stubs that call + // `k9 run ` rather than inlining the commands. This keeps the + // generated Justfile independent of nickel availability. + let k9_files = find_files_with_extension(contractiles_dir, "ncl")?; + if !k9_files.is_empty() { + writeln!(output, "# === K9 Components ===").unwrap(); + writeln!(output).unwrap(); + for path in &k9_files { + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + // Strip the `.k9` portion if present (e.g., "setup-repo.k9" → "setup-repo"). + let base_name = filename.strip_suffix(".k9").unwrap_or(filename); + let recipe_name = sanitise_recipe_name(&format!("k9-{}", base_name)); + + writeln!(output, "# K9 component: {}", path.display()).unwrap(); + writeln!(output, "{}:", recipe_name).unwrap(); + writeln!( + output, + " contractile k9 run {}", + path.display() + ) + .unwrap(); + writeln!(output).unwrap(); + } + } + + Ok(output) +} + +/// Emit Just recipes for a Mustfile A2ML document. +/// Creates a `must-check` aggregate recipe and individual `must-` recipes +/// for each check that has a `run:` entry. +pub fn emit_must(doc: &A2mlDocument) -> String { + let mut out = String::new(); + writeln!(out, "# === MUST (Physical State Checks) ===").unwrap(); + writeln!(out, "# Source: Mustfile.a2ml").unwrap(); + writeln!(out).unwrap(); + + let items = doc.executable_items(); + if items.is_empty() { + writeln!(out, "# (no executable checks found)").unwrap(); + return out; + } + + // Collect recipe names for the aggregate target. + let recipe_names: Vec = items + .iter() + .map(|item| sanitise_recipe_name(&format!("must-{}", item.subsection))) + .collect(); + + // Aggregate recipe. + writeln!(out, "# Run all must checks").unwrap(); + writeln!(out, "must-check: {}", recipe_names.join(" ")).unwrap(); + writeln!(out, " @echo 'All must checks passed'").unwrap(); + writeln!(out).unwrap(); + + // Individual recipes. + for item in &items { + let name = sanitise_recipe_name(&format!("must-{}", item.subsection)); + if let Some(desc) = item.description { + writeln!(out, "# {}", desc).unwrap(); + } + writeln!(out, "{}:", name).unwrap(); + writeln!(out, " {}", item.command).unwrap(); + writeln!(out).unwrap(); + } + + out +} + +/// Emit Just recipes for a Trustfile A2ML document. +/// Creates a `trust-verify` aggregate and individual `trust-` recipes. +pub fn emit_trust(doc: &A2mlDocument) -> String { + let mut out = String::new(); + writeln!(out, "# === TRUST (Integrity & Provenance Verification) ===").unwrap(); + writeln!(out, "# Source: Trustfile.a2ml").unwrap(); + writeln!(out).unwrap(); + + let items = doc.executable_items(); + if items.is_empty() { + writeln!(out, "# (no executable verifications found)").unwrap(); + return out; + } + + let recipe_names: Vec = items + .iter() + .map(|item| sanitise_recipe_name(&format!("trust-{}", item.subsection))) + .collect(); + + writeln!(out, "# Run all trust verifications").unwrap(); + writeln!(out, "trust-verify: {}", recipe_names.join(" ")).unwrap(); + writeln!(out, " @echo 'All trust verifications passed'").unwrap(); + writeln!(out).unwrap(); + + for item in &items { + let name = sanitise_recipe_name(&format!("trust-{}", item.subsection)); + if let Some(desc) = item.description { + writeln!(out, "# {}", desc).unwrap(); + } + writeln!(out, "{}:", name).unwrap(); + writeln!(out, " {}", item.command).unwrap(); + writeln!(out).unwrap(); + } + + out +} + +/// Emit Just recipes for a Dustfile A2ML document. +/// Creates individual `dust-` recipes for each recovery/rollback action. +/// No aggregate is generated because dust actions are typically invoked +/// selectively, not all at once. +pub fn emit_dust(doc: &A2mlDocument) -> String { + let mut out = String::new(); + writeln!(out, "# === DUST (Recovery & Rollback) ===").unwrap(); + writeln!(out, "# Source: Dustfile.a2ml").unwrap(); + writeln!(out).unwrap(); + + let items = doc.executable_items(); + if items.is_empty() { + writeln!(out, "# (no executable recovery actions found)").unwrap(); + return out; + } + + // List available dust actions. + writeln!(out, "# List available dust recovery actions").unwrap(); + writeln!(out, "dust-status:").unwrap(); + for item in &items { + let desc = item.description.unwrap_or(item.subsection); + writeln!( + out, + " @echo ' dust-{}: {} [{}]'", + sanitise_recipe_name(item.subsection), + desc, + item.key + ) + .unwrap(); + } + writeln!(out).unwrap(); + + for item in &items { + let name = sanitise_recipe_name(&format!("dust-{}", item.subsection)); + if let Some(desc) = item.description { + writeln!(out, "# {}", desc).unwrap(); + } + writeln!(out, "{}:", name).unwrap(); + writeln!(out, " @echo 'Executing {} for {}'", item.key, item.subsection).unwrap(); + writeln!(out, " {}", item.command).unwrap(); + writeln!(out).unwrap(); + } + + out +} + +/// Emit Just recipes for an Intentfile A2ML document. +/// Intentfiles are declarative — they have no executable commands. The emitted +/// recipe simply prints the declared intents as a readable checklist. +pub fn emit_intend(doc: &A2mlDocument) -> String { + let mut out = String::new(); + writeln!(out, "# === INTEND (Declared Future Intent) ===").unwrap(); + writeln!(out, "# Source: Intentfile.a2ml").unwrap(); + writeln!(out).unwrap(); + + writeln!(out, "# Display declared future intents").unwrap(); + writeln!(out, "intend-list:").unwrap(); + writeln!(out, " @echo '=== Declared Intent ==='").unwrap(); + + for section in &doc.sections { + writeln!(out, " @echo ''").unwrap(); + writeln!(out, " @echo '{}:'", section.name).unwrap(); + // Print direct entries (bullet points). + for entry in §ion.entries { + writeln!(out, " @echo ' - {}'", entry.value).unwrap(); + } + // Print prose lines. + for line in §ion.prose { + let trimmed = line.trim(); + if trimmed.starts_with('-') || trimmed.starts_with("- ") { + writeln!(out, " @echo ' {}'", trimmed).unwrap(); + } + } + } + writeln!(out).unwrap(); + + out +} + +/// Emit Just recipes for K9 Hunt-level components that have already been +/// evaluated via `nickel export`. This is used when the caller has a +/// [`K9Component`] in hand (as opposed to the stub approach in `emit_all`). +pub fn emit_k9_evaluated(component: &K9Component) -> String { + let mut out = String::new(); + + let name = component + .pedigree + .as_ref() + .and_then(|p| p.metadata.as_ref()) + .and_then(|m| m.name.as_deref()) + .unwrap_or("k9-component"); + + writeln!(out, "# === K9: {} ===", name).unwrap(); + writeln!(out, "# Source: {}", component.source_path).unwrap(); + writeln!(out, "# Security level: {}", component.leash_level()).unwrap(); + writeln!(out).unwrap(); + + for recipe in &component.recipes { + let recipe_name = sanitise_recipe_name(&format!("k9-{}", recipe.name)); + + if let Some(desc) = &recipe.description { + writeln!(out, "# {}", desc).unwrap(); + } + + // Dependencies. + let deps: Vec = recipe + .dependencies + .iter() + .map(|d| sanitise_recipe_name(&format!("k9-{}", d))) + .collect(); + + if deps.is_empty() { + writeln!(out, "{}:", recipe_name).unwrap(); + } else { + writeln!(out, "{}: {}", recipe_name, deps.join(" ")).unwrap(); + } + + for cmd in &recipe.commands { + writeln!(out, " {}", cmd).unwrap(); + } + writeln!(out).unwrap(); + } + + out +} + +/// Emit generic executable recipes for an unknown/future contractile type. +/// Uses the file type name as the recipe prefix. +pub fn emit_generic(type_name: &str, doc: &A2mlDocument) -> String { + let mut out = String::new(); + let prefix = type_name.to_lowercase().replace("file", ""); + + writeln!(out, "# === {} (auto-detected) ===", type_name.to_uppercase()).unwrap(); + writeln!(out).unwrap(); + + let items = doc.executable_items(); + if items.is_empty() { + writeln!(out, "# (no executable items found)").unwrap(); + return out; + } + + for item in &items { + let name = sanitise_recipe_name(&format!("{}-{}", prefix, item.subsection)); + if let Some(desc) = item.description { + writeln!(out, "# {}", desc).unwrap(); + } + writeln!(out, "{}:", name).unwrap(); + writeln!(out, " {}", item.command).unwrap(); + writeln!(out).unwrap(); + } + + out +} + +/// Sanitise a string for use as a Just recipe name. +/// Just recipe names allow alphanumerics, hyphens, and underscores. +fn sanitise_recipe_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect() +} + +/// Recursively find all files with a given extension under a directory. +fn find_files_with_extension(dir: &Path, ext: &str) -> Result> { + let mut results = Vec::new(); + + if !dir.is_dir() { + return Ok(results); + } + + for entry in fs::read_dir(dir).with_context(|| format!("reading directory: {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + results.extend(find_files_with_extension(&path, ext)?); + } else if path.extension().and_then(|e| e.to_str()) == Some(ext) { + results.push(path); + } + } + + results.sort(); + Ok(results) +} diff --git a/cli/crates/contractile-core/src/k9.rs b/cli/crates/contractile-core/src/k9.rs new file mode 100644 index 0000000..e131f2c --- /dev/null +++ b/cli/crates/contractile-core/src/k9.rs @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// k9.rs — Bridge to K9 Nickel components. +// +// K9 contractiles are `.k9.ncl` files evaluated by the Nickel configuration +// language. This module shells out to `nickel export` to get a JSON +// representation, then extracts the structured data (pedigree, config, +// recipes, validation) for use by the contractile CLI. +// +// The three K9 security levels ("The Leash"): +// Kennel — pure data, no execution, no signature required +// Yard — Nickel evaluation with type contracts, signature recommended +// Hunt — full execution with Just recipes, signature REQUIRED +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::path::Path; +use std::process::Command; + +/// Security level for a K9 component, determining what operations are allowed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum LeashLevel { + Kennel, + Yard, + Hunt, +} + +impl std::fmt::Display for LeashLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Kennel => write!(f, "Kennel (pure data)"), + Self::Yard => write!(f, "Yard (validated config)"), + Self::Hunt => write!(f, "Hunt (full execution)"), + } + } +} + +/// Parsed representation of a K9 component's pedigree metadata. +#[derive(Debug, Clone, Deserialize)] +pub struct K9Pedigree { + pub schema_version: Option, + pub component_type: Option, + pub security: Option, + pub metadata: Option, +} + +/// Security configuration from a K9 component's pedigree. +#[derive(Debug, Clone, Deserialize)] +pub struct K9Security { + /// The leash level is serialised as a Nickel enum tag string. + /// Nickel exports enum tags like `"Kennel"`, `"Yard"`, `"Hunt"`. + pub leash: Option, + pub trust_level: Option, + pub allow_network: Option, + pub allow_filesystem_write: Option, + pub allow_subprocess: Option, + pub signature_required: Option, +} + +/// Descriptive metadata from a K9 component. +#[derive(Debug, Clone, Deserialize)] +pub struct K9Metadata { + pub name: Option, + pub version: Option, + pub description: Option, + pub author: Option, +} + +/// A recipe extracted from a K9 Hunt-level component. +/// These correspond to the `recipes` field in the K9 Nickel structure. +#[derive(Debug, Clone, Deserialize)] +pub struct K9Recipe { + pub name: String, + pub description: Option, + pub dependencies: Vec, + pub commands: Vec, +} + +/// The full parsed result of evaluating a K9 component via Nickel. +#[derive(Debug, Clone)] +pub struct K9Component { + /// File path this component was loaded from. + pub source_path: String, + + /// Pedigree metadata (schema version, security level, etc.). + pub pedigree: Option, + + /// The raw JSON value for the entire component, so callers can + /// inspect arbitrary fields beyond what we extract. + pub raw_json: serde_json::Value, + + /// Extracted recipes (only present in Hunt-level components). + pub recipes: Vec, +} + +impl K9Component { + /// Determine the declared leash level from the pedigree security block. + pub fn leash_level(&self) -> LeashLevel { + self.pedigree + .as_ref() + .and_then(|p| p.security.as_ref()) + .and_then(|s| s.leash.as_deref()) + .map(|l| match l { + "Hunt" => LeashLevel::Hunt, + "Yard" => LeashLevel::Yard, + _ => LeashLevel::Kennel, + }) + .unwrap_or(LeashLevel::Kennel) + } + + /// Returns true if this component requires a signature before execution. + pub fn requires_signature(&self) -> bool { + self.pedigree + .as_ref() + .and_then(|p| p.security.as_ref()) + .and_then(|s| s.signature_required) + .unwrap_or(self.leash_level() == LeashLevel::Hunt) + } +} + +/// Evaluate a K9 `.k9.ncl` file by shelling out to `nickel export`. +/// Returns the parsed component with extracted pedigree and recipes. +/// +/// Requires the `nickel` binary to be available on PATH. +pub fn evaluate(path: &Path) -> Result { + let path_str = path + .to_str() + .context("K9 component path is not valid UTF-8")?; + + // Shell out to nickel to export the file as JSON. + let output = Command::new("nickel") + .args(["export", path_str]) + .output() + .context("failed to run `nickel export` — is nickel installed?")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "nickel export failed for '{}': {}", + path_str, + stderr.trim() + ); + } + + let json_str = String::from_utf8(output.stdout) + .context("nickel export produced non-UTF-8 output")?; + + let raw_json: serde_json::Value = + serde_json::from_str(&json_str).context("nickel export produced invalid JSON")?; + + // Extract pedigree from the JSON structure. + let pedigree: Option = raw_json + .get("pedigree") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + // Extract recipes from Hunt-level components. + let recipes = extract_recipes(&raw_json); + + Ok(K9Component { + source_path: path_str.to_string(), + pedigree, + raw_json, + recipes, + }) +} + +/// Typecheck a K9 component without evaluating it. +/// Returns Ok(()) if the Nickel contracts are satisfied. +pub fn typecheck(path: &Path) -> Result<()> { + let path_str = path + .to_str() + .context("K9 component path is not valid UTF-8")?; + + let output = Command::new("nickel") + .args(["typecheck", path_str]) + .output() + .context("failed to run `nickel typecheck` — is nickel installed?")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "nickel typecheck failed for '{}': {}", + path_str, + stderr.trim() + ); + } + + Ok(()) +} + +/// Extract recipe definitions from the `recipes` field of a K9 JSON export. +/// Handles the structure: `{ recipes: { "name": { commands: [...], ... } } }`. +fn extract_recipes(json: &serde_json::Value) -> Vec { + let mut recipes = Vec::new(); + + let recipes_obj = match json.get("recipes").and_then(|v| v.as_object()) { + Some(obj) => obj, + None => return recipes, + }; + + for (name, value) in recipes_obj { + // Skip the "default" entry — it just names the entry-point recipe, + // it doesn't contain commands itself. + if name == "default" { + continue; + } + + let obj = match value.as_object() { + Some(o) => o, + None => continue, + }; + + let description = obj + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + + let dependencies = obj + .get("dependencies") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let commands = obj + .get("commands") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + recipes.push(K9Recipe { + name: name.clone(), + description, + dependencies, + commands, + }); + } + + recipes +} diff --git a/cli/crates/contractile-core/src/lib.rs b/cli/crates/contractile-core/src/lib.rs new file mode 100644 index 0000000..80fc4e4 --- /dev/null +++ b/cli/crates/contractile-core/src/lib.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// contractile-core — Core library for the contractile CLI family. +// +// Provides three main capabilities: +// 1. A2ML parsing (a2ml) — reads Mustfile/Trustfile/Dustfile/Intentfile.a2ml +// 2. K9 bridge (k9) — evaluates K9 Nickel components via `nickel export` +// 3. Just emitter (just_emitter) — generates .just recipes from A2ML + K9 +// +// These form the shared foundation for the `must`, `trust`, `dust`, `intend`, +// and `k9` CLI subcommands, as well as the `gen-just` integration command. +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +#![forbid(unsafe_code)] +pub mod a2ml; +pub mod just_emitter; +pub mod k9; +pub mod toml_compat; + +/// Canonical file names for each contractile type. +/// The CLI tools search for these (case-insensitive) when no explicit +/// path is given. +pub mod filenames { + /// Mustfile: state/invariant contract. + pub const MUSTFILE_A2ML: &str = "Mustfile.a2ml"; + pub const MUSTFILE_TOML: &str = "mustfile.toml"; + + /// Trustfile: integrity and provenance verification. + pub const TRUSTFILE_A2ML: &str = "Trustfile.a2ml"; + + /// Dustfile: recovery and rollback semantics. + pub const DUSTFILE_A2ML: &str = "Dustfile.a2ml"; + + /// Intentfile: declared future intent / roadmap. + pub const INTENTFILE_A2ML: &str = "Intentfile.a2ml"; + + /// Adjustfile: accessibility & digital justice invariants. + pub const ADJUSTFILE_A2ML: &str = "Adjustfile.a2ml"; + /// Adjustfile: S-expression contractile format (in .machine_readable/). + pub const ADJUST_CONTRACTILE: &str = "ADJUST.contractile"; +} + +/// Search order for locating a contractile file. The CLI tools check these +/// directories in order, taking the first match: +/// 1. `.machine_readable/contractiles//` (canonical estate layout — highest priority) +/// 2. `./contractiles//` (portable contract set) +/// 3. `.//` (top-level legacy location) +/// 4. `./` (repo root) +pub fn find_contractile(filename: &str) -> Option { + // Derive the type subdirectory from the filename. + // "Mustfile.a2ml" → "must", "Trustfile.a2ml" → "trust", etc. + let type_dir = filename + .split('.') + .next() + .unwrap_or("") + .to_lowercase() + .replace("file", ""); + + // Build candidate paths. Canonical estate layout (.machine_readable/contractiles//) + // is searched first; legacy locations follow for backward compatibility. + let mut candidates = vec![ + format!(".machine_readable/contractiles/{}/{}", type_dir, filename), + format!("contractiles/{}/{}", type_dir, filename), + format!("{}/{}", type_dir, filename), + filename.to_string(), + ]; + + // Add legacy "lust" alias for Intentfile. + if type_dir == "intent" { + candidates.insert(1, format!("contractiles/lust/{}", filename)); + candidates.insert(2, format!("lust/{}", filename)); + } + + // Adjustfile also lives in .machine_readable/ as ADJUST.contractile. + if type_dir == "adjust" { + candidates.push(".machine_readable/ADJUST.contractile".to_string()); + } + + for candidate in &candidates { + let path = std::path::PathBuf::from(candidate); + if path.exists() { + return Some(path); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// Verify that find_contractile() resolves a Mustfile.a2ml placed at the + /// canonical estate path `.machine_readable/contractiles/must/Mustfile.a2ml` + /// without requiring an explicit --file flag. + #[test] + fn find_contractile_resolves_canonical_machine_readable_path() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let canonical = tmp + .path() + .join(".machine_readable/contractiles/must"); + fs::create_dir_all(&canonical).expect("failed to create canonical dir"); + let mustfile = canonical.join("Mustfile.a2ml"); + fs::write(&mustfile, "(must)").expect("failed to write Mustfile.a2ml"); + + // Change cwd to the temp dir so relative path resolution works. + let original_dir = std::env::current_dir().expect("no cwd"); + std::env::set_current_dir(tmp.path()).expect("failed to chdir"); + + let result = find_contractile("Mustfile.a2ml"); + + std::env::set_current_dir(original_dir).expect("failed to restore cwd"); + + assert!( + result.is_some(), + "expected Mustfile.a2ml to be found in .machine_readable/contractiles/must/" + ); + assert_eq!( + result.unwrap(), + std::path::PathBuf::from(".machine_readable/contractiles/must/Mustfile.a2ml") + ); + } +} diff --git a/cli/crates/contractile-core/src/toml_compat.rs b/cli/crates/contractile-core/src/toml_compat.rs new file mode 100644 index 0000000..fb0ccd1 --- /dev/null +++ b/cli/crates/contractile-core/src/toml_compat.rs @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// toml_compat.rs — Parse mustfile.toml into A2mlDocument for backward compatibility. +// +// The Ada must runner uses mustfile.toml (TOML format) as its contract file. +// This module converts that format into the same A2mlDocument structure used +// by the A2ML parser, so the rest of the CLI works identically regardless +// of input format. +// +// TOML sections mapped to A2ML: +// [project] → Section "Project" with direct entries +// [tasks.] → Section "Tasks", subsection per task +// [requirements] → Section "Requirements" with must_have/must_not_have +// [requirements.content] → Subsections under "Requirements" +// [enforcement] → Section "Enforcement" with direct entries +// [deploy] → Section "Deploy" with direct entries +// +// The key difference: TOML tasks have `commands` arrays that become +// executable `run:` entries, so `must list` and `must run` work on TOML files. +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use crate::a2ml::{A2mlDocument, Entry, Section, Subsection}; +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; + +/// Parse a mustfile.toml and convert it into an A2mlDocument. +pub fn parse_mustfile_toml(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("reading mustfile.toml: {}", path.display()))?; + + let table: toml::Table = content + .parse() + .with_context(|| format!("parsing TOML: {}", path.display()))?; + + let mut doc = A2mlDocument { + spdx_license: None, + file_type: Some("Mustfile".to_string()), + abstract_text: None, + requires: Vec::new(), + sections: Vec::new(), + }; + + // ── Project section ── + if let Some(project) = table.get("project").and_then(|v| v.as_table()) { + let mut entries = Vec::new(); + for (key, value) in project { + entries.push(Entry { + key: key.clone(), + value: value_to_string(value), + }); + } + doc.sections.push(Section { + name: "Project".to_string(), + entries, + subsections: Vec::new(), + prose: Vec::new(), + }); + } + + // ── Tasks section → each task becomes a subsection with `run:` commands ── + if let Some(tasks) = table.get("tasks").and_then(|v| v.as_table()) { + let mut task_section = Section { + name: "Tasks".to_string(), + entries: Vec::new(), + subsections: Vec::new(), + prose: Vec::new(), + }; + + for (task_name, task_value) in tasks { + if let Some(task_table) = task_value.as_table() { + let mut entries = Vec::new(); + + if let Some(desc) = task_table.get("description").and_then(|v| v.as_str()) { + entries.push(Entry { + key: "description".to_string(), + value: desc.to_string(), + }); + } + + if let Some(deps) = task_table.get("dependencies").and_then(|v| v.as_array()) { + let dep_str: Vec<&str> = deps.iter().filter_map(|v| v.as_str()).collect(); + if !dep_str.is_empty() { + entries.push(Entry { + key: "dependencies".to_string(), + value: dep_str.join(", "), + }); + } + } + + // Convert commands array into a single `run:` entry. + // Multiple commands are joined with ` && `. + if let Some(cmds) = task_table.get("commands").and_then(|v| v.as_array()) { + let cmd_strs: Vec<&str> = cmds.iter().filter_map(|v| v.as_str()).collect(); + if !cmd_strs.is_empty() { + entries.push(Entry { + key: "run".to_string(), + value: cmd_strs.join(" && "), + }); + } + } + + task_section.subsections.push(Subsection { + name: task_name.clone(), + entries, + }); + } + } + + doc.sections.push(task_section); + } + + // ── Requirements section ── + if let Some(reqs) = table.get("requirements").and_then(|v| v.as_table()) { + let mut req_section = Section { + name: "Requirements".to_string(), + entries: Vec::new(), + subsections: Vec::new(), + prose: Vec::new(), + }; + + // must_have → subsection "must-have" with a run command checking file existence + if let Some(must_have) = reqs.get("must_have").and_then(|v| v.as_array()) { + let files: Vec<&str> = must_have.iter().filter_map(|v| v.as_str()).collect(); + if !files.is_empty() { + let check_cmd = files + .iter() + .map(|f| format!("test -f \"{}\"", f)) + .collect::>() + .join(" && "); + + req_section.subsections.push(Subsection { + name: "must-have".to_string(), + entries: vec![ + Entry { + key: "description".to_string(), + value: format!("Files that must exist ({})", files.len()), + }, + Entry { + key: "run".to_string(), + value: check_cmd, + }, + ], + }); + } + } + + // must_not_have → subsection "must-not-have" + if let Some(must_not) = reqs.get("must_not_have").and_then(|v| v.as_array()) { + let files: Vec<&str> = must_not.iter().filter_map(|v| v.as_str()).collect(); + if !files.is_empty() { + let check_cmd = files + .iter() + .map(|f| format!("test ! -e \"{}\"", f)) + .collect::>() + .join(" && "); + + req_section.subsections.push(Subsection { + name: "must-not-have".to_string(), + entries: vec![ + Entry { + key: "description".to_string(), + value: format!("Files that must not exist ({})", files.len()), + }, + Entry { + key: "run".to_string(), + value: check_cmd, + }, + ], + }); + } + } + + // content requirements → subsection per file + if let Some(content) = reqs.get("content").and_then(|v| v.as_table()) { + for (file, patterns) in content { + if let Some(pats) = patterns.as_array() { + let pat_strs: Vec<&str> = pats.iter().filter_map(|v| v.as_str()).collect(); + let check_cmd = pat_strs + .iter() + .map(|p| format!("grep -q \"{}\" \"{}\"", p, file)) + .collect::>() + .join(" && "); + + req_section.subsections.push(Subsection { + name: format!("content-{}", file.replace('/', "-").replace('.', "-")), + entries: vec![ + Entry { + key: "description".to_string(), + value: format!("{} must contain required strings", file), + }, + Entry { + key: "run".to_string(), + value: check_cmd, + }, + ], + }); + } + } + } + + doc.sections.push(req_section); + } + + // ── Enforcement section ── + if let Some(enforcement) = table.get("enforcement").and_then(|v| v.as_table()) { + let mut entries = Vec::new(); + for (key, value) in enforcement { + if key == "checks" { + continue; // handled separately + } + entries.push(Entry { + key: key.clone(), + value: value_to_string(value), + }); + } + + if let Some(checks) = enforcement.get("checks").and_then(|v| v.as_table()) { + for (key, value) in checks { + entries.push(Entry { + key: key.clone(), + value: value_to_string(value), + }); + } + } + + doc.sections.push(Section { + name: "Enforcement".to_string(), + entries, + subsections: Vec::new(), + prose: Vec::new(), + }); + } + + // Set abstract from project info. + if let Some(project) = table.get("project").and_then(|v| v.as_table()) { + let name = project + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + doc.abstract_text = Some(format!( + "Physical State contract for {} (converted from mustfile.toml)", + name + )); + } + + Ok(doc) +} + +/// Convert a TOML value to a string representation. +fn value_to_string(value: &toml::Value) -> String { + match value { + toml::Value::String(s) => s.clone(), + toml::Value::Integer(n) => n.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + toml::Value::Array(arr) => { + let items: Vec = arr.iter().map(value_to_string).collect(); + items.join(", ") + } + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn parse_minimal_mustfile_toml() { + let toml_content = r#" +schema = "0.1" + +[project] +name = "test-project" +version = "1.0.0" + +[tasks.build] +description = "Build the project" +commands = ["cargo build"] + +[tasks.test] +description = "Run tests" +dependencies = ["build"] +commands = ["cargo test"] + +[requirements] +must_have = ["Cargo.toml", "src/main.rs"] +must_not_have = ["Makefile"] +"#; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "{}", toml_content).unwrap(); + + let doc = parse_mustfile_toml(tmp.path()).unwrap(); + assert_eq!(doc.file_type.as_deref(), Some("Mustfile")); + + // Should have Tasks section with executable items. + let execs = doc.executable_items(); + // build (run), test (run), must-have (run), must-not-have (run) = 4 + assert!(execs.len() >= 2); + + // Tasks should have descriptions. + let tasks = doc.section("Tasks").unwrap(); + let build = tasks.subsection("build").unwrap(); + assert_eq!(build.get("description"), Some("Build the project")); + assert_eq!(build.get("run"), Some("cargo build")); + } +} diff --git a/cli/crates/contractile/Cargo.toml b/cli/crates/contractile/Cargo.toml new file mode 100644 index 0000000..c8db3d4 --- /dev/null +++ b/cli/crates/contractile/Cargo.toml @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# contractile — unified CLI for Must/Trust/Dust/Intend/K9 contractile runners +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +[package] +name = "contractile" +description = "Unified CLI for executing contractile files: must check, trust verify, dust rollback, intend list, k9 eval" +version.workspace = true +authors.workspace = true +# PMPL-1.0-or-later preferred; MPL-2.0 required for crates.io (no PMPL SPDX ID yet) +license = "MPL-2.0" +edition.workspace = true +rust-version.workspace = true +repository = "https://github.com/hyperpolymath/contractiles" +homepage = "https://github.com/hyperpolymath/contractiles" +readme = "../../../../contractiles/docs/QUICKSTART.adoc" +keywords = ["contractile", "must", "trust", "dust", "devops"] +categories = ["command-line-utilities", "development-tools"] + +[dependencies] +contractile-core.workspace = true +clap.workspace = true +anyhow.workspace = true +serde_json.workspace = true +colored.workspace = true +toml.workspace = true +clap_complete.workspace = true diff --git a/cli/crates/contractile/src/adjust.rs b/cli/crates/contractile/src/adjust.rs new file mode 100644 index 0000000..c14006f --- /dev/null +++ b/cli/crates/contractile/src/adjust.rs @@ -0,0 +1,538 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// adjust.rs — `adjust` subcommand: Accessibility & Digital Justice for +// Universal Software & Technology. +// +// ADJUST contractiles define accessibility invariants that must hold for all +// user-facing interfaces. They are machine-readable S-expression files +// (typically .machine_readable/ADJUST.contractile) that declare WCAG 2.2 AA +// minimum requirements. +// +// Commands: +// adjust check — verify ADJUST.contractile is present and well-formed +// adjust audit — scan the repo for accessibility violations +// adjust report — generate an accessibility status report +// adjust list — display all accessibility invariants +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use contractile_core::{filenames, find_contractile}; +use std::fs; + +#[derive(Subcommand, Clone)] +pub enum AdjustAction { + /// Verify ADJUST.contractile is present and well-formed + Check { + #[arg(long)] + file: Option, + + /// Output results as JSON (for CI/CD consumption) + #[arg(long)] + json: bool, + }, + + /// Scan the repo for common accessibility violations + Audit { + /// Directory to scan (default: current directory) + #[arg(default_value = ".")] + path: String, + + /// Output results as JSON + #[arg(long)] + json: bool, + }, + + /// Generate an accessibility status report + Report { + #[arg(long)] + file: Option, + }, + + /// Display all accessibility invariants from the ADJUST.contractile + List { + #[arg(long)] + file: Option, + }, +} + +/// Entry point when invoked as a symlink (`adjust check`, `adjust audit`, etc.). +pub fn run_from_args() -> Result<()> { + #[derive(Parser)] + #[command( + name = "adjust", + about = "Accessibility & Digital Justice for Universal Software & Technology" + )] + struct AdjustCli { + #[command(subcommand)] + action: AdjustAction, + } + + let cli = AdjustCli::parse(); + run(cli.action) +} + +/// Execute an adjust action. +pub fn run(action: AdjustAction) -> Result<()> { + match action { + AdjustAction::Check { file, json } => check(file.as_deref(), json), + AdjustAction::Audit { path, json } => audit(&path, json), + AdjustAction::Report { file } => report(file.as_deref()), + AdjustAction::List { file } => list(file.as_deref()), + } +} + +/// Check that ADJUST.contractile exists and is well-formed. +fn check(explicit_path: Option<&str>, json: bool) -> Result<()> { + let (path, content) = load_adjustfile(explicit_path)?; + let invariants = parse_invariants(&content); + let version = parse_field(&content, "version").unwrap_or_else(|| "unknown".to_string()); + let standard = parse_field(&content, "standard").unwrap_or_else(|| "unknown".to_string()); + + if json { + let output = serde_json::json!({ + "tool": "adjust", + "file": path.display().to_string(), + "version": version, + "standard": standard, + "invariant_count": invariants.len(), + "well_formed": true, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("{}", "=== ADJUST Check ===".bold()); + println!(" File: {}", path.display()); + println!(" Version: {}", version); + println!(" Standard: {}", standard.cyan()); + println!(" Invariants: {}", invariants.len().to_string().green()); + println!(); + println!(" {} ADJUST.contractile is present and well-formed", "[OK]".green()); + } + + Ok(()) +} + +/// Scan the repo for common accessibility violations. +fn audit(scan_path: &str, json: bool) -> Result<()> { + let mut findings: Vec = Vec::new(); + + // Check 1: Colour-only signalling in CLI output + check_colour_only_signalling(scan_path, &mut findings); + + // Check 2: Missing alt text in HTML/AsciiDoc + check_missing_alt_text(scan_path, &mut findings); + + // Check 3: Missing --help on CLI commands + check_missing_help_flag(scan_path, &mut findings); + + // Check 4: Hardcoded colour without prefers-reduced-motion + check_missing_reduced_motion(scan_path, &mut findings); + + // Check 5: Small touch targets in CSS + check_small_touch_targets(scan_path, &mut findings); + + // Check 6: Missing ARIA landmarks + check_missing_aria(scan_path, &mut findings); + + if json { + let output = serde_json::json!({ + "tool": "adjust audit", + "path": scan_path, + "finding_count": findings.len(), + "findings": findings.iter().map(|f| serde_json::json!({ + "rule": f.rule, + "severity": f.severity, + "file": f.file, + "line": f.line, + "message": f.message, + })).collect::>(), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("{}", "=== ADJUST Audit ===".bold()); + println!(" Scanning: {}", scan_path); + println!(); + + if findings.is_empty() { + println!(" {} No accessibility issues found", "[OK]".green()); + } else { + for finding in &findings { + let severity_tag = match finding.severity.as_str() { + "error" => "[ERROR]".red().to_string(), + "warning" => "[WARN]".yellow().to_string(), + _ => "[INFO]".dimmed().to_string(), + }; + println!( + " {} {} {}:{}", + severity_tag, finding.rule, finding.file, finding.line + ); + println!(" {}", finding.message); + } + println!(); + println!( + " {} accessibility issue(s) found", + findings.len().to_string().yellow() + ); + } + } + + Ok(()) +} + +/// Display all accessibility invariants. +fn list(explicit_path: Option<&str>) -> Result<()> { + let (_path, content) = load_adjustfile(explicit_path)?; + let invariants = parse_invariants(&content); + let standard = parse_field(&content, "standard").unwrap_or_else(|| "unknown".to_string()); + + println!( + "{} ({})", + "=== ADJUST Invariants ===".bold(), + standard.cyan() + ); + println!( + " {}", + "Accessibility & Digital Justice for Universal Software & Technology" + .dimmed() + ); + println!(); + + let mut current_category = String::new(); + for inv in &invariants { + // Detect category from comment lines preceding invariants + if inv.category != current_category { + current_category = inv.category.clone(); + println!(" {}:", current_category.cyan().bold()); + } + println!(" {} {}", "•".green(), inv.text); + } + + println!(); + println!(" {} invariants total", invariants.len()); + Ok(()) +} + +/// Generate a status report. +fn report(explicit_path: Option<&str>) -> Result<()> { + let (path, content) = load_adjustfile(explicit_path)?; + let invariants = parse_invariants(&content); + let version = parse_field(&content, "version").unwrap_or_else(|| "unknown".to_string()); + let standard = parse_field(&content, "standard").unwrap_or_else(|| "unknown".to_string()); + let repo = parse_field(&content, "repo").unwrap_or_else(|| ".".to_string()); + + println!("{}", "=== ADJUST Status Report ===".bold()); + println!(); + println!(" Repository: {}", repo); + println!(" Standard: {}", standard); + println!(" Version: {}", version); + println!(" File: {}", path.display()); + println!(" Invariants: {}", invariants.len()); + println!(); + + // Count invariants by category + let mut categories: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for inv in &invariants { + *categories.entry(inv.category.clone()).or_insert(0) += 1; + } + + println!(" Coverage by category:"); + for (cat, count) in &categories { + println!(" {:20} {} invariants", cat, count.to_string().green()); + } + + println!(); + println!( + " {} ADJUST contractile present with {} invariants", + "[OK]".green(), + invariants.len() + ); + println!( + " {} Run 'adjust audit' for automated violation scanning", + "[TIP]".cyan() + ); + + Ok(()) +} + +// ── Internal types ──────────────────────────────────────────── + +struct Invariant { + category: String, + text: String, +} + +struct AuditFinding { + rule: String, + severity: String, + file: String, + line: usize, + message: String, +} + +// ── File loading ────────────────────────────────────────────── + +fn load_adjustfile(explicit_path: Option<&str>) -> Result<(std::path::PathBuf, String)> { + let path = if let Some(p) = explicit_path { + std::path::PathBuf::from(p) + } else { + // Search for ADJUST.contractile in multiple locations + find_contractile(filenames::ADJUST_CONTRACTILE) + .or_else(|| find_contractile(filenames::ADJUSTFILE_A2ML)) + .context( + "ADJUST.contractile not found. Searched: .machine_readable/, contractiles/adjust/, ./", + )? + }; + + let content = fs::read_to_string(&path) + .with_context(|| format!("reading ADJUST contractile: {}", path.display()))?; + + Ok((path, content)) +} + +// ── Parsing ─────────────────────────────────────────────────── + +/// Parse (adjust "...") invariant lines from the S-expression contractile. +fn parse_invariants(content: &str) -> Vec { + let mut invariants = Vec::new(); + let mut current_category = "General".to_string(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Detect category comments: "; ── Visual ──" or "; ── Keyboard ──" + if trimmed.starts_with("; ──") { + if let Some(cat) = trimmed + .trim_start_matches("; ──") + .split("──") + .next() + .map(|s| s.trim().to_string()) + { + if !cat.is_empty() { + current_category = cat; + } + } + continue; + } + + // Parse (adjust "...") lines + if trimmed.starts_with("(adjust ") || trimmed.starts_with("(must ") { + if let Some(text) = extract_quoted_string(trimmed) { + invariants.push(Invariant { + category: current_category.clone(), + text, + }); + } + } + } + + invariants +} + +/// Extract a quoted string from an S-expression like (adjust "text here"). +fn extract_quoted_string(line: &str) -> Option { + let start = line.find('"')?; + let rest = &line[start + 1..]; + let end = rest.rfind('"')?; + Some(rest[..end].to_string()) +} + +/// Parse a simple field like (version "1.0.0") from the content. +fn parse_field(content: &str, field: &str) -> Option { + let pattern = format!("({} ", field); + content + .lines() + .find(|l| l.trim().starts_with(&pattern)) + .and_then(|l| extract_quoted_string(l)) +} + +// ── Audit checks ────────────────────────────────────────────── + +fn check_colour_only_signalling(path: &str, findings: &mut Vec) { + // Look for println with colour codes but no text symbols like [OK]/[FAIL] + let output = std::process::Command::new("rg") + .args([ + "-n", + r#"\\033\[|\\e\[|\\x1b\["#, + "--glob", + "*.rs", + "--glob", + "*.sh", + path, + ]) + .output() + .ok(); + + if let Some(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines().take(20) { + // Only flag if line doesn't also contain text indicators + if !line.contains("[OK]") + && !line.contains("[FAIL]") + && !line.contains("[WARN]") + && !line.contains("✓") + && !line.contains("✗") + { + if let Some((file_line, _)) = line.split_once(':') { + if let Some((file, line_num)) = file_line.rsplit_once(':') { + findings.push(AuditFinding { + rule: "ADJUST-CLI-01".to_string(), + severity: "warning".to_string(), + file: file.to_string(), + line: line_num.parse().unwrap_or(0), + message: "Colour code without text fallback — may be invisible to colour-blind users or in no-colour terminals".to_string(), + }); + } + } + } + } + } +} + +fn check_missing_alt_text(path: &str, findings: &mut Vec) { + // Look for images without alt text in HTML and AsciiDoc + let output = std::process::Command::new("rg") + .args([ + "-n", + r#"]*(?!alt=)[^>]*>"#, + "--glob", + "*.html", + "--glob", + "*.htm", + path, + ]) + .output() + .ok(); + + if let Some(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines().take(20) { + if let Some((file_line, _)) = line.split_once(':') { + if let Some((file, line_num)) = file_line.rsplit_once(':') { + findings.push(AuditFinding { + rule: "ADJUST-IMG-01".to_string(), + severity: "error".to_string(), + file: file.to_string(), + line: line_num.parse().unwrap_or(0), + message: "Image tag missing alt attribute".to_string(), + }); + } + } + } + } +} + +fn check_missing_help_flag(_path: &str, _findings: &mut Vec) { + // This would need to parse CLI definitions — skip for now as it requires + // understanding the specific CLI framework used (clap, argparse, etc.) +} + +fn check_missing_reduced_motion(path: &str, findings: &mut Vec) { + // Check CSS files for animation without prefers-reduced-motion + let has_animation = std::process::Command::new("rg") + .args([ + "-l", + r"animation:|transition:", + "--glob", + "*.css", + path, + ]) + .output() + .ok() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false); + + let has_reduced_motion = std::process::Command::new("rg") + .args([ + "-l", + "prefers-reduced-motion", + "--glob", + "*.css", + path, + ]) + .output() + .ok() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false); + + if has_animation && !has_reduced_motion { + findings.push(AuditFinding { + rule: "ADJUST-MOTION-01".to_string(), + severity: "warning".to_string(), + file: "(CSS files)".to_string(), + line: 0, + message: "CSS animations found but no prefers-reduced-motion media query".to_string(), + }); + } +} + +fn check_small_touch_targets(path: &str, findings: &mut Vec) { + // Look for explicit small sizes on interactive elements + let output = std::process::Command::new("rg") + .args([ + "-n", + r"(width|height)\s*:\s*(1[0-9]|2[0-9]|3[0-9]|4[0-3])px", + "--glob", + "*.css", + path, + ]) + .output() + .ok(); + + if let Some(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines().take(10) { + if let Some((file_line, _)) = line.split_once(':') { + if let Some((file, line_num)) = file_line.rsplit_once(':') { + findings.push(AuditFinding { + rule: "ADJUST-TOUCH-01".to_string(), + severity: "warning".to_string(), + file: file.to_string(), + line: line_num.parse().unwrap_or(0), + message: "Element smaller than 44px — may be hard to tap on touch devices" + .to_string(), + }); + } + } + } + } +} + +fn check_missing_aria(path: &str, findings: &mut Vec) { + // Check HTML files for missing ARIA landmarks + let has_html = std::process::Command::new("rg") + .args(["-l", " anyhow::Result<()> { + println!("{}", "=== Contractile Doctor ===".bold()); + println!(); + + let mut ok_count = 0; + let mut warn_count = 0; + let mut fail_count = 0; + + for tool in TOOLS { + let result = Command::new(tool.command) + .args(tool.args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + let version_short = if version.len() > 50 { + format!("{}...", &version[..47]) + } else { + version + }; + println!( + " {} {} — {}", + "OK".green().bold(), + tool.name, + version_short.dimmed() + ); + ok_count += 1; + } + _ => { + if tool.required { + println!( + " {} {} — {}", + "MISSING".red().bold(), + tool.name, + tool.needed_for.dimmed() + ); + fail_count += 1; + } else { + println!( + " {} {} — {} (optional: {})", + "WARN".yellow().bold(), + tool.name, + "not found".dimmed(), + tool.needed_for.dimmed() + ); + warn_count += 1; + } + } + } + } + + // Check contractile CLI itself. + println!(); + println!(" {} contractile v{}", "CLI".cyan().bold(), env!("CARGO_PKG_VERSION")); + + // Check for contractile files in current directory. + let has_contractiles = std::path::Path::new("contractiles").is_dir(); + if has_contractiles { + println!(" {} contractiles/ directory found", "OK".green().bold()); + } else { + println!( + " {} No contractiles/ directory — run `contractile init`", + "INFO".cyan() + ); + } + + println!(); + println!( + " {} available, {} warnings, {} missing", + ok_count.to_string().green(), + warn_count.to_string().yellow(), + fail_count.to_string().red() + ); + + if fail_count > 0 { + println!(); + println!( + " {} Install missing required tools for full functionality", + "ACTION".red().bold() + ); + } + + Ok(()) +} diff --git a/cli/crates/contractile/src/dust.rs b/cli/crates/contractile/src/dust.rs new file mode 100644 index 0000000..1440707 --- /dev/null +++ b/cli/crates/contractile/src/dust.rs @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// dust.rs — `dust` subcommand: Recovery & rollback from Dustfile.a2ml. +// +// Dust provides the "undo layer" for contractile-managed repos. Each action +// in the Dustfile declares a recovery path: handler for log replay, rollback +// for file reversion, undo for deployment failure, transform for event mapping. +// +// Unlike must/trust which run all checks by default, dust actions are +// invoked selectively — you rollback a specific thing, not everything. +// +// Commands: +// dust status — list available recovery actions +// dust rollback NAME — execute a named rollback +// dust replay NAME — replay a named handler +// dust run NAME — execute any dust action by name +// dust list — list all dust actions +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use contractile_core::{a2ml, filenames, find_contractile}; +use std::fs; +use std::process::Command; + +#[derive(Subcommand, Clone)] +pub enum DustAction { + /// Show available recovery/rollback actions + Status { + #[arg(long)] + file: Option, + }, + + /// Execute a named rollback action + Rollback { + /// Name of the rollback target (matches ### heading in Dustfile.a2ml) + name: String, + + #[arg(long)] + dry_run: bool, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + file: Option, + }, + + /// Replay a named handler (e.g. decision log replay) + Replay { + /// Name of the handler to replay + name: String, + + #[arg(long)] + dry_run: bool, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + file: Option, + }, + + /// Execute any dust action by subsection name + Run { + /// Name of the dust action (matches ### heading) + name: String, + + #[arg(long)] + dry_run: bool, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + file: Option, + }, + + /// List all dust actions with their types + List { + #[arg(long)] + file: Option, + }, +} + +/// Entry point when invoked as a symlink (`dust status`, `dust rollback`, etc.). +pub fn run_from_args() -> Result<()> { + #[derive(Parser)] + #[command(name = "dust", about = "Recovery & rollback from Dustfile.a2ml")] + struct DustCli { + #[command(subcommand)] + action: DustAction, + } + + let cli = DustCli::parse(); + run(cli.action) +} + +/// Execute a dust action. +pub fn run(action: DustAction) -> Result<()> { + match action { + DustAction::Status { file } | DustAction::List { file } => { + let doc = load_dustfile(file.as_deref())?; + list_actions(&doc); + Ok(()) + } + DustAction::Rollback { + name, + dry_run, + verbose, + file, + } => { + let doc = load_dustfile(file.as_deref())?; + run_action(&doc, &name, Some("rollback"), verbose, dry_run) + } + DustAction::Replay { + name, + dry_run, + verbose, + file, + } => { + let doc = load_dustfile(file.as_deref())?; + run_action(&doc, &name, Some("handler"), verbose, dry_run) + } + DustAction::Run { + name, + dry_run, + verbose, + file, + } => { + let doc = load_dustfile(file.as_deref())?; + // Run any executable action matching the name, regardless of key type. + run_action(&doc, &name, None, verbose, dry_run) + } + } +} + +/// Load and parse the Dustfile. +fn load_dustfile(explicit_path: Option<&str>) -> Result { + let path = if let Some(p) = explicit_path { + std::path::PathBuf::from(p) + } else { + find_contractile(filenames::DUSTFILE_A2ML) + .context("Dustfile.a2ml not found. Searched: contractiles/dust/, dust/, ./")? + }; + + let content = fs::read_to_string(&path) + .with_context(|| format!("reading Dustfile: {}", path.display()))?; + + a2ml::parse(&content).with_context(|| format!("parsing Dustfile: {}", path.display())) +} + +/// List all available dust actions with their types and descriptions. +fn list_actions(doc: &a2ml::A2mlDocument) { + let items = doc.executable_items(); + if items.is_empty() { + println!("{}", "No recovery actions found in Dustfile".yellow()); + return; + } + + println!("{}", "Available dust recovery actions:".bold()); + for item in &items { + let desc = item.description.unwrap_or(""); + let key_tag = match item.key { + "rollback" => "rollback".yellow(), + "undo" => "undo".red(), + "handler" => "handler".cyan(), + "transform" => "transform".blue(), + other => other.normal(), + }; + println!( + " {} [{}] — {}", + item.subsection.cyan(), + key_tag, + desc + ); + } +} + +/// Execute a dust action by name, optionally filtering by key type. +/// Checks preconditions before executing, runs verify_after on success. +fn run_action( + doc: &a2ml::A2mlDocument, + name: &str, + key_filter: Option<&str>, + verbose: bool, + dry_run: bool, +) -> Result<()> { + let items = doc.executable_items(); + + // Find matching items — if key_filter is set, only match that key type. + let matching: Vec<_> = items + .iter() + .filter(|i| { + i.subsection == name && key_filter.map_or(true, |k| i.key == k) + }) + .collect(); + + if matching.is_empty() { + let available: Vec = items + .iter() + .map(|i| format!("{} [{}]", i.subsection, i.key)) + .collect(); + bail!( + "dust action '{}'{} not found. Available:\n {}", + name, + key_filter + .map(|k| format!(" (type: {})", k)) + .unwrap_or_default(), + available.join("\n ") + ); + } + + // Look up precondition, verify_after, and blast_radius from the subsection. + let subsection_meta = doc.sections.iter() + .flat_map(|s| s.subsections.iter()) + .find(|sub| sub.name == name); + + let precondition = subsection_meta.and_then(|s| s.get("precondition")); + let verify_after = subsection_meta.and_then(|s| s.get("verify_after")); + let blast_radius = subsection_meta.and_then(|s| s.get("blast_radius")); + + for item in &matching { + let desc = item.description.unwrap_or(item.subsection); + + if dry_run { + if let Some(pre) = precondition { + println!(" {} precondition: {}", "[DRY-RUN]".cyan(), pre); + } + println!( + " {} [{}] {} → {}", + "[DRY-RUN]".cyan(), + item.key, + desc, + item.command + ); + if let Some(verify) = verify_after { + println!(" {} verify_after: {}", "[DRY-RUN]".cyan(), verify); + } + continue; + } + + // ── Precondition check ── + if let Some(pre_cmd) = precondition { + if verbose { + println!(" {} precondition: {}", "checking".dimmed(), pre_cmd); + } + let pre_status = Command::new("bash") + .args(["-c", pre_cmd]) + .status() + .with_context(|| format!("running precondition for: {}", name))?; + + if !pre_status.success() { + bail!( + "precondition failed for dust action '{}': {}", + name, + pre_cmd + ); + } + if verbose { + println!(" {} precondition passed", "OK".green()); + } + } + + // ── Show blast radius warning ── + if let Some(radius) = blast_radius { + match radius { + "cluster" | "global" => { + println!( + " {} blast radius: {} — proceed with caution", + "WARNING".yellow().bold(), + radius.red().bold() + ); + } + _ => { + if verbose { + println!(" {} blast radius: {}", "info".dimmed(), radius); + } + } + } + } + + // ── Execute the action ── + println!( + "{} Executing {} for {}...", + "dust:".bold(), + item.key.yellow(), + item.subsection.cyan() + ); + + if verbose { + println!(" {}", item.command.dimmed()); + } + + let status = Command::new("bash") + .args(["-c", item.command]) + .status() + .with_context(|| format!("executing dust action: {}", item.subsection))?; + + if !status.success() { + println!(" {} {}", "FAILED".red().bold(), desc); + bail!( + "dust {} '{}' failed (exit {})", + item.key, + name, + status.code().unwrap_or(-1) + ); + } + + println!(" {} {}", "DONE".green().bold(), desc); + + // ── Post-recovery verification ── + if let Some(verify_cmd) = verify_after { + println!( + " {} verifying recovery...", + "dust:".bold() + ); + if verbose { + println!(" {}", verify_cmd.dimmed()); + } + let verify_status = Command::new("bash") + .args(["-c", verify_cmd]) + .status() + .with_context(|| format!("running verify_after for: {}", name))?; + + if verify_status.success() { + println!(" {} post-recovery verification passed", "VERIFIED".green().bold()); + } else { + println!( + " {} post-recovery verification failed: {}", + "WARNING".yellow().bold(), + verify_cmd + ); + } + } + } + + Ok(()) +} diff --git a/cli/crates/contractile/src/gen_just.rs b/cli/crates/contractile/src/gen_just.rs new file mode 100644 index 0000000..a8b773a --- /dev/null +++ b/cli/crates/contractile/src/gen_just.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// gen_just.rs — `contractile gen-just`: Generate contractile.just from A2ML + K9 sources. +// +// Scans a directory for *.a2ml and *.k9.ncl files, parses them, and emits a +// single `contractile.just` file that can be imported into any repo's Justfile: +// +// import "contractile.just" +// +// This bridges the contractile system into the Just task runner, allowing +// developers to use `just must-check`, `just trust-verify`, `just dust-status` +// etc. without needing the contractile CLI installed. +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{Context, Result}; +use colored::Colorize; +use contractile_core::just_emitter; +use std::fs; +use std::path::Path; + +/// Generate contractile.just from all sources in the given directory. +pub fn run(dir: &str, output: &str) -> Result<()> { + let dir_path = Path::new(dir); + + if !dir_path.is_dir() { + // If the specified directory doesn't exist, try common alternatives. + let alternatives = ["contractiles", ".", "contracts"]; + let found = alternatives.iter().find(|d| Path::new(d).is_dir()); + + if let Some(alt) = found { + println!( + "{} '{}' not found, using '{}'", + "gen-just:".bold(), + dir, + alt + ); + return run(alt, output); + } + + anyhow::bail!( + "Directory '{}' not found. Create it or specify --dir", + dir + ); + } + + println!( + "{} Scanning {} for A2ML and K9 sources...", + "gen-just:".bold(), + dir_path.display() + ); + + let content = just_emitter::emit_all(dir_path) + .context("generating Just recipes from contractile sources")?; + + fs::write(output, &content) + .with_context(|| format!("writing output file: {}", output))?; + + // Count what we generated. + let recipe_count = content.lines().filter(|l| l.ends_with(':') && !l.starts_with('#')).count(); + + println!( + "{} Generated {} with {} recipe(s)", + "gen-just:".bold(), + output.cyan(), + recipe_count + ); + println!( + " Add `import \"{}\"` to your Justfile to use them", + output + ); + + Ok(()) +} diff --git a/cli/crates/contractile/src/init.rs b/cli/crates/contractile/src/init.rs new file mode 100644 index 0000000..06eb33a --- /dev/null +++ b/cli/crates/contractile/src/init.rs @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// init.rs — `contractile init`: Scaffold contractile files into a repository. +// +// Creates the contractiles/ directory structure with starter A2ML files +// tailored to the detected project type. This is how repos adopt the +// contractile system — run `contractile init` and you get Mustfile, +// Trustfile, Dustfile, Intentfile, and a generated contractile.just. +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use colored::Colorize; +use std::fs; +use std::path::Path; + +/// Run the init command. +pub fn run(project_name: Option<&str>, force: bool) -> Result<()> { + let contractiles_dir = Path::new("contractiles"); + + if contractiles_dir.exists() && !force { + bail!( + "contractiles/ already exists. Use --force to overwrite starter files." + ); + } + + // Detect project name from current directory or Cargo.toml/deno.json/gleam.toml. + let name = project_name + .map(String::from) + .or_else(detect_project_name) + .unwrap_or_else(|| { + std::env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + .unwrap_or_else(|| "my-project".to_string()) + }); + + println!( + "{} Initialising contractiles for '{}'...", + "init:".bold(), + name.cyan() + ); + + // Create directory structure. + let dirs = [ + "contractiles/must", + "contractiles/trust", + "contractiles/dust", + "contractiles/lust", + "contractiles/k9/validators", + ]; + + for dir in &dirs { + fs::create_dir_all(dir) + .with_context(|| format!("creating directory: {}", dir))?; + } + + // Write starter A2ML files. + write_if_missing( + "contractiles/must/Mustfile.a2ml", + &generate_mustfile(&name), + force, + )?; + + write_if_missing( + "contractiles/trust/Trustfile.a2ml", + &generate_trustfile(&name), + force, + )?; + + write_if_missing( + "contractiles/dust/Dustfile.a2ml", + &generate_dustfile(&name), + force, + )?; + + write_if_missing( + "contractiles/lust/Intentfile.a2ml", + &generate_intentfile(&name), + force, + )?; + + // Generate contractile.just. + println!(" {} Generating contractile.just...", "+".green()); + let just_content = + contractile_core::just_emitter::emit_all(contractiles_dir) + .context("generating contractile.just")?; + fs::write("contractile.just", &just_content) + .context("writing contractile.just")?; + + // Print summary. + println!(); + println!("{}", "Contractiles initialised:".bold()); + println!(" contractiles/must/Mustfile.a2ml — Physical State checks"); + println!(" contractiles/trust/Trustfile.a2ml — Integrity verifications"); + println!(" contractiles/dust/Dustfile.a2ml — Recovery/rollback actions"); + println!(" contractiles/lust/Intentfile.a2ml — Future intent/roadmap"); + println!(" contractile.just — Generated Just recipes"); + println!(); + println!("{}", "Next steps:".bold()); + println!(" 1. Edit the A2ML files to match your project"); + println!(" 2. Add `import? \"contractile.just\"` to your Justfile"); + println!(" 3. Run `must check` to verify Physical State"); + println!(" 4. Run `trust list` to see available verifications"); + println!(" 5. Run `intend list` to see your roadmap"); + + Ok(()) +} + +/// Write a file only if it doesn't exist (or force is set). +fn write_if_missing(path: &str, content: &str, force: bool) -> Result<()> { + let p = Path::new(path); + if p.exists() && !force { + println!(" {} {} (already exists)", "skip".dimmed(), path); + return Ok(()); + } + fs::write(p, content) + .with_context(|| format!("writing: {}", path))?; + println!(" {} {}", "+".green(), path); + Ok(()) +} + +/// Try to detect the project name from common config files. +fn detect_project_name() -> Option { + // Cargo.toml + if let Ok(content) = fs::read_to_string("Cargo.toml") { + if let Ok(table) = content.parse::() { + if let Some(name) = table + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + return Some(name.to_string()); + } + } + } + + // deno.json + if let Ok(content) = fs::read_to_string("deno.json") { + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(name) = json.get("name").and_then(|n| n.as_str()) { + return Some(name.to_string()); + } + } + } + + // gleam.toml + if let Ok(content) = fs::read_to_string("gleam.toml") { + if let Ok(table) = content.parse::() { + if let Some(name) = table.get("name").and_then(|n| n.as_str()) { + return Some(name.to_string()); + } + } + } + + None +} + +/// Generate a starter Mustfile.a2ml for a project. +fn generate_mustfile(name: &str) -> String { + format!( + r#"# SPDX-License-Identifier: PMPL-1.0-or-later +# Mustfile (A2ML Canonical) + +@abstract: +Physical State contract for {name}. +Declares what must be true about this project's files and configuration. +@end + +@requires: +- section: Checks +@end + +## Checks + +### license-present +- description: LICENSE file must exist +- run: test -f LICENSE +- severity: critical + +### readme-present +- description: README must exist +- run: test -f README.adoc || test -f README.md +- severity: critical + +### spdx-headers +- description: Source files should have SPDX license headers +- run: find . -name '*.rs' -o -name '*.res' -o -name '*.gleam' | head -20 | xargs -r grep -L 'SPDX-License-Identifier' | wc -l | grep -q '^0$' +- severity: warning + +### no-banned-files +- description: No Dockerfiles or Makefiles +- run: test ! -f Dockerfile && test ! -f Makefile +- severity: critical +"# + ) +} + +/// Generate a starter Trustfile.a2ml for a project. +fn generate_trustfile(name: &str) -> String { + format!( + r#"# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Integrity and provenance verification for {name}. +@end + +@requires: +- section: Verifications +@end + +## Verifications + +### license-content +- description: LICENSE contains expected SPDX identifier +- command: grep -q 'SPDX\|License\|MIT\|Apache\|PMPL\|MPL' LICENSE +- severity: warning + +### no-secrets-committed +- description: No .env or credential files in repo +- command: test ! -f .env && test ! -f credentials.json && test ! -f .env.local +- severity: critical + +### container-images-pinned +- description: Containerfile base images use pinned digests +- command: test ! -f Containerfile || grep -q '@sha256:' Containerfile +- severity: warning +"# + ) +} + +/// Generate a starter Dustfile.a2ml for a project. +fn generate_dustfile(name: &str) -> String { + format!( + r#"# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery and rollback paths for {name}. +Declares how to undo significant state changes. +@end + +@requires: +- section: Source +@end + +## Source + +### source-rollback +- description: Revert all source changes to last commit +- rollback: git checkout HEAD -- . +- blast_radius: file +- precondition: git stash +- notes: Stashes uncommitted work before reverting +"# + ) +} + +/// Generate a starter Intentfile.a2ml for a project. +fn generate_intentfile(name: &str) -> String { + format!( + r#"# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Declared future intent for {name}. +@end + +@requires: +- section: Features +- section: Quality +@end + +## Features + +### initial-release +- description: Ship v1.0.0 +- status: in-progress +- priority: critical + +## Quality + +### test-coverage +- description: Achieve meaningful test coverage +- status: declared +- priority: medium +"# + ) +} diff --git a/cli/crates/contractile/src/intend.rs b/cli/crates/contractile/src/intend.rs new file mode 100644 index 0000000..c1a9675 --- /dev/null +++ b/cli/crates/contractile/src/intend.rs @@ -0,0 +1,705 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// intend.rs — `intend` subcommand: Future intent & roadmap from Intentfile.a2ml. +// +// Intentfiles are purely declarative — they declare what the project intends +// to do, not what it does now. The `intend` CLI displays this information, +// probes whether declared intents have been realised, and provides lifecycle +// commands that modify the Intentfile in place. +// +// Commands: +// intend list — display all declared intents as a readable checklist +// intend check — probe whether declared intents have been realised +// intend progress — summary of intent realisation status +// intend accept — move intent from declared → accepted +// intend start — move intent from accepted → in-progress +// intend realise — move intent from in-progress → realised +// intend abandon — move intent to abandoned (with reason) +// intend supersede — mark intent as superseded by another +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use contractile_core::{a2ml, filenames, find_contractile}; +use std::fs; + +#[derive(Subcommand, Clone)] +pub enum IntendAction { + /// Display all declared intents as a readable checklist + List { + #[arg(long)] + file: Option, + }, + + /// Probe whether declared intents have been realised + /// (checks for evidence of each intent in the codebase) + Check { + #[arg(long)] + file: Option, + + #[arg(long, short)] + verbose: bool, + + /// Output results as JSON (for CI/CD consumption) + #[arg(long)] + json: bool, + }, + + /// Summary of intent realisation progress + Progress { + #[arg(long)] + file: Option, + }, + + /// Move an intent from declared → accepted + Accept { + /// Name of the intent (matches ### heading) + name: String, + #[arg(long)] + file: Option, + }, + + /// Move an intent from accepted → in-progress + Start { + /// Name of the intent (matches ### heading) + name: String, + #[arg(long)] + file: Option, + }, + + /// Move an intent from in-progress → realised + Realise { + /// Name of the intent (matches ### heading) + name: String, + /// Optional note to add (defaults to "Realised YYYY-MM-DD") + #[arg(long)] + note: Option, + #[arg(long)] + file: Option, + }, + + /// Move an intent to abandoned status + Abandon { + /// Name of the intent (matches ### heading) + name: String, + /// Reason for abandonment (required) + #[arg(long)] + reason: String, + #[arg(long)] + file: Option, + }, + + /// Mark an intent as superseded by another + Supersede { + /// Name of the intent to supersede + old: String, + /// Name of the new intent that replaces it + new: String, + #[arg(long)] + file: Option, + }, +} + +/// Entry point when invoked as a symlink (`intend list`, `intend check`, etc.). +pub fn run_from_args() -> Result<()> { + #[derive(Parser)] + #[command(name = "intend", about = "Future intent & roadmap from Intentfile.a2ml")] + struct IntendCli { + #[command(subcommand)] + action: IntendAction, + } + + let cli = IntendCli::parse(); + run(cli.action) +} + +/// Execute an intend action. +pub fn run(action: IntendAction) -> Result<()> { + match action { + IntendAction::List { file } => { + let doc = load_intentfile(file.as_deref())?; + display_intents(&doc); + Ok(()) + } + IntendAction::Check { file, verbose, json } => { + let doc = load_intentfile(file.as_deref())?; + if json { + check_intents_json(&doc) + } else { + check_intents(&doc, verbose) + } + } + IntendAction::Progress { file } => { + let doc = load_intentfile(file.as_deref())?; + show_progress(&doc); + Ok(()) + } + IntendAction::Accept { name, file } => { + transition_intent(file.as_deref(), &name, "declared", "accepted", None) + } + IntendAction::Start { name, file } => { + transition_intent(file.as_deref(), &name, "accepted", "in-progress", None) + } + IntendAction::Realise { name, note, file } => { + let today = today_string(); + let default_note = format!("Realised {}", today); + let note_text = note.as_deref().unwrap_or(&default_note); + transition_intent( + file.as_deref(), + &name, + "in-progress", + "realised", + Some(note_text), + ) + } + IntendAction::Abandon { name, reason, file } => { + let note = format!("Abandoned: {}", reason); + transition_intent(file.as_deref(), &name, "", "abandoned", Some(¬e)) + } + IntendAction::Supersede { old, new, file } => { + let note = format!("Superseded by {}", new); + transition_intent(file.as_deref(), &old, "", "superseded", Some(¬e)) + } + } +} + +/// Load and parse the Intentfile. +fn load_intentfile(explicit_path: Option<&str>) -> Result { + let path = if let Some(p) = explicit_path { + std::path::PathBuf::from(p) + } else { + // Intentfile lives in lust/ (legacy naming) or contractiles/lust/. + find_contractile(filenames::INTENTFILE_A2ML) + .context("Intentfile.a2ml not found. Searched: contractiles/lust/, lust/, ./")? + }; + + let content = fs::read_to_string(&path) + .with_context(|| format!("reading Intentfile: {}", path.display()))?; + + a2ml::parse(&content).with_context(|| format!("parsing Intentfile: {}", path.display())) +} + +/// Display all declared intents as a formatted checklist. +/// Handles both simple intents (direct `- text` entries) and structured +/// intents (`### name` subsections with metadata fields like status, +/// priority, evidence, etc.). +fn display_intents(doc: &a2ml::A2mlDocument) { + if let Some(abstract_text) = &doc.abstract_text { + println!("{}", abstract_text.dimmed()); + println!(); + } + + println!("{}", "=== Declared Intent ===".bold()); + + if doc.sections.is_empty() { + println!("{}", "No intents declared".yellow()); + return; + } + + for section in &doc.sections { + println!(); + println!("{}:", section.name.cyan().bold()); + + // Print direct entries as bullet points (simple intents). + for entry in §ion.entries { + if entry.key == entry.value || entry.value.is_empty() { + println!(" [ ] {}", entry.key); + } else { + println!(" [ ] {}", entry.value); + } + } + + // Print structured intents (subsections with metadata). + for sub in §ion.subsections { + let description = sub + .get("description") + .unwrap_or(&sub.name); + let status = sub.get("status").unwrap_or("declared"); + let priority = sub.get("priority"); + + // Status-aware checkbox rendering. + let checkbox = match status { + "realised" => "[x]".green().to_string(), + "in-progress" => "[~]".yellow().to_string(), + "abandoned" => "[/]".red().to_string(), + "superseded" => "[>]".dimmed().to_string(), + "accepted" => "[+]".cyan().to_string(), + _ => "[ ]".normal().to_string(), + }; + + let priority_tag = priority + .map(|p| format!(" [{}]", p)) + .unwrap_or_default(); + + println!( + " {} {} {}{}", + checkbox, + description, + format!("({})", status).dimmed(), + priority_tag.dimmed() + ); + + // Show target and notes if present. + if let Some(target) = sub.get("target") { + println!(" target: {}", target.dimmed()); + } + if let Some(depends) = sub.get("depends_on") { + println!(" depends: {}", depends.dimmed()); + } + } + + // Print prose lines (plain text within the section). + for line in §ion.prose { + let trimmed = line.trim(); + if !trimmed.is_empty() { + if trimmed.starts_with('-') { + let content = trimmed.trim_start_matches('-').trim(); + println!(" [ ] {}", content); + } else { + println!(" {}", trimmed); + } + } + } + } +} + +/// Check whether declared intents have been realised. +/// Runs evidence probes for structured intents and reports results. +fn check_intents(doc: &a2ml::A2mlDocument, verbose: bool) -> Result<()> { + println!("{}", "=== Intent Realisation Check ===".bold()); + println!(); + + let mut total = 0; + let mut realised = 0; + let mut probed = 0; + + for section in &doc.sections { + println!("{}:", section.name.cyan().bold()); + + // Simple intents (no evidence probes). + for entry in §ion.entries { + total += 1; + let text = if entry.value.is_empty() { + &entry.key + } else { + &entry.value + }; + println!(" {} {}", "[ ]".yellow(), text); + } + + // Structured intents — run evidence probes if available. + for sub in §ion.subsections { + total += 1; + let description = sub.get("description").unwrap_or(&sub.name); + let status = sub.get("status").unwrap_or("declared"); + + // Already marked as realised in the Intentfile. + if status == "realised" { + realised += 1; + println!(" {} {} {}", "[x]".green(), description, "(realised)".dimmed()); + continue; + } + + if status == "abandoned" || status == "superseded" { + println!(" {} {} ({})", "[/]".dimmed(), description, status); + continue; + } + + // Try evidence probe if available. + if let Some(evidence) = sub.get("evidence") { + probed += 1; + let probe_result = run_evidence_probe(evidence, verbose); + if probe_result { + realised += 1; + println!(" {} {} {}", "[x]".green(), description, "(evidence confirmed)".green()); + } else { + println!(" {} {} {}", "[ ]".yellow(), description, format!("({})", status).dimmed()); + } + } else { + println!(" {} {} {} {}", "[ ]".yellow(), description, format!("({})", status).dimmed(), "(no probe)".dimmed()); + } + } + + // Prose intents. + for line in §ion.prose { + let trimmed = line.trim(); + if trimmed.starts_with('-') { + total += 1; + let content = trimmed.trim_start_matches('-').trim(); + println!(" {} {}", "[ ]".yellow(), content); + } + } + } + + println!(); + println!( + "{} intent(s): {} realised, {} probed, {} remaining", + total, + realised.to_string().green(), + probed, + (total - realised).to_string().yellow() + ); + Ok(()) +} + +/// Run all intent checks and output as JSON. +fn check_intents_json(doc: &a2ml::A2mlDocument) -> Result<()> { + let mut results = Vec::new(); + + for section in &doc.sections { + for sub in §ion.subsections { + let description = sub.get("description").unwrap_or(&sub.name); + let status = sub.get("status").unwrap_or("declared"); + let evidence = sub.get("evidence"); + + let realised = if status == "realised" { + true + } else if let Some(ev) = evidence { + run_evidence_probe(ev, false) + } else { + false + }; + + results.push(serde_json::json!({ + "name": sub.name, + "section": section.name, + "description": description, + "status": status, + "evidence": evidence.unwrap_or(""), + "realised": realised, + })); + } + } + + let realised_count = results.iter().filter(|r| r["realised"] == true).count(); + + let output = serde_json::json!({ + "tool": "intend", + "total": results.len(), + "realised": realised_count, + "remaining": results.len() - realised_count, + "intents": results, + }); + + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +/// Run a single evidence probe and return true if the intent is realised. +/// Supported probe formats: +/// "FILE exists" — check file existence +/// "FILE contains PATTERN" — check file contents +/// "command: COMMAND" — run shell command, check exit code +/// "must: CHECK_NAME" — delegate to must check +/// "trust: VERIFY_NAME" — delegate to trust verify +fn run_evidence_probe(evidence: &str, verbose: bool) -> bool { + let evidence = evidence.trim(); + + // "command: ..." — run a shell command. + if let Some(cmd) = evidence.strip_prefix("command:") { + let cmd = cmd.trim(); + if verbose { + println!(" probe: {}", cmd); + } + return std::process::Command::new("bash") + .args(["-c", cmd]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + } + + // "must: CHECK_NAME" — delegate to contractile must. + if let Some(check) = evidence.strip_prefix("must:") { + let cmd = format!("contractile must run {} 2>/dev/null", check.trim()); + if verbose { + println!(" probe: {}", cmd); + } + return std::process::Command::new("bash") + .args(["-c", &cmd]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + } + + // "trust: VERIFY_NAME" — delegate to contractile trust. + if let Some(verify) = evidence.strip_prefix("trust:") { + let cmd = format!("contractile trust verify {} 2>/dev/null", verify.trim()); + if verbose { + println!(" probe: {}", cmd); + } + return std::process::Command::new("bash") + .args(["-c", &cmd]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + } + + // "FILE contains PATTERN" — grep for pattern in file. + if evidence.contains(" contains ") { + let parts: Vec<&str> = evidence.splitn(2, " contains ").collect(); + if parts.len() == 2 { + let file = parts[0].trim(); + let pattern = parts[1].trim(); + if verbose { + println!(" probe: grep -q '{}' {}", pattern, file); + } + return std::process::Command::new("grep") + .args(["-q", pattern, file]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + } + } + + // "FILE exists" — check file existence. + if evidence.ends_with(" exists") { + let file = evidence.trim_end_matches(" exists").trim(); + if verbose { + println!(" probe: test -e {}", file); + } + return std::path::Path::new(file).exists(); + } + + // Unknown probe format — cannot determine. + false +} + +/// Transition an intent's status by editing the Intentfile.a2ml in place. +/// Finds the `### name` subsection and updates its `- status:` line. +/// If `expected_from` is non-empty, validates the current status first. +fn transition_intent( + explicit_path: Option<&str>, + name: &str, + expected_from: &str, + new_status: &str, + note: Option<&str>, +) -> Result<()> { + let path = if let Some(p) = explicit_path { + std::path::PathBuf::from(p) + } else { + find_contractile(filenames::INTENTFILE_A2ML) + .context("Intentfile.a2ml not found")? + }; + + let content = fs::read_to_string(&path) + .with_context(|| format!("reading Intentfile: {}", path.display()))?; + + // Parse to validate the intent exists and check current status. + let doc = a2ml::parse(&content) + .with_context(|| format!("parsing Intentfile: {}", path.display()))?; + + // Find the intent across all sections. + let mut found = false; + let mut current_status = String::new(); + for section in &doc.sections { + if let Some(sub) = section.subsection(name) { + found = true; + current_status = sub.get("status").unwrap_or("declared").to_string(); + break; + } + } + + if !found { + let all_names: Vec<&str> = doc + .sections + .iter() + .flat_map(|s| s.subsections.iter().map(|sub| sub.name.as_str())) + .collect(); + bail!( + "intent '{}' not found. Available:\n {}", + name, + all_names.join("\n ") + ); + } + + // Validate transition if expected_from is specified. + if !expected_from.is_empty() && current_status != expected_from { + bail!( + "intent '{}' is '{}', expected '{}'. Cannot transition to '{}'", + name, + current_status, + expected_from, + new_status + ); + } + + // Edit the file in place using line-level manipulation. + // Strategy: track when we're inside the target `### name` subsection, + // replace `- status:` and `- notes:` lines, and handle the boundary + // between subsections correctly. + let lines: Vec<&str> = content.lines().collect(); + let mut new_lines: Vec = Vec::with_capacity(lines.len() + 2); + let mut in_target = false; + let mut status_replaced = false; + let mut notes_replaced = false; + + for line in &lines { + let trimmed = line.trim(); + + // ── Heading detection ── + // When we hit a new heading (### or ##), check if we're leaving + // the target subsection or entering it. + if trimmed.starts_with("### ") || trimmed.starts_with("## ") { + // If we were in the target and leaving without replacing status, + // insert the status line before this heading. + if in_target && !status_replaced { + new_lines.push(format!("- status: {}", new_status)); + status_replaced = true; + if let Some(note_text) = note { + if !notes_replaced { + new_lines.push(format!("- notes: {}", note_text)); + notes_replaced = true; + } + } + } + + // Check if this heading IS the target subsection. + if let Some(heading) = trimmed.strip_prefix("### ") { + in_target = heading.trim() == name; + } else { + // It's a ## section heading — we've left any subsection. + in_target = false; + } + + new_lines.push(line.to_string()); + continue; + } + + // ── Inside the target subsection: replace status/notes lines ── + if in_target { + if trimmed.starts_with("- status:") { + new_lines.push(format!("- status: {}", new_status)); + status_replaced = true; + continue; + } + + if let Some(note_text) = note { + if trimmed.starts_with("- notes:") { + new_lines.push(format!("- notes: {}", note_text)); + notes_replaced = true; + continue; + } + } + } + + new_lines.push(line.to_string()); + } + + // If we reached EOF still inside the target without replacing, append. + if in_target && !status_replaced { + new_lines.push(format!("- status: {}", new_status)); + } + + // If we have a note but no existing notes line was found, insert it + // right after the status line within the target subsection. + if let Some(note_text) = note { + if !notes_replaced { + let mut final_lines: Vec = Vec::with_capacity(new_lines.len() + 1); + let mut inserted = false; + let mut scanning_target = false; + for line in &new_lines { + if line.trim().starts_with("### ") { + let heading = line.trim().strip_prefix("### ").unwrap_or("").trim(); + scanning_target = heading == name; + } + final_lines.push(line.clone()); + if scanning_target + && line.trim().starts_with("- status:") + && !inserted + { + final_lines.push(format!("- notes: {}", note_text)); + inserted = true; + } + } + new_lines = final_lines; + } + } + + // Write the modified content back. + let new_content = new_lines.join("\n"); + // Preserve trailing newline if original had one. + let new_content = if content.ends_with('\n') && !new_content.ends_with('\n') { + format!("{}\n", new_content) + } else { + new_content + }; + + fs::write(&path, &new_content) + .with_context(|| format!("writing Intentfile: {}", path.display()))?; + + println!( + "{} '{}': {} → {}", + "intend:".bold(), + name.cyan(), + current_status.dimmed(), + new_status.green() + ); + + if let Some(note_text) = note { + println!(" {}", note_text.dimmed()); + } + + Ok(()) +} + +/// Get today's date as YYYY-MM-DD string. +fn today_string() -> String { + // Use a simple approach without chrono dependency. + let output = std::process::Command::new("date") + .args(["+%Y-%m-%d"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown-date".to_string()); + output +} + +/// Show a summary of intent progress. +fn show_progress(doc: &a2ml::A2mlDocument) { + let mut total_sections = 0; + let mut total_items = 0; + let mut by_status: std::collections::HashMap = std::collections::HashMap::new(); + + for section in &doc.sections { + total_sections += 1; + // Count direct entries. + total_items += section.entries.len(); + // Count prose intents. + total_items += section + .prose + .iter() + .filter(|l| l.trim().starts_with('-')) + .count(); + // Count structured intents and tally by status. + for sub in §ion.subsections { + total_items += 1; + let status = sub.get("status").unwrap_or("declared").to_string(); + *by_status.entry(status).or_insert(0) += 1; + } + } + + println!("{}", "=== Intent Progress ===".bold()); + println!(" Sections: {}", total_sections); + println!(" Intent items: {}", total_items); + + // Print status breakdown in lifecycle order. + let order = ["declared", "accepted", "in-progress", "realised", "superseded", "abandoned"]; + for status in &order { + if let Some(count) = by_status.get(*status) { + let colored_status = match *status { + "realised" => status.green().to_string(), + "in-progress" => status.yellow().to_string(), + "abandoned" => status.red().to_string(), + _ => status.normal().to_string(), + }; + println!(" {:14} {}", colored_status, count); + } + } +} diff --git a/cli/crates/contractile/src/k9_cmd.rs b/cli/crates/contractile/src/k9_cmd.rs new file mode 100644 index 0000000..862334f --- /dev/null +++ b/cli/crates/contractile/src/k9_cmd.rs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// k9_cmd.rs — `k9` subcommand: K9 Nickel component operations. +// +// K9 contractiles are self-validating components written in Nickel. This +// subcommand bridges to the `nickel` binary for evaluation and type checking, +// and handles the three security levels (Kennel/Yard/Hunt) with appropriate +// safety checks. +// +// Commands: +// k9 eval FILE — evaluate a K9 component (Kennel/Yard level) +// k9 run FILE — execute Hunt-level recipes (signature check) +// k9 typecheck FILE — validate Nickel contracts +// k9 info FILE — display component pedigree and security level +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use contractile_core::k9::{self, LeashLevel}; +use std::path::Path; +use std::process::Command; + +#[derive(Subcommand, Clone)] +pub enum K9Action { + /// Evaluate a K9 component and display its exported JSON + Eval { + /// Path to the .k9.ncl file + file: String, + + /// Output format: json (default), pretty + #[arg(long, default_value = "pretty")] + format: String, + }, + + /// Execute Hunt-level recipes from a K9 component + Run { + /// Path to the .k9.ncl file + file: String, + + /// Specific recipe to run (default: the component's default recipe) + #[arg(long)] + recipe: Option, + + /// Preview commands without executing them + #[arg(long)] + dry_run: bool, + + /// Skip signature verification (DANGEROUS — use only for local dev) + #[arg(long)] + no_verify: bool, + }, + + /// Validate Nickel contracts without evaluating + Typecheck { + /// Path to the .k9.ncl file + file: String, + }, + + /// Display component pedigree, security level, and available recipes + Info { + /// Path to the .k9.ncl file + file: String, + }, +} + +/// Entry point when invoked as a symlink (`k9 eval`, `k9 run`, etc.). +pub fn run_from_args() -> Result<()> { + #[derive(Parser)] + #[command(name = "k9", about = "K9 Nickel component operations")] + struct K9Cli { + #[command(subcommand)] + action: K9Action, + } + + let cli = K9Cli::parse(); + run(cli.action) +} + +/// Execute a k9 action. +pub fn run(action: K9Action) -> Result<()> { + match action { + K9Action::Eval { file, format } => { + let path = Path::new(&file); + let component = k9::evaluate(path)?; + + match format.as_str() { + "json" => { + println!( + "{}", + serde_json::to_string(&component.raw_json) + .context("serialising JSON")? + ); + } + "pretty" | _ => { + println!( + "{}", + serde_json::to_string_pretty(&component.raw_json) + .context("serialising JSON")? + ); + } + } + Ok(()) + } + + K9Action::Run { + file, + recipe, + dry_run, + no_verify, + } => { + let path = Path::new(&file); + let component = k9::evaluate(path)?; + + // ── Security level check ── + let level = component.leash_level(); + if level != LeashLevel::Hunt { + println!( + "{} Component is {} — no recipes to execute", + "k9:".bold(), + level, + ); + println!("Use `k9 eval` for Kennel/Yard components"); + return Ok(()); + } + + // ── Signature check ── + if component.requires_signature() && !no_verify { + let sig_path = format!("{}.sig", file); + let pub_path = format!("{}.pub", file); + + if !Path::new(&sig_path).exists() { + bail!( + "Hunt-level component requires signature but {} not found.\n\ + Use --no-verify for local development only.", + sig_path + ); + } + + // Verify the signature if a public key is available. + if Path::new(&pub_path).exists() { + println!(" {} Verifying signature...", "k9:".bold()); + let verify_status = Command::new("openssl") + .args([ + "dgst", + "-sha256", + "-verify", + &pub_path, + "-signature", + &sig_path, + &file, + ]) + .output() + .context("running openssl for signature verification")?; + + if verify_status.status.success() { + println!( + " {} Signature verified against {}", + "VERIFIED".green().bold(), + pub_path + ); + } else { + let stderr = String::from_utf8_lossy(&verify_status.stderr); + bail!( + "Signature verification FAILED for Hunt-level component.\n\ + sig: {}\n\ + pub: {}\n\ + openssl: {}", + sig_path, + pub_path, + stderr.trim() + ); + } + } else { + // Signature file exists but no public key — warn but proceed. + println!( + " {} Signature file found but no public key at {}", + "WARNING".yellow().bold(), + pub_path + ); + println!( + " {} Signature cannot be verified — proceeding on trust", + "WARNING".yellow().bold() + ); + } + } + + if component.recipes.is_empty() { + println!("{}", "No recipes found in K9 component".yellow()); + return Ok(()); + } + + // Determine which recipe to run. + let target_recipe = recipe.as_deref().unwrap_or_else(|| { + // Look for the default recipe pointer. + component + .raw_json + .get("recipes") + .and_then(|r| r.get("default")) + .and_then(|d| d.get("recipe")) + .and_then(|r| r.as_str()) + .unwrap_or("setup") + }); + + // Find and execute the recipe. + let recipe_def = component + .recipes + .iter() + .find(|r| r.name == target_recipe) + .with_context(|| { + let available: Vec<&str> = + component.recipes.iter().map(|r| r.name.as_str()).collect(); + format!( + "recipe '{}' not found. Available: {}", + target_recipe, + available.join(", ") + ) + })?; + + println!( + "{} Running recipe '{}' ({} command(s))", + "k9:".bold(), + target_recipe.cyan(), + recipe_def.commands.len() + ); + + for cmd in &recipe_def.commands { + if dry_run { + println!(" {} {}", "[DRY-RUN]".cyan(), cmd); + continue; + } + + println!(" {} {}", "$".dimmed(), cmd); + let status = Command::new("bash") + .args(["-c", cmd]) + .status() + .with_context(|| format!("executing K9 recipe command: {}", cmd))?; + + if !status.success() { + bail!( + "K9 recipe '{}' command failed (exit {}): {}", + target_recipe, + status.code().unwrap_or(-1), + cmd + ); + } + } + + if !dry_run { + println!(" {} Recipe '{}' complete", "DONE".green().bold(), target_recipe); + } + Ok(()) + } + + K9Action::Typecheck { file } => { + let path = Path::new(&file); + k9::typecheck(path)?; + println!( + "{} {} passes type checking", + "OK".green().bold(), + file + ); + Ok(()) + } + + K9Action::Info { file } => { + let path = Path::new(&file); + let component = k9::evaluate(path)?; + + println!("{}", "=== K9 Component Info ===".bold()); + println!(" File: {}", file); + println!(" Security: {}", component.leash_level()); + + if let Some(pedigree) = &component.pedigree { + if let Some(meta) = &pedigree.metadata { + if let Some(name) = &meta.name { + println!(" Name: {}", name); + } + if let Some(ver) = &meta.version { + println!(" Version: {}", ver); + } + if let Some(desc) = &meta.description { + println!(" About: {}", desc); + } + if let Some(author) = &meta.author { + println!(" Author: {}", author); + } + } + if let Some(schema) = &pedigree.schema_version { + println!(" Schema: {}", schema); + } + } + + if component.requires_signature() { + println!(" Signed: {} (signature required)", "YES".yellow()); + } + + if !component.recipes.is_empty() { + println!(); + println!(" {}:", "Recipes".bold()); + for recipe in &component.recipes { + let desc = recipe + .description + .as_deref() + .unwrap_or(""); + println!( + " {} — {} ({} cmd(s))", + recipe.name.cyan(), + desc, + recipe.commands.len() + ); + } + } + + Ok(()) + } + } +} diff --git a/cli/crates/contractile/src/main.rs b/cli/crates/contractile/src/main.rs new file mode 100644 index 0000000..349b11c --- /dev/null +++ b/cli/crates/contractile/src/main.rs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// contractile — Unified CLI for the contractile system. +// +// Provides subcommands for each contractile type: +// contractile must check|fix|enforce|list +// contractile trust verify|hash|sign +// contractile dust status|rollback|replay +// contractile intend list|check|progress +// contractile k9 eval|run|typecheck +// contractile gen-just +// +// Can also be invoked via symlinks: `must`, `trust`, `dust`, `intend`, `k9` +// which behave as if the binary name were the subcommand. +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +#![forbid(unsafe_code)] +mod adjust; +mod doctor; +mod dust; +mod gen_just; +mod init; +mod intend; +mod k9_cmd; +mod must; +mod status; +mod trust; + +use clap::{CommandFactory, Parser, Subcommand}; +use std::env; + +/// Contractile — unified runner for Must/Trust/Dust/Intend/K9 contract files. +/// +/// Each subcommand processes its corresponding A2ML contractile file and +/// executes the declared operations: checks, verifications, rollbacks, +/// intent reporting, or K9 component evaluation. +#[derive(Parser)] +#[command(name = "contractile", version, about)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Physical state checks from Mustfile.a2ml (or mustfile.toml) + Must { + #[command(subcommand)] + action: must::MustAction, + }, + + /// Integrity and provenance verification from Trustfile.a2ml + Trust { + #[command(subcommand)] + action: trust::TrustAction, + }, + + /// Recovery and rollback actions from Dustfile.a2ml + Dust { + #[command(subcommand)] + action: dust::DustAction, + }, + + /// Future intent and roadmap from Intentfile.a2ml + Intend { + #[command(subcommand)] + action: intend::IntendAction, + }, + + /// Accessibility & Digital Justice for Universal Software & Technology + Adjust { + #[command(subcommand)] + action: adjust::AdjustAction, + }, + + /// K9 Nickel component operations + K9 { + #[command(subcommand)] + action: k9_cmd::K9Action, + }, + + /// Generate contractile.just from all A2ML and K9 sources + GenJust { + /// Directory containing contractile files (default: ./contractiles/) + #[arg(long, default_value = "contractiles")] + dir: String, + + /// Output file path (default: contractile.just) + #[arg(long, short, default_value = "contractile.just")] + output: String, + }, + + /// Scaffold contractile files into a repository + Init { + /// Project name (auto-detected from Cargo.toml/deno.json if omitted) + #[arg(long)] + name: Option, + + /// Overwrite existing contractile files + #[arg(long)] + force: bool, + }, + + /// Show unified status dashboard across all contractile types + Status { + /// Quick mode: just count items without running checks + #[arg(long, short)] + quick: bool, + }, + + /// Diagnose tooling availability and versions + Doctor, + + /// Create symlinks (must, trust, dust, intend, k9) pointing to this binary + Setup, + + /// Generate shell completions + Completions { + /// Shell to generate completions for (bash, zsh, fish, elvish, powershell) + shell: clap_complete::Shell, + }, +} + +fn main() { + // ── Symlink dispatch ── + // If the binary was invoked as `must`, `trust`, `dust`, `intend`, or `k9` + // (via a symlink), treat the binary name as the subcommand and re-parse + // arguments with that prefix. We use argv[0] rather than current_exe() + // because current_exe() resolves symlinks to the real binary path. + let exe_name = env::args() + .next() + .and_then(|arg0| { + std::path::Path::new(&arg0) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }) + .unwrap_or_default(); + + let result = match exe_name.as_str() { + "must" => must::run_from_args(), + "trust" => trust::run_from_args(), + "dust" => dust::run_from_args(), + "intend" => intend::run_from_args(), + "adjust" => adjust::run_from_args(), + "k9" => k9_cmd::run_from_args(), + _ => run_unified(), + }; + + if let Err(e) = result { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } +} + +/// Create symlinks for must, trust, dust, intend, k9 alongside the contractile binary. +fn setup_symlinks() -> anyhow::Result<()> { + let exe = env::current_exe()?; + let dir = exe + .parent() + .ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?; + + let commands = ["must", "trust", "dust", "intend", "adjust", "k9"]; + + for cmd in &commands { + let link_path = dir.join(cmd); + + // Remove existing symlink/file if present + if link_path.exists() || link_path.symlink_metadata().is_ok() { + std::fs::remove_file(&link_path).ok(); + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&exe, &link_path)?; + + #[cfg(windows)] + std::fs::copy(&exe, &link_path)?; + + println!(" {} → {}", cmd, exe.display()); + } + + println!( + "\n{} symlinks created in {}", + commands.len(), + dir.display() + ); + println!("Make sure {} is in your PATH.", dir.display()); + + Ok(()) +} + +/// Run the unified `contractile ` dispatcher. +fn run_unified() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Some(Commands::Must { action }) => must::run(action), + Some(Commands::Trust { action }) => trust::run(action), + Some(Commands::Dust { action }) => dust::run(action), + Some(Commands::Intend { action }) => intend::run(action), + Some(Commands::Adjust { action }) => adjust::run(action), + Some(Commands::K9 { action }) => k9_cmd::run(action), + Some(Commands::GenJust { dir, output }) => gen_just::run(&dir, &output), + Some(Commands::Init { name, force }) => init::run(name.as_deref(), force), + Some(Commands::Status { quick }) => status::run(quick), + Some(Commands::Doctor) => doctor::run(), + Some(Commands::Setup) => setup_symlinks(), + Some(Commands::Completions { shell }) => { + clap_complete::generate( + shell, + &mut Cli::command(), + "contractile", + &mut std::io::stdout(), + ); + Ok(()) + } + None => { + // No subcommand — print help. + Cli::command().print_help()?; + println!(); + Ok(()) + } + } +} diff --git a/cli/crates/contractile/src/must.rs b/cli/crates/contractile/src/must.rs new file mode 100644 index 0000000..e67aa99 --- /dev/null +++ b/cli/crates/contractile/src/must.rs @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// must.rs — `must` subcommand: Physical State checks from Mustfile.a2ml. +// +// Must enforces the Physical State model — the verifiable, observable condition +// of a project's files, dependencies, and build artifacts. It reads checks +// from Mustfile.a2ml (A2ML format) or mustfile.toml (legacy TOML format) and +// executes them, reporting pass/fail for each. +// +// Commands: +// must check — run all checks (read-only) +// must fix — auto-fix violations where possible +// must enforce — check + fix + verify cycle +// must list — list available checks +// must run NAME — run a single named check +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use contractile_core::{a2ml, filenames, find_contractile, toml_compat}; +use std::fs; +use std::process::Command; + +#[derive(Subcommand, Clone)] +pub enum MustAction { + /// Run all must checks (read-only verification) + Check { + /// Fail on warnings as well as errors + #[arg(long)] + strict: bool, + + /// Show detailed output for each check + #[arg(long, short)] + verbose: bool, + + /// Output results as JSON (for CI/CD consumption) + #[arg(long)] + json: bool, + + /// Path to the Mustfile (auto-detected if omitted) + #[arg(long)] + file: Option, + }, + + /// Auto-fix violations where the fix is deterministic + Fix { + /// Preview fixes without applying them + #[arg(long)] + dry_run: bool, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + file: Option, + }, + + /// Full enforcement cycle: check → fix → verify + Enforce { + #[arg(long)] + strict: bool, + + #[arg(long)] + dry_run: bool, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + file: Option, + }, + + /// List available checks without running them + List { + #[arg(long)] + file: Option, + }, + + /// Run a single named check + Run { + /// Name of the check to run (matches ### heading in Mustfile.a2ml) + name: String, + + #[arg(long)] + dry_run: bool, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + file: Option, + }, +} + +/// Entry point when invoked as a symlink (`must check`, `must list`, etc.). +pub fn run_from_args() -> Result<()> { + #[derive(Parser)] + #[command(name = "must", about = "Physical State checks from Mustfile.a2ml")] + struct MustCli { + #[command(subcommand)] + action: MustAction, + } + + let cli = MustCli::parse(); + run(cli.action) +} + +/// Execute a must action. +pub fn run(action: MustAction) -> Result<()> { + match action { + MustAction::Check { + strict: _, + verbose, + json, + file, + } => { + let doc = load_mustfile(file.as_deref())?; + if json { + run_all_checks_json(&doc) + } else { + run_all_checks(&doc, verbose, false) + } + } + MustAction::Fix { + dry_run, + verbose, + file, + } => { + let doc = load_mustfile(file.as_deref())?; + // Fix mode: run checks and report what would be fixed. + // Actual auto-fix logic depends on the check type — for now, + // we report failures and suggest manual fixes. + println!("{}", "must fix: running checks to identify violations...".bold()); + run_all_checks(&doc, verbose, dry_run) + } + MustAction::Enforce { + strict: _, + dry_run, + verbose, + file, + } => { + let doc = load_mustfile(file.as_deref())?; + println!("{}", "must enforce: check → fix → verify cycle".bold()); + run_all_checks(&doc, verbose, dry_run) + } + MustAction::List { file } => { + let doc = load_mustfile(file.as_deref())?; + list_checks(&doc); + Ok(()) + } + MustAction::Run { + name, + dry_run, + verbose, + file, + } => { + let doc = load_mustfile(file.as_deref())?; + run_single_check(&doc, &name, verbose, dry_run) + } + } +} + +/// Load and parse the Mustfile, trying A2ML first, then TOML fallback. +/// Search order: explicit path → Mustfile.a2ml → mustfile.toml +fn load_mustfile(explicit_path: Option<&str>) -> Result { + if let Some(p) = explicit_path { + let path = std::path::PathBuf::from(p); + // Detect format by extension. + if p.ends_with(".toml") { + return toml_compat::parse_mustfile_toml(&path); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("reading Mustfile: {}", path.display()))?; + return a2ml::parse(&content) + .with_context(|| format!("parsing Mustfile: {}", path.display())); + } + + // Try A2ML first. + if let Some(path) = find_contractile(filenames::MUSTFILE_A2ML) { + let content = fs::read_to_string(&path) + .with_context(|| format!("reading Mustfile: {}", path.display()))?; + return a2ml::parse(&content) + .with_context(|| format!("parsing Mustfile: {}", path.display())); + } + + // Fall back to mustfile.toml. + if let Some(path) = find_contractile(filenames::MUSTFILE_TOML) { + println!( + "{} Using {} (A2ML not found)", + "must:".bold(), + path.display().to_string().dimmed() + ); + return toml_compat::parse_mustfile_toml(&path); + } + + bail!("No Mustfile found. Searched for Mustfile.a2ml and mustfile.toml in: contractiles/must/, must/, ./") +} + +/// Run all executable checks in the document. Returns an error if any check fails. +fn run_all_checks(doc: &a2ml::A2mlDocument, verbose: bool, dry_run: bool) -> Result<()> { + let items = doc.executable_items(); + if items.is_empty() { + println!("{}", "No executable checks found in Mustfile".yellow()); + return Ok(()); + } + + println!( + "{} {} check(s)...", + "must:".bold(), + items.len() + ); + + let mut passed = 0; + let mut failed = 0; + + for item in &items { + let desc = item.description.unwrap_or(item.subsection); + + if dry_run { + println!(" {} {} → {}", "[DRY-RUN]".cyan(), desc, item.command); + passed += 1; + continue; + } + + if verbose { + println!(" {} {}", "Running:".dimmed(), item.command); + } + + let status = Command::new("bash") + .args(["-c", item.command]) + .status() + .with_context(|| format!("executing check: {}", item.subsection))?; + + if status.success() { + println!(" {} {}", "PASS".green().bold(), desc); + passed += 1; + } else { + println!(" {} {}", "FAIL".red().bold(), desc); + if verbose { + println!(" command: {}", item.command); + println!(" exit code: {}", status.code().unwrap_or(-1)); + } + failed += 1; + } + } + + println!(); + let failed_str = failed.to_string(); + let failed_display = if failed > 0 { + failed_str.red() + } else { + failed_str.normal() + }; + println!("{} passed, {} failed", passed.to_string().green(), failed_display); + + if failed > 0 { + bail!("{} must check(s) failed", failed); + } + + Ok(()) +} + +/// Run all checks and output results as JSON for CI/CD consumption. +fn run_all_checks_json(doc: &a2ml::A2mlDocument) -> Result<()> { + let items = doc.executable_items(); + let mut results = Vec::new(); + + for item in &items { + let status = Command::new("bash") + .args(["-c", item.command]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .with_context(|| format!("executing check: {}", item.subsection))?; + + results.push(serde_json::json!({ + "name": item.subsection, + "section": item.section, + "description": item.description.unwrap_or(""), + "command": item.command, + "passed": status.success(), + "exit_code": status.code().unwrap_or(-1), + })); + } + + let passed = results.iter().filter(|r| r["passed"] == true).count(); + let failed = results.len() - passed; + + let output = serde_json::json!({ + "tool": "must", + "total": results.len(), + "passed": passed, + "failed": failed, + "checks": results, + }); + + println!("{}", serde_json::to_string_pretty(&output)?); + + if failed > 0 { + std::process::exit(2); + } + + Ok(()) +} + +/// Print a listing of all checks without running them. +fn list_checks(doc: &a2ml::A2mlDocument) { + let items = doc.executable_items(); + if items.is_empty() { + println!("{}", "No checks found in Mustfile".yellow()); + return; + } + + println!("{}", "Available must checks:".bold()); + for item in &items { + let desc = item.description.unwrap_or(""); + println!(" {} — {}", item.subsection.cyan(), desc); + } +} + +/// Run a single named check by matching against subsection names. +fn run_single_check( + doc: &a2ml::A2mlDocument, + name: &str, + verbose: bool, + dry_run: bool, +) -> Result<()> { + let items = doc.executable_items(); + let item = items + .iter() + .find(|i| i.subsection == name) + .with_context(|| { + let available: Vec<&str> = items.iter().map(|i| i.subsection).collect(); + format!( + "check '{}' not found. Available: {}", + name, + available.join(", ") + ) + })?; + + let desc = item.description.unwrap_or(item.subsection); + + if dry_run { + println!("[DRY-RUN] {} → {}", desc, item.command); + return Ok(()); + } + + if verbose { + println!("Running: {}", item.command); + } + + let status = Command::new("bash") + .args(["-c", item.command]) + .status() + .with_context(|| format!("executing check: {}", name))?; + + if status.success() { + println!("{} {}", "PASS".green().bold(), desc); + Ok(()) + } else { + println!("{} {}", "FAIL".red().bold(), desc); + bail!("must check '{}' failed (exit {})", name, status.code().unwrap_or(-1)); + } +} + diff --git a/cli/crates/contractile/src/status.rs b/cli/crates/contractile/src/status.rs new file mode 100644 index 0000000..5a85378 --- /dev/null +++ b/cli/crates/contractile/src/status.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// status.rs — `contractile status`: Unified dashboard across all contractile types. +// +// Shows a single-screen overview of the project's contractile health: +// - Must: how many checks pass/fail +// - Trust: how many verifications pass/fail +// - Dust: how many recovery actions are available +// - Intend: how many intents are realised/in-progress/declared +// +// This is the daily-driver command — run `contractile status` to see +// where the project stands across all contractile dimensions. +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{Context, Result}; +use colored::Colorize; +use contractile_core::{a2ml, filenames, find_contractile}; +use std::fs; +use std::process::Command; + +/// Run the status dashboard. +pub fn run(quick: bool) -> Result<()> { + println!("{}", "=== Contractile Status ===".bold()); + println!(); + + let mut any_found = false; + + // ── Must ── + if let Some(path) = find_contractile(filenames::MUSTFILE_A2ML) + .or_else(|| find_contractile(filenames::MUSTFILE_TOML)) + { + any_found = true; + let doc = load_a2ml_or_toml(&path)?; + let items = doc.executable_items(); + + if quick || items.is_empty() { + println!( + " {} {} check(s) declared", + "MUST".cyan().bold(), + items.len() + ); + } else { + let (passed, failed) = run_checks_silent(&items); + let status_icon = if failed == 0 { + "PASS".green().bold() + } else { + "FAIL".red().bold() + }; + println!( + " {} {} — {}/{} passed", + "MUST".cyan().bold(), + status_icon, + passed, + items.len() + ); + if failed > 0 { + // Show which ones failed. + for item in &items { + let ok = Command::new("bash") + .args(["-c", item.command]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + let desc = item.description.unwrap_or(item.subsection); + println!(" {} {}", "FAIL".red(), desc); + } + } + } + } + } + + // ── Trust ── + if let Some(path) = find_contractile(filenames::TRUSTFILE_A2ML) { + any_found = true; + let content = fs::read_to_string(&path)?; + let doc = a2ml::parse(&content)?; + let items = doc.executable_items(); + + if quick || items.is_empty() { + println!( + " {} {} verification(s) declared", + "TRUST".cyan().bold(), + items.len() + ); + } else { + let (passed, failed) = run_checks_silent(&items); + let status_icon = if failed == 0 { + "PASS".green().bold() + } else { + "FAIL".red().bold() + }; + println!( + " {} {} — {}/{} verified", + "TRUST".cyan().bold(), + status_icon, + passed, + items.len() + ); + } + } + + // ── Dust ── + if let Some(path) = find_contractile(filenames::DUSTFILE_A2ML) { + any_found = true; + let content = fs::read_to_string(&path)?; + let doc = a2ml::parse(&content)?; + let items = doc.executable_items(); + + // Count by type. + let rollbacks = items.iter().filter(|i| i.key == "rollback").count(); + let undos = items.iter().filter(|i| i.key == "undo").count(); + let handlers = items.iter().filter(|i| i.key == "handler").count(); + let transforms = items.iter().filter(|i| i.key == "transform").count(); + + println!( + " {} {} action(s): {} rollback, {} undo, {} handler, {} transform", + "DUST".cyan().bold(), + items.len(), + rollbacks, + undos, + handlers, + transforms + ); + } + + // ── Intend ── + if let Some(path) = find_contractile(filenames::INTENTFILE_A2ML) { + any_found = true; + let content = fs::read_to_string(&path)?; + let doc = a2ml::parse(&content)?; + + let mut total = 0; + let mut by_status: std::collections::HashMap<&str, usize> = + std::collections::HashMap::new(); + + for section in &doc.sections { + total += section.entries.len(); + total += section.prose.iter().filter(|l| l.trim().starts_with('-')).count(); + for sub in §ion.subsections { + total += 1; + let status = sub.get("status").unwrap_or("declared"); + *by_status.entry(status).or_insert(0) += 1; + } + } + + let realised = by_status.get("realised").copied().unwrap_or(0); + let in_progress = by_status.get("in-progress").copied().unwrap_or(0); + let declared = total - realised - in_progress + - by_status.get("abandoned").copied().unwrap_or(0) + - by_status.get("superseded").copied().unwrap_or(0); + + println!( + " {} {} intent(s): {} realised, {} active, {} pending", + "INTEND".cyan().bold(), + total, + realised.to_string().green(), + in_progress.to_string().yellow(), + declared + ); + } + + // ── K9 ── + let k9_count = count_k9_files(); + if k9_count > 0 { + any_found = true; + println!( + " {} {} component(s) available", + "K9".cyan().bold(), + k9_count + ); + } + + if !any_found { + println!(" {} No contractile files found", "NONE".yellow()); + println!(" Run `contractile init` to scaffold contractiles for this repo"); + } + + println!(); + Ok(()) +} + +/// Load an A2ML file, or fall back to TOML if the path ends in .toml. +fn load_a2ml_or_toml(path: &std::path::Path) -> Result { + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + contractile_core::toml_compat::parse_mustfile_toml(path) + } else { + let content = fs::read_to_string(path) + .with_context(|| format!("reading: {}", path.display()))?; + a2ml::parse(&content) + .with_context(|| format!("parsing: {}", path.display())) + } +} + +/// Run all executable items silently and return (passed, failed) counts. +fn run_checks_silent(items: &[a2ml::ExecutableItem<'_>]) -> (usize, usize) { + let mut passed = 0; + let mut failed = 0; + + for item in items { + let ok = Command::new("bash") + .args(["-c", item.command]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if ok { + passed += 1; + } else { + failed += 1; + } + } + + (passed, failed) +} + +/// Count .k9.ncl files in the contractiles directory. +fn count_k9_files() -> usize { + let dirs = ["contractiles/k9", "k9"]; + let mut count = 0; + + for dir in &dirs { + let dir_path = std::path::Path::new(dir); + if dir_path.is_dir() { + count += count_ncl_recursive(dir_path); + } + } + + count +} + +/// Recursively count .k9.ncl files. +fn count_ncl_recursive(dir: &std::path::Path) -> usize { + let mut count = 0; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + count += count_ncl_recursive(&path); + } else if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".k9.ncl")) + .unwrap_or(false) + { + count += 1; + } + } + } + count +} diff --git a/cli/crates/contractile/src/trust.rs b/cli/crates/contractile/src/trust.rs new file mode 100644 index 0000000..09f658a --- /dev/null +++ b/cli/crates/contractile/src/trust.rs @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// trust.rs — `trust` subcommand: Integrity & provenance verification from Trustfile.a2ml. +// +// Trust handles cryptographic verification: hash checking, signature +// validation, provenance attestation, and post-quantum crypto verification. +// Each verification step in the Trustfile has a `command:` entry that is +// executed and its exit code checked. +// +// Commands: +// trust verify — run all verification steps +// trust verify NAME — run a single named verification +// trust list — list available verifications +// trust hash FILE — compute and display the SHA-256 hash of a file +// trust sign FILE — sign a file (placeholder for key management) +// +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use contractile_core::{a2ml, filenames, find_contractile}; +use std::fs; +use std::process::Command; + +#[derive(Subcommand, Clone)] +pub enum TrustAction { + /// Run all trust verifications (hash checks, signature validations) + Verify { + /// Run only a specific named verification + name: Option, + + #[arg(long, short)] + verbose: bool, + + #[arg(long)] + dry_run: bool, + + /// Output results as JSON (for CI/CD consumption) + #[arg(long)] + json: bool, + + #[arg(long)] + file: Option, + }, + + /// List available verification steps + List { + #[arg(long)] + file: Option, + }, + + /// Compute SHA-256 hash of a file + Hash { + /// Path to the file to hash + path: String, + }, + + /// Sign a file (creates .sig alongside it) + Sign { + /// Path to the file to sign + path: String, + + /// Path to the signing key + #[arg(long)] + key: Option, + }, +} + +/// Entry point when invoked as a symlink (`trust verify`, `trust list`, etc.). +pub fn run_from_args() -> Result<()> { + #[derive(Parser)] + #[command(name = "trust", about = "Integrity & provenance verification from Trustfile.a2ml")] + struct TrustCli { + #[command(subcommand)] + action: TrustAction, + } + + let cli = TrustCli::parse(); + run(cli.action) +} + +/// Execute a trust action. +pub fn run(action: TrustAction) -> Result<()> { + match action { + TrustAction::Verify { + name, + verbose, + dry_run, + json, + file, + } => { + let doc = load_trustfile(file.as_deref())?; + if json { + run_all_verifications_json(&doc) + } else if let Some(name) = name { + run_single_verification(&doc, &name, verbose, dry_run) + } else { + run_all_verifications(&doc, verbose, dry_run) + } + } + TrustAction::List { file } => { + let doc = load_trustfile(file.as_deref())?; + list_verifications(&doc); + Ok(()) + } + TrustAction::Hash { path } => { + let output = Command::new("sha256sum") + .arg(&path) + .output() + .context("running sha256sum")?; + if output.status.success() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + } else { + bail!( + "sha256sum failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) + } + TrustAction::Sign { path, key } => { + let key_path = key.as_deref().unwrap_or("signing.key"); + println!( + "{} Signing {} with key {}", + "trust:".bold(), + path.cyan(), + key_path.dimmed() + ); + println!( + "{}", + "Sign operation is a placeholder — integrate with your key management system" + .yellow() + ); + Ok(()) + } + } +} + +/// Load and parse the Trustfile. +fn load_trustfile(explicit_path: Option<&str>) -> Result { + let path = if let Some(p) = explicit_path { + std::path::PathBuf::from(p) + } else { + find_contractile(filenames::TRUSTFILE_A2ML) + .context("Trustfile.a2ml not found. Searched: contractiles/trust/, trust/, ./")? + }; + + let content = fs::read_to_string(&path) + .with_context(|| format!("reading Trustfile: {}", path.display()))?; + + a2ml::parse(&content).with_context(|| format!("parsing Trustfile: {}", path.display())) +} + +/// Run all verification steps and report results. +fn run_all_verifications(doc: &a2ml::A2mlDocument, verbose: bool, dry_run: bool) -> Result<()> { + let items = doc.executable_items(); + if items.is_empty() { + println!("{}", "No verifications found in Trustfile".yellow()); + return Ok(()); + } + + println!( + "{} {} verification(s)...", + "trust:".bold(), + items.len() + ); + + let mut passed = 0; + let mut failed = 0; + + for item in &items { + let desc = item.description.unwrap_or(item.subsection); + + if dry_run { + println!(" {} {} → {}", "[DRY-RUN]".cyan(), desc, item.command); + passed += 1; + continue; + } + + if verbose { + println!(" {} {}", "Verifying:".dimmed(), item.command); + } + + let status = Command::new("bash") + .args(["-c", item.command]) + .status() + .with_context(|| format!("executing verification: {}", item.subsection))?; + + if status.success() { + println!(" {} {}", "VERIFIED".green().bold(), desc); + passed += 1; + } else { + println!(" {} {}", "FAILED".red().bold(), desc); + if verbose { + println!(" command: {}", item.command); + } + failed += 1; + } + } + + println!(); + println!("{} verified, {} failed", passed, failed); + + if failed > 0 { + bail!("{} trust verification(s) failed", failed); + } + Ok(()) +} + +/// Run a single named verification. +fn run_single_verification( + doc: &a2ml::A2mlDocument, + name: &str, + verbose: bool, + dry_run: bool, +) -> Result<()> { + let items = doc.executable_items(); + let item = items + .iter() + .find(|i| i.subsection == name) + .with_context(|| { + let available: Vec<&str> = items.iter().map(|i| i.subsection).collect(); + format!( + "verification '{}' not found. Available: {}", + name, + available.join(", ") + ) + })?; + + let desc = item.description.unwrap_or(item.subsection); + + if dry_run { + println!("[DRY-RUN] {} → {}", desc, item.command); + return Ok(()); + } + + if verbose { + println!("Verifying: {}", item.command); + } + + let status = Command::new("bash") + .args(["-c", item.command]) + .status() + .with_context(|| format!("executing verification: {}", name))?; + + if status.success() { + println!("{} {}", "VERIFIED".green().bold(), desc); + Ok(()) + } else { + println!("{} {}", "FAILED".red().bold(), desc); + bail!("trust verification '{}' failed", name); + } +} + +/// Run all verifications and output results as JSON. +fn run_all_verifications_json(doc: &a2ml::A2mlDocument) -> Result<()> { + let items = doc.executable_items(); + let mut results = Vec::new(); + + for item in &items { + let status = Command::new("bash") + .args(["-c", item.command]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .with_context(|| format!("executing verification: {}", item.subsection))?; + + results.push(serde_json::json!({ + "name": item.subsection, + "section": item.section, + "description": item.description.unwrap_or(""), + "command": item.command, + "verified": status.success(), + "exit_code": status.code().unwrap_or(-1), + })); + } + + let verified = results.iter().filter(|r| r["verified"] == true).count(); + let failed = results.len() - verified; + + let output = serde_json::json!({ + "tool": "trust", + "total": results.len(), + "verified": verified, + "failed": failed, + "verifications": results, + }); + + println!("{}", serde_json::to_string_pretty(&output)?); + + if failed > 0 { + std::process::exit(2); + } + Ok(()) +} + +/// List all available verifications. +fn list_verifications(doc: &a2ml::A2mlDocument) { + let items = doc.executable_items(); + if items.is_empty() { + println!("{}", "No verifications found in Trustfile".yellow()); + return; + } + + println!("{}", "Available trust verifications:".bold()); + for item in &items { + let desc = item.description.unwrap_or(""); + println!(" {} — {}", item.subsection.cyan(), desc); + } +} diff --git a/config/attestation.ncl b/config/attestation.ncl new file mode 100644 index 0000000..079d1e2 --- /dev/null +++ b/config/attestation.ncl @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# attestation.ncl — Node attestation configuration for deployment targets. +# +# Defines attestation requirements for nodes that run contractile-managed +# services. Each deployment target must prove its identity and integrity +# before receiving signed artifacts. +# +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +{ + attestation = { + # Enable node attestation for deployments + enabled = true, + + # Attestation method + method = "tpm2-quote", + + # Required PCR values for boot integrity + pcr_policy = { + # PCR 0: BIOS/UEFI firmware + pcr0 = "required", + # PCR 7: Secure Boot state + pcr7 = "required", + # PCR 4-5: Boot manager + pcr4 = "advisory", + pcr5 = "advisory", + }, + + # Node identity verification + identity = { + # Require device certificate + require_cert = true, + # Certificate authority + ca = "keys/node-ca.pem", + # Minimum key strength + min_key_bits = 256, + }, + + # Deployment constraints + constraints = { + # Only deploy to attested nodes + require_attestation_for_deploy = true, + # Re-attest on every deployment + re_attest_on_deploy = true, + # Maximum time since last attestation (seconds) + max_attestation_age = 3600, + }, + }, +} diff --git a/config/canary.ncl b/config/canary.ncl new file mode 100644 index 0000000..f1e8f79 --- /dev/null +++ b/config/canary.ncl @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# canary.ncl — Canary and staged rollout configuration. +# +# Defines canary deployment parameters for the contractile system. +# Used by the control plane to gradually roll out policy changes. +# +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +{ + canary = { + # Enable canary deployments + enabled = true, + + # Percentage of traffic to route to canary + initial_weight = 10, + + # Step size for traffic increase + step_weight = 10, + + # Time between steps (seconds) + step_interval = 300, + + # Maximum weight before full promotion + max_weight = 50, + + # Metrics to evaluate canary health + analysis = { + # Maximum acceptable error rate (percentage) + max_error_rate = 1.0, + + # Maximum acceptable latency p99 (milliseconds) + max_latency_p99 = 500, + + # Minimum evaluation period (seconds) + min_evaluation_period = 600, + }, + + # Automatic rollback on failure + rollback = { + enabled = true, + # Rollback if error rate exceeds threshold + on_error_rate = true, + # Rollback if latency exceeds threshold + on_latency = true, + # Notify on rollback + notify = true, + }, + }, + + # Staged rollout phases + phases = [ + { + name = "canary", + weight = 10, + duration = "10m", + checks = ["must check", "trust verify"], + }, + { + name = "expansion", + weight = 50, + duration = "30m", + checks = ["must check"], + }, + { + name = "full", + weight = 100, + duration = "0", + checks = ["must check", "trust verify"], + }, + ], +} diff --git a/config/policy.yaml b/config/policy.yaml new file mode 100644 index 0000000..49e73dd --- /dev/null +++ b/config/policy.yaml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# policy.yaml — Simple policy config (validated by must checks). +# For complex policies, use policy/policy.ncl (Nickel) instead. + +project: + name: contractiles + version: 1.1.0 + license: PMPL-1.0-or-later + +deployment: + registry: ghcr.io/hyperpolymath + container_engine: podman + ban_dockerfile: true + +enforcement: + spdx_headers: true + no_trailing_whitespace: true + unix_line_endings: true diff --git a/config/tpm.toml b/config/tpm.toml new file mode 100644 index 0000000..c22bfb8 --- /dev/null +++ b/config/tpm.toml @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# tpm.toml — Hardware-backed key management configuration. +# +# Configures TPM 2.0 and YubiKey integration for the contractile trust system. +# Used by `trust sign` and `trust verify` for hardware-backed operations. +# +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +[tpm] +# TPM 2.0 device path (auto-detected if empty) +device = "" + +# Key hierarchy +primary_handle = "0x81000001" +signing_handle = "0x81000002" + +# Algorithms supported by the TPM +algorithms = ["RSA-2048", "ECC-P256", "Ed25519"] + +# Post-quantum: Dilithium5 requires firmware update +# Set to true when TPM firmware supports PQC +pq_supported = false + +[yubikey] +# YubiKey slot for signing operations +slot = "9a" + +# PIV certificate subject +subject = "CN=contractile-signing,O=hyperpolymath" + +# Require touch for every signing operation +require_touch = true + +[rotation] +# Key rotation schedule +interval = "annual" + +# Notify before rotation (days) +notify_before = 30 + +# Archive old keys +archive_old_keys = true +archive_path = "keys/archive/" + +[fallback] +# Software-only fallback when hardware is unavailable +allow_software_fallback = true +software_key_path = "keys/signing.key" +software_pub_path = "keys/signing.pub" diff --git a/config/tracing.toml b/config/tracing.toml new file mode 100644 index 0000000..383f8fa --- /dev/null +++ b/config/tracing.toml @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# tracing.toml — End-to-end request tracing configuration. +# +# Configures OpenTelemetry-compatible tracing for contractile-managed +# services. Traces request decisions through the policy gateway. +# +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +[tracing] +enabled = true +service_name = "contractiles-gateway" +version = "1.0.0" + +[tracing.exporter] +type = "otlp" +endpoint = "http://localhost:4317" +protocol = "grpc" + +[tracing.sampling] +# Sample rate (0.0 to 1.0) +rate = 0.1 +# Always sample errors +always_sample_errors = true + +[tracing.propagation] +# W3C Trace Context +formats = ["tracecontext", "baggage"] + +[tracing.spans] +# Span attributes to include +include_policy_evaluation = true +include_decision_latency = true +include_capability_check = true + +[metrics] +enabled = true +endpoint = "http://localhost:9090" +export_interval = 15 + +[metrics.histograms] +decision_latency_buckets = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0] diff --git a/docs/A2ML-FORMAT.adoc b/docs/A2ML-FORMAT.adoc new file mode 100644 index 0000000..85ca06a --- /dev/null +++ b/docs/A2ML-FORMAT.adoc @@ -0,0 +1,319 @@ += A2ML Format for Contractiles +:author: Jonathan D.A. Jewell +:revnumber: 1.0.0 +:toc: left +:icons: font + +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +== What is A2ML? + +A2ML (Annotated Markup with Machine Logic) is a line-oriented, human-readable format designed for contract files that are both human-auditable and machine-parseable. It is the **canonical format** for all contractile types. + +== Format Policy + +**A2ML is the preferred and canonical format for contractile formulation.** + +[cols="1,3"] +|=== +| Format | Role + +| **A2ML** (`.a2ml`) +| Canonical. Source of truth. Human-readable declarations. Parsed by the `contractile` CLI. + +| **K9 Nickel** (`.k9.ncl`) +| Validation and execution layer. Type-checks A2ML semantics via Nickel contracts. Hunt-level components execute with Just recipes. **K9 does not replace A2ML — it validates and extends it.** + +| **TOML** (`mustfile.toml`) +| Legacy fallback. Backward compatibility with the Ada must runner. The CLI reads it but A2ML is preferred. + +| **YAML** (Mustfile, Dustfile, etc.) +| Deprecated. Retained only as compatibility shims. Will be removed. +|=== + +== Syntax Reference + +=== Comments + +Lines starting with `#` (but not `##`) are comments. The first comment often contains the SPDX header and file type identifier: + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Mustfile (A2ML Canonical) +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +---- + +=== Metadata Blocks + +Metadata blocks are delimited by `@name:` and `@end`: + +[source,a2ml] +---- +@abstract: +A human-readable summary of what this contractile file declares. +Can span multiple lines. +@end + +@requires: +- section: Parameters +- section: Checks +@end +---- + +Recognised blocks: + +[cols="1,3"] +|=== +| Block | Purpose + +| `@abstract:` +| Human-readable summary of the contractile's purpose + +| `@requires:` +| Lists mandatory sections (for completeness validation) +|=== + +Unknown `@block:` names are silently ignored for forward compatibility. + +=== Sections + +Sections are introduced by level-2 headings (`## Name`): + +[source,a2ml] +---- +## Parameters + +- gateway_port: 8080 +- schema_version: v1.0.0 + +## Checks + +### policy-config-valid +- description: config/policy.yaml must be valid +- run: just validate-policy +---- + +Sections contain: + +- **Direct entries** (`- key: value`) — simple key-value pairs +- **Subsections** (`### Name`) — named items with their own entries +- **Prose** — plain text lines (used in Intentfiles for simple intents) + +=== Subsections + +Subsections are introduced by level-3 headings (`### Name`) within a section: + +[source,a2ml] +---- +### policy-hash +- description: SHA-256 of policy matches expected value +- command: sha256sum -c policy/policy.ncl.sha256 +- algorithm: SHA-256 +- severity: critical +---- + +Each subsection represents one _item_ — a check, verification, recovery action, or intent. + +=== Entries + +Entries follow the format `- key: value`: + +[source,a2ml] +---- +- description: All source files must have SPDX headers +- run: find src/ -name '*.rs' -exec grep -L 'SPDX' {} + | wc -l | grep -q '^0$' +- severity: warning +---- + +Rules: + +- Split on the **first** colon only — values may contain colons (URLs, commands) +- Leading `- ` is required +- Keys and values are trimmed of whitespace +- Empty values are allowed (`- key:`) + +== Executable Fields + +Each contractile type uses specific keys to mark entries as executable: + +[cols="1,1,3"] +|=== +| Key | Contractile | Meaning + +| `run` +| Mustfile +| Shell command for a check (exit 0 = pass) + +| `command` +| Trustfile +| Shell command for a verification (exit 0 = verified) + +| `handler` +| Dustfile +| Shell command to replay/reverse a log or event stream + +| `rollback` +| Dustfile +| Shell command to revert a file/config to a previous version + +| `undo` +| Dustfile +| Shell command to compensate for a failed operation + +| `transform` +| Dustfile +| Shell command to convert data into reversible dust events +|=== + +The `contractile` CLI and `gen-just` recognise these keys automatically. + +== Common Fields + +These fields are used across multiple contractile types: + +[cols="1,3"] +|=== +| Field | Meaning + +| `description` +| Human-readable description of the item + +| `severity` +| `critical` (default) or `warning` + +| `notes` +| Additional context for operators + +| `status` +| Intentfile lifecycle: `declared`, `accepted`, `in-progress`, `realised`, `superseded`, `abandoned` + +| `priority` +| `critical`, `high`, `medium`, `low` + +| `evidence` +| Intentfile probe expression for realisation checking + +| `precondition` +| Dustfile command that must succeed before recovery + +| `verify_after` +| Dustfile command to run after recovery + +| `blast_radius` +| Dustfile scope: `file`, `service`, `cluster`, `global` + +| `algorithm` +| Trustfile cryptographic algorithm used + +| `target` +| Intentfile target date or milestone + +| `depends_on` +| Intentfile dependency on another intent +|=== + +== Minimal Templates + +=== Mustfile.a2ml + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Mustfile (A2ML Canonical) + +@abstract: +Physical State contract for my-project. +@end + +@requires: +- section: Checks +@end + +## Checks + +### license-present +- description: LICENSE file must exist +- run: test -f LICENSE +- severity: critical +---- + +=== Trustfile.a2ml + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Integrity verification for my-project. +@end + +@requires: +- section: Verifications +@end + +## Verifications + +### lock-file-hash +- description: Lock file unchanged +- command: sha256sum -c Cargo.lock.sha256 +- algorithm: SHA-256 +- severity: critical +---- + +=== Dustfile.a2ml + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery paths for my-project. +@end + +@requires: +- section: Source +@end + +## Source + +### source-rollback +- description: Revert source to last commit +- rollback: git checkout HEAD -- src/ +- blast_radius: file +---- + +=== Intentfile.a2ml + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Roadmap for my-project. +@end + +@requires: +- section: Features +@end + +## Features + +### initial-release +- description: Ship v1.0.0 +- status: in-progress +- priority: critical +- evidence: command: grep -q '1.0.0' Cargo.toml +---- + +== See Also + +- **Full specifications**: `mustfile/docs/must-spec.adoc`, `trustfile/docs/trust-spec.adoc`, `dustfile/docs/dust-spec.adoc`, `intentfile/docs/intent-spec.adoc` +- **A2ML language spec**: `standards/a2ml/docs/CONTRACTILES-A2ML-V1.adoc` (formal field requirements) +- **A2ML language overview**: `standards/a2ml/docs/STATE-OF-A2ML.adoc` +- **K9 Nickel components**: `contractiles/k9/README.adoc` +- **CLI reference**: `contractile --help` diff --git a/docs/CLI-REFERENCE.adoc b/docs/CLI-REFERENCE.adoc new file mode 100644 index 0000000..3b93544 --- /dev/null +++ b/docs/CLI-REFERENCE.adoc @@ -0,0 +1,513 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + += Contractile CLI Reference +:toc: +:sectnums: +:author: Jonathan D.A. Jewell + +== Overview + +The contractile system provides six CLI binaries built from a single Rust workspace. +Five correspond to contractile types; the sixth (`contractile`) is a unified entry +point that wraps all five plus orchestration commands. + +[cols="1,1,2,2"] +|=== +| CLI Binary | File | Directory | Purpose + +| `must` +| `Mustfile.a2ml` +| `contractiles/must/` +| Physical State — invariants that must hold (files, configs, format rules) + +| `trust` +| `Trustfile.a2ml` +| `contractiles/trust/` +| Provenance State — integrity verification (hashes, signatures, proofs) + +| `dust` +| `Dustfile.a2ml` +| `contractiles/dust/` +| Reversibility State — recovery actions (rollback, undo, replay) + +| `intend` +| `Intentfile.a2ml` +| `contractiles/lust/` +| Intent State — committed future work (roadmap, migrations, evidence probes) + +| `k9` +| `*.k9.ncl` +| `contractiles/k9/` +| Self-validating Nickel components (Kennel/Yard/Hunt security levels) + +| `contractile` +| _(all of the above)_ +| `contractiles/` +| Unified entry point + orchestration (gen-just, init, status, doctor) +|=== + +NOTE: The `intend` CLI reads from `contractiles/lust/`. The directory name `lust/` +is a legacy convention (from "future lust" — aspiration). The CLI tool and file +are named `intend` and `Intentfile.a2ml` respectively. Both `contractiles/lust/` +and `contractiles/intend/` are accepted. + +== Installation + +[source,bash] +---- +cd reposystem/contractiles +just build-cli # Build all binaries +just install-cli # Install to ~/.cargo/bin +---- + +Binaries: `must`, `trust`, `dust`, `intend`, `k9`, `contractile`. + +== must — Physical State + +Validates that declared invariants hold true. Checks are executable commands +declared in `Mustfile.a2ml` that must exit 0. + +[cols="2,3"] +|=== +| Command | Description + +| `must check` +| Run all must checks. Exit 0 if all pass, exit 1 on any failure. + +| `must check --verbose` +| Show each command as it runs. + +| `must check --dry-run` +| Print commands without executing. + +| `must check --json` +| Output results as JSON (for CI consumption). + +| `must fix` +| Auto-fix violations where a deterministic fix is declared. + +| `must enforce` +| Cycle: check → fix → verify. Ensures fixes actually resolve violations. + +| `must list` +| List all available checks with their names and descriptions. + +| `must run ` +| Run a single named check. +|=== + +=== Mustfile.a2ml Format + +[source] +---- +[metadata] +version = "1.0" +description = "Physical state checks for myproject" + +[check "spdx-headers"] +description = "All source files have SPDX headers" +command = "rg -L 'SPDX-License-Identifier' src/ --files-without-match | wc -l | grep -q '^0$'" +fix = "just add-spdx-headers" +severity = "error" + +[check "no-secrets"] +description = "No hardcoded secrets in source" +command = "! rg -i 'password|secret|api.key' src/ --quiet" +severity = "error" +---- + +=== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning +| 0 | All checks passed +| 1 | One or more checks failed +| 2 | Mustfile parse error or missing file +| 3 | Check command not found or not executable +|=== + +== trust — Provenance State + +Verifies integrity of artifacts through cryptographic hashes and signatures. + +[cols="2,3"] +|=== +| Command | Description + +| `trust verify` +| Run all trust verifications. Exit 0 if all pass. + +| `trust verify --verbose` +| Show each verification step. + +| `trust verify --json` +| Output results as JSON. + +| `trust list` +| List all verifications with their artifact paths. + +| `trust hash ` +| Compute and display SHA-256 hash for a file. + +| `trust sign ` +| Sign a file with Ed25519 key (creates `.sig`). + +| `trust audit` +| Show full provenance chain for all declared artifacts. +|=== + +=== Trustfile.a2ml Format + +[source] +---- +[metadata] +version = "1.0" +description = "Provenance verification for myproject" + +[verify "binary-integrity"] +description = "Release binary matches build hash" +artifact = "target/release/myproject" +algorithm = "sha256" +hash = "abc123..." + +[verify "license-signature"] +description = "LICENSE file is signed" +artifact = "LICENSE" +signature = "LICENSE.sig" +key = "keys/release.pub" +---- + +=== Supported Algorithms + +[cols="1,2"] +|=== +| Algorithm | Use Case +| SHA-256 | Standard hash verification (default) +| BLAKE3 | High-performance hash verification +| Ed25519 | Signature verification (current) +| Dilithium5 | Post-quantum signature verification (future) +| SPHINCS+ | Post-quantum signature verification (future) +|=== + +=== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning +| 0 | All verifications passed +| 1 | One or more verifications failed (hash mismatch or signature invalid) +| 2 | Trustfile parse error or missing file +| 3 | Missing artifact or verification tool +|=== + +== dust — Reversibility State + +Tracks recovery actions and provides rollback capability. Operates on a +compensating transaction model — each action declares its inverse. + +[cols="2,3"] +|=== +| Command | Description + +| `dust status` +| Show all cleanup/recovery items with current state. + +| `dust list` +| List all declared recovery actions. + +| `dust rollback ` +| Execute the inverse action for a named item. + +| `dust replay ` +| Re-execute a recovery action (idempotent). + +| `dust run ` +| Run a specific cleanup action. + +| `dust history` +| Show execution history with timestamps. +|=== + +=== Dustfile.a2ml Format + +[source] +---- +[metadata] +version = "1.0" +description = "Recovery actions for myproject" + +[recovery "database-migration"] +description = "Rollback database to previous schema" +forward = "just db-migrate" +inverse = "just db-rollback" +precondition = "pg_isready -q" +blast-radius = "database" +idempotent = true + +[recovery "config-change"] +description = "Restore previous configuration" +forward = "cp config.new.toml config.toml" +inverse = "cp config.backup.toml config.toml" +audit-preserve = true +---- + +=== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning +| 0 | Action completed successfully +| 1 | Action failed +| 2 | Dustfile parse error or missing file +| 3 | Precondition not met +| 4 | Inverse action not declared (irreversible) +|=== + +== intend — Intent State + +Tracks committed future work with evidence-based lifecycle transitions. +Each intent has an evidence probe — a command that tests whether the +intent has been realised. + +[cols="2,3"] +|=== +| Command | Description + +| `intend list` +| Show all intents with current status. + +| `intend check` +| Run evidence probes for all intents. Updates status if probes pass. + +| `intend progress` +| Show summary: declared/accepted/in-progress/realised/abandoned counts. + +| `intend accept ` +| Transition: declared → accepted (team acknowledges the intent). + +| `intend start ` +| Transition: accepted → in-progress (work has begun). + +| `intend realise ` +| Transition: in-progress → realised (evidence probe confirms completion). + +| `intend abandon ` +| Mark as abandoned with reason. + +| `intend supersede ` +| Mark as superseded by a new intent. +|=== + +=== Intentfile.a2ml Format + +[source] +---- +[metadata] +version = "1.0" +description = "Committed future work for myproject" + +[intent "migrate-to-rescript"] +description = "Convert all TypeScript to ReScript" +status = "in-progress" +evidence = "! find src/ -name '*.ts' -not -name '*.d.ts' | grep -q ." +deadline = "2026-06-01" +owner = "hyperpolymath" + +[intent "add-property-tests"] +description = "Property-based testing for all public APIs" +status = "declared" +evidence = "test -d tests/property && just test-property" +---- + +=== Lifecycle + +[source] +---- +declared → accepted → in-progress → realised + ↘ ↗ + abandoned / superseded +---- + +=== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning +| 0 | All probes passed / transition successful +| 1 | One or more probes failed +| 2 | Intentfile parse error or missing file +| 3 | Invalid transition (e.g., declared → realised without intermediate steps) +|=== + +== k9 — Self-Validating Components + +K9 components are Nickel files (`.k9.ncl`) that carry their own validation +contracts. Three security levels ("The Leash") control execution scope. + +[cols="2,3"] +|=== +| Command | Description + +| `k9 eval ` +| Evaluate a K9 component and print its output. + +| `k9 run ` +| Run a Hunt-level component (executes side effects). + +| `k9 typecheck ` +| Typecheck without executing. Safe for untrusted components. + +| `k9 info ` +| Show component metadata (name, level, description, inputs/outputs). +|=== + +=== Security Levels (The Leash) + +[cols="1,1,3"] +|=== +| Level | Risk | Capability + +| *Kennel* +| None +| Pure data declarations. No imports, no functions, no execution. + +| *Yard* +| Low +| Validated configuration. Type contracts enforced, functions allowed, no I/O. + +| *Hunt* +| Medium +| Full execution with side effects. Sandboxed. Requires explicit `k9 run`. +|=== + +=== K9 Validators for Contractiles + +Built-in Yard-level validators check A2ML file semantics: + +[source,bash] +---- +k9 eval contractiles/k9/validators/mustfile-validator.k9.ncl +k9 eval contractiles/k9/validators/trustfile-validator.k9.ncl +k9 eval contractiles/k9/validators/dustfile-validator.k9.ncl +k9 eval contractiles/k9/validators/intentfile-validator.k9.ncl +---- + +== contractile — Unified Entry Point + +Wraps all five CLIs plus orchestration commands. + +[cols="2,3"] +|=== +| Command | Description + +| `contractile must check` +| Equivalent to `must check`. + +| `contractile trust verify` +| Equivalent to `trust verify`. + +| `contractile dust status` +| Equivalent to `dust status`. + +| `contractile intend list` +| Equivalent to `intend list`. + +| `contractile k9 eval ` +| Equivalent to `k9 eval `. + +| `contractile gen-just` +| Generate `contractile.just` recipes from all A2ML + K9 sources. + +| `contractile init` +| Scaffold contractile directory structure in current repo. + +| `contractile status` +| Unified dashboard: must + trust + dust + intend status in one view. + +| `contractile doctor` +| Check that all required tools are available (just, rg, jq, nickel, etc.). + +| `contractile completions ` +| Generate shell completions (bash, zsh, fish, elvish, powershell). +|=== + +== File Layout in a Consumer Repo + +When a repo adopts contractiles, the standard layout is: + +[source] +---- +myrepo/ +├── Justfile # imports contractile.just +├── contractile.just # auto-generated by: contractile gen-just +└── .machine_readable/ + └── contractiles/ + ├── must/Mustfile.a2ml # Physical State + ├── trust/Trustfile.a2ml # Provenance State + ├── dust/Dustfile.a2ml # Reversibility State + ├── lust/Intentfile.a2ml # Intent State + └── k9/ # K9 components (optional) + └── project-metadata.k9.ncl +---- + +== Just Integration + +Running `contractile gen-just` produces `contractile.just` with recipes: + +[source] +---- +# Auto-generated by contractile gen-just — do not edit +must-check: must check +must-fix: must fix +must-enforce: must enforce +trust-verify: trust verify +trust-hash file: trust hash {{file}} +dust-status: dust status +dust-rollback name: dust rollback {{name}} +intend-list: intend list +intend-check: intend check +k9-eval file: k9 eval {{file}} +check: must check # alias +verify: trust verify # alias +audit: must check && trust verify +---- + +Import in your Justfile: + +[source] +---- +import "contractile.just" +---- + +== Cross-References + +[cols="1,3"] +|=== +| Document | What It Covers + +| `docs/A2ML-FORMAT.adoc` +| A2ML syntax reference (blocks, sections, entries, executable fields) + +| `docs/QUICKSTART.adoc` +| Getting started guide with examples and CI/CD integration + +| `mustfile/docs/must-spec.adoc` +| Full Must specification (659 lines, 5 golden examples) + +| `trustfile/docs/trust-spec.adoc` +| Full Trust specification (759 lines, 5 golden examples) + +| `dustfile/docs/dust-spec.adoc` +| Full Dust specification (592 lines, 5 golden examples) + +| `intentfile/docs/intent-spec.adoc` +| Full Intent specification (705 lines, 5 golden examples) + +| `contractiles/k9/README.adoc` +| K9 security model and component authoring guide + +| `standards/a2ml/docs/CONTRACTILES-A2ML-V1.adoc` +| Formal A2ML field requirements (external, in standards repo) +|=== diff --git a/dustfile/docs/dust-spec.adoc b/dustfile/docs/dust-spec.adoc new file mode 100644 index 0000000..36e1b19 --- /dev/null +++ b/dustfile/docs/dust-spec.adoc @@ -0,0 +1,592 @@ += Dust Specification +:author: Jonathan D.A. Jewell +:revnumber: 0.1.0 +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +toc::[] + +== Overview + +Dust is a **contract-driven recovery tool** that enforces **Reversibility State** — the declared ability to undo, roll back, or replay any operation that changes a project's physical or operational state. + +Where Must declares _what must be true_ and Trust declares _what to verify_, Dust declares _what to do when things go wrong_ — and provides the commands to execute those recoveries mechanically. + +The name "Dust" reflects the philosophy: every action produces dust (side effects, state changes, artifacts). A Dustfile declares how to sweep that dust away — returning to a known-good state. + +== Reversibility State: Definition + +**Reversibility State** is the complete set of declared recovery paths for a project: + +[cols="1,3"] +|=== +| Dimension | What It Captures + +| **Rollback Targets** +| Files and configurations that can be reverted to a previous version (e.g., `git checkout HEAD~1 -- file`) + +| **Undo Actions** +| Deployment and infrastructure operations that can be reversed (e.g., `kubectl rollout undo`) + +| **Replay Handlers** +| Log and event streams that can be replayed in reverse to undo decisions + +| **Transform Pipelines** +| Operations that convert operational logs into reversible dust events + +| **Recovery Preconditions** +| Conditions that must hold before a recovery action is safe to execute + +| **Blast Radius** +| The scope of impact for each recovery action (single file, service, cluster) +|=== + +=== Recovery Semantics + +Dust follows **compensating transaction** semantics: + +1. **Every forward action has a declared reverse**: No operation should be irreversible without explicit acknowledgement +2. **Recovery is selective**: You roll back _specific things_, not everything +3. **Recovery preserves audit trail**: Undo actions do not destroy logs; they create new log entries +4. **Recovery is idempotent**: Running a rollback twice produces the same state as running it once +5. **Recovery is verifiable**: After rollback, `must check` should pass for the rolled-back component + +== Relationship to Other Contractiles + +[cols="1,3"] +|=== +| Contractile | Dust's Relationship + +| **Must** +| Must declares _what must be true_. Dust declares _how to restore that truth when it breaks_. After `dust rollback`, `must check` should pass. + +| **Trust** +| Trust declares _how to verify integrity_. Dust can invoke `trust verify` after rollback to confirm the restored state is genuine. Dust actions SHOULD NOT bypass trust verification. + +| **Intend** +| Intend declares _what we plan to do_. If a planned change causes problems, Dust provides the path back. Dust does not remove intents — it undoes their _implementations_. + +| **K9** +| K9 Hunt-level components can automate dust operations. K9 Yard-level components can validate that Dustfile coverage is complete (every Must check has a corresponding Dust recovery). +|=== + +== Contract Format: A2ML + +=== Header + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery and rollback semantics for this project. +Declares how to undo every significant state change. +@end + +@requires: +- section: Logs +- section: Policy +- section: Gateway +- section: Dust-Events +@end +---- + +Sections are named by the _domain_ of recovery (Logs, Policy, Gateway, Database, etc.), not by action type. This keeps related recovery actions together. + +=== Recovery Entry Structure + +Each recovery action is a named subsection (`### name`) within a domain section. A recovery entry MUST have exactly one executable field from the following set: + +[cols="1,1,3"] +|=== +| Field | Type | Meaning + +| `handler` +| Replay +| A command that replays or reverses a log/event stream. Used for decision logs, event sourcing, audit trails. + +| `rollback` +| Revert +| A command that reverts a file, config, or data store to a previous known-good version. Typically git-based. + +| `undo` +| Compensate +| A command that compensates for a failed operation. Used for deployments, infrastructure changes, external service calls. + +| `transform` +| Convert +| A command that converts operational data into reversible dust events. A preparation step, not a recovery action itself. +|=== + +=== Recovery Entry Fields + +[cols="1,1,3"] +|=== +| Field | Required | Meaning + +| `handler` / `rollback` / `undo` / `transform` +| MUST (exactly one) +| The shell command to execute for recovery + +| `description` +| SHOULD +| Human-readable description of what this recovery does + +| `path` +| OPTIONAL +| The file or resource this recovery targets + +| `event` +| OPTIONAL +| The event type that triggers this recovery (e.g., `deploy.failure`) + +| `source` +| OPTIONAL +| The input data source for transforms + +| `notes` +| OPTIONAL +| Additional context for operators and auditors + +| `precondition` +| OPTIONAL +| A command that must succeed (exit 0) before the recovery is allowed to execute + +| `blast_radius` +| OPTIONAL +| `file`, `service`, `cluster`, `global` — scope of impact + +| `idempotent` +| OPTIONAL +| `true` (default) or `false` — whether repeated execution is safe + +| `preserves_audit` +| OPTIONAL +| `true` (default) or `false` — whether the action preserves audit logs + +| `verify_after` +| OPTIONAL +| A command to run after recovery to confirm success (e.g., `must check`) +|=== + +=== Complete Dustfile Example + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery and rollback semantics for a policy gateway. +Covers log replay, policy rollback, deployment undo, and event transforms. +@end + +@requires: +- section: Logs +- section: Policy +- section: Gateway +- section: Dust-Events +@end + +## Logs + +### gateway-decision-log +- path: logs/decisions.json +- description: Replay decision log in reverse to undo gateway decisions +- handler: dust-replay --reverse logs/decisions.json +- blast_radius: service +- idempotent: true +- preserves_audit: true +- verify_after: just validate-policy + +## Policy + +### policy-rollback +- path: policy/policy.ncl +- description: Revert policy to the previous known-good revision +- rollback: git checkout HEAD~1 -- policy/policy.ncl +- blast_radius: file +- precondition: git diff --quiet policy/policy.ncl || echo "Warning: uncommitted changes will be lost" +- verify_after: trust verify policy-hash + +### policy-config-rollback +- path: config/policy.yaml +- description: Revert policy config to previous revision +- rollback: git checkout HEAD~1 -- config/policy.yaml +- blast_radius: file + +## Gateway + +### bad-deployment +- event: deploy.failure +- description: Undo a failed gateway deployment, preserving audit logs +- undo: gatewayctl rollback --last +- blast_radius: service +- preserves_audit: true +- notes: Rollback to the previous deployment version; audit logs are retained + +### bad-container-deploy +- event: container.failure +- description: Roll back to the previous container image +- undo: podman rollback gateway:latest +- blast_radius: service +- precondition: podman image exists gateway:previous + +## Dust-Events + +### decision-log-to-dust +- source: logs/decisions.json +- description: Transform decision logs into reversible dust events +- transform: dustify --input logs/decisions.json --output logs/dust-events.json +- notes: Produces a dust-events.json file that can be fed back to dust-replay +---- + +== Golden Examples + +=== Example 1: Minimal Library + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery paths for a minimal Rust library. +@end + +@requires: +- section: Source +- section: Dependencies +@end + +## Source + +### source-rollback +- path: src/ +- description: Revert all source changes to last commit +- rollback: git checkout HEAD -- src/ +- blast_radius: file +- precondition: git stash + +## Dependencies + +### cargo-lock-rollback +- path: Cargo.lock +- description: Revert dependency changes +- rollback: git checkout HEAD~1 -- Cargo.lock && cargo update +- blast_radius: file +- verify_after: trust verify cargo-lock-hash +---- + +=== Example 2: Microservice with Database + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery paths for a microservice with database migrations. +@end + +@requires: +- section: Database +- section: Deployment +- section: Config +@end + +## Database + +### migration-rollback +- description: Roll back the last database migration +- rollback: diesel migration revert +- blast_radius: service +- precondition: diesel migration pending | wc -l | grep -q '^0$' +- verify_after: diesel migration list +- idempotent: false +- notes: Database rollbacks are NOT idempotent — only run once + +### seed-data-restore +- description: Restore seed data from backup +- undo: pg_restore --clean --dbname=myapp backups/latest.dump +- blast_radius: service +- precondition: test -f backups/latest.dump + +## Deployment + +### service-rollback +- event: deploy.failure +- description: Roll back to previous deployment +- undo: kubectl rollout undo deployment/myapp +- blast_radius: service +- preserves_audit: true + +### canary-abort +- event: canary.failure +- description: Abort canary deployment and route all traffic to stable +- undo: kubectl delete canary myapp-canary && kubectl scale deployment/myapp --replicas=3 +- blast_radius: service + +## Config + +### env-rollback +- path: config/production.env +- description: Revert production configuration +- rollback: git checkout HEAD~1 -- config/production.env +- blast_radius: file +---- + +=== Example 3: Monorepo with Per-Component Recovery + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Per-component recovery for a polyglot monorepo. +@end + +@requires: +- section: API +- section: CLI +- section: Web +@end + +## API + +### api-rollback +- description: Revert API to last known-good state +- rollback: cd api && git checkout HEAD~1 -- . && gleam build +- blast_radius: service +- verify_after: cd api && gleam test + +## CLI + +### cli-rollback +- description: Revert CLI to last known-good state +- rollback: cd cli && git checkout HEAD~1 -- . && cargo build +- blast_radius: service +- verify_after: cd cli && cargo test + +## Web + +### web-rollback +- description: Revert frontend to last known-good state +- rollback: cd web && git checkout HEAD~1 -- . && deno task build +- blast_radius: service +- verify_after: cd web && deno task test +---- + +=== Example 4: Ada Safety-Critical with Formal Verification + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Recovery paths for a safety-critical Ada system. +Rollbacks require re-proof of SPARK obligations. +@end + +@requires: +- section: Source +- section: Proofs +@end + +## Source + +### source-rollback +- path: src/ +- description: Revert source to previous audited version +- rollback: git checkout $(git log --oneline --grep='[AUDITED]' -1 --format='%H') -- src/ +- blast_radius: file +- verify_after: gnatprove -P flight.gpr --level=2 + +## Proofs + +### proof-rollback +- path: proofs/ +- description: Revert formal proofs to match source version +- rollback: git checkout HEAD~1 -- proofs/ +- blast_radius: file +- verify_after: lake build && lake exectest +- notes: Proof rollback must be paired with source rollback +---- + +=== Example 5: Infrastructure with Transparency Log + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Dustfile (A2ML Canonical) + +@abstract: +Infrastructure recovery with transparency log preservation. +@end + +@requires: +- section: DNS +- section: TLS +- section: Audit +@end + +## DNS + +### dns-rollback +- description: Revert DNS records to previous configuration +- rollback: terraform apply -target=module.dns -var-file=dns-previous.tfvars +- blast_radius: global +- precondition: terraform plan -target=module.dns -var-file=dns-previous.tfvars +- notes: DNS changes propagate globally — allow 24h for full resolution + +## TLS + +### cert-rollback +- description: Revert to previous TLS certificate +- rollback: cp certs/server.pem.bak certs/server.pem && systemctl reload nginx +- blast_radius: service +- verify_after: openssl x509 -in certs/server.pem -checkend 0 -noout + +## Audit + +### audit-log-archive +- description: Archive current audit logs before any recovery operation +- handler: tar czf backups/audit-$(date +%Y%m%d%H%M%S).tar.gz logs/ +- blast_radius: file +- notes: Always run this BEFORE any other dust action +---- + +== Command Reference + +=== Status and Listing + +[source,bash] +---- +dust status # List all available recovery actions with types +dust list # Alias for dust status +---- + +=== Recovery Commands + +[source,bash] +---- +dust rollback NAME # Execute a named rollback action +dust replay NAME # Execute a named handler (log replay) +dust run NAME # Execute any dust action by name, regardless of type +dust run NAME --dry-run # Preview the command without executing +dust run NAME --verbose # Show command output +---- + +=== Precondition Checking + +[source,bash] +---- +dust run NAME --check-precondition # Run precondition only, don't execute +---- + +=== Integration with Just + +[source,bash] +---- +contractile gen-just # Generates dust-status, dust- recipes +just dust-status # List recovery actions via Just +just dust-policy-rollback # Execute a specific rollback via Just +---- + +== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning + +| 0 +| Recovery action completed successfully + +| 1 +| General error (command failed to execute) + +| 2 +| Recovery action failed (the undo/rollback/handler command returned non-zero) + +| 3 +| Missing Dustfile.a2ml (no contract found) + +| 4 +| Invalid Dustfile.a2ml syntax (parse error) + +| 5 +| Precondition failed (recovery aborted because precondition returned non-zero) + +| 6 +| Verification after recovery failed (`verify_after` command returned non-zero) + +| 7 +| Named action not found in Dustfile +|=== + +== K9 Integration + +=== Yard-Level: Dustfile Coverage Validation + +A K9 Yard-level component can validate that every Must check has a corresponding Dust recovery path — ensuring that every invariant is recoverable. + +[source,nickel] +---- +# dust-coverage-validator.k9.ncl (Yard level) +{ + pedigree = { security = { leash = 'Yard } }, + validation = { + # Every must check should have a dust recovery + check_coverage = std.array.all + (fun must_check => + std.array.any + (fun dust_action => dust_action.path == must_check.path) + dustfile.actions) + mustfile.checks, + }, +} +---- + +=== Hunt-Level: Automated Recovery + +A K9 Hunt-level component can automate recovery workflows — running precondition checks, executing the recovery, verifying the result, and logging the action. + +[source,nickel] +---- +# auto-recover.k9.ncl (Hunt level — requires signature) +{ + pedigree = { security = { leash = 'Hunt, signature_required = true } }, + recipes = { + "full-recovery" = { + description = "Automated recovery: precondition → rollback → verify", + commands = [ + "dust run audit-log-archive", + "dust rollback policy-rollback", + "trust verify policy-hash", + "must check", + ], + }, + }, +} +---- + +== Best Practices + +1. **Every Must check needs a Dust recovery**: If `must check` can fail, `dust rollback` should fix it +2. **Always archive audit logs first**: Run `dust run audit-log-archive` before any recovery +3. **Use preconditions**: Prevent unsafe recoveries with `precondition:` checks +4. **Verify after rollback**: Use `verify_after:` to confirm recovery success +5. **Mark non-idempotent actions**: Database rollbacks and similar should set `idempotent: false` +6. **Document blast radius**: Operators need to know if a recovery affects one file or the entire cluster +7. **Test recoveries regularly**: A recovery path that has never been tested is not a recovery path +8. **Preserve audit trails**: Recovery actions should create new log entries, not destroy old ones +9. **Pair source and proof rollbacks**: In formally verified systems, rolling back source without rolling back proofs leaves the system in an inconsistent state +10. **CI integration**: Run `dust status` in pipelines to verify recovery paths are declared diff --git a/intentfile/docs/intent-spec.adoc b/intentfile/docs/intent-spec.adoc new file mode 100644 index 0000000..68e5d51 --- /dev/null +++ b/intentfile/docs/intent-spec.adoc @@ -0,0 +1,705 @@ += Intent Specification +:author: Jonathan D.A. Jewell +:revnumber: 0.1.0 +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +toc::[] + +== Overview + +Intend is a **contract-driven roadmap tool** that enforces **Intent State** — the declared future direction of a project, expressed as verifiable commitments that can be tracked from declaration through realisation. + +Where Must declares _what is true now_, Trust declares _what to verify_, and Dust declares _how to recover_, Intend declares _what we commit to making true_ — and provides the tools to track whether those commitments have been honoured. + +The Intentfile is the only contractile that is purely declarative by default — it has no executable commands. Its power comes from making intent _explicit, versioned, and auditable_, rather than leaving roadmap decisions in wiki pages, issue trackers, or people's heads. + +== Intent State: Definition + +**Intent State** is the complete set of declared future directions for a project: + +[cols="1,3"] +|=== +| Dimension | What It Captures + +| **Architectural Intent** +| Planned structural changes to the system (e.g., "migrate to post-quantum crypto") + +| **Feature Intent** +| Capabilities planned for future delivery (e.g., "add canary rollout support") + +| **Migration Intent** +| Technology transitions underway (e.g., "replace Node with Deno") + +| **Compliance Intent** +| Regulatory or policy requirements being worked toward (e.g., "achieve SOC2 Type II") + +| **Deprecation Intent** +| Components or patterns being phased out (e.g., "remove legacy YAML configs") + +| **Integration Intent** +| Planned connections to external systems (e.g., "integrate with GitBot-Fleet") +|=== + +=== Intent Lifecycle + +Every intent moves through a defined lifecycle: + +[source] +---- +declared → accepted → in-progress → realised → superseded + ↘ abandoned +---- + +[cols="1,3"] +|=== +| Status | Meaning + +| `declared` +| Intent has been written into the Intentfile but not yet reviewed or committed to + +| `accepted` +| Intent has been reviewed and the team commits to pursuing it + +| `in-progress` +| Active work is underway to realise this intent + +| `realised` +| The intent has been fully implemented and verified + +| `superseded` +| The intent has been replaced by a different approach + +| `abandoned` +| The intent has been explicitly dropped (with documented reason) +|=== + +=== Relationship to STATE.scm + +Intents are _forward-looking state_. STATE.scm captures _current state_ (what has been done). The relationship: + +- An `in-progress` intent corresponds to an active task in STATE.scm +- A `realised` intent corresponds to a completed milestone in STATE.scm +- An `abandoned` intent should be documented in STATE.scm's session history + +== Relationship to Other Contractiles + +[cols="1,3"] +|=== +| Contractile | Intend's Relationship + +| **Must** +| Must declares _what is true_. Intend declares _what we want to become true_. When an intent is realised, it typically results in new Must checks being added. + +| **Trust** +| Trust declares _what to verify_. Intend can track crypto-related intents (e.g., "migrate to post-quantum") whose realisation is verified by Trust (e.g., "Dilithium5 keys deployed"). + +| **Dust** +| Dust declares _how to recover_. If an intent's implementation causes problems, Dust provides the recovery path. Intend does not undo — it only tracks direction. + +| **K9** +| K9 Yard-level components can validate intent coverage. K9 Hunt-level components can implement intent realisation probes. +|=== + +== Contract Format: A2ML + +=== Header + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Declared future intent for this project. +Tracks architectural direction, planned features, and migration roadmap. +@end + +@requires: +- section: Architecture +- section: Features +- section: Migrations +@end +---- + +Sections are named by the _domain_ of intent (Architecture, Features, Migrations, Compliance, Deprecations, Integrations, etc.). The section names are free-form — use whatever categories make sense for the project. + +=== Intent Entry Structure + +There are two ways to express intents in A2ML: + +==== Simple Intents (Prose) + +For lightweight intent tracking, intents can be expressed as prose entries within a section: + +[source,a2ml] +---- +## Architecture + +- integrate hardware-backed key management +- support node attestation for deployments +- move to capability-based access control +---- + +Simple intents have no metadata beyond their text. They are useful for early-stage roadmapping where detail would be premature. + +==== Structured Intents (Subsections) + +For intents that need tracking metadata, use named subsections: + +[source,a2ml] +---- +## Architecture + +### hardware-key-management +- description: Integrate hardware-backed key management (TPM 2.0, YubiKey) +- status: in-progress +- priority: high +- target: 2026-Q3 +- owner: security-team +- evidence: keys/tpm-config.toml exists +- depends_on: post-quantum-migration +- notes: Blocked until Dilithium5 key support lands in TPM firmware + +### node-attestation +- description: Support node attestation for deployment targets +- status: declared +- priority: medium +- target: 2026-Q4 +- evidence: config/attestation.ncl exists +---- + +=== Intent Entry Fields + +[cols="1,1,3"] +|=== +| Field | Required | Meaning + +| `description` +| SHOULD +| Human-readable description of the intent + +| `status` +| OPTIONAL +| Lifecycle status: `declared` (default), `accepted`, `in-progress`, `realised`, `superseded`, `abandoned` + +| `priority` +| OPTIONAL +| `critical`, `high`, `medium`, `low` — relative importance + +| `target` +| OPTIONAL +| Target date or milestone (e.g., `2026-Q3`, `v2.0`, `before-release`) + +| `owner` +| OPTIONAL +| Person or team responsible for realising this intent + +| `evidence` +| OPTIONAL +| A probe expression used by `intend check` to detect realisation. Can be a file existence check, a command, or a pattern match. + +| `depends_on` +| OPTIONAL +| Name of another intent that must be realised first + +| `supersedes` +| OPTIONAL +| Name of a previous intent that this one replaces + +| `abandoned_reason` +| OPTIONAL +| Why this intent was dropped (required when status is `abandoned`) + +| `notes` +| OPTIONAL +| Additional context for humans and AI agents +|=== + +=== Evidence Probes + +The `evidence:` field declares how `intend check` can detect whether an intent has been realised. Evidence probes are the bridge between declarative intent and verifiable state. + +Probe types (detected by format): + +[cols="1,3"] +|=== +| Format | Meaning + +| `FILE exists` +| Check if a file exists (e.g., `keys/tpm-config.toml exists`) + +| `FILE contains PATTERN` +| Check if a file contains a string (e.g., `Cargo.toml contains dilithium`) + +| `command: COMMAND` +| Run a shell command; exit 0 = realised (e.g., `command: kyber-verify --version`) + +| `must: CHECK_NAME` +| Check if a specific Must check passes (e.g., `must: pq-keys-deployed`) + +| `trust: VERIFY_NAME` +| Check if a specific Trust verification passes (e.g., `trust: dilithium-signature`) +|=== + +== Golden Examples + +=== Example 1: Minimal Library Roadmap + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Roadmap for a Rust library. +@end + +@requires: +- section: Features +- section: Quality +@end + +## Features + +- add async support for all public APIs +- support no_std environments +- add WASM compilation target + +## Quality + +- achieve 90% test coverage +- add property-based testing with proptest +- set up continuous fuzzing +---- + +=== Example 2: Gateway with Structured Intents + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Declared future intent for a policy gateway system. +Tracks security, operations, and tooling direction. +@end + +@requires: +- section: Trust-Engine +- section: Control-Plane +- section: Pipeline +- section: Introspection +@end + +## Trust-Engine + +### hardware-key-management +- description: Integrate hardware-backed key management (TPM 2.0, YubiKey) +- status: in-progress +- priority: high +- target: 2026-Q3 +- owner: security-team +- evidence: config/tpm.toml exists +- notes: Waiting on TPM firmware update for Dilithium5 + +### node-attestation +- description: Support node attestation for deployment targets +- status: declared +- priority: medium +- target: 2026-Q4 +- evidence: command: attestation-agent --version +- depends_on: hardware-key-management + +## Control-Plane + +### gitops-config-promotion +- description: Move to GitOps-backed configuration promotion +- status: accepted +- priority: high +- target: 2026-Q2 +- evidence: .flux/ exists +- notes: Evaluating Flux vs ArgoCD + +### canary-rollouts +- description: Add canary and staged rollout support +- status: declared +- priority: medium +- evidence: config/canary.ncl exists + +## Pipeline + +### nickel-policy-language +- description: Adopt Nickel as the primary policy language +- status: in-progress +- priority: high +- evidence: policy/policy.ncl exists +- notes: Migration from YAML to Nickel underway + +### signed-artifact-promotion +- description: Automate policy promotion from dev to prod with signed artifacts +- status: declared +- priority: medium +- depends_on: hardware-key-management +- evidence: trust: artifact-signature + +## Introspection + +### decision-latency-metrics +- description: Expose decision latency and policy evaluation metrics +- status: declared +- priority: low +- evidence: command: curl -s localhost:9090/metrics | grep -q decision_latency + +### request-tracing +- description: Add tracing for end-to-end request decisions +- status: declared +- priority: low +- evidence: config/tracing.toml exists +---- + +=== Example 3: Migration-Focused Intentfile + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Technology migration roadmap for a polyglot project. +@end + +@requires: +- section: Migrations +- section: Deprecations +@end + +## Migrations + +### typescript-to-rescript +- description: Convert all TypeScript code to ReScript +- status: in-progress +- priority: critical +- owner: frontend-team +- target: 2026-Q2 +- evidence: command: find . -name '*.ts' -not -path '*/node_modules/*' | wc -l | grep -q '^0$' +- notes: 47 files remaining as of 2026-03-01 + +### node-to-deno +- description: Replace Node.js runtime with Deno +- status: in-progress +- priority: critical +- owner: platform-team +- target: 2026-Q3 +- evidence: deno.json exists +- depends_on: typescript-to-rescript + +### go-to-rust +- description: Rewrite Go services in Rust +- status: accepted +- priority: high +- target: 2026-Q4 +- evidence: command: find . -name '*.go' | wc -l | grep -q '^0$' + +### post-quantum-migration +- description: Migrate all cryptographic operations to post-quantum algorithms +- status: in-progress +- priority: critical +- target: 2026-Q3 +- evidence: trust: pq-keys-deployed +- notes: Dilithium5 for signatures, Kyber1024 for key exchange + +## Deprecations + +### yaml-configs +- description: Phase out YAML configuration in favour of Nickel +- status: in-progress +- priority: medium +- evidence: command: find . -name '*.yaml' -o -name '*.yml' | grep -v .github | wc -l | grep -q '^0$' +- notes: Keep .github/workflows/ YAML (GitHub requires it) + +### legacy-auth-middleware +- description: Remove legacy session-based auth (replaced by capability tokens) +- status: accepted +- priority: high +- abandoned_reason: +- evidence: command: grep -rn 'session_token' src/ | wc -l | grep -q '^0$' +---- + +=== Example 4: Compliance-Driven Intentfile + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Compliance and security intent for a regulated system. +@end + +@requires: +- section: Compliance +- section: Security +@end + +## Compliance + +### soc2-type-ii +- description: Achieve SOC2 Type II certification +- status: in-progress +- priority: critical +- target: 2026-Q4 +- owner: compliance-team +- evidence: docs/SOC2-REPORT.pdf exists + +### gdpr-data-mapping +- description: Complete data flow mapping for GDPR compliance +- status: accepted +- priority: high +- evidence: docs/DATA-FLOW-MAP.adoc exists + +## Security + +### sbom-generation +- description: Generate SBOM for every release +- status: in-progress +- priority: high +- evidence: sbom.json exists +- notes: Using SPDX format, generated by cargo-sbom + +### vuln-scanning +- description: Automated vulnerability scanning in CI +- status: realised +- evidence: command: grep -q 'trivy' .github/workflows/*.yml +- notes: Realised 2026-02-15 — Trivy integrated into quality.yml +---- + +=== Example 5: Ada Safety-Critical Roadmap + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Intentfile (A2ML Canonical) + +@abstract: +Certification and verification roadmap for a safety-critical Ada system. +@end + +@requires: +- section: Certification +- section: Verification +- section: Tooling +@end + +## Certification + +### do178c-level-a +- description: Achieve DO-178C Level A certification +- status: in-progress +- priority: critical +- target: 2026-Q4 +- evidence: docs/DO178C-COMPLIANCE.adoc exists + +### mcdc-coverage +- description: Achieve MC/DC structural coverage on all critical modules +- status: in-progress +- priority: critical +- evidence: command: gcov --summary src/ | grep -q 'MC/DC.*100%' + +## Verification + +### full-spark-proof +- description: Discharge all SPARK proof obligations at level 4 +- status: in-progress +- priority: critical +- evidence: command: gnatprove -P flight.gpr --level=4 --report=all 2>&1 | grep -q '0 unproved' + +### timing-analysis +- description: Complete WCET analysis for all critical paths +- status: declared +- priority: high +- depends_on: full-spark-proof + +## Tooling + +### lean4-integration +- description: Add LEAN4 formal proofs alongside SPARK +- status: declared +- priority: medium +- evidence: lakefile.lean exists +---- + +== Command Reference + +=== Display Commands + +[source,bash] +---- +intend list # Display all intents as a readable checklist +intend list --status in-progress # Filter by status +intend list --priority critical # Filter by priority +intend list --section Migrations # Filter by section +---- + +=== Verification Commands + +[source,bash] +---- +intend check # Run evidence probes for all intents +intend check NAME # Run evidence probe for a specific intent +intend check --verbose # Show probe commands and output +---- + +=== Progress Commands + +[source,bash] +---- +intend progress # Summary of intent realisation status +intend progress --json # Machine-readable progress report +---- + +=== Lifecycle Commands + +[source,bash] +---- +intend accept NAME # Move intent from declared → accepted +intend start NAME # Move intent from accepted → in-progress +intend realise NAME # Move intent from in-progress → realised +intend abandon NAME --reason "..." # Move intent to abandoned with reason +intend supersede OLD NEW # Mark OLD as superseded by NEW +---- + +NOTE: Lifecycle commands modify the Intentfile.a2ml in place. They are the only contractile commands that write to an A2ML file. + +=== Integration with Just + +[source,bash] +---- +contractile gen-just # Generates intend-list recipe +just intend-list # Display intents via Just +---- + +== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning + +| 0 +| Command completed successfully (for `intend check`: all probed intents are realised) + +| 1 +| General error (command failed to execute) + +| 2 +| Intent not realised (evidence probe returned non-zero) + +| 3 +| Missing Intentfile.a2ml (no contract found) + +| 4 +| Invalid Intentfile.a2ml syntax (parse error) + +| 5 +| Named intent not found + +| 6 +| Lifecycle transition invalid (e.g., moving from `abandoned` to `in-progress`) +|=== + +== K9 Integration + +=== Yard-Level: Intent Coverage Validation + +A K9 Yard-level component can validate that intents are well-formed — every `in-progress` intent has an owner and target, every intent has an evidence probe, etc. + +[source,nickel] +---- +# intent-validator.k9.ncl (Yard level) +{ + pedigree = { security = { leash = 'Yard } }, + validation = { + # In-progress intents must have an owner + check_owners = std.array.all + (fun i => i.status != "in-progress" || i.owner != null) + intentfile.intents, + + # All intents should have evidence probes + check_evidence = std.array.all + (fun i => i.evidence != null) + intentfile.intents, + + # Critical intents must have a target date + check_targets = std.array.all + (fun i => i.priority != "critical" || i.target != null) + intentfile.intents, + }, +} +---- + +=== Hunt-Level: Automated Intent Checking + +A K9 Hunt-level component can automate intent checking as part of a CI/CD pipeline. + +[source,nickel] +---- +# intent-ci.k9.ncl (Hunt level — requires signature) +{ + pedigree = { security = { leash = 'Hunt, signature_required = true } }, + recipes = { + "check-all-intents" = { + description = "Run all intent evidence probes and report", + commands = [ + "intend check --verbose", + "intend progress --json > intent-report.json", + ], + }, + "update-state" = { + description = "Sync realised intents to STATE.scm", + commands = [ + "intend progress --json | intent-to-state > .machine_readable/STATE.scm", + ], + }, + }, +} +---- + +== Design Philosophy + +=== Why Intents Are Not Issues + +Issues track _work_ — they have assignees, due dates, comments, and close when done. Intents track _direction_ — they express _where the project is going_, not _what tasks need doing_. An intent like "migrate to post-quantum crypto" may spawn dozens of issues, but the intent itself is a single declaration. + +=== Why Intents Live in the Repo + +Putting intents in the repository (not a wiki, not an issue tracker) means: + +1. **They are versioned**: You can see when an intent was declared, modified, or realised +2. **They are reviewed**: PRs that change intents go through code review +3. **They are colocated**: The intent to "add WASM support" lives next to the code that implements it +4. **They are machine-readable**: CI/CD pipelines can check intent realisation automatically +5. **They are portable**: Forking a repo includes its intents; they survive platform migration + +=== Why Evidence Probes Exist + +Without evidence probes, intent checking is a manual process: someone reads the Intentfile, reads the code, and decides whether each intent has been realised. Evidence probes automate this by declaring _what to look for_ — a file that exists, a command that succeeds, a Must check that passes. + +Evidence probes turn intents from aspirational prose into verifiable commitments. + +== Best Practices + +1. **Start simple**: Use prose intents (`- add WASM support`) until they need tracking metadata +2. **Add evidence probes early**: Even a rough probe is better than none +3. **Review intents quarterly**: Remove `realised` intents after they've been stable for a quarter +4. **Document abandonment**: Always set `abandoned_reason` when dropping an intent +5. **Link intents to Must**: When an intent is realised, add corresponding Must checks +6. **Use `depends_on` sparingly**: Only for true blocking dependencies, not preferences +7. **Keep sections domain-focused**: "Migrations", "Security", "Features" — not "Sprint 17" +8. **CI integration**: Run `intend check` in pipelines to track realisation automatically +9. **One Intentfile per project**: Don't split intents across multiple files +10. **Intents are not tasks**: Keep them strategic, not tactical diff --git a/keys/signing.pub b/keys/signing.pub new file mode 100644 index 0000000..0105476 --- /dev/null +++ b/keys/signing.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAGtpDheQ3ghbOY93kdpvCevl3WVog9DEji1A9bYszihE= +-----END PUBLIC KEY----- diff --git a/mustfile/CHANGELOG.adoc b/mustfile/CHANGELOG.adoc new file mode 100644 index 0000000..773a027 --- /dev/null +++ b/mustfile/CHANGELOG.adoc @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0-or-later += Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +== [Unreleased] + +=== Added +- `must deploy` command for container-based deployment +- Containerfile for self-contained build environment +- INSTALL.adoc with comprehensive installation guide +- docs/must-spec.adoc with formal specification and golden examples +- Physical State contract definition +- `[requirements.content]` for file content verification +- `[deploy]` configuration section + +=== Changed +- Updated README with Quick Start and Physical State documentation +- Enhanced CLI help with deploy options + +== [0.1.0] - 2025-12-27 + +=== Added +- Initial release of Must - task runner + template engine + enforcer +- Core commands: init, list, check, fix, enforce, apply, templates +- TOML-based configuration (mustfile.toml) +- Mustache template engine for code generation +- Requirements enforcement (must_have, must_not_have) +- Task dependency resolution with topological sort +- Circular dependency detection +- Ada 2022 implementation with GNAT project file +- Templates for Ada packages and Elixir modules +- Justfile with 40+ recipes +- GitHub Actions workflows (CodeQL, Scorecard, quality checks) +- GitLab CI/CD pipeline + +=== Security +- SHA-pinned all GitHub Actions for supply chain security +- No MD5/SHA1 for security purposes +- HTTPS-only enforcement +- Secret scanning with TruffleHog + +[Unreleased]: https://github.com/hyperpolymath/mustfile/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/hyperpolymath/mustfile/releases/tag/v0.1.0 diff --git a/mustfile/Containerfile b/mustfile/Containerfile new file mode 100644 index 0000000..d9481ec --- /dev/null +++ b/mustfile/Containerfile @@ -0,0 +1,60 @@ +# Containerfile for Must - task runner + template engine + enforcer +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell +# +# Build: podman build -t must:latest -f Containerfile . +# Run: podman run --rm -it must:latest --help +# Shell: podman run --rm -it --entrypoint /bin/bash must:latest + +# Stage 1: Build environment with GNAT Ada compiler +FROM docker.io/library/debian:bookworm-slim AS builder + +# Install GNAT Ada compiler and build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gnat \ + gprbuild \ + make \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /build + +# Copy source files +COPY must.gpr . +COPY src/ src/ +COPY templates/ templates/ + +# Build release binary +RUN gprbuild -P must.gpr -XMODE=release -j0 + +# Verify binary works +RUN ./bin/must --version && ./bin/must --help + +# Stage 2: Minimal runtime image +FROM docker.io/library/debian:bookworm-slim AS runtime + +# Install minimal runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN useradd -m -s /bin/bash must + +# Copy binary from builder +COPY --from=builder /build/bin/must /usr/local/bin/must + +# Copy templates (needed for template operations) +COPY --from=builder /build/templates /opt/must/templates + +# Set ownership +RUN chown -R must:must /opt/must + +# Switch to non-root user +USER must +WORKDIR /home/must + +# Default entrypoint +ENTRYPOINT ["/usr/local/bin/must"] +CMD ["--help"] diff --git a/mustfile/INSTALL.adoc b/mustfile/INSTALL.adoc new file mode 100644 index 0000000..902bf29 --- /dev/null +++ b/mustfile/INSTALL.adoc @@ -0,0 +1,269 @@ += Must Installation Guide +:author: Jonathan D.A. Jewell +:revnumber: 0.1.0-Alpha +:toc: macro +:toclevels: 2 + +toc::[] + +== Quick Start (Container) + +The fastest way to try Must is via container: + +[source,bash] +---- +# Build the container image +podman build -t must:latest -f Containerfile . + +# Run must --help +podman run --rm must:latest --help + +# Run must on a project (mount current directory) +podman run --rm -v "$(pwd):/project:Z" -w /project must:latest check +---- + +== Native Installation + +=== Prerequisites + +Must requires the GNAT Ada 2022 compiler: + +==== Debian/Ubuntu + +[source,bash] +---- +sudo apt-get update +sudo apt-get install -y gnat gprbuild +---- + +==== Fedora + +[source,bash] +---- +sudo dnf install -y gcc-gnat gprbuild +---- + +==== Arch Linux + +[source,bash] +---- +sudo pacman -S gcc-ada gprbuild +---- + +==== macOS (Homebrew) + +[source,bash] +---- +brew install gnat gprbuild +---- + +==== Guix (Recommended) + +[source,bash] +---- +guix install gnat gprbuild +---- + +==== Nix + +[source,bash] +---- +nix-shell -p gnat gprbuild +---- + +=== Build from Source + +Clone the repository and build: + +[source,bash] +---- +git clone https://gitlab.com/hyperpolymath/mustfile.git +cd mustfile + +# Build debug version +gprbuild -P must.gpr -XMODE=debug + +# Or build release version (optimized) +gprbuild -P must.gpr -XMODE=release + +# Verify it works +./bin/must --version +./bin/must --help +---- + +=== Install System-Wide + +[source,bash] +---- +# Build release and install +gprbuild -P must.gpr -XMODE=release +sudo cp bin/must /usr/local/bin/ + +# Verify installation +must --version +---- + +== Usage Examples + +=== Initialize a New Project + +[source,bash] +---- +mkdir my-project && cd my-project +must init +---- + +This creates a default `mustfile.toml` configuration. + +=== List Available Tasks + +[source,bash] +---- +must list +---- + +=== Run a Task + +[source,bash] +---- +must build # Run the 'build' task +must test # Run the 'test' task +must build --dry-run # Preview without executing +---- + +=== Check Requirements + +[source,bash] +---- +must check # Check project requirements +must check --strict # Fail on warnings +must check --verbose # Show detailed output +---- + +=== Enforce Requirements (Check + Fix + Verify) + +[source,bash] +---- +must enforce # Full enforcement cycle +must enforce --dry-run # Preview what would be fixed +---- + +=== Deploy (Container-based) + +Must uses Podman for deployment: + +[source,bash] +---- +# Build the must binary in a container +podman build -t must:latest -f Containerfile . + +# Run must in a container against your project +podman run --rm -v "$(pwd):/project:Z" -w /project must:latest enforce +---- + +== CI/CD Integration + +=== GitLab CI + +[source,yaml] +---- +stages: + - build + - check + +build: + stage: build + image: registry.gitlab.com/hyperpolymath/must:latest + script: + - must --version + +check: + stage: check + image: registry.gitlab.com/hyperpolymath/must:latest + script: + - must check --strict +---- + +=== GitHub Actions + +[source,yaml] +---- +jobs: + must-check: + runs-on: ubuntu-latest + container: + image: ghcr.io/hyperpolymath/must:latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - run: must check --strict +---- + +== Troubleshooting + +=== GNAT Not Found + +[source,bash] +---- +# Check if GNAT is installed +gnat --version +gprbuild --version + +# If not found, install via your package manager (see Prerequisites) +---- + +=== Build Fails with Missing Dependencies + +Ensure you have the complete GNAT toolchain: + +[source,bash] +---- +# Debian/Ubuntu +sudo apt-get install -y gnat gprbuild + +# The project has no external Ada library dependencies +---- + +=== Permission Denied on Install + +[source,bash] +---- +# Use sudo for system-wide installation +sudo cp bin/must /usr/local/bin/ + +# Or install to user directory +mkdir -p ~/.local/bin +cp bin/must ~/.local/bin/ +export PATH="$HOME/.local/bin:$PATH" +---- + +== Verify Installation + +After installation, verify must works: + +[source,bash] +---- +# Show version +must --version +# Expected: must 0.1.0-alpha + +# Show help +must --help + +# Initialize and check a test project +cd /tmp +mkdir must-test && cd must-test +must init +must list +must check +---- + +== Uninstall + +[source,bash] +---- +# Remove system-wide installation +sudo rm /usr/local/bin/must + +# Or remove user installation +rm ~/.local/bin/must +---- diff --git a/mustfile/Justfile b/mustfile/Justfile new file mode 100644 index 0000000..757ae78 --- /dev/null +++ b/mustfile/Justfile @@ -0,0 +1,473 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# mustfile - Mustfile Task Runner for RSR Projects +# https://just.systems/man/en/ +# +# IMPORTANT: This file MUST be named "Justfile" (capital J) for RSR compliance. +# Mustfile files MUST also be named "Mustfile" (capital M). +# +# Run `just` to see all available recipes +# Run `just cookbook` to generate docs/just-cookbook.adoc +# Run `just combinations` to see matrix recipe options + +set shell := ["bash", "-uc"] +set dotenv-load := true +set positional-arguments := true + +# Project metadata +project := "must" +version := "0.1.0" +tier := "infrastructure" # 1 | 2 | infrastructure + +# ═══════════════════════════════════════════════════════════════════════════════ +# DEFAULT & HELP +# ═══════════════════════════════════════════════════════════════════════════════ + +# Show all available recipes with descriptions +default: + @just --list --unsorted + +# Show detailed help for a specific recipe +help recipe="": + #!/usr/bin/env bash + if [ -z "{{recipe}}" ]; then + just --list --unsorted + echo "" + echo "Usage: just help " + echo " just cookbook # Generate full documentation" + echo " just combinations # Show matrix recipes" + else + just --show "{{recipe}}" 2>/dev/null || echo "Recipe '{{recipe}}' not found" + fi + +# Show this project's info +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "RSR Tier: {{tier}}" + @echo "Recipes: $(just --summary | wc -w)" + @[ -f STATE.scm ] && grep -oP '\(phase\s+\.\s+\K[^)]+' STATE.scm | head -1 | xargs -I{} echo "Phase: {}" || true + +# ═══════════════════════════════════════════════════════════════════════════════ +# BUILD & COMPILE +# ═══════════════════════════════════════════════════════════════════════════════ + +# Build the project (debug mode) +build *args: + @echo "Building {{project}} (debug)..." + gprbuild -P must.gpr -XMODE=debug {{args}} + +# Build in release mode with optimizations +build-release *args: + @echo "Building {{project}} (release)..." + gprbuild -P must.gpr -XMODE=release {{args}} + +# Build and watch for changes (requires entr) +build-watch: + @echo "Watching for changes..." + find src -name '*.ad[sb]' | entr -c just build + +# Clean build artifacts [reversible: rebuild with `just build`] +clean: + @echo "Cleaning..." + gnatclean -P must.gpr || true + rm -rf obj/ bin/ + +# Deep clean including caches [reversible: rebuild] +clean-all: clean + rm -rf .cache .tmp + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST & QUALITY +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run all tests +test *args: build + @echo "Running tests..." + bin/must --version + bin/must --help + @echo "Tests passed!" + +# Run tests with verbose output +test-verbose: build + @echo "Running tests (verbose)..." + bin/must --version + bin/must --list || echo "No mustfile in current dir (expected)" + +# Verify the tool works +test-smoke: build + @echo "Smoke test..." + bin/must init || true + bin/must --list + bin/must check || true + rm -f mustfile.toml + +# ═══════════════════════════════════════════════════════════════════════════════ +# LINT & FORMAT +# ═══════════════════════════════════════════════════════════════════════════════ + +# Format all source files [reversible: git checkout] +fmt: + @echo "Formatting Ada source files..." + @if command -v gnatpp > /dev/null 2>&1; then \ + find src -name "*.adb" -o -name "*.ads" | xargs -I{} gnatpp -rnb --max-line-length=120 {} 2>/dev/null || true; \ + echo "Formatting complete"; \ + else \ + echo "gnatpp not found - install GNAT Studio or libadalang-tools for formatting"; \ + fi + +# Check formatting without changes +fmt-check: + @echo "Checking Ada formatting..." + @if command -v gnatpp > /dev/null 2>&1; then \ + DIFF=$$(find src -name "*.adb" -o -name "*.ads" | while read f; do \ + gnatpp -rnb --max-line-length=120 --pipe "$$f" 2>/dev/null | diff -q "$$f" - 2>/dev/null || echo "$$f"; \ + done); \ + if [ -n "$$DIFF" ]; then echo "Files need formatting:"; echo "$$DIFF"; exit 1; fi; \ + echo "All files properly formatted"; \ + else \ + echo "gnatpp not found - skipping format check"; \ + fi + +# Run linter +lint: + @echo "Linting Ada source files..." + @echo "Compiling with strict warnings (acts as linter)..." + gprbuild -P must.gpr -XMODE=debug -gnatwe -gnatwa -gnatyM120 -q || exit 1 + @echo "Lint passed - no warnings" + +# Run all quality checks +quality: fmt-check lint test + @echo "All quality checks passed!" + +# Fix all auto-fixable issues [reversible: git checkout] +fix: fmt + @echo "Fixed all auto-fixable issues" + +# ═══════════════════════════════════════════════════════════════════════════════ +# RUN & EXECUTE +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run the application +run *args: build + bin/must {{args}} + +# Run with verbose output +run-verbose *args: build + bin/must --verbose {{args}} + +# Install to /usr/local/bin +install: build-release + @echo "Installing must to /usr/local/bin..." + sudo cp bin/must /usr/local/bin/ + @echo "Installed: $(which must)" + +# ═══════════════════════════════════════════════════════════════════════════════ +# DEPENDENCIES +# ═══════════════════════════════════════════════════════════════════════════════ + +# Install all dependencies +deps: + @echo "Checking Ada/GNAT dependencies..." + @command -v gnat > /dev/null 2>&1 || { echo "ERROR: gnat not found - install GNAT"; exit 1; } + @command -v gprbuild > /dev/null 2>&1 || { echo "ERROR: gprbuild not found - install gprbuild"; exit 1; } + @echo "GNAT: $(gnat --version | head -1)" + @echo "gprbuild: $(gprbuild --version | head -1)" + @echo "All dependencies satisfied (Ada projects have no external runtime dependencies)" + +# Audit dependencies for vulnerabilities +deps-audit: + @echo "Auditing for vulnerabilities..." + @echo "Ada/GNAT security checks:" + @echo " - No external package dependencies (self-contained)" + @echo " - GNAT compiler version: $(gnat --version | head -1)" + @echo "" + @echo "Running supply chain checks..." + @if command -v trivy > /dev/null 2>&1; then \ + trivy fs --severity HIGH,CRITICAL --quiet . || true; \ + else \ + echo " trivy not installed - skipping container/filesystem scan"; \ + fi + @if command -v gitleaks > /dev/null 2>&1; then \ + gitleaks detect --source . --no-git --quiet || true; \ + else \ + echo " gitleaks not installed - skipping secret scan"; \ + fi + @echo "Audit complete" + +# ═══════════════════════════════════════════════════════════════════════════════ +# DOCUMENTATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Generate all documentation +docs: + @mkdir -p docs/generated docs/man + just cookbook + just man + @echo "Documentation generated in docs/" + +# Generate justfile cookbook documentation +cookbook: + #!/usr/bin/env bash + mkdir -p docs + OUTPUT="docs/just-cookbook.adoc" + echo "= {{project}} Justfile Cookbook" > "$OUTPUT" + echo ":toc: left" >> "$OUTPUT" + echo ":toclevels: 3" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "Generated: $(date -Iseconds)" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "== Recipes" >> "$OUTPUT" + echo "" >> "$OUTPUT" + just --list --unsorted | while read -r line; do + if [[ "$line" =~ ^[[:space:]]+([a-z_-]+) ]]; then + recipe="${BASH_REMATCH[1]}" + echo "=== $recipe" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "[source,bash]" >> "$OUTPUT" + echo "----" >> "$OUTPUT" + echo "just $recipe" >> "$OUTPUT" + echo "----" >> "$OUTPUT" + echo "" >> "$OUTPUT" + fi + done + echo "Generated: $OUTPUT" + +# Generate man page +man: + #!/usr/bin/env bash + mkdir -p docs/man + cat > docs/man/{{project}}.1 << EOF +.TH RSR-TEMPLATE-REPO 1 "$(date +%Y-%m-%d)" "{{version}}" "RSR Template Manual" +.SH NAME +{{project}} \- RSR standard repository template +.SH SYNOPSIS +.B just +[recipe] [args...] +.SH DESCRIPTION +Canonical template for RSR (Rhodium Standard Repository) projects. +.SH AUTHOR +Hyperpolymath +EOF + echo "Generated: docs/man/{{project}}.1" + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONTAINERS (nerdctl-first, podman-fallback) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Detect container runtime: nerdctl > podman > docker +[private] +container-cmd: + #!/usr/bin/env bash + if command -v nerdctl >/dev/null 2>&1; then + echo "nerdctl" + elif command -v podman >/dev/null 2>&1; then + echo "podman" + elif command -v docker >/dev/null 2>&1; then + echo "docker" + else + echo "ERROR: No container runtime found (install nerdctl, podman, or docker)" >&2 + exit 1 + fi + +# Build container image +container-build tag="latest": + #!/usr/bin/env bash + CTR=$(just container-cmd) + if [ -f Containerfile ]; then + echo "Building with $CTR..." + $CTR build -t {{project}}:{{tag}} -f Containerfile . + else + echo "No Containerfile found" + fi + +# Run container +container-run tag="latest" *args: + #!/usr/bin/env bash + CTR=$(just container-cmd) + $CTR run --rm -it {{project}}:{{tag}} {{args}} + +# Push container image +container-push registry="ghcr.io/hyperpolymath" tag="latest": + #!/usr/bin/env bash + CTR=$(just container-cmd) + $CTR tag {{project}}:{{tag}} {{registry}}/{{project}}:{{tag}} + $CTR push {{registry}}/{{project}}:{{tag}} + +# ═══════════════════════════════════════════════════════════════════════════════ +# CI & AUTOMATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run full CI pipeline locally +ci: deps quality + @echo "CI pipeline complete!" + +# Install git hooks +install-hooks: + @mkdir -p .git/hooks + @cat > .git/hooks/pre-commit << 'EOF' +#!/bin/bash +just fmt-check || exit 1 +just lint || exit 1 +EOF + @chmod +x .git/hooks/pre-commit + @echo "Git hooks installed" + +# ═══════════════════════════════════════════════════════════════════════════════ +# SECURITY +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run security audit +security: deps-audit + @echo "=== Security Audit ===" + @command -v gitleaks >/dev/null && gitleaks detect --source . --verbose || true + @command -v trivy >/dev/null && trivy fs --severity HIGH,CRITICAL . || true + @echo "Security audit complete" + +# Generate SBOM +sbom: + @mkdir -p docs/security + @command -v syft >/dev/null && syft . -o spdx-json > docs/security/sbom.spdx.json || echo "syft not found" + +# ═══════════════════════════════════════════════════════════════════════════════ +# VALIDATION & COMPLIANCE +# ═══════════════════════════════════════════════════════════════════════════════ + +# Validate RSR compliance +validate-rsr: + #!/usr/bin/env bash + echo "=== RSR Compliance Check ===" + MISSING="" + for f in .editorconfig .gitignore Justfile RSR_COMPLIANCE.adoc README.adoc; do + [ -f "$f" ] || MISSING="$MISSING $f" + done + for d in .well-known; do + [ -d "$d" ] || MISSING="$MISSING $d/" + done + for f in .well-known/security.txt .well-known/ai.txt .well-known/humans.txt; do + [ -f "$f" ] || MISSING="$MISSING $f" + done + if [ ! -f "guix.scm" ] && [ ! -f ".guix-channel" ] && [ ! -f "flake.nix" ]; then + MISSING="$MISSING guix.scm/flake.nix" + fi + if [ -n "$MISSING" ]; then + echo "MISSING:$MISSING" + exit 1 + fi + echo "RSR compliance: PASS" + +# Validate STATE.scm syntax +validate-state: + @if [ -f "STATE.scm" ]; then \ + guile -c "(primitive-load \"STATE.scm\")" 2>/dev/null && echo "STATE.scm: valid" || echo "STATE.scm: INVALID"; \ + else \ + echo "No STATE.scm found"; \ + fi + +# Full validation suite +validate: validate-rsr validate-state + @echo "All validations passed!" + +# ═══════════════════════════════════════════════════════════════════════════════ +# STATE MANAGEMENT +# ═══════════════════════════════════════════════════════════════════════════════ + +# Update STATE.scm timestamp +state-touch: + @if [ -f "STATE.scm" ]; then \ + sed -i 's/(updated . "[^"]*")/(updated . "'"$(date -Iseconds)"'")/' STATE.scm && \ + echo "STATE.scm timestamp updated"; \ + fi + +# Show current phase from STATE.scm +state-phase: + @grep -oP '\(phase\s+\.\s+\K[^)]+' STATE.scm 2>/dev/null | head -1 || echo "unknown" + +# ═══════════════════════════════════════════════════════════════════════════════ +# GUIX & NIX +# ═══════════════════════════════════════════════════════════════════════════════ + +# Enter Guix development shell (primary) +guix-shell: + guix shell -D -f guix.scm + +# Build with Guix +guix-build: + guix build -f guix.scm + +# Enter Nix development shell (fallback) +nix-shell: + @if [ -f "flake.nix" ]; then nix develop; else echo "No flake.nix"; fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# HYBRID AUTOMATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run local automation tasks +automate task="all": + #!/usr/bin/env bash + case "{{task}}" in + all) just fmt && just lint && just test && just docs && just state-touch ;; + cleanup) just clean && find . -name "*.orig" -delete && find . -name "*~" -delete ;; + update) just deps && just validate ;; + *) echo "Unknown: {{task}}. Use: all, cleanup, update" && exit 1 ;; + esac + +# ═══════════════════════════════════════════════════════════════════════════════ +# COMBINATORIC MATRIX RECIPES +# ═══════════════════════════════════════════════════════════════════════════════ + +# Build matrix: [debug|release] × [target] × [features] +build-matrix mode="debug" target="" features="": + @echo "Build matrix: mode={{mode}} target={{target}} features={{features}}" + # Customize for your build system + +# Test matrix: [unit|integration|e2e|all] × [verbosity] × [parallel] +test-matrix suite="unit" verbosity="normal" parallel="true": + @echo "Test matrix: suite={{suite}} verbosity={{verbosity}} parallel={{parallel}}" + +# Container matrix: [build|run|push|shell|scan] × [registry] × [tag] +container-matrix action="build" registry="ghcr.io/hyperpolymath" tag="latest": + @echo "Container matrix: action={{action}} registry={{registry}} tag={{tag}}" + +# CI matrix: [lint|test|build|security|all] × [quick|full] +ci-matrix stage="all" depth="quick": + @echo "CI matrix: stage={{stage}} depth={{depth}}" + +# Show all matrix combinations +combinations: + @echo "=== Combinatoric Matrix Recipes ===" + @echo "" + @echo "Build Matrix: just build-matrix [debug|release] [target] [features]" + @echo "Test Matrix: just test-matrix [unit|integration|e2e|all] [verbosity] [parallel]" + @echo "Container: just container-matrix [build|run|push|shell|scan] [registry] [tag]" + @echo "CI Matrix: just ci-matrix [lint|test|build|security|all] [quick|full]" + @echo "" + @echo "Total combinations: ~10 billion" + +# ═══════════════════════════════════════════════════════════════════════════════ +# VERSION CONTROL +# ═══════════════════════════════════════════════════════════════════════════════ + +# Show git status +status: + @git status --short + +# Show recent commits +log count="20": + @git log --oneline -{{count}} + +# ═══════════════════════════════════════════════════════════════════════════════ +# UTILITIES +# ═══════════════════════════════════════════════════════════════════════════════ + +# Count lines of code +loc: + @find . \( -name "*.rs" -o -name "*.ex" -o -name "*.res" -o -name "*.ncl" -o -name "*.scm" \) 2>/dev/null | xargs wc -l 2>/dev/null | tail -1 || echo "0" + +# Show TODO comments +todos: + @grep -rn "TODO\|FIXME" --include="*.rs" --include="*.ex" --include="*.res" . 2>/dev/null || echo "No TODOs" + +# Open in editor +edit: + ${EDITOR:-code} . diff --git a/mustfile/Mustfile b/mustfile/Mustfile new file mode 100644 index 0000000..bd54bd5 --- /dev/null +++ b/mustfile/Mustfile @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# Mustfile - hyperpolymath mandatory checks +# See: https://github.com/hyperpolymath/mustfile + +version: 1 + +checks: + - name: security + run: just lint + - name: tests + run: just test + - name: format + run: just fmt + diff --git a/mustfile/README.adoc b/mustfile/README.adoc new file mode 100644 index 0000000..6dafe7b --- /dev/null +++ b/mustfile/README.adoc @@ -0,0 +1,183 @@ += Mustfile Format Specification +image:https://img.shields.io/badge/License-MPL_2.0-blue.svg[MPL-2.0-or-later,link="https://opensource.org/licenses/MPL-2.0"] + +[NOTE] +==== +**THIS IS THE SPECIFICATION** for the Mustfile format. + +For the **implementation** (the `must` binary that executes Mustfiles), see https://github.com/hyperpolymath/must[hyperpolymath/must]. + +Think of it like: **Mustfile:must :: Justfile:just** +==== + +== License & Philosophy + +This project must declare **MPL-2.0-or-later** for platform/tooling compatibility. + +Philosophy: **Palimpsest**. The Palimpsest-MPL (PMPL) text is provided in `license/PMPL-1.0.txt`, and the canonical source is the palimpsest-license repository. + + + + +:author: Hyperpolymath +:revnumber: 0.2.0-beta +:toc: macro +:toclevels: 3 +:icons: font + +toc::[] + +--- + +== Status: Pre-1.0 (Alpha) + +This repository defines the **Mustfile format specification** (v0.1.0-alpha). It represents a **Contract of Physical State** — the verifiable, observable condition of project artifacts. The v1.0.0 release will freeze the specification. + +See link:docs/must-spec.adoc[must-spec.adoc] for the complete specification with golden examples. + +=== Specification vs Implementation + +[cols="1,3,3",options="header"] +|=== +|Component |This Repo (mustfile) |Sister Repo (must) + +|**Purpose** +|Defines the Mustfile format +|Implements the execution engine + +|**Contains** +|Specification documents, golden examples, test cases +|Ada 2022 source code, CLI binary, runtime logic + +|**Relation** +|WHAT a Mustfile is +|HOW to execute a Mustfile + +|**Analogy** +|Justfile format +|`just` command +|=== + +To **use** Mustfiles, install the `must` binary from https://github.com/hyperpolymath/must[hyperpolymath/must]. + +IMPORTANT: This project strictly forbids the use of `Makefiles`. All local tasks are orchestrated via `just`, and all deployment transitions are managed via `must`. + +=== What is Physical State? + +**Physical State** is the complete set of observable facts: + +* **Files Present**: Which files must exist (`must_have`) +* **Files Absent**: Which files must NOT exist (`must_not_have`) +* **Content**: Required strings in specific files (`requirements.content`) +* **Artifacts**: Compiled binaries, container images + +Every `must` operation enforces this contract. + +== Design Philosophy +The deployment architecture is environment-agnostic and prioritises **Just Route** logic. Every operation is treated as a state transition where resources are consumed to produce outputs, ensuring no orphaned state or unvalidated side effects. + +* **Podman First**: Containerisation is the primary deployment vector. +* **Universal Shell Support**: 17 shell variants (bash, oil, nushell, etc.) are supported via referenced scripts. +* **Configuration**: Handled via `nickel` for validated, type-safe manifests. + +== Quick Start + +See link:INSTALL.adoc[INSTALL.adoc] for detailed installation instructions. + +=== Installing the `must` Binary + +To execute Mustfiles, you need the `must` binary. See the https://github.com/hyperpolymath/must[must repository] for installation instructions. + +Quick install options: + +[source,bash] +---- +# Container (fastest) +podman pull ghcr.io/hyperpolymath/must:latest + +# Native build (from must repo) +git clone https://github.com/hyperpolymath/must.git +cd must +gprbuild -P must.gpr -XMODE=release +sudo cp bin/must /usr/local/bin/ +---- + +== Usage + +=== Using Mustfiles + +Once you have the `must` binary installed (from https://github.com/hyperpolymath/must[hyperpolymath/must]), you can use it with Mustfiles: + +[source,bash] +---- +# Initialize a new Mustfile in your project +must init + +# Validate your Mustfile +must validate + +# List available deployment tasks +must list + +# Execute a specific deployment +must apply + +# Preview without executing +must apply --dry-run +---- + +For examples and golden test cases, see link:docs/must-spec.adoc[must-spec.adoc]. + +== Shell Support +This project provides native wrappers for a comprehensive list of shells to ensure compatibility across Linux, Minix, macOS, iOS, Android, and PC (ASIC/Edge-specific environments included). + +Only the **bash** entry point is shown here. All other 16 shell implementations (cmd, oil, ash, csh, dash, elvish, fish, ion, ksh, murex, ngs, nushell, powershell-core, tcsh, tsh, zsh, and minix shell) are located in the link:scripts/shells/[scripts/shells/] directory. + +.scripts/entrypoint.sh (Bash) +[source,bash] +---- +#!/usr/bin/env bash +set -euo pipefail + +# Ensure just is present +if ! command -v just &> /dev/null; then + echo "Error: 'just' not found. Please install via https://just.systems" + exit 1 +fi + +# Route to just recipe +just "$@" +---- + +== Package Management +Supported package managers and deployment routes are managed through `nicagu` (Nickel-Augmented) manifests: + +| OS / Area | Tooling | +| :--- | :--- | +| **Fedora/Silverblue** | `rpm-ostree`, `dnf` | +| **Debian/Ubuntu** | `apt`, `nala` | +| **macOS** | `brew`, `macports` | +| **Windows** | `winget`, `scoop` | +| **Arch** | `pacman` | +| **Cross-platform** | `asdf`, `cargo`, `pwa` | + +== Documentation +* link:INSTALL.adoc[**Installation Guide**]: Build and install instructions for all platforms. +* link:cookbook.adoc[**Cookbook**]: Detailed recipes for all `just` commands. +* link:docs/must-spec.adoc[**must-spec**]: Technical specification for the Mustfile transitions. +* link:docs/man/[**Man Pages**]: CLI help and man documents for all custom binaries. + +== Release & Publishing +The v1.0.0 release will publish both **GitHub Releases** artifacts and a **GHCR** container image. + +Targets: + +* GitHub Releases: tarball + checksums +* GHCR: `ghcr.io/hyperpolymath/mustfile:` + +Example: +[source,bash] +---- +# Build and publish GHCR image +must deploy --tag v1.0.0 --push +---- diff --git a/mustfile/ROADMAP.adoc b/mustfile/ROADMAP.adoc new file mode 100644 index 0000000..6a51d19 --- /dev/null +++ b/mustfile/ROADMAP.adoc @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0-or-later += Mustfile Format Specification Roadmap + +[NOTE] +==== +This roadmap tracks the **specification** development. + +For the **implementation** roadmap (the `must` binary), see https://github.com/hyperpolymath/must/ROADMAP.adoc[must/ROADMAP.adoc]. +==== + +== Current Status + +Specification v0.1.0-alpha published. Working toward v1.0.0 frozen spec. + +== Milestones + +=== v0.1.0-alpha - Foundation (Current) +* [x] Initial must-spec format definition +* [x] Golden examples and test cases +* [x] Basic documentation +* [x] Physical state contract definition + +=== v0.2.0 - Specification Enhancement +* [ ] Comprehensive golden test suite +* [ ] Edge case documentation +* [ ] Platform-specific considerations +* [ ] Reference implementation alignment + +=== v1.0.0 - Frozen Specification +* [ ] Complete specification freeze +* [ ] Full golden test coverage +* [ ] Implementation conformance test suite +* [ ] Production-ready documentation +* [ ] Alignment with must v1.0.0 + +== Future Directions + +_To be determined based on community feedback._ diff --git a/mustfile/cookbook.adoc b/mustfile/cookbook.adoc new file mode 100644 index 0000000..a18761d --- /dev/null +++ b/mustfile/cookbook.adoc @@ -0,0 +1,461 @@ += Must Justfile Cookbook +:toc: left +:toclevels: 3 +:icons: font + +This cookbook documents all available `just` recipes for the Must project. + +== Quick Reference + +[source,bash] +---- +just # Show all recipes +just help # Show help for specific recipe +just info # Show project info +---- + +== Build & Compile + +=== build + +Build the project in debug mode with full checks. + +[source,bash] +---- +just build +---- + +Compiles with: `-g -gnata -gnatwa -gnatwe -gnaty -gnatyM120 -gnat2022 -fstack-check` + +=== build-release + +Build optimized release binary. + +[source,bash] +---- +just build-release +---- + +Compiles with: `-O3 -gnatp -gnatn -gnat2022` + +=== build-watch + +Watch for changes and rebuild automatically (requires `entr`). + +[source,bash] +---- +just build-watch +---- + +=== clean + +Remove build artifacts. + +[source,bash] +---- +just clean +---- + +=== clean-all + +Deep clean including caches. + +[source,bash] +---- +just clean-all +---- + +== Test & Quality + +=== test + +Run all tests. + +[source,bash] +---- +just test +---- + +=== test-verbose + +Run tests with verbose output. + +[source,bash] +---- +just test-verbose +---- + +=== test-smoke + +Quick smoke test of core functionality. + +[source,bash] +---- +just test-smoke +---- + +=== quality + +Run all quality checks (format, lint, test). + +[source,bash] +---- +just quality +---- + +== Format & Lint + +=== fmt + +Format Ada source files using gnatpp. + +[source,bash] +---- +just fmt +---- + +=== fmt-check + +Check formatting without making changes. + +[source,bash] +---- +just fmt-check +---- + +=== lint + +Run linter checks. + +[source,bash] +---- +just lint +---- + +=== fix + +Auto-fix all fixable issues. + +[source,bash] +---- +just fix +---- + +== Run & Execute + +=== run + +Run the application with optional arguments. + +[source,bash] +---- +just run +just run --help +just run check --verbose +---- + +=== run-verbose + +Run with verbose output enabled. + +[source,bash] +---- +just run-verbose check +---- + +=== install + +Install to `/usr/local/bin`. + +[source,bash] +---- +just install +---- + +== Dependencies + +=== deps + +Install all dependencies. + +[source,bash] +---- +just deps +---- + +=== deps-audit + +Audit dependencies for vulnerabilities. + +[source,bash] +---- +just deps-audit +---- + +== Documentation + +=== docs + +Generate all documentation. + +[source,bash] +---- +just docs +---- + +=== cookbook + +Regenerate this cookbook from Justfile. + +[source,bash] +---- +just cookbook +---- + +=== man + +Generate man pages. + +[source,bash] +---- +just man +---- + +== Containers + +=== container-build + +Build container image. + +[source,bash] +---- +just container-build +just container-build v1.0 # with tag +---- + +=== container-run + +Run container. + +[source,bash] +---- +just container-run +just container-run latest --help +---- + +=== container-push + +Push container to registry. + +[source,bash] +---- +just container-push ghcr.io/hyperpolymath latest +---- + +== CI & Automation + +=== ci + +Run full CI pipeline locally. + +[source,bash] +---- +just ci +---- + +=== install-hooks + +Install git pre-commit hooks. + +[source,bash] +---- +just install-hooks +---- + +== Security + +=== security + +Run security audit. + +[source,bash] +---- +just security +---- + +=== sbom + +Generate Software Bill of Materials. + +[source,bash] +---- +just sbom +---- + +== Validation + +=== validate + +Run full validation suite. + +[source,bash] +---- +just validate +---- + +=== validate-rsr + +Validate RSR compliance. + +[source,bash] +---- +just validate-rsr +---- + +=== validate-state + +Validate STATE.scm syntax. + +[source,bash] +---- +just validate-state +---- + +== State Management + +=== state-touch + +Update STATE.scm timestamp. + +[source,bash] +---- +just state-touch +---- + +=== state-phase + +Show current phase from STATE.scm. + +[source,bash] +---- +just state-phase +---- + +== Guix & Nix + +=== guix-shell + +Enter Guix development shell. + +[source,bash] +---- +just guix-shell +---- + +=== guix-build + +Build with Guix. + +[source,bash] +---- +just guix-build +---- + +=== nix-shell + +Enter Nix development shell (fallback). + +[source,bash] +---- +just nix-shell +---- + +== Utilities + +=== status + +Show git status. + +[source,bash] +---- +just status +---- + +=== log + +Show recent commits. + +[source,bash] +---- +just log +just log 10 # last 10 commits +---- + +=== loc + +Count lines of code. + +[source,bash] +---- +just loc +---- + +=== todos + +Show TODO comments in code. + +[source,bash] +---- +just todos +---- + +=== edit + +Open project in editor. + +[source,bash] +---- +just edit +---- + +== Matrix Recipes + +These recipes support combinatoric options: + +=== build-matrix + +[source,bash] +---- +just build-matrix debug # mode +just build-matrix release x86_64 # mode + target +---- + +=== test-matrix + +[source,bash] +---- +just test-matrix unit normal true # suite, verbosity, parallel +---- + +=== container-matrix + +[source,bash] +---- +just container-matrix build ghcr.io/hyperpolymath latest +---- + +=== ci-matrix + +[source,bash] +---- +just ci-matrix all full +---- + +=== combinations + +Show all matrix combinations. + +[source,bash] +---- +just combinations +---- diff --git a/mustfile/docs/ROADMAP.adoc b/mustfile/docs/ROADMAP.adoc new file mode 100644 index 0000000..0223f3d --- /dev/null +++ b/mustfile/docs/ROADMAP.adoc @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MPL-2.0-or-later += Mustfile Specification Technical Roadmap +:toc: +:sectnums: + +[NOTE] +==== +This is the **detailed technical roadmap** for the Mustfile format specification. + +* For the high-level specification roadmap, see link:../ROADMAP.adoc[ROADMAP.adoc] +* For the implementation roadmap, see https://github.com/hyperpolymath/must/ROADMAP.adoc[must/ROADMAP.adoc] +==== + +== Overview + +This roadmap tracks the evolution of the **Mustfile format specification** (not the implementation). The Mustfile specification defines the declarative format for physical state contracts in deployment orchestration. + +**Relationship:** `Mustfile:must :: Justfile:just` + +== Current Status (v0.1.0-alpha) + +[cols="1,2,1"] +|=== +|Component |Description |Status + +|Core Specification +|must-spec format definition +|80% + +|Physical State Contract +|File presence/absence/content requirements +|100% + +|Golden Examples +|Reference test cases +|60% + +|Documentation +|Specification document, examples +|70% + +|Implementation Alignment +|Coordination with must binary (Ada) +|50% +|=== + +== Specification Components + +=== Core Format (v0.1.0-alpha - Current) + +* [x] `must_have` directive - files that must exist +* [x] `must_not_have` directive - files that must not exist +* [x] Basic task structure +* [x] Template variable syntax (Mustache-compatible) +* [ ] Content requirement checks (partial) +* [ ] File permission specifications + +=== Extended Format (v0.2.0) + +* [ ] Nested task dependencies +* [ ] Conditional requirements +* [ ] Platform-specific sections +* [ ] Variable scope definitions +* [ ] Import/include mechanism for modular Mustfiles + +=== Advanced Features (v0.3.0) + +* [ ] State transition validation +* [ ] Rollback specifications +* [ ] Partial state application +* [ ] Lock file format for reproducibility + +== Documentation Roadmap + +=== v0.1.0-alpha (Current) +* [x] Basic must-spec.adoc +* [x] Simple golden examples +* [ ] Complete format reference +* [ ] Migration guide from Makefiles + +=== v0.2.0 +* [ ] Comprehensive format specification +* [ ] Complete golden test suite +* [ ] Platform-specific guidelines +* [ ] Integration patterns (nickel, just, etc.) + +=== v1.0.0 +* [ ] Formal grammar definition +* [ ] Conformance test suite +* [ ] Best practices guide +* [ ] Security considerations document + +== Golden Test Suite + +=== Current Coverage +* Basic file presence checks +* Simple task execution +* Template variable expansion + +=== Planned Coverage +* [ ] Content requirement validation +* [ ] Permission checks +* [ ] Error handling edge cases +* [ ] Circular dependency detection +* [ ] Invalid Mustfile rejection + +== Integration Points + +=== must Binary (Implementation) +* **Status:** In active coordination +* **Repository:** https://github.com/hyperpolymath/must[hyperpolymath/must] +* **Language:** Ada 2022 +* **Sync Plan:** Bi-weekly specification/implementation alignment + +=== nickel Configuration +* **Status:** Planned +* **Purpose:** Type-safe Mustfile generation +* **Use Case:** Complex deployment manifests + +=== _pathroot Integration +* **Status:** Planned (nicaug engine) +* **Purpose:** Cross-platform substrate management +* **Use Case:** Multi-environment orchestration + +=== just Task Runner +* **Status:** Sibling tool +* **Relationship:** just handles development tasks, must handles deployment state + +== Versioning Strategy + +=== Pre-1.0 (Current Phase) +* Breaking changes allowed between minor versions +* Active iteration on format +* Implementation can lag specification + +=== Post-1.0 +* Semantic versioning strictly followed +* Breaking changes only in major versions +* Specification frozen, extensions via opt-in features +* Implementation must maintain backward compatibility + +== Timeline + +[cols="1,2,2"] +|=== +|Version |Target Date |Key Deliverables + +|v0.1.0-alpha +|2025-12-27 (Released) +|Initial specification, basic golden tests + +|v0.2.0 +|2026-Q2 +|Extended format, comprehensive docs, test suite + +|v0.3.0 +|2026-Q3 +|Advanced features, platform guidelines + +|v1.0.0 +|2026-Q4 +|Frozen specification, conformance suite, production docs +|=== + +== Community & Governance + +=== Specification Process +1. **Proposal:** RFC-style proposals for format changes +2. **Discussion:** Community feedback period (2 weeks minimum) +3. **Implementation:** Prototype in must binary +4. **Acceptance:** Golden test cases added, specification updated + +=== Breaking Changes +* Pre-1.0: Announced in release notes +* Post-1.0: Major version bump, migration guide required + +== References + +* https://github.com/hyperpolymath/must[must Implementation Repository] +* https://github.com/hyperpolymath/mustfile[Mustfile Specification Repository] +* https://github.com/hyperpolymath/_pathroot[_pathroot Integration Point] +* https://just.systems[just Task Runner] (analogous tool) diff --git a/mustfile/docs/case-studies/flatracoon-netstack.adoc b/mustfile/docs/case-studies/flatracoon-netstack.adoc new file mode 100644 index 0000000..1634951 --- /dev/null +++ b/mustfile/docs/case-studies/flatracoon-netstack.adoc @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: MPL-2.0-or-later +// SPDX-FileCopyrightText: 2025 Hyperpolymath += Case Study: FlatRacoon Network Stack +:author: Hyperpolymath +:toc: left +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +== Overview + +This case study demonstrates how **Mustfile** is used in practice to orchestrate a complex, multi-module infrastructure stack. The link:https://github.com/hyperpolymath/flatracoon-netstack[FlatRacoon Network Stack] is a real-world example of declarative, type-safe deployment orchestration using Must + Just + Nickel. + +== Project Summary + +**FlatRacoon Network Stack** integrates 8 independent infrastructure modules: + +[cols="1,2,1"] +|=== +| Module | Purpose | Layer + +| twingate-helm-deploy | Secure zero-trust access | Access +| zerotier-k8s-link | Encrypted overlay mesh | Overlay +| ipfs-overlay | Distributed storage | Storage +| ipv6-site-enforcer | IPv6-only networking | Network +| hesiod-dns-map | Service discovery | Naming +| bgp-backbone-lab | BGP routing simulation | Network +| flatracoon-os | Microkernel OS prototype | Platform +| network-dashboard | Phoenix LiveView monitoring | Observability +|=== + +Plus 3 MCP (Model Context Protocol) integrations for tooling. + +== Why Mustfile? + +The FlatRacoon stack needed: + +1. **Declarative deployment order** - Modules have dependencies +2. **Type-safe configuration** - Complex nested settings +3. **Environment-specific overrides** - Dev/staging/production +4. **Machine-readable manifests** - For orchestrator consumption +5. **Separation of concerns** - Just for tasks, Must for state + +=== The Separation: Just vs Must + +[cols="1,1,1"] +|=== +| Aspect | Just (Justfile) | Must (Mustfile) + +| Purpose | Run tasks | Define state +| Nature | Imperative | Declarative +| Focus | "How to do it" | "What must be true" +| Example | `deploy-access` recipe | Module dependencies +| Runtime | Immediate execution | State validation +|=== + +== The Mustfile + +Here's the actual Mustfile from FlatRacoon: + +[source,nickel] +---- +# SPDX-License-Identifier: MPL-2.0-or-later +# SPDX-FileCopyrightText: 2025 Hyperpolymath +# +# FlatRacoon Network Stack - Mustfile +# Type-safe, contract-driven deployment orchestration +# Like Justfile for Just, Mustfile for Must (powered by Nickel) + +let MustSpec = { + version = "1.0.0", + name = "flatracoon-netstack", + description = "Modular, declarative network stack orchestration", +} +in + +let Environments = { + development = { + kubernetes_context = "minikube", + twingate_network = "dev-network", + zerotier_network_id = "dev-zt-network", + ipfs_bootstrap = [], + ipv6_prefix = "fd00:flatracoon:dev::/48", + }, + + staging = { + kubernetes_context = "staging-cluster", + twingate_network = "staging-network", + zerotier_network_id = "staging-zt-network", + ipfs_bootstrap = ["ipfs-bootstrap-staging.internal"], + ipv6_prefix = "fd00:flatracoon:staging::/48", + }, + + production = { + kubernetes_context = "production-cluster", + twingate_network = "prod-network", + zerotier_network_id = "prod-zt-network", + ipfs_bootstrap = [ + "ipfs-bootstrap-1.flatracoon.net", + "ipfs-bootstrap-2.flatracoon.net", + ], + ipv6_prefix = "2001:db8:flatracoon::/48", + }, +} +in + +let Modules = { + twingate = { + name = "twingate-helm-deploy", + layer = "access", + path = "modules/twingate-helm-deploy", + deploy_command = "just deploy", + health_endpoint = "/health", + requires = ["kubernetes"], + }, + + zerotier = { + name = "zerotier-k8s-link", + layer = "overlay", + path = "modules/zerotier-k8s-link", + deploy_command = "just deploy", + health_endpoint = "/zerotier/health", + requires = ["kubernetes"], + }, + + ipfs = { + name = "ipfs-overlay", + layer = "storage", + path = "modules/ipfs-overlay", + deploy_command = "just deploy", + health_endpoint = "/ipfs/health", + requires = ["kubernetes", "zerotier"], # Depends on ZeroTier! + }, + + # ... more modules ... +} +in + +let DeploymentOrder = [ + "twingate", + "zerotier", + "ipfs", + "ipv6", + "hesiod", + "dashboard", +] +in + +let HealthChecks = { + timeout_seconds = 30, + retry_count = 3, + retry_delay_seconds = 5, +} +in + +{ + spec = MustSpec, + environments = Environments, + modules = Modules, + deployment_order = DeploymentOrder, + health_checks = HealthChecks, +} +---- + +== Key Patterns + +=== 1. Module Definition + +Each module declares its properties and dependencies: + +[source,nickel] +---- +ipfs = { + name = "ipfs-overlay", + layer = "storage", + path = "modules/ipfs-overlay", + deploy_command = "just deploy", + health_endpoint = "/ipfs/health", + requires = ["kubernetes", "zerotier"], # <-- Dependencies +} +---- + +The `requires` field enables the orchestrator to: +- Validate deployment order +- Check prerequisites before deployment +- Handle dependency failures + +=== 2. Environment Overrides + +Different environments have different configurations: + +[source,nickel] +---- +development = { + kubernetes_context = "minikube", + ipfs_bootstrap = [], # No bootstrap in dev + ipv6_prefix = "fd00:flatracoon:dev::/48", # Private prefix +} + +production = { + kubernetes_context = "production-cluster", + ipfs_bootstrap = [ + "ipfs-bootstrap-1.flatracoon.net", + "ipfs-bootstrap-2.flatracoon.net", + ], + ipv6_prefix = "2001:db8:flatracoon::/48", # Real prefix +} +---- + +=== 3. Deployment Order + +Explicit ordering ensures correct dependency resolution: + +[source,nickel] +---- +let DeploymentOrder = [ + "twingate", # 1. Access first + "zerotier", # 2. Then overlay + "ipfs", # 3. Storage (needs zerotier) + "ipv6", # 4. Network enforcement + "hesiod", # 5. Service discovery + "dashboard", # 6. Monitoring last +] +---- + +=== 4. Health Check Configuration + +Centralized health check settings: + +[source,nickel] +---- +let HealthChecks = { + timeout_seconds = 30, + retry_count = 3, + retry_delay_seconds = 5, +} +---- + +== Integration with Just + +The Justfile consumes the Mustfile for deployment: + +[source,just] +---- +# Validate all Nickel configurations including Mustfile +config-validate: + @echo "Validating Nickel configurations..." + nickel export Mustfile > /dev/null + nickel export configs/base.ncl > /dev/null + nickel export configs/modules.ncl > /dev/null + nickel export configs/environments.ncl > /dev/null + @echo "✓ All configurations valid" + +# Export Mustfile to JSON +must-export: + nickel export Mustfile + +# Show deployment order from Mustfile +must-order: + @nickel export Mustfile | jq -r '.deployment_order[]' + +# Show module info from Mustfile +must-modules: + @nickel export Mustfile | jq '.modules' + +# Show environment config +must-env env="development": + @nickel export configs/environments.ncl | jq '.environments.{{env}}' + +# Deploy entire stack (reads order from Mustfile concept) +deploy: config-validate + @echo "Deploying FlatRacoon Network Stack..." + just deploy-access + just deploy-overlay + just deploy-storage + just deploy-network + just deploy-naming + just deploy-observability + @echo "✓ Stack deployed" +---- + +== Usage Examples + +=== Validate Configuration + +[source,bash] +---- +$ just config-validate +Validating Nickel configurations... +✓ All configurations valid +---- + +=== View Deployment Order + +[source,bash] +---- +$ just must-order +twingate +zerotier +ipfs +ipv6 +hesiod +dashboard +---- + +=== Export Full Mustfile + +[source,bash] +---- +$ just must-export | jq '.spec' +{ + "version": "1.0.0", + "name": "flatracoon-netstack", + "description": "Modular, declarative network stack orchestration" +} +---- + +=== Get Module Dependencies + +[source,bash] +---- +$ just must-export | jq '.modules.ipfs.requires' +[ + "kubernetes", + "zerotier" +] +---- + +=== Environment-Specific Config + +[source,bash] +---- +$ just must-env production +{ + "kubernetes_context": "production-cluster", + "twingate_network": "prod-network", + "zerotier_network_id": "prod-zt-network", + "ipfs_bootstrap": [ + "ipfs-bootstrap-1.flatracoon.net", + "ipfs-bootstrap-2.flatracoon.net" + ], + "ipv6_prefix": "2001:db8:flatracoon::/48" +} +---- + +== File Structure + +The FlatRacoon project uses this structure: + +[source] +---- +flatracoon-netstack/ +├── Justfile # Task runner (imperative) +├── Mustfile # State definition (declarative) +├── configs/ +│ ├── base.ncl # Type contracts +│ ├── modules.ncl # Module registry (imports Mustfile) +│ └── environments.ncl # Environment configs +├── modules/ # Git submodules (11 total) +│ ├── twingate-helm-deploy/ +│ ├── zerotier-k8s-link/ +│ ├── ipfs-overlay/ +│ ├── ipv6-site-enforcer/ +│ ├── hesiod-dns-map/ +│ ├── bgp-backbone-lab/ +│ ├── flatracoon-os/ +│ ├── network-dashboard/ +│ ├── poly-k8s-mcp/ +│ ├── poly-secret-mcp/ +│ └── poly-observability-mcp/ +├── STATE.scm # Project state +├── META.scm # Architecture decisions +├── ECOSYSTEM.scm # Ecosystem relationships +├── PLAYBOOK.scm # Operational runbooks +├── AGENTIC.scm # AI agent guidance +└── NEUROSYM.scm # Neurosymbolic reasoning +---- + +== Benefits Observed + +=== Type Safety + +Nickel catches configuration errors at validation time: + +[source,bash] +---- +$ nickel export Mustfile +error: missing field `requires` in record + ┌─ Mustfile:45:3 + │ +45 │ zerotier = { + │ ^^^^^^^^ this record is missing the `requires` field +---- + +=== Documentation as Code + +The Mustfile is self-documenting: + +[source,nickel] +---- +let MustSpec = { + version = "1.0.0", + name = "flatracoon-netstack", + description = "Modular, declarative network stack orchestration", +} +---- + +=== Machine-Readable + +Orchestrators can consume the Mustfile programmatically: + +[source,elixir] +---- +# In the Elixir orchestrator +{:ok, mustfile} = File.read!("Mustfile") + |> Nickel.export() + |> Jason.decode!() + +deployment_order = mustfile["deployment_order"] +modules = mustfile["modules"] +---- + +=== Separation of Concerns + +- **Mustfile**: What state must exist +- **Justfile**: How to achieve that state +- **SCM files**: Project context and history + +== Lessons Learned + +1. **Start with the Mustfile** - Define state before writing tasks +2. **Use `requires` for dependencies** - Explicit is better than implicit +3. **Environment configs separate** - Keep prod secrets out of the Mustfile +4. **Validate early** - Run `nickel export` in CI +5. **Export to JSON** - For cross-language consumption + +== Conclusion + +The FlatRacoon Network Stack demonstrates that Mustfile + Just + Nickel provides: + +- **Type-safe configuration** for complex infrastructure +- **Clear separation** between state definition and task execution +- **Machine-readable manifests** for orchestrator consumption +- **Environment-aware** deployment configurations +- **Dependency-aware** deployment ordering + +The pattern scales from simple single-module projects to complex multi-module stacks with dozens of interdependent components. + +== Links + +- link:https://github.com/hyperpolymath/flatracoon-netstack[FlatRacoon Network Stack] +- link:https://github.com/hyperpolymath/mustfile[Mustfile Project] +- link:https://nickel-lang.org[Nickel Language] +- link:https://just.systems[Just Command Runner] diff --git a/mustfile/docs/man/must.1 b/mustfile/docs/man/must.1 new file mode 100644 index 0000000..5da5273 --- /dev/null +++ b/mustfile/docs/man/must.1 @@ -0,0 +1,135 @@ +.TH MUST 1 "2025-12-27" "0.1.0" "Must Manual" +.SH NAME +must \- task runner, template engine, and project enforcer +.SH SYNOPSIS +.B must +[\fICOMMAND\fR] [\fIOPTIONS\fR] +.SH DESCRIPTION +.B must +is a task runner, template engine, and project enforcer that implements +Physical State contracts. It ensures projects maintain required files, +forbidden patterns, and content requirements through declarative TOML +configuration. +.PP +Physical State is the complete set of observable facts about a project: +which files exist, which must not exist, what content they contain, and +what artifacts are produced. +.SH COMMANDS +.TP +.B +Run a task defined in mustfile.toml +.TP +.B init +Create a default mustfile.toml in the current directory +.TP +.B list, \-l, \-\-list +List all available tasks +.TP +.B check +Verify Physical State requirements (read-only) +.TP +.B fix +Auto-fix requirement violations where possible +.TP +.B enforce +Full enforcement cycle: check + fix + verify +.TP +.B apply +Apply templates to generate files +.TP +.B templates +List available templates +.TP +.B deploy +Build and deploy via Containerfile +.TP +.B \-\-help, \-h +Show help message +.TP +.B \-\-version, \-v +Show version information +.SH OPTIONS +.TP +.B \-\-strict +Fail on any requirement violations (exit code 2) +.TP +.B \-\-dry\-run +Show what would be executed without making changes +.TP +.B \-\-verbose, \-V +Enable verbose output +.TP +.B \-\-template NAME +Apply a specific template by name +.TP +.B \-\-var KEY=VALUE +Set a template variable +.TP +.B \-\-vars FILE +Load variables from a TOML file +.SH DEPLOY OPTIONS +.TP +.B \-\-target TARGET +Deployment target: "container" (default) or "local" +.TP +.B \-\-tag TAG +Container image tag (default: "latest") +.TP +.B \-\-push +Push image to registry after building +.SH EXIT CODES +.TP +.B 0 +Success +.TP +.B 1 +General error +.TP +.B 2 +Physical State violation +.TP +.B 3 +Missing mustfile.toml +.TP +.B 4 +Invalid mustfile.toml syntax +.TP +.B 5 +Circular task dependency +.SH FILES +.TP +.I mustfile.toml +Project configuration file defining tasks, requirements, and templates. +.TP +.I Containerfile +Container build definition for deployment. +.SH EXAMPLES +.TP +Initialize a new project: +.B must init +.TP +List available tasks: +.B must list +.TP +Run the build task: +.B must build +.TP +Check requirements strictly: +.B must check \-\-strict +.TP +Apply a template: +.B must apply \-\-template ada_package \-\-var module=MyModule +.TP +Deploy container: +.B must deploy \-\-tag v1.0 \-\-push +.SH SEE ALSO +.BR just (1), +.BR podman (1), +.BR gprbuild (1) +.SH BUGS +Report bugs at: https://github.com/hyperpolymath/mustfile/issues +.SH AUTHOR +Jonathan D.A. Jewell +.SH COPYRIGHT +Copyright (C) 2025 Jonathan D.A. Jewell. +License: PMPL-1.0-or-later diff --git a/mustfile/docs/must-spec.adoc b/mustfile/docs/must-spec.adoc new file mode 100644 index 0000000..8daf6c7 --- /dev/null +++ b/mustfile/docs/must-spec.adoc @@ -0,0 +1,658 @@ += Must Specification +:author: Jonathan D.A. Jewell +:revnumber: 0.1.0 +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +toc::[] + +== Overview + +Must is a **contract-driven deployment tool** that enforces **Physical State** — the verifiable, observable condition of a project's artifacts at any point in time. + +== Physical State: Definition + +**Physical State** is the complete set of observable facts about a project: + +[cols="1,3"] +|=== +| Dimension | What It Captures + +| **Files Present** +| Which files exist (source, config, docs, artifacts) + +| **Files Absent** +| Which files must NOT exist (legacy cruft, banned patterns) + +| **File Contents** +| Required strings, headers, licenses in specific files + +| **Dependencies** +| External packages, versions, hashes + +| **Build Artifacts** +| Compiled binaries, container images, their checksums + +| **Runtime Environment** +| Required tools, versions, paths +|=== + +=== Ephapax Linear Logic + +Physical State follows **linear logic** principles: + +1. **Resources are consumed**: Building consumes source → produces binary +2. **No duplication**: Each artifact has exactly one authoritative source +3. **No weakening**: Unused resources are violations (dead code, orphan files) +4. **Explicit transitions**: Every state change requires a declared operation + +== Contract Format: `mustfile.toml` + +=== Schema Version + +[source,toml] +---- +# Required: Schema version for forward compatibility +schema = "0.1" +---- + +=== Project Section (Required) + +[source,toml] +---- +[project] +name = "my-project" # Project identifier (lowercase, hyphens) +version = "1.0.0" # SemVer version +license = "MIT" # SPDX license identifier +author = "Name " # Primary author +---- + +=== Variables Section (Optional) + +Define reusable values for templates and commands: + +[source,toml] +---- +[variables] +copyright = "Copyright (C) 2025 Author Name" +org = "my-org" +registry = "ghcr.io/my-org" +---- + +=== Tasks Section (Required) + +Tasks are named operations with optional dependencies: + +[source,toml] +---- +[tasks.build] +description = "Build the project" # Human-readable description +commands = ["cargo build --release"] # Shell commands to execute +dependencies = [] # Tasks to run first + +[tasks.test] +description = "Run tests" +dependencies = ["build"] # Runs 'build' before 'test' +commands = ["cargo test"] + +[tasks.deploy] +description = "Deploy to production" +dependencies = ["test"] # Full chain: build → test → deploy +commands = ["must deploy --push"] +---- + +=== Requirements Section (Required) + +Defines the **Physical State Contract**: + +[source,toml] +---- +[requirements] +# Files that MUST exist (relative paths) +must_have = [ + "LICENSE.txt", + "README.adoc", + "src/main.rs", +] + +# Files that MUST NOT exist (violations) +must_not_have = [ + "Makefile", # Banned: use Justfile + "Dockerfile", # Banned: use Containerfile + ".env", # Banned: secrets in repo + "node_modules/", # Banned: vendored deps +] + +# Content requirements (file must contain string) +[requirements.content] +"LICENSE.txt" = ["MIT License", "2025"] +"src/main.rs" = ["SPDX-License-Identifier"] +---- + +=== Templates Section (Optional) + +Code generation via Mustache templates: + +[source,toml] +---- +[templates.rust_module] +source = "templates/rust/module.rs.mustache" +destination = "src/{{module_name}}.rs" +description = "Generate Rust module" + +[templates.ada_package] +source = "templates/ada/package.ads.mustache" +destination = "src/{{module_name}}.ads" +description = "Generate Ada package spec" +---- + +=== Enforcement Section (Optional) + +Project-wide policy enforcement: + +[source,toml] +---- +[enforcement] +license = "PMPL-1.0-or-later" # Required license +copyright_holder = "Author Name" # Copyright holder +podman_not_docker = true # Container engine policy +gitlab_not_github = false # Git hosting policy + +[enforcement.checks] +no_trailing_whitespace = true # Style: no trailing whitespace +no_tabs = false # Style: tabs allowed +unix_line_endings = true # Style: LF only, no CRLF +max_line_length = 120 # Style: max chars per line +---- + +=== Deploy Section (Optional) + +Container deployment configuration: + +[source,toml] +---- +[deploy] +containerfile = "Containerfile" # Container build file +registry = "ghcr.io/my-org" # Default push registry +default_tag = "latest" # Default image tag +---- + +== Golden Examples + +=== Example 1: Minimal Library + +A minimal Rust library with CI enforcement: + +[source,toml] +---- +# mustfile.toml - Minimal Rust Library + +[project] +name = "my-lib" +version = "0.1.0" +license = "MIT" +author = "Developer " + +[tasks.build] +description = "Build library" +commands = ["cargo build"] + +[tasks.test] +description = "Run tests" +dependencies = ["build"] +commands = ["cargo test"] + +[tasks.lint] +description = "Run clippy" +commands = ["cargo clippy -- -D warnings"] + +[tasks.all] +description = "Build, test, lint" +dependencies = ["build", "test", "lint"] +commands = ["echo 'All checks passed'"] + +[requirements] +must_have = [ + "Cargo.toml", + "LICENSE", + "README.md", + "src/lib.rs", +] +must_not_have = [ + "Makefile", + ".env", +] +---- + +=== Example 2: CLI Application with Container + +A Rust CLI tool with container deployment: + +[source,toml] +---- +# mustfile.toml - CLI Application + +[project] +name = "my-cli" +version = "1.2.0" +license = "Apache-2.0" +author = "Team " + +[variables] +registry = "ghcr.io/my-org" + +[tasks.build] +description = "Build debug binary" +commands = ["cargo build"] + +[tasks.build-release] +description = "Build release binary" +commands = ["cargo build --release"] + +[tasks.test] +description = "Run all tests" +dependencies = ["build"] +commands = ["cargo test --all-features"] + +[tasks.install] +description = "Install to /usr/local/bin" +dependencies = ["build-release"] +commands = ["sudo cp target/release/my-cli /usr/local/bin/"] + +[tasks.deploy] +description = "Build and push container" +dependencies = ["test"] +commands = ["must deploy --push"] + +[requirements] +must_have = [ + "Cargo.toml", + "Cargo.lock", + "LICENSE", + "README.md", + "Containerfile", + "src/main.rs", +] +must_not_have = [ + "Dockerfile", + "Makefile", + "package.json", +] + +[requirements.content] +"Cargo.toml" = ["edition = \"2021\""] +"src/main.rs" = ["SPDX-License-Identifier: Apache-2.0"] + +[enforcement] +license = "Apache-2.0" +podman_not_docker = true + +[enforcement.checks] +no_trailing_whitespace = true +unix_line_endings = true +max_line_length = 100 + +[deploy] +containerfile = "Containerfile" +registry = "ghcr.io/my-org" +---- + +=== Example 3: Ada Safety-Critical System + +An Ada project with strict enforcement: + +[source,toml] +---- +# mustfile.toml - Safety-Critical Ada System + +[project] +name = "flight-controller" +version = "2.1.0" +license = "PMPL-1.0-or-later" +author = "Aerospace Team " + +[variables] +copyright = "Copyright (C) 2025 Aerospace Corp" + +[tasks.build] +description = "Build with all checks" +commands = ["gprbuild -P flight.gpr -XMODE=debug"] + +[tasks.build-release] +description = "Build release (still with checks)" +commands = ["gprbuild -P flight.gpr -XMODE=release"] + +[tasks.test] +description = "Run test suite" +dependencies = ["build"] +commands = [ + "bin/flight_tests", + "bin/integration_tests", +] + +[tasks.prove] +description = "Run SPARK prover" +commands = ["gnatprove -P flight.gpr --level=2"] + +[tasks.analyze] +description = "Static analysis" +commands = ["codepeer -P flight.gpr"] + +[tasks.all] +description = "Full verification pipeline" +dependencies = ["build", "test", "prove", "analyze"] +commands = ["echo 'All verifications passed'"] + +[requirements] +must_have = [ + "flight.gpr", + "LICENSE.txt", + "README.adoc", + "SAFETY.adoc", + "src/main.adb", + "src/flight_controller.ads", + "src/flight_controller.adb", + "tests/flight_tests.adb", +] +must_not_have = [ + "Makefile", + "Dockerfile", + "*.pyc", + "__pycache__/", +] + +[requirements.content] +"src/main.adb" = [ + "pragma SPARK_Mode", + "SPDX-License-Identifier: PMPL-1.0-or-later", +] +"SAFETY.adoc" = ["DO-178C", "Level A"] + +[enforcement] +license = "PMPL-1.0-or-later" +copyright_holder = "Aerospace Corp" +podman_not_docker = true + +[enforcement.checks] +no_trailing_whitespace = true +no_tabs = true +unix_line_endings = true +max_line_length = 79 +---- + +=== Example 4: Multi-Language Monorepo + +A polyglot project with multiple components: + +[source,toml] +---- +# mustfile.toml - Polyglot Monorepo + +[project] +name = "platform" +version = "3.0.0" +license = "MIT" +author = "Platform Team " + +[variables] +registry = "ghcr.io/platform-org" + +[tasks.build-api] +description = "Build Gleam API" +commands = ["cd api && gleam build"] + +[tasks.build-cli] +description = "Build Rust CLI" +commands = ["cd cli && cargo build --release"] + +[tasks.build-web] +description = "Build ReScript frontend" +commands = ["cd web && npm run build"] + +[tasks.build] +description = "Build all components" +dependencies = ["build-api", "build-cli", "build-web"] +commands = ["echo 'All components built'"] + +[tasks.test-api] +description = "Test API" +commands = ["cd api && gleam test"] + +[tasks.test-cli] +description = "Test CLI" +commands = ["cd cli && cargo test"] + +[tasks.test-web] +description = "Test frontend" +commands = ["cd web && npm test"] + +[tasks.test] +description = "Test all components" +dependencies = ["test-api", "test-cli", "test-web"] +commands = ["echo 'All tests passed'"] + +[tasks.deploy] +description = "Deploy all containers" +dependencies = ["test"] +commands = [ + "must deploy --target container", + "cd api && must deploy --push", + "cd cli && must deploy --push", + "cd web && must deploy --push", +] + +[requirements] +must_have = [ + "LICENSE", + "README.md", + "mustfile.toml", + # API (Gleam) + "api/gleam.toml", + "api/src/api.gleam", + "api/Containerfile", + # CLI (Rust) + "cli/Cargo.toml", + "cli/src/main.rs", + "cli/Containerfile", + # Web (ReScript) + "web/rescript.json", + "web/src/App.res", + "web/Containerfile", +] +must_not_have = [ + "Makefile", + "Dockerfile", + # No TypeScript allowed + "**/*.ts", + "**/*.tsx", + # No Go allowed + "go.mod", + "**/*.go", +] + +[enforcement] +license = "MIT" +podman_not_docker = true + +[enforcement.checks] +unix_line_endings = true +---- + +=== Example 5: Infrastructure/DevOps + +A deployment automation project (like Must itself): + +[source,toml] +---- +# mustfile.toml - Infrastructure Tool + +[project] +name = "must" +version = "0.1.0" +license = "PMPL-1.0-or-later" +author = "Jonathan D.A. Jewell" + +[variables] +copyright = "Copyright (C) 2025 Jonathan D.A. Jewell" + +[tasks.build] +description = "Build (debug)" +commands = ["gprbuild -P must.gpr -XMODE=debug"] + +[tasks.build-release] +description = "Build (release)" +commands = ["gprbuild -P must.gpr -XMODE=release"] + +[tasks.test] +description = "Run tests" +dependencies = ["build"] +commands = ["bin/must --version", "bin/must --help"] + +[tasks.check] +description = "Check requirements" +commands = ["bin/must check"] + +[tasks.deploy] +description = "Build container" +dependencies = ["build-release"] +commands = ["must deploy"] + +[tasks.deploy-push] +description = "Build and push container" +dependencies = ["build-release"] +commands = ["must deploy --push"] + +[tasks.all] +description = "Full CI pipeline" +dependencies = ["build", "test", "check"] +commands = ["echo 'CI passed'"] + +[requirements] +must_have = [ + "LICENSE.txt", + "README.adoc", + "INSTALL.adoc", + "Containerfile", + "justfile", + "mustfile.toml", + "must.gpr", + "src/must.adb", + "src/deploy/deployer.ads", + "src/deploy/deployer.adb", +] +must_not_have = [ + "Makefile", + "Dockerfile", +] + +[requirements.content] +"LICENSE.txt" = ["AGPL", "Version 3"] +"src/must.adb" = ["SPDX-License-Identifier: PMPL-1.0-or-later"] + +[templates.ada_package] +source = "templates/ada/package.ads.mustache" +destination = "src/{{module_name}}.ads" +description = "Generate Ada package spec" + +[templates.ada_body] +source = "templates/ada/package.adb.mustache" +destination = "src/{{module_name}}.adb" +description = "Generate Ada package body" + +[enforcement] +license = "PMPL-1.0-or-later" +copyright_holder = "Jonathan D.A. Jewell" +podman_not_docker = true + +[enforcement.checks] +no_trailing_whitespace = true +no_tabs = false +unix_line_endings = true +max_line_length = 120 + +[deploy] +containerfile = "Containerfile" +registry = "ghcr.io/hyperpolymath" +---- + +== Command Reference + +=== Enforcement Commands + +[source,bash] +---- +must check # Verify Physical State (read-only) +must check --strict # Fail on warnings +must check --verbose # Show all checks + +must fix # Auto-fix violations where possible +must fix --dry-run # Preview fixes + +must enforce # check + fix + verify (full cycle) +---- + +=== Task Commands + +[source,bash] +---- +must list # List available tasks +must # Run a task +must build --dry-run # Preview task execution +must build --verbose # Verbose output +---- + +=== Deployment Commands + +[source,bash] +---- +must deploy # Build container +must deploy --tag v1.0 # Build with tag +must deploy --push # Build and push to registry +must deploy --target local # Local build (no container) +must deploy --dry-run --verbose # Preview deployment +---- + +=== Template Commands + +[source,bash] +---- +must templates # List templates +must apply --template ada_package --var module=Foo # Apply template +---- + +== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning + +| 0 +| Success + +| 1 +| General error (command failed) + +| 2 +| Physical State violation (check failed) + +| 3 +| Missing mustfile.toml + +| 4 +| Invalid mustfile.toml syntax + +| 5 +| Circular task dependency +|=== + +== Best Practices + +1. **Start with `must check`**: Always verify before changing +2. **Use `--dry-run` first**: Preview destructive operations +3. **Pin versions**: Use exact versions in dependencies +4. **Minimal must_have**: Only require truly essential files +5. **Aggressive must_not_have**: Explicitly ban anti-patterns +6. **Content checks for licenses**: Ensure SPDX headers exist +7. **CI integration**: Run `must check --strict` in pipelines diff --git a/mustfile/must.gpr b/mustfile/must.gpr new file mode 100644 index 0000000..921bd80 --- /dev/null +++ b/mustfile/must.gpr @@ -0,0 +1,51 @@ +-- must.gpr +-- GNAT project file for Must - task runner + template engine + enforcer +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +project Must is + + for Source_Dirs use ("src", "src/**"); + for Object_Dir use "obj"; + for Exec_Dir use "bin"; + for Main use ("must.adb"); + + type Build_Mode is ("debug", "release"); + Mode : Build_Mode := external ("MODE", "debug"); + + package Compiler is + case Mode is + when "debug" => + for Switches ("Ada") use + ("-g", -- Debug info + "-gnata", -- Enable assertions + "-gnatwa", -- All warnings + "-gnatwj", -- Warnings for obsolescent features + "-gnatwe", -- Warnings as errors + "-gnaty", -- Style checks + "-gnatyM120", -- Max line length 120 + "-gnat2022", -- Ada 2022 mode + "-fstack-check"); -- Stack overflow checking + when "release" => + for Switches ("Ada") use + ("-O3", -- Maximum optimization + "-gnatp", -- Suppress all checks (for speed) + "-gnatn", -- Enable inlining + "-gnat2022"); -- Ada 2022 mode + end case; + end Compiler; + + package Binder is + for Switches ("Ada") use ("-E"); -- Store traceback in exceptions + end Binder; + + package Linker is + for Switches ("Ada") use ("-g"); + end Linker; + + package Builder is + for Executable ("must.adb") use "must"; + for Switches ("Ada") use ("-j0"); -- Use all available CPUs + end Builder; + +end Must; diff --git a/mustfile/mustfile.toml b/mustfile/mustfile.toml new file mode 100644 index 0000000..78a7fec --- /dev/null +++ b/mustfile/mustfile.toml @@ -0,0 +1,136 @@ +# mustfile.toml +# Configuration for Must - task runner + template engine + enforcer +# https://gitlab.com/hyperpolymath/must +# See docs/must-spec.adoc for format specification + +schema = "0.1" + +[project] +name = "must" +version = "0.1.0" +license = "PMPL-1.0-or-later" +author = "Jonathan D.A. Jewell" + +[variables] +copyright = "Copyright (C) 2025 Jonathan D.A. Jewell" +license = "PMPL-1.0-or-later" + +# Task definitions +[tasks] + +[tasks.build] +description = "Build the project (debug mode)" +commands = ["gprbuild -P must.gpr -XMODE=debug"] + +[tasks.build-release] +description = "Build the project (release mode)" +commands = ["gprbuild -P must.gpr -XMODE=release"] + +[tasks.test] +description = "Run tests" +dependencies = ["build"] +commands = ["echo 'Running tests...'", "bin/must --version", "bin/must --help"] + +[tasks.clean] +description = "Clean build artifacts" +commands = ["gnatclean -P must.gpr", "rm -rf obj/ bin/"] + +[tasks.install] +description = "Install to /usr/local/bin" +dependencies = ["build-release"] +commands = ["sudo cp bin/must /usr/local/bin/"] + +[tasks.docs] +description = "Generate documentation" +commands = ["echo 'Documentation generated'"] + +[tasks.fmt] +description = "Format Ada code using gnatpp" +commands = ["just fmt"] + +[tasks.lint] +description = "Lint Ada code (compile with strict warnings)" +commands = ["just lint"] + +[tasks.all] +description = "Build, test, and check" +dependencies = ["build", "test", "check"] +commands = ["echo 'All checks passed'"] + +[tasks.check] +description = "Check requirements" +commands = ["bin/must check"] + +[tasks.deploy] +description = "Build and deploy container" +dependencies = ["build-release"] +commands = ["bin/must deploy"] + +[tasks.deploy-push] +description = "Build, deploy, and push container" +dependencies = ["build-release"] +commands = ["bin/must deploy --push"] + +# Requirements enforcement - defines Physical State contract +[requirements] +must_have = [ + "LICENSE.txt", + "README.adoc", + "INSTALL.adoc", + "Containerfile", + "justfile", + "mustfile.toml", + "must.gpr", + "src/must.adb", + "src/deploy/deployer.ads", + "src/deploy/deployer.adb", + "docs/must-spec.adoc", +] + +must_not_have = [ + "Makefile", + "Dockerfile", + ".env", +] + +# Content requirements - strings that must appear in files +[requirements.content] +"LICENSE.txt" = ["AGPL", "Version 3"] +"src/must.adb" = ["SPDX-License-Identifier: PMPL-1.0-or-later"] + +# Templates +[templates] + +[templates.ada_package] +source = "templates/ada/package.ads.mustache" +destination = "src/{{module_name}}.ads" +description = "Generate Ada package specification" + +[templates.ada_body] +source = "templates/ada/package.adb.mustache" +destination = "src/{{module_name}}.adb" +description = "Generate Ada package body" + +[templates.elixir_module] +source = "templates/elixir/module.ex.mustache" +destination = "lib/{{app_name}}/{{module_name}}.ex" +description = "Generate Elixir module" + +# Enforcement rules +[enforcement] +license = "PMPL-1.0-or-later" +copyright_holder = "Jonathan D.A. Jewell" +podman_not_docker = true +gitlab_not_github = false + +[enforcement.checks] +no_trailing_whitespace = true +no_tabs = false +unix_line_endings = true +max_line_length = 120 + +# Deployment configuration +[deploy] +containerfile = "Containerfile" +registry = "ghcr.io/hyperpolymath" +default_tag = "latest" diff --git a/mustfile/scripts/bootstrap.sh b/mustfile/scripts/bootstrap.sh new file mode 100755 index 0000000..4672f8c --- /dev/null +++ b/mustfile/scripts/bootstrap.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# bootstrap.sh - Quick bootstrap for Must development environment +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -euo pipefail + +echo "=== Must Bootstrap ===" +echo "" + +# Detect OS +OS="$(uname -s)" +case "$OS" in + Linux) + if [ -f /etc/debian_version ]; then + DISTRO="debian" + elif [ -f /etc/fedora-release ]; then + DISTRO="fedora" + elif [ -f /etc/arch-release ]; then + DISTRO="arch" + else + DISTRO="linux" + fi + ;; + Darwin) + DISTRO="macos" + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +echo "Detected: $DISTRO" +echo "" + +# Install GNAT if not present +if ! command -v gnat &> /dev/null; then + echo "Installing GNAT Ada compiler..." + case "$DISTRO" in + debian) + sudo apt-get update + sudo apt-get install -y gnat gprbuild + ;; + fedora) + sudo dnf install -y gcc-gnat gprbuild + ;; + arch) + sudo pacman -S --noconfirm gcc-ada gprbuild + ;; + macos) + if command -v brew &> /dev/null; then + brew install gnat gprbuild + else + echo "Please install Homebrew first: https://brew.sh" + exit 1 + fi + ;; + *) + echo "Please install GNAT manually for your distribution" + exit 1 + ;; + esac +else + echo "GNAT already installed: $(gnat --version | head -1)" +fi + +# Install just if not present +if ! command -v just &> /dev/null; then + echo "Installing just..." + case "$DISTRO" in + debian) + sudo apt-get install -y just || { + # Fallback to cargo if not in repos + if command -v cargo &> /dev/null; then + cargo install just + else + echo "Please install 'just' manually: https://just.systems" + exit 1 + fi + } + ;; + fedora) + sudo dnf install -y just + ;; + arch) + sudo pacman -S --noconfirm just + ;; + macos) + brew install just + ;; + *) + if command -v cargo &> /dev/null; then + cargo install just + else + echo "Please install 'just' manually: https://just.systems" + exit 1 + fi + ;; + esac +else + echo "just already installed: $(just --version)" +fi + +# Install podman if not present (optional) +if ! command -v podman &> /dev/null; then + echo "" + echo "Note: podman not found. Install it for container deployment:" + case "$DISTRO" in + debian) + echo " sudo apt-get install podman" + ;; + fedora) + echo " sudo dnf install podman" + ;; + arch) + echo " sudo pacman -S podman" + ;; + macos) + echo " brew install podman" + ;; + esac +fi + +echo "" +echo "=== Bootstrap Complete ===" +echo "" +echo "Next steps:" +echo " just build # Build must" +echo " just test # Run tests" +echo " just install # Install to /usr/local/bin" +echo "" diff --git a/mustfile/scripts/shells/README.adoc b/mustfile/scripts/shells/README.adoc new file mode 100755 index 0000000..b734a26 --- /dev/null +++ b/mustfile/scripts/shells/README.adoc @@ -0,0 +1,104 @@ += Shell Entrypoints +:toc: + +This directory contains entrypoint scripts for 17 different shell environments. + +== Supported Shells + +[cols="1,2,1"] +|=== +| Shell | Script | Platform + +| Bash +| `entrypoint.bash` +| Linux, macOS, Windows (WSL/Git Bash) + +| Zsh +| `entrypoint.zsh` +| Linux, macOS + +| Fish +| `entrypoint.fish` +| Linux, macOS + +| Dash +| `entrypoint.dash` +| Debian/Ubuntu (default /bin/sh) + +| Ash +| `entrypoint.ash` +| Alpine Linux, BusyBox + +| Korn Shell +| `entrypoint.ksh` +| Unix, Linux + +| Nushell +| `entrypoint.nu` +| Cross-platform + +| Elvish +| `entrypoint.elvish` +| Cross-platform + +| Ion +| `entrypoint.ion` +| Linux (Redox OS origin) + +| Oil/Osh +| `entrypoint.oil` +| Cross-platform + +| Tcsh +| `entrypoint.tcsh` +| BSD, macOS + +| C Shell +| `entrypoint.csh` +| BSD, Unix + +| Murex +| `entrypoint.murex` +| Cross-platform + +| PowerShell Core +| `entrypoint.ps1` +| Cross-platform + +| Windows CMD +| `entrypoint.cmd` +| Windows + +| NGS +| `entrypoint.ngs` +| Linux + +| Tcl Shell +| `entrypoint.tsh` +| Cross-platform + +| MINIX Shell +| `entrypoint.minix` +| MINIX 3 +|=== + +== Usage + +Each script attempts to locate the `must` binary in the following order: + +1. System PATH (`must`) +2. Local build (`./bin/must`) + +If neither is found, it exits with an error. + +== Making Scripts Executable + +[source,bash] +---- +chmod +x scripts/shells/* +---- + +== Customization + +These scripts are minimal by design. For project-specific customization, +copy the appropriate script and modify as needed. diff --git a/mustfile/scripts/shells/entrypoint.ash b/mustfile/scripts/shells/entrypoint.ash new file mode 100755 index 0000000..7206041 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.ash @@ -0,0 +1,17 @@ +#!/bin/ash +# entrypoint.ash - BusyBox ash entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -eu + +if ! command -v must > /dev/null 2>&1; then + if [ -x "./bin/must" ]; then + exec ./bin/must "$@" + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + fi +fi + +exec must "$@" diff --git a/mustfile/scripts/shells/entrypoint.bash b/mustfile/scripts/shells/entrypoint.bash new file mode 100755 index 0000000..bfb75b0 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.bash @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# entrypoint.bash - Bash shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -euo pipefail + +# Ensure must is available +if ! command -v must &> /dev/null; then + if [[ -x "./bin/must" ]]; then + exec ./bin/must "$@" + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + fi +fi + +exec must "$@" diff --git a/mustfile/scripts/shells/entrypoint.cmd b/mustfile/scripts/shells/entrypoint.cmd new file mode 100755 index 0000000..2eaa5ca --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.cmd @@ -0,0 +1,18 @@ +@echo off +REM entrypoint.cmd - Windows CMD entrypoint for Must +REM SPDX-License-Identifier: PMPL-1.0-or-later +REM Copyright (C) 2025 Jonathan D.A. Jewell + +where must >nul 2>&1 +if %ERRORLEVEL% EQU 0 ( + must %* + exit /b %ERRORLEVEL% +) + +if exist ".\bin\must.exe" ( + .\bin\must.exe %* + exit /b %ERRORLEVEL% +) + +echo Error: 'must' not found. Build with 'just build' or install. 1>&2 +exit /b 1 diff --git a/mustfile/scripts/shells/entrypoint.csh b/mustfile/scripts/shells/entrypoint.csh new file mode 100755 index 0000000..62c1ee2 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.csh @@ -0,0 +1,15 @@ +#!/usr/bin/env csh +# entrypoint.csh - C shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +if ( ! -X must ) then + if ( -x ./bin/must ) then + exec ./bin/must $argv:q + else + echo "Error: 'must' not found. Build with 'just build' or install." + exit 1 + endif +endif + +exec must $argv:q diff --git a/mustfile/scripts/shells/entrypoint.dash b/mustfile/scripts/shells/entrypoint.dash new file mode 100755 index 0000000..ab59da7 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.dash @@ -0,0 +1,17 @@ +#!/usr/bin/env dash +# entrypoint.dash - Dash shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -eu + +if ! command -v must > /dev/null 2>&1; then + if [ -x "./bin/must" ]; then + exec ./bin/must "$@" + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + fi +fi + +exec must "$@" diff --git a/mustfile/scripts/shells/entrypoint.elvish b/mustfile/scripts/shells/entrypoint.elvish new file mode 100755 index 0000000..249440a --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.elvish @@ -0,0 +1,18 @@ +#!/usr/bin/env elvish +# entrypoint.elvish - Elvish shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +fn main {|@args| + if (not (has-external must)) { + if (path:is-regular ./bin/must) { + exec ./bin/must $@args + } else { + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + } + } + exec must $@args +} + +main $@args diff --git a/mustfile/scripts/shells/entrypoint.fish b/mustfile/scripts/shells/entrypoint.fish new file mode 100755 index 0000000..3885571 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.fish @@ -0,0 +1,15 @@ +#!/usr/bin/env fish +# entrypoint.fish - Fish shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +if not command -v must &> /dev/null + if test -x "./bin/must" + exec ./bin/must $argv + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + end +end + +exec must $argv diff --git a/mustfile/scripts/shells/entrypoint.ion b/mustfile/scripts/shells/entrypoint.ion new file mode 100755 index 0000000..d6a3405 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.ion @@ -0,0 +1,15 @@ +#!/usr/bin/env ion +# entrypoint.ion - Ion shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +if not exists -b must + if test -x ./bin/must + exec ./bin/must @args + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + end +end + +exec must @args diff --git a/mustfile/scripts/shells/entrypoint.ksh b/mustfile/scripts/shells/entrypoint.ksh new file mode 100755 index 0000000..847e358 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.ksh @@ -0,0 +1,17 @@ +#!/usr/bin/env ksh +# entrypoint.ksh - Korn shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -eu + +if ! command -v must > /dev/null 2>&1; then + if [[ -x "./bin/must" ]]; then + exec ./bin/must "$@" + else + print -u2 "Error: 'must' not found. Build with 'just build' or install." + exit 1 + fi +fi + +exec must "$@" diff --git a/mustfile/scripts/shells/entrypoint.minix b/mustfile/scripts/shells/entrypoint.minix new file mode 100755 index 0000000..f67ffaf --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.minix @@ -0,0 +1,14 @@ +#!/bin/sh +# entrypoint.minix - MINIX shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell +# Compatible with MINIX 3 /bin/sh + +if command -v must > /dev/null 2>&1; then + exec must "$@" +elif test -x "./bin/must"; then + exec ./bin/must "$@" +else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 +fi diff --git a/mustfile/scripts/shells/entrypoint.murex b/mustfile/scripts/shells/entrypoint.murex new file mode 100755 index 0000000..36667de --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.murex @@ -0,0 +1,15 @@ +#!/usr/bin/env murex +# entrypoint.murex - Murex shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +if { which must } then { + exec must @ARGS +} else { + if { g ./bin/must } then { + exec ./bin/must @ARGS + } else { + err "Error: 'must' not found. Build with 'just build' or install." + exit 1 + } +} diff --git a/mustfile/scripts/shells/entrypoint.ngs b/mustfile/scripts/shells/entrypoint.ngs new file mode 100755 index 0000000..af1cd61 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.ngs @@ -0,0 +1,18 @@ +#!/usr/bin/env ngs +# entrypoint.ngs - Next Generation Shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +F main(argv:Arr) { + must_exists = try $(which must) catch(e:ProcessFail) false + if must_exists { + $(must $*argv) + } else { + if Path('./bin/must').exists() { + $('./bin/must' $*argv) + } else { + echo("Error: 'must' not found. Build with 'just build' or install.") > 2 + exit(1) + } + } +} diff --git a/mustfile/scripts/shells/entrypoint.nu b/mustfile/scripts/shells/entrypoint.nu new file mode 100755 index 0000000..5d1a2b3 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.nu @@ -0,0 +1,19 @@ +#!/usr/bin/env nu +# entrypoint.nu - Nushell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +def main [...args: string] { + let must_path = if (which must | is-empty) { + if ("./bin/must" | path exists) { + "./bin/must" + } else { + print -e "Error: 'must' not found. Build with 'just build' or install." + exit 1 + } + } else { + "must" + } + + run-external $must_path ...$args +} diff --git a/mustfile/scripts/shells/entrypoint.oil b/mustfile/scripts/shells/entrypoint.oil new file mode 100755 index 0000000..dc3977b --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.oil @@ -0,0 +1,17 @@ +#!/usr/bin/env osh +# entrypoint.oil - Oil shell (Oils for Unix) entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +shopt -s strict:all + +if ! command -v must > /dev/null 2>&1; then + if test -x "./bin/must"; then + exec ./bin/must "$@" + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + fi +fi + +exec must "$@" diff --git a/mustfile/scripts/shells/entrypoint.ps1 b/mustfile/scripts/shells/entrypoint.ps1 new file mode 100755 index 0000000..0087023 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.ps1 @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh +# entrypoint.ps1 - PowerShell Core entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +$ErrorActionPreference = "Stop" + +$mustPath = Get-Command must -ErrorAction SilentlyContinue + +if (-not $mustPath) { + if (Test-Path "./bin/must") { + & "./bin/must" @args + exit $LASTEXITCODE + } else { + Write-Error "Error: 'must' not found. Build with 'just build' or install." + exit 1 + } +} + +& must @args +exit $LASTEXITCODE diff --git a/mustfile/scripts/shells/entrypoint.tcsh b/mustfile/scripts/shells/entrypoint.tcsh new file mode 100755 index 0000000..c3e4833 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.tcsh @@ -0,0 +1,15 @@ +#!/usr/bin/env tcsh +# entrypoint.tcsh - TENEX C shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +if ( ! -X must ) then + if ( -x ./bin/must ) then + exec ./bin/must $argv:q + else + echo "Error: 'must' not found. Build with 'just build' or install." + exit 1 + endif +endif + +exec must $argv:q diff --git a/mustfile/scripts/shells/entrypoint.tsh b/mustfile/scripts/shells/entrypoint.tsh new file mode 100755 index 0000000..cd59163 --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.tsh @@ -0,0 +1,23 @@ +#!/usr/bin/env tclsh +# entrypoint.tsh - Tcl shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +if {[catch {exec which must} result]} { + if {[file executable "./bin/must"]} { + if {[catch {exec ./bin/must {*}$argv} output]} { + puts stderr $output + exit 1 + } + puts $output + } else { + puts stderr "Error: 'must' not found. Build with 'just build' or install." + exit 1 + } +} else { + if {[catch {exec must {*}$argv} output]} { + puts stderr $output + exit 1 + } + puts $output +} diff --git a/mustfile/scripts/shells/entrypoint.zsh b/mustfile/scripts/shells/entrypoint.zsh new file mode 100755 index 0000000..a2dcedb --- /dev/null +++ b/mustfile/scripts/shells/entrypoint.zsh @@ -0,0 +1,17 @@ +#!/usr/bin/env zsh +# entrypoint.zsh - Zsh shell entrypoint for Must +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -euo pipefail + +if ! command -v must &> /dev/null; then + if [[ -x "./bin/must" ]]; then + exec ./bin/must "$@" + else + echo "Error: 'must' not found. Build with 'just build' or install." >&2 + exit 1 + fi +fi + +exec must "$@" diff --git a/mustfile/src/cli/cli_parser.adb b/mustfile/src/cli/cli_parser.adb new file mode 100644 index 0000000..951db05 --- /dev/null +++ b/mustfile/src/cli/cli_parser.adb @@ -0,0 +1,202 @@ +-- cli_parser.adb +-- Command-line argument parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Command_Line; +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Strings.Fixed; + +package body CLI_Parser is + + Version_String : constant String := "0.1.0"; + + function Get_Arguments return String_Vector is + Args : String_Vector; + begin + for I in 1 .. Ada.Command_Line.Argument_Count loop + Args.Append (Ada.Command_Line.Argument (I)); + end loop; + return Args; + end Get_Arguments; + + function Parse return Parsed_Args is + Result : Parsed_Args; + Args : constant String_Vector := Get_Arguments; + I : Positive := 1; + + procedure Parse_Var (Arg : String) is + Eq_Pos : constant Natural := + Ada.Strings.Fixed.Index (Arg, "="); + begin + if Eq_Pos = 0 then + raise Parse_Error with "Invalid --var format: " & Arg; + end if; + + declare + Key : constant String := Arg (Arg'First .. Eq_Pos - 1); + Value : constant String := Arg (Eq_Pos + 1 .. Arg'Last); + begin + Result.Variables.Include (Key, Value); + end; + end Parse_Var; + + begin + if Args.Is_Empty then + Result.Command := Cmd_None; + return Result; + end if; + + while I <= Natural (Args.Length) loop + declare + Arg : constant String := Args (I); + begin + if Arg = "--help" or else Arg = "-h" then + Result.Command := Cmd_Help; + return Result; + + elsif Arg = "--version" or else Arg = "-v" then + Result.Command := Cmd_Version; + return Result; + + elsif Arg = "--list" or else Arg = "-l" then + Result.Command := Cmd_List; + + elsif Arg = "--strict" then + Result.Strict := True; + + elsif Arg = "--dry-run" then + Result.Dry_Run := True; + + elsif Arg = "--verbose" or else Arg = "-V" then + Result.Verbose := True; + + elsif Arg = "--template" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--template requires an argument"; + end if; + Result.Template_Name := To_Unbounded (Args (I)); + + elsif Arg = "--var" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--var requires KEY=VALUE argument"; + end if; + Parse_Var (Args (I)); + + elsif Arg = "--vars" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--vars requires a file argument"; + end if; + Result.Vars_File := To_Unbounded (Args (I)); + + elsif Arg = "init" then + Result.Command := Cmd_Init; + + elsif Arg = "apply" then + Result.Command := Cmd_Apply; + + elsif Arg = "check" then + Result.Command := Cmd_Check; + + elsif Arg = "fix" then + Result.Command := Cmd_Fix; + + elsif Arg = "enforce" then + Result.Command := Cmd_Enforce; + + elsif Arg = "templates" then + Result.Command := Cmd_Templates; + + elsif Arg = "deploy" then + Result.Command := Cmd_Deploy; + + elsif Arg = "--target" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--target requires an argument"; + end if; + Result.Deploy_Target := To_Unbounded (Args (I)); + + elsif Arg = "--tag" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--tag requires an argument"; + end if; + Result.Deploy_Tag := To_Unbounded (Args (I)); + + elsif Arg = "--push" then + Result.Deploy_Push := True; + + elsif Arg'Length > 0 and then Arg (Arg'First) = '-' then + raise Parse_Error with "Unknown option: " & Arg; + + else + -- Task name or extra argument + if Result.Command = Cmd_None then + Result.Command := Cmd_Run_Task; + Result.Task_Name := To_Unbounded (Arg); + else + Result.Extra_Args.Append (Arg); + end if; + end if; + + I := I + 1; + end; + end loop; + + return Result; + end Parse; + + procedure Show_Help is + begin + Put_Line ("Must v" & Version_String); + Put_Line ("Task runner + template engine + project enforcer"); + Put_Line (""); + Put_Line ("Usage: must [COMMAND] [OPTIONS]"); + Put_Line (""); + Put_Line ("Commands:"); + Put_Line (" Run a task from mustfile.toml"); + Put_Line (" --list, -l List all available tasks"); + Put_Line (" apply Apply templates"); + Put_Line (" check Check requirements"); + Put_Line (" fix Fix violations automatically"); + Put_Line (" enforce Check + apply + verify"); + Put_Line (" deploy Build and deploy via Containerfile"); + Put_Line (" init Create default mustfile.toml"); + Put_Line (" templates List available templates"); + Put_Line (" --help, -h Show this help"); + Put_Line (" --version, -v Show version"); + Put_Line (""); + Put_Line ("Options:"); + Put_Line (" --strict Fail on requirement violations"); + Put_Line (" --dry-run Show what would be executed"); + Put_Line (" --verbose, -V Verbose output"); + Put_Line (" --template NAME Apply specific template"); + Put_Line (" --var KEY=VALUE Set template variable"); + Put_Line (" --vars FILE Load variables from TOML file"); + Put_Line (""); + Put_Line ("Deploy Options:"); + Put_Line (" --target TARGET Target (container, local)"); + Put_Line (" --tag TAG Container image tag (default: latest)"); + Put_Line (" --push Push image to registry after build"); + Put_Line (""); + Put_Line ("Examples:"); + Put_Line (" must build Run the 'build' task"); + Put_Line (" must --list List all tasks"); + Put_Line (" must apply --template ada_package --var module=Test"); + Put_Line (" must check --strict"); + Put_Line (" must deploy Build container from Containerfile"); + Put_Line (" must deploy --tag v1.0 --push"); + end Show_Help; + + procedure Show_Version is + begin + Put_Line ("must " & Version_String); + end Show_Version; + +end CLI_Parser; diff --git a/mustfile/src/cli/cli_parser.ads b/mustfile/src/cli/cli_parser.ads new file mode 100644 index 0000000..60908d3 --- /dev/null +++ b/mustfile/src/cli/cli_parser.ads @@ -0,0 +1,59 @@ +-- cli_parser.ads +-- Command-line argument parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package CLI_Parser is + + -- Command types + type Command_Type is + (Cmd_None, + Cmd_Help, + Cmd_Version, + Cmd_List, + Cmd_Init, + Cmd_Run_Task, + Cmd_Apply, + Cmd_Check, + Cmd_Fix, + Cmd_Enforce, + Cmd_Templates, + Cmd_Deploy); + + -- Parsed arguments + type Parsed_Args is record + Command : Command_Type := Cmd_None; + Task_Name : Unbounded_String; + Template_Name : Unbounded_String; + Variables : String_Map; + Vars_File : Unbounded_String; + Strict : Boolean := False; + Dry_Run : Boolean := False; + Verbose : Boolean := False; + Extra_Args : String_Vector; + -- Deploy-specific options + Deploy_Target : Unbounded_String; -- Target OS/container + Deploy_Push : Boolean := False; -- Push to registry + Deploy_Tag : Unbounded_String; -- Container tag + end record; + + -- Parse command-line arguments + function Parse return Parsed_Args; + + -- Get raw arguments as a vector + function Get_Arguments return String_Vector; + + -- Show help text + procedure Show_Help; + + -- Show version + procedure Show_Version; + + -- Parse error exception + Parse_Error : exception; + +end CLI_Parser; diff --git a/mustfile/src/config/mustfile_loader.adb b/mustfile/src/config/mustfile_loader.adb new file mode 100644 index 0000000..1b6cdf8 --- /dev/null +++ b/mustfile/src/config/mustfile_loader.adb @@ -0,0 +1,218 @@ +-- mustfile_loader.adb +-- Mustfile configuration loader for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Directories; +with Ada.Text_IO; use Ada.Text_IO; +with TOML_Parser; use TOML_Parser; + +package body Mustfile_Loader is + + function Mustfile_Exists return Boolean is + begin + return Ada.Directories.Exists (Mustfile_Name); + end Mustfile_Exists; + + function Load return Mustfile_Config is + begin + return Load (Mustfile_Name); + end Load; + + function Load (Path : String) return Mustfile_Config is + Doc : TOML_Document; + Config : Mustfile_Config; + begin + Doc := Parse_File (Path); + + -- Load project section + Config.Project.Name := To_Unbounded (Get_String (Doc, "project.name", "")); + Config.Project.Version := To_Unbounded (Get_String (Doc, "project.version", "")); + Config.Project.License := To_Unbounded (Get_String (Doc, "project.license", "")); + Config.Project.Author := To_Unbounded (Get_String (Doc, "project.author", "")); + + -- Load tasks section + declare + Task_Keys : constant String_Vector := Get_Table_Keys (Doc, "tasks"); + begin + for Key of Task_Keys loop + declare + Task_Path : constant String := "tasks." & Key; + T : Task_Def; + begin + T.Name := To_Unbounded (Key); + T.Description := To_Unbounded + (Get_String (Doc, Task_Path & ".description", "")); + T.Commands := Get_String_Array (Doc, Task_Path & ".commands"); + T.Dependencies := Get_String_Array (Doc, Task_Path & ".dependencies"); + T.Script := To_Unbounded + (Get_String (Doc, Task_Path & ".script", "")); + T.Working_Dir := To_Unbounded + (Get_String (Doc, Task_Path & ".working_dir", "")); + Config.Tasks.Append (T); + end; + end loop; + end; + + -- Load variables section + declare + Var_Keys : constant String_Vector := Get_Table_Keys (Doc, "variables"); + begin + for Key of Var_Keys loop + Config.Variables.Insert + (Key, Get_String (Doc, "variables." & Key, "")); + end loop; + end; + + -- Load requirements section + declare + Must_Have : constant String_Vector := + Get_String_Array (Doc, "requirements.must_have"); + Must_Not_Have : constant String_Vector := + Get_String_Array (Doc, "requirements.must_not_have"); + begin + for Path of Must_Have loop + Config.Requirements.Append + ((Kind => Must_Types.Must_Have, + Path => To_Unbounded (Path), + Pattern => To_Unbounded (""))); + end loop; + + for Path of Must_Not_Have loop + Config.Requirements.Append + ((Kind => Must_Not_Have, + Path => To_Unbounded (Path), + Pattern => To_Unbounded (""))); + end loop; + end; + + -- Load templates section + declare + Template_Keys : constant String_Vector := Get_Table_Keys (Doc, "templates"); + begin + for Key of Template_Keys loop + declare + Tpl_Path : constant String := "templates." & Key; + T : Template_Def; + begin + T.Name := To_Unbounded (Key); + T.Source := To_Unbounded + (Get_String (Doc, Tpl_Path & ".source", "")); + T.Destination := To_Unbounded + (Get_String (Doc, Tpl_Path & ".destination", "")); + T.Description := To_Unbounded + (Get_String (Doc, Tpl_Path & ".description", "")); + Config.Templates.Append (T); + end; + end loop; + end; + + -- Load enforcement section + Config.Enforcement.License := To_Unbounded + (Get_String (Doc, "enforcement.license", "")); + Config.Enforcement.Copyright_Holder := To_Unbounded + (Get_String (Doc, "enforcement.copyright_holder", "")); + Config.Enforcement.Podman_Not_Docker := + Get_Boolean (Doc, "enforcement.podman_not_docker", True); + Config.Enforcement.Gitlab_Not_Github := + Get_Boolean (Doc, "enforcement.gitlab_not_github", True); + Config.Enforcement.No_Trailing_Whitespace := + Get_Boolean (Doc, "enforcement.checks.no_trailing_whitespace", True); + Config.Enforcement.No_Tabs := + Get_Boolean (Doc, "enforcement.checks.no_tabs", True); + Config.Enforcement.Unix_Line_Endings := + Get_Boolean (Doc, "enforcement.checks.unix_line_endings", True); + Config.Enforcement.Max_Line_Length := Natural + (Get_Integer (Doc, "enforcement.checks.max_line_length", 100)); + + return Config; + exception + when TOML_Parser.Parse_Error => + raise Load_Error with "Failed to parse mustfile: " & Path; + end Load; + + procedure Create_Default_Mustfile is + begin + Create_Default_Mustfile (Mustfile_Name); + end Create_Default_Mustfile; + + procedure Create_Default_Mustfile (Path : String) is + F : File_Type; + begin + Create (F, Out_File, Path); + Put_Line (F, "# mustfile.toml"); + Put_Line (F, "# Configuration for Must - task runner + template engine + enforcer"); + Put_Line (F, "# https://gitlab.com/hyperpolymath/must"); + Put_Line (F, ""); + Put_Line (F, "[project]"); + Put_Line (F, "name = ""my-project"""); + Put_Line (F, "version = ""0.1.0"""); + Put_Line (F, "license = ""PMPL-1.0-or-later"""); + Put_Line (F, "author = ""Your Name"""); + Put_Line (F, ""); + Put_Line (F, "# Variables available in tasks and templates"); + Put_Line (F, "[variables]"); + Put_Line (F, "# server = ""production.example.com"""); + Put_Line (F, ""); + Put_Line (F, "# Task definitions"); + Put_Line (F, "[tasks]"); + Put_Line (F, ""); + Put_Line (F, "[tasks.build]"); + Put_Line (F, "description = ""Build the project"""); + Put_Line (F, "commands = [""echo 'Building...'""]"); + Put_Line (F, ""); + Put_Line (F, "[tasks.test]"); + Put_Line (F, "description = ""Run tests"""); + Put_Line (F, "dependencies = [""build""]"); + Put_Line (F, "commands = [""echo 'Testing...'""]"); + Put_Line (F, ""); + Put_Line (F, "[tasks.clean]"); + Put_Line (F, "description = ""Clean build artifacts"""); + Put_Line (F, "commands = [""rm -rf bin/ obj/""]"); + Put_Line (F, ""); + Put_Line (F, "# Requirements enforcement"); + Put_Line (F, "[requirements]"); + Put_Line (F, "must_have = ["); + Put_Line (F, " ""LICENSE"","); + Put_Line (F, " ""README.md"","); + Put_Line (F, "]"); + Put_Line (F, ""); + Put_Line (F, "must_not_have = ["); + Put_Line (F, " ""Makefile"","); + Put_Line (F, " ""Dockerfile"","); + Put_Line (F, "]"); + Put_Line (F, ""); + Put_Line (F, "# Templates"); + Put_Line (F, "[templates]"); + Put_Line (F, ""); + Put_Line (F, "# [templates.ada_package]"); + Put_Line (F, "# source = ""templates/ada/package.ads.mustache"""); + Put_Line (F, "# destination = ""src/{{module_name}}.ads"""); + Put_Line (F, "# description = ""Generate Ada package specification"""); + Put_Line (F, ""); + Put_Line (F, "# Enforcement rules"); + Put_Line (F, "[enforcement]"); + Put_Line (F, "license = ""PMPL-1.0-or-later"""); + Put_Line (F, "copyright_holder = ""Your Name"""); + Put_Line (F, "podman_not_docker = true"); + Put_Line (F, "gitlab_not_github = true"); + Put_Line (F, ""); + Put_Line (F, "[enforcement.checks]"); + Put_Line (F, "no_trailing_whitespace = true"); + Put_Line (F, "no_tabs = true"); + Put_Line (F, "unix_line_endings = true"); + Put_Line (F, "max_line_length = 100"); + Close (F); + + Put_Line ("Created " & Path); + exception + when others => + if Is_Open (F) then + Close (F); + end if; + raise; + end Create_Default_Mustfile; + +end Mustfile_Loader; diff --git a/mustfile/src/config/mustfile_loader.ads b/mustfile/src/config/mustfile_loader.ads new file mode 100644 index 0000000..8eb8243 --- /dev/null +++ b/mustfile/src/config/mustfile_loader.ads @@ -0,0 +1,33 @@ +-- mustfile_loader.ads +-- Mustfile configuration loader for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Mustfile_Loader is + + -- Default mustfile name + Mustfile_Name : constant String := "mustfile.toml"; + + -- Check if mustfile exists in current directory + function Mustfile_Exists return Boolean; + + -- Load mustfile configuration + function Load return Mustfile_Config; + + -- Load mustfile from specific path + function Load (Path : String) return Mustfile_Config; + + -- Create default mustfile + procedure Create_Default_Mustfile; + + -- Create default mustfile at specific path + procedure Create_Default_Mustfile (Path : String); + + -- Load error exception + Load_Error : exception; + +end Mustfile_Loader; diff --git a/mustfile/src/config/toml_parser.adb b/mustfile/src/config/toml_parser.adb new file mode 100644 index 0000000..5fa2664 --- /dev/null +++ b/mustfile/src/config/toml_parser.adb @@ -0,0 +1,469 @@ +-- toml_parser.adb +-- TOML parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; +with Ada.Strings.Fixed; +with Ada.Characters.Handling; +with Ada.Strings.Maps; + +package body TOML_Parser is + + -- Character sets + Whitespace : constant Ada.Strings.Maps.Character_Set := + Ada.Strings.Maps.To_Set (" " & ASCII.HT); + + function Trim (S : String) return String is + begin + return Ada.Strings.Fixed.Trim (S, Whitespace, Whitespace); + end Trim; + + function Starts_With (S : String; Prefix : String) return Boolean is + begin + return S'Length >= Prefix'Length and then + S (S'First .. S'First + Prefix'Length - 1) = Prefix; + end Starts_With; + + function Make_String (S : String) return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_String, + Str_Val => To_Unbounded (S)); + end Make_String; + + function Make_Boolean (B : Boolean) return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Boolean, Bool_Val => B); + end Make_Boolean; + + function Make_Integer (I : Long_Integer) return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Integer, Int_Val => I); + end Make_Integer; + + function Make_Table return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Table, Table_Val => Value_Maps.Empty_Map); + end Make_Table; + + function Make_Array return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Array, Arr_Val => Value_Vectors.Empty_Vector); + end Make_Array; + + -- Parse a quoted string value + function Parse_String_Value (S : String) return String is + Result : Unbounded_String; + I : Positive := S'First; + Quote : Character; + begin + if S'Length < 2 then + raise Parse_Error with "Invalid string: " & S; + end if; + + Quote := S (I); + if Quote /= '"' and then Quote /= ''' then + raise Parse_Error with "String must start with quote: " & S; + end if; + + I := I + 1; + while I <= S'Last loop + if S (I) = Quote then + return To_String (Result); + elsif S (I) = '\' and then I < S'Last then + I := I + 1; + case S (I) is + when 'n' => Append (Result, ASCII.LF); + when 't' => Append (Result, ASCII.HT); + when 'r' => Append (Result, ASCII.CR); + when '\' => Append (Result, '\'); + when '"' => Append (Result, '"'); + when ''' => Append (Result, '''); + when others => Append (Result, S (I)); + end case; + else + Append (Result, S (I)); + end if; + I := I + 1; + end loop; + + raise Parse_Error with "Unterminated string: " & S; + end Parse_String_Value; + + -- Parse a value (string, integer, boolean, array) + function Parse_Value (S : String) return TOML_Value_Access is + Trimmed : constant String := Trim (S); + begin + if Trimmed'Length = 0 then + return Make_String (""); + end if; + + -- Boolean + if Trimmed = "true" then + return Make_Boolean (True); + elsif Trimmed = "false" then + return Make_Boolean (False); + end if; + + -- String + if Trimmed (Trimmed'First) = '"' or else Trimmed (Trimmed'First) = ''' then + return Make_String (Parse_String_Value (Trimmed)); + end if; + + -- Array + if Trimmed (Trimmed'First) = '[' then + declare + Arr : constant TOML_Value_Access := Make_Array; + Inner : constant String := + Trim (Trimmed (Trimmed'First + 1 .. Trimmed'Last - 1)); + Start : Positive := Inner'First; + I : Positive := Inner'First; + Depth : Natural := 0; + In_Str : Boolean := False; + begin + if Inner'Length = 0 then + return Arr; + end if; + + while I <= Inner'Last loop + if not In_Str then + if Inner (I) = '"' then + In_Str := True; + elsif Inner (I) = '[' then + Depth := Depth + 1; + elsif Inner (I) = ']' then + Depth := Depth - 1; + elsif Inner (I) = ',' and Depth = 0 then + Arr.Arr_Val.Append + (Parse_Value (Inner (Start .. I - 1))); + Start := I + 1; + end if; + else + if Inner (I) = '"' and then + (I = Inner'First or else Inner (I - 1) /= '\') + then + In_Str := False; + end if; + end if; + I := I + 1; + end loop; + + -- Last element + if Start <= Inner'Last then + Arr.Arr_Val.Append (Parse_Value (Inner (Start .. Inner'Last))); + end if; + + return Arr; + end; + end if; + + -- Integer + declare + Val : Long_Integer; + begin + Val := Long_Integer'Value (Trimmed); + return Make_Integer (Val); + exception + when others => + -- Not a valid integer, treat as bareword/string + return Make_String (Trimmed); + end; + end Parse_Value; + + -- Navigate to or create path in document + procedure Navigate_Or_Create + (Doc : in out TOML_Document; + Path : String; + Target : out TOML_Value_Access; + Parent : out TOML_Value_Access; + Last_Key : out Unbounded_String) + is + Current : TOML_Value_Access := null; + Dot_Pos : Natural; + Start : Positive := Path'First; + Key : Unbounded_String; + begin + Parent := null; + Last_Key := To_Unbounded (""); + + -- Start with doc root as implicit table + while Start <= Path'Last loop + Dot_Pos := Ada.Strings.Fixed.Index (Path (Start .. Path'Last), "."); + + if Dot_Pos = 0 then + Key := To_Unbounded (Path (Start .. Path'Last)); + Start := Path'Last + 1; + else + Key := To_Unbounded (Path (Start .. Dot_Pos - 1)); + Start := Dot_Pos + 1; + end if; + + if Current = null then + -- At root level + if not Doc.Contains (To_String (Key)) then + Doc.Insert (To_String (Key), Make_Table); + end if; + Parent := null; + Current := Doc (To_String (Key)); + else + -- Inside a table + if Current.Kind /= Val_Table then + raise Parse_Error with "Cannot navigate into non-table: " & Path; + end if; + if not Current.Table_Val.Contains (To_String (Key)) then + Current.Table_Val.Insert (To_String (Key), Make_Table); + end if; + Parent := Current; + Current := Current.Table_Val (To_String (Key)); + end if; + + Last_Key := Key; + end loop; + + Target := Current; + end Navigate_Or_Create; + + function Parse_String (Content : String) return TOML_Document is + Doc : TOML_Document; + Current_Table : Unbounded_String := To_Unbounded (""); + Lines : String_Vector; + Line_Start : Positive := Content'First; + I : Positive := Content'First; + begin + -- Split into lines + while I <= Content'Last loop + if Content (I) = ASCII.LF then + if I > Line_Start then + Lines.Append (Content (Line_Start .. I - 1)); + else + Lines.Append (""); + end if; + Line_Start := I + 1; + end if; + I := I + 1; + end loop; + if Line_Start <= Content'Last then + Lines.Append (Content (Line_Start .. Content'Last)); + end if; + + -- Process each line + for Line of Lines loop + declare + Trimmed : constant String := Trim (Line); + Target : TOML_Value_Access; + Parent : TOML_Value_Access; + Last_Key : Unbounded_String; + begin + -- Skip empty lines and comments + if Trimmed'Length = 0 or else Trimmed (Trimmed'First) = '#' then + null; + + -- Table header [table.name] + elsif Trimmed (Trimmed'First) = '[' then + if Trimmed'Length > 1 and then + Trimmed (Trimmed'First + 1) = '[' + then + -- Array of tables [[table.name]] + Current_Table := To_Unbounded + (Trim (Trimmed (Trimmed'First + 2 .. Trimmed'Last - 2))); + Navigate_Or_Create (Doc, To_String (Current_Table), + Target, Parent, Last_Key); + if Target.Kind /= Val_Array then + -- Convert to array + declare + Arr : constant TOML_Value_Access := Make_Array; + begin + Arr.Arr_Val.Append (Make_Table); + if Parent = null then + Doc.Include (To_String (Last_Key), Arr); + else + Parent.Table_Val.Include (To_String (Last_Key), Arr); + end if; + end; + else + Target.Arr_Val.Append (Make_Table); + end if; + else + -- Regular table [table.name] + Current_Table := To_Unbounded + (Trim (Trimmed (Trimmed'First + 1 .. Trimmed'Last - 1))); + Navigate_Or_Create (Doc, To_String (Current_Table), + Target, Parent, Last_Key); + end if; + + -- Key = value + else + declare + Eq_Pos : constant Natural := + Ada.Strings.Fixed.Index (Trimmed, "="); + begin + if Eq_Pos > 0 then + declare + Key : constant String := + Trim (Trimmed (Trimmed'First .. Eq_Pos - 1)); + Value : constant String := + Trim (Trimmed (Eq_Pos + 1 .. Trimmed'Last)); + Full_Path : constant String := + (if Length (Current_Table) > 0 + then To_String (Current_Table) & "." & Key + else Key); + Val : constant TOML_Value_Access := Parse_Value (Value); + begin + Navigate_Or_Create (Doc, Full_Path, Target, Parent, Last_Key); + if Parent = null then + Doc.Include (To_String (Last_Key), Val); + else + Parent.Table_Val.Include (To_String (Last_Key), Val); + end if; + end; + end if; + end; + end if; + end; + end loop; + + return Doc; + end Parse_String; + + function Parse_File (Filename : String) return TOML_Document is + use Ada.Text_IO; + File : File_Type; + Content : Unbounded_String; + begin + Open (File, In_File, Filename); + while not End_Of_File (File) loop + Append (Content, Get_Line (File)); + Append (Content, ASCII.LF); + end loop; + Close (File); + + return Parse_String (To_String (Content)); + exception + when Name_Error => + raise Parse_Error with "File not found: " & Filename; + when others => + if Is_Open (File) then + Close (File); + end if; + raise; + end Parse_File; + + function Get (Doc : TOML_Document; Path : String) return TOML_Value_Access is + Current : TOML_Value_Access := null; + Dot_Pos : Natural; + Start : Positive := Path'First; + Key : Unbounded_String; + begin + while Start <= Path'Last loop + Dot_Pos := Ada.Strings.Fixed.Index (Path (Start .. Path'Last), "."); + + if Dot_Pos = 0 then + Key := To_Unbounded (Path (Start .. Path'Last)); + Start := Path'Last + 1; + else + Key := To_Unbounded (Path (Start .. Dot_Pos - 1)); + Start := Dot_Pos + 1; + end if; + + if Current = null then + if not Doc.Contains (To_String (Key)) then + return null; + end if; + Current := Doc (To_String (Key)); + else + if Current.Kind /= Val_Table then + return null; + end if; + if not Current.Table_Val.Contains (To_String (Key)) then + return null; + end if; + Current := Current.Table_Val (To_String (Key)); + end if; + end loop; + + return Current; + end Get; + + function Has (Doc : TOML_Document; Path : String) return Boolean is + begin + return Get (Doc, Path) /= null; + end Has; + + function Get_String (Doc : TOML_Document; Path : String; + Default : String := "") return String is + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null then + return Default; + end if; + if Val.Kind /= Val_String then + return Default; + end if; + return To_String (Val.Str_Val); + end Get_String; + + function Get_Boolean (Doc : TOML_Document; Path : String; + Default : Boolean := False) return Boolean is + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null then + return Default; + end if; + if Val.Kind /= Val_Boolean then + return Default; + end if; + return Val.Bool_Val; + end Get_Boolean; + + function Get_Integer (Doc : TOML_Document; Path : String; + Default : Long_Integer := 0) return Long_Integer is + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null then + return Default; + end if; + if Val.Kind /= Val_Integer then + return Default; + end if; + return Val.Int_Val; + end Get_Integer; + + function Get_String_Array (Doc : TOML_Document; Path : String) + return String_Vector + is + Result : String_Vector; + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null or else Val.Kind /= Val_Array then + return Result; + end if; + + for Item of Val.Arr_Val loop + if Item.Kind = Val_String then + Result.Append (To_String (Item.Str_Val)); + end if; + end loop; + + return Result; + end Get_String_Array; + + function Get_Table_Keys (Doc : TOML_Document; Path : String) + return String_Vector + is + Result : String_Vector; + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null or else Val.Kind /= Val_Table then + return Result; + end if; + + for C in Val.Table_Val.Iterate loop + Result.Append (Value_Maps.Key (C)); + end loop; + + return Result; + end Get_Table_Keys; + +end TOML_Parser; diff --git a/mustfile/src/config/toml_parser.ads b/mustfile/src/config/toml_parser.ads new file mode 100644 index 0000000..199aeae --- /dev/null +++ b/mustfile/src/config/toml_parser.ads @@ -0,0 +1,108 @@ +-- toml_parser.ads +-- TOML parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Containers.Indefinite_Ordered_Maps; +with Ada.Containers.Indefinite_Vectors; +with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; +with Must_Types; use Must_Types; + +package TOML_Parser is + + -- TOML value types + type Value_Kind is + (Val_String, + Val_Integer, + Val_Float, + Val_Boolean, + Val_Array, + Val_Table); + + type TOML_Value; + type TOML_Value_Access is access all TOML_Value; + + -- Forward declarations for containers + package Value_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => TOML_Value_Access); + + package Value_Maps is new Ada.Containers.Indefinite_Ordered_Maps + (Key_Type => String, + Element_Type => TOML_Value_Access); + + -- TOML value type (discriminated record) + type TOML_Value (Kind : Value_Kind := Val_String) is record + case Kind is + when Val_String => + Str_Val : Unbounded_String; + when Val_Integer => + Int_Val : Long_Integer; + when Val_Float => + Float_Val : Long_Float; + when Val_Boolean => + Bool_Val : Boolean; + when Val_Array => + Arr_Val : Value_Vectors.Vector; + when Val_Table => + Table_Val : Value_Maps.Map; + end case; + end record; + + -- Root TOML document (table) + type TOML_Document is new Value_Maps.Map with null record; + + -- Parse a TOML file + function Parse_File (Filename : String) return TOML_Document; + + -- Parse a TOML string + function Parse_String (Content : String) return TOML_Document; + + -- Get a value by path (e.g., "project.name") + function Get (Doc : TOML_Document; Path : String) + return TOML_Value_Access; + + -- Get string value + function Get_String (Doc : TOML_Document; Path : String; + Default : String := "") return String; + + -- Get boolean value + function Get_Boolean (Doc : TOML_Document; Path : String; + Default : Boolean := False) return Boolean; + + -- Get integer value + function Get_Integer (Doc : TOML_Document; Path : String; + Default : Long_Integer := 0) return Long_Integer; + + -- Get string array + function Get_String_Array (Doc : TOML_Document; Path : String) + return String_Vector; + + -- Check if path exists + function Has (Doc : TOML_Document; Path : String) return Boolean; + + -- Get table keys at path + function Get_Table_Keys (Doc : TOML_Document; Path : String) + return String_Vector; + + -- Parse error exception + Parse_Error : exception; + + -- Helper: create string value + function Make_String (S : String) return TOML_Value_Access; + + -- Helper: create boolean value + function Make_Boolean (B : Boolean) return TOML_Value_Access; + + -- Helper: create integer value + function Make_Integer (I : Long_Integer) return TOML_Value_Access; + + -- Helper: create empty table + function Make_Table return TOML_Value_Access; + + -- Helper: create empty array + function Make_Array return TOML_Value_Access; + +end TOML_Parser; diff --git a/mustfile/src/deploy/deployer.adb b/mustfile/src/deploy/deployer.adb new file mode 100644 index 0000000..14612d6 --- /dev/null +++ b/mustfile/src/deploy/deployer.adb @@ -0,0 +1,227 @@ +-- deployer.adb +-- Container deployment for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; use Ada.Directories; +with GNAT.OS_Lib; use GNAT.OS_Lib; + +package body Deployer is + + Containerfile_Name : constant String := "Containerfile"; + + function Containerfile_Exists return Boolean is + begin + return Exists (Containerfile_Name); + end Containerfile_Exists; + + function Get_Containerfile_Path return String is + begin + return Containerfile_Name; + end Get_Containerfile_Path; + + procedure Run_Command + (Command : String; + Args : Argument_List_Access; + Dry_Run : Boolean; + Verbose : Boolean) + is + Success : Boolean; + Return_Code : Integer; + begin + if Verbose or Dry_Run then + Put (" $ " & Command); + for I in Args'Range loop + Put (" " & Args (I).all); + end loop; + New_Line; + end if; + + if Dry_Run then + return; + end if; + + Spawn + (Program_Name => Command, + Args => Args.all, + Success => Success); + + if not Success then + raise Deploy_Error with "Command failed: " & Command; + end if; + end Run_Command; + + procedure Build_Container + (Project_Name : String; + Tag : String; + Dry_Run : Boolean; + Verbose : Boolean) + is + Actual_Tag : constant String := + (if Tag'Length > 0 then Tag else "latest"); + Image_Name : constant String := + Project_Name & ":" & Actual_Tag; + Args : Argument_List_Access; + begin + Put_Line ("Building container image: " & Image_Name); + + if not Containerfile_Exists then + raise Deploy_Error with "Containerfile not found"; + end if; + + -- podman build -t : -f Containerfile . + Args := new Argument_List (1 .. 5); + Args (1) := new String'("build"); + Args (2) := new String'("-t"); + Args (3) := new String'(Image_Name); + Args (4) := new String'("-f"); + Args (5) := new String'(Containerfile_Name); + + declare + Dot_Args : Argument_List_Access := new Argument_List (1 .. 6); + begin + Dot_Args (1 .. 5) := Args (1 .. 5); + Dot_Args (6) := new String'("."); + + Run_Command ("podman", Dot_Args, Dry_Run, Verbose); + + -- Free memory + for I in Dot_Args'Range loop + Free (Dot_Args (I)); + end loop; + Free (Dot_Args); + end; + + if not Dry_Run then + Put_Line ("Container image built successfully: " & Image_Name); + end if; + end Build_Container; + + procedure Push_Container + (Project_Name : String; + Tag : String; + Registry : String; + Dry_Run : Boolean; + Verbose : Boolean) + is + Actual_Tag : constant String := + (if Tag'Length > 0 then Tag else "latest"); + Local_Image : constant String := Project_Name & ":" & Actual_Tag; + Remote_Image : constant String := + Registry & "/" & Project_Name & ":" & Actual_Tag; + Args : Argument_List_Access; + begin + Put_Line ("Pushing container image to registry..."); + + -- Tag for registry: podman tag + Args := new Argument_List (1 .. 3); + Args (1) := new String'("tag"); + Args (2) := new String'(Local_Image); + Args (3) := new String'(Remote_Image); + + Run_Command ("podman", Args, Dry_Run, Verbose); + + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + + -- Push: podman push + Args := new Argument_List (1 .. 2); + Args (1) := new String'("push"); + Args (2) := new String'(Remote_Image); + + Run_Command ("podman", Args, Dry_Run, Verbose); + + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + + if not Dry_Run then + Put_Line ("Image pushed: " & Remote_Image); + end if; + end Push_Container; + + procedure Deploy + (Config : Mustfile_Config; + Target : String; + Tag : String; + Push : Boolean; + Dry_Run : Boolean; + Verbose : Boolean) + is + Project_Name : constant String := To_String (Config.Project.Name); + Actual_Target : Deploy_Target_Type := Target_Container; + begin + -- Parse target + if Target'Length > 0 then + if Target = "local" then + Actual_Target := Target_Local; + elsif Target = "container" then + Actual_Target := Target_Container; + else + raise Deploy_Error with "Unknown target: " & Target & + " (use 'container' or 'local')"; + end if; + end if; + + case Actual_Target is + when Target_Container => + if not Containerfile_Exists then + raise Deploy_Error with + "Containerfile not found. Create one or use --target local"; + end if; + + Put_Line ("Deploying via container..."); + Build_Container (Project_Name, Tag, Dry_Run, Verbose); + + if Push then + -- Use default registry from config or environment + Push_Container + (Project_Name => Project_Name, + Tag => Tag, + Registry => "ghcr.io/hyperpolymath", + Dry_Run => Dry_Run, + Verbose => Verbose); + end if; + + if not Dry_Run then + Put_Line ("Deployment complete!"); + New_Line; + Put_Line ("Run the container:"); + Put_Line (" podman run --rm -it " & Project_Name & ":" & + (if Tag'Length > 0 then Tag else "latest")); + end if; + + when Target_Local => + Put_Line ("Building locally..."); + declare + Args : Argument_List_Access := new Argument_List (1 .. 3); + begin + Args (1) := new String'("-P"); + Args (2) := new String'("must.gpr"); + Args (3) := new String'("-XMODE=release"); + + Run_Command ("gprbuild", Args, Dry_Run, Verbose); + + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + end; + + if not Dry_Run then + Put_Line ("Local build complete: bin/must"); + end if; + end case; + + exception + when E : Deploy_Error => + raise; + end Deploy; + +end Deployer; diff --git a/mustfile/src/deploy/deployer.ads b/mustfile/src/deploy/deployer.ads new file mode 100644 index 0000000..4d2865a --- /dev/null +++ b/mustfile/src/deploy/deployer.ads @@ -0,0 +1,48 @@ +-- deployer.ads +-- Container deployment for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Deployer is + + -- Deploy target types + type Deploy_Target_Type is (Target_Container, Target_Local); + + -- Check if Containerfile exists + function Containerfile_Exists return Boolean; + + -- Get the Containerfile path + function Get_Containerfile_Path return String; + + -- Deploy the project + procedure Deploy + (Config : Mustfile_Config; + Target : String; + Tag : String; + Push : Boolean; + Dry_Run : Boolean; + Verbose : Boolean); + + -- Build container image + procedure Build_Container + (Project_Name : String; + Tag : String; + Dry_Run : Boolean; + Verbose : Boolean); + + -- Push container image to registry + procedure Push_Container + (Project_Name : String; + Tag : String; + Registry : String; + Dry_Run : Boolean; + Verbose : Boolean); + + -- Deploy error exception + Deploy_Error : exception; + +end Deployer; diff --git a/mustfile/src/must.adb b/mustfile/src/must.adb new file mode 100644 index 0000000..9b76fad --- /dev/null +++ b/mustfile/src/must.adb @@ -0,0 +1,225 @@ +-- must.adb +-- Main entry point for Must - task runner + template engine + enforcer +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Command_Line; +with Ada.Exceptions; + +with Must_Types; use Must_Types; +with CLI_Parser; use CLI_Parser; +with Mustfile_Loader; +with Task_Runner; +with Mustache_Engine; +with Requirement_Checker; +with Deployer; + +procedure Must is +begin + declare + Args : constant Parsed_Args := Parse; + Config : Mustfile_Config; + begin + case Args.Command is + when Cmd_Help => + Show_Help; + + when Cmd_Version => + Show_Version; + + when Cmd_Init => + if Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml already exists"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + else + Mustfile_Loader.Create_Default_Mustfile; + end if; + + when Cmd_None => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Put_Line ("Run 'must init' to create one"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + else + Put_Line ("Error: No command specified"); + Put_Line ("Run 'must --help' for usage"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end if; + + when Cmd_List => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + Task_Runner.List_Tasks (Config); + + when Cmd_Run_Task => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + declare + Task_Name : constant String := To_String (Args.Task_Name); + begin + if not Task_Runner.Task_Exists (Config, Task_Name) then + Put_Line ("Error: Unknown task '" & Task_Name & "'"); + Put_Line ("Run 'must --list' to see available tasks"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Task_Runner.Run_Task + (Config => Config, + Task_Name => Task_Name, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + end; + + when Cmd_Apply => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + + if Length (Args.Template_Name) > 0 then + -- Apply specific template + Mustache_Engine.Apply_Named + (Config => Config, + Template_Name => To_String (Args.Template_Name), + Variables => Args.Variables, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + else + -- Apply all templates + Mustache_Engine.Apply_All + (Config => Config, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + end if; + + when Cmd_Check => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + begin + Requirement_Checker.Check + (Config => Config, + Strict => Args.Strict, + Verbose => Args.Verbose); + exception + when Requirement_Checker.Requirement_Failed => + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end; + + when Cmd_Fix => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + Requirement_Checker.Fix + (Config => Config, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + + when Cmd_Enforce => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + begin + Requirement_Checker.Enforce + (Config => Config, + Strict => Args.Strict, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + exception + when Requirement_Checker.Requirement_Failed => + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end; + + when Cmd_Templates => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + Mustache_Engine.List_Templates (Config); + + when Cmd_Deploy => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + begin + Deployer.Deploy + (Config => Config, + Target => To_String (Args.Deploy_Target), + Tag => To_String (Args.Deploy_Tag), + Push => Args.Deploy_Push, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + exception + when Deployer.Deploy_Error => + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end; + end case; + end; + +exception + when E : CLI_Parser.Parse_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Mustfile_Loader.Load_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Task_Runner.Task_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Task_Runner.Circular_Dependency => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Mustache_Engine.Template_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Deployer.Deploy_Error => + Put_Line ("Deploy error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : others => + Put_Line ("Unexpected error: " & Ada.Exceptions.Exception_Message (E)); + Put_Line ("Exception: " & Ada.Exceptions.Exception_Name (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + +end Must; diff --git a/mustfile/src/must_types.adb b/mustfile/src/must_types.adb new file mode 100644 index 0000000..b0659a1 --- /dev/null +++ b/mustfile/src/must_types.adb @@ -0,0 +1,11 @@ +-- must_types.adb +-- Common type definitions for Must (body) +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +package body Must_Types is + -- Currently no implementation needed; all types and renames are in spec + null; +end Must_Types; diff --git a/mustfile/src/must_types.ads b/mustfile/src/must_types.ads new file mode 100644 index 0000000..993984a --- /dev/null +++ b/mustfile/src/must_types.ads @@ -0,0 +1,121 @@ +-- must_types.ads +-- Common type definitions for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Containers.Indefinite_Vectors; +with Ada.Containers.Indefinite_Ordered_Maps; +with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; + +package Must_Types is + + -- String vector type + package String_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => String); + + subtype String_Vector is String_Vectors.Vector; + + -- String-to-String map type + package String_Maps is new Ada.Containers.Indefinite_Ordered_Maps + (Key_Type => String, + Element_Type => String); + + subtype String_Map is String_Maps.Map; + + -- Unbounded string vector + package Unbounded_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => Unbounded_String); + + subtype Unbounded_Vector is Unbounded_Vectors.Vector; + + -- Task definition + type Task_Def is record + Name : Unbounded_String; + Description : Unbounded_String; + Commands : String_Vector; + Dependencies : String_Vector; + Script : Unbounded_String; + Working_Dir : Unbounded_String; + end record; + + -- Task vector + package Task_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => Task_Def); + + subtype Task_Vector is Task_Vectors.Vector; + + -- Requirement kind + type Requirement_Kind is (Must_Have, Must_Not_Have, Must_Contain); + + -- Requirement definition + type Requirement_Def is record + Kind : Requirement_Kind; + Path : Unbounded_String; + Pattern : Unbounded_String; -- For Must_Contain + end record; + + -- Requirement vector + package Requirement_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => Requirement_Def); + + subtype Requirement_Vector is Requirement_Vectors.Vector; + + -- Template definition + type Template_Def is record + Name : Unbounded_String; + Source : Unbounded_String; + Destination : Unbounded_String; + Description : Unbounded_String; + end record; + + -- Template vector + package Template_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => Template_Def); + + subtype Template_Vector is Template_Vectors.Vector; + + -- Project configuration + type Project_Config is record + Name : Unbounded_String; + Version : Unbounded_String; + License : Unbounded_String; + Author : Unbounded_String; + end record; + + -- Enforcement configuration + type Enforcement_Config is record + License : Unbounded_String; + Copyright_Holder : Unbounded_String; + Podman_Not_Docker : Boolean := True; + Gitlab_Not_Github : Boolean := True; + No_Trailing_Whitespace : Boolean := True; + No_Tabs : Boolean := True; + Unix_Line_Endings : Boolean := True; + Max_Line_Length : Natural := 100; + end record; + + -- Full mustfile configuration + type Mustfile_Config is record + Project : Project_Config; + Tasks : Task_Vector; + Variables : String_Map; + Requirements : Requirement_Vector; + Templates : Template_Vector; + Enforcement : Enforcement_Config; + end record; + + -- Helper functions + function To_String (S : Unbounded_String) return String + renames Ada.Strings.Unbounded.To_String; + + function To_Unbounded (S : String) return Unbounded_String + renames Ada.Strings.Unbounded.To_Unbounded_String; + +end Must_Types; diff --git a/mustfile/src/requirements/requirement_checker.adb b/mustfile/src/requirements/requirement_checker.adb new file mode 100644 index 0000000..8652bd9 --- /dev/null +++ b/mustfile/src/requirements/requirement_checker.adb @@ -0,0 +1,253 @@ +-- requirement_checker.adb +-- Requirements checker for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; +with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; +with Ada.Strings.Fixed; + +package body Requirement_Checker is + + -- Check if a path exists (file or directory) + function Path_Exists (Path : String) return Boolean is + begin + return Ada.Directories.Exists (Path); + end Path_Exists; + + -- Check if file contains pattern + function File_Contains (Path : String; Pattern : String) return Boolean is + F : File_Type; + Content : Unbounded_String; + begin + if not Ada.Directories.Exists (Path) then + return False; + end if; + + -- Only check files, not directories + if Ada.Directories.Kind (Path) /= Ada.Directories.Ordinary_File then + return False; + end if; + + Open (F, In_File, Path); + while not End_Of_File (F) loop + Append (Content, Get_Line (F)); + Append (Content, ASCII.LF); + end loop; + Close (F); + + return Ada.Strings.Fixed.Index (To_String (Content), Pattern) > 0; + exception + when others => + if Is_Open (F) then + Close (F); + end if; + return False; + end File_Contains; + + function Check_Requirement (Req : Requirement_Def) return Check_Result is + Path : constant String := To_String (Req.Path); + Pattern : constant String := To_String (Req.Pattern); + Result : Check_Result; + begin + Result.Requirement := Req; + + case Req.Kind is + when Must_Have => + if Path_Exists (Path) then + Result.Passed := True; + Result.Message := To_Unbounded ("OK: " & Path & " exists"); + else + Result.Passed := False; + Result.Message := To_Unbounded ("MISSING: " & Path); + end if; + + when Must_Not_Have => + if not Path_Exists (Path) then + Result.Passed := True; + Result.Message := To_Unbounded + ("OK: " & Path & " does not exist"); + else + Result.Passed := False; + Result.Message := To_Unbounded ("FORBIDDEN: " & Path & " exists"); + end if; + + when Must_Contain => + if File_Contains (Path, Pattern) then + Result.Passed := True; + Result.Message := To_Unbounded + ("OK: " & Path & " contains pattern"); + else + Result.Passed := False; + Result.Message := To_Unbounded + ("MISSING CONTENT: " & Path & " should contain: " & Pattern); + end if; + end case; + + return Result; + end Check_Requirement; + + function Check_All (Config : Mustfile_Config) return Result_Vector is + Results : Result_Vector; + begin + for Req of Config.Requirements loop + Results.Append (Check_Requirement (Req)); + end loop; + return Results; + end Check_All; + + procedure Check + (Config : Mustfile_Config; + Strict : Boolean := False; + Verbose : Boolean := False) + is + Results : constant Result_Vector := Check_All (Config); + Passed_Count : Natural := 0; + Failed_Count : Natural := 0; + begin + if Config.Requirements.Is_Empty then + Put_Line ("No requirements defined"); + return; + end if; + + Put_Line ("Checking requirements:"); + Put_Line (""); + + for R of Results loop + if R.Passed then + Passed_Count := Passed_Count + 1; + if Verbose then + Put_Line (" [PASS] " & To_String (R.Message)); + end if; + else + Failed_Count := Failed_Count + 1; + Put_Line (" [FAIL] " & To_String (R.Message)); + end if; + end loop; + + Put_Line (""); + Put_Line ("Passed:" & Natural'Image (Passed_Count) & + " / Failed:" & Natural'Image (Failed_Count)); + + if Failed_Count > 0 and then Strict then + raise Requirement_Failed with + "Requirements check failed (" & Natural'Image (Failed_Count) & + " violations)"; + end if; + end Check; + + procedure Fix + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Results : constant Result_Vector := Check_All (Config); + Fixed_Count : Natural := 0; + begin + Put_Line ("Fixing violations:"); + Put_Line (""); + + for R of Results loop + if not R.Passed then + case R.Requirement.Kind is + when Must_Have => + -- Create empty file/directory + declare + Path : constant String := To_String (R.Requirement.Path); + begin + if Verbose or Dry_Run then + Put_Line (" Creating: " & Path); + end if; + + if not Dry_Run then + -- Check if it's a directory (ends with /) + if Path (Path'Last) = '/' then + Ada.Directories.Create_Path (Path); + else + -- Create empty file + declare + F : File_Type; + begin + Create (F, Out_File, Path); + Close (F); + end; + end if; + Fixed_Count := Fixed_Count + 1; + end if; + end; + + when Must_Not_Have => + -- Delete file/directory + declare + Path : constant String := To_String (R.Requirement.Path); + begin + if Verbose or Dry_Run then + Put_Line (" Removing: " & Path); + end if; + + if not Dry_Run then + if Ada.Directories.Exists (Path) then + if Ada.Directories.Kind (Path) = + Ada.Directories.Directory + then + Ada.Directories.Delete_Tree (Path); + else + Ada.Directories.Delete_File (Path); + end if; + Fixed_Count := Fixed_Count + 1; + end if; + end if; + end; + + when Must_Contain => + -- Cannot auto-fix content requirements + Put_Line (" Cannot auto-fix: " & To_String (R.Message)); + end case; + end if; + end loop; + + if Dry_Run then + Put_Line ("(dry run - no changes made)"); + else + Put_Line (""); + Put_Line ("Fixed:" & Natural'Image (Fixed_Count) & " violations"); + end if; + end Fix; + + procedure Enforce + (Config : Mustfile_Config; + Strict : Boolean := True; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + begin + Put_Line ("=== Enforcement Mode ==="); + Put_Line (""); + + -- Step 1: Check requirements + Put_Line ("Step 1: Check requirements"); + begin + Check (Config, Strict => False, Verbose => Verbose); + exception + when others => + null; -- Continue even if check fails + end; + Put_Line (""); + + -- Step 2: Fix violations + Put_Line ("Step 2: Fix violations"); + Fix (Config, Dry_Run, Verbose); + Put_Line (""); + + -- Step 3: Verify + Put_Line ("Step 3: Verify"); + Check (Config, Strict, Verbose); + Put_Line (""); + + Put_Line ("=== Enforcement Complete ==="); + end Enforce; + +end Requirement_Checker; diff --git a/mustfile/src/requirements/requirement_checker.ads b/mustfile/src/requirements/requirement_checker.ads new file mode 100644 index 0000000..b87d79a --- /dev/null +++ b/mustfile/src/requirements/requirement_checker.ads @@ -0,0 +1,56 @@ +-- requirement_checker.ads +-- Requirements checker for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Containers.Indefinite_Vectors; +with Must_Types; use Must_Types; + +package Requirement_Checker is + + -- Check result + type Check_Result is record + Passed : Boolean; + Message : Unbounded_String; + Requirement : Requirement_Def; + end record; + + -- Check result vector + package Result_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => Check_Result); + + subtype Result_Vector is Result_Vectors.Vector; + + -- Check all requirements + function Check_All + (Config : Mustfile_Config) return Result_Vector; + + -- Check requirements and report + procedure Check + (Config : Mustfile_Config; + Strict : Boolean := False; + Verbose : Boolean := False); + + -- Fix violations (where possible) + procedure Fix + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Full enforcement (check + apply + verify) + procedure Enforce + (Config : Mustfile_Config; + Strict : Boolean := True; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Check a single requirement + function Check_Requirement (Req : Requirement_Def) return Check_Result; + + -- Requirement check failed + Requirement_Failed : exception; + +end Requirement_Checker; diff --git a/mustfile/src/tasks/task_runner.adb b/mustfile/src/tasks/task_runner.adb new file mode 100644 index 0000000..497fa5f --- /dev/null +++ b/mustfile/src/tasks/task_runner.adb @@ -0,0 +1,281 @@ +-- task_runner.adb +-- Task runner with dependency resolution for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; +with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; +with GNAT.OS_Lib; + +package body Task_Runner is + + -- Check if task name is in vector + function Contains (Vec : String_Vector; Name : String) return Boolean is + begin + for Item of Vec loop + if Item = Name then + return True; + end if; + end loop; + return False; + end Contains; + + function Task_Exists + (Config : Mustfile_Config; + Task_Name : String) return Boolean + is + begin + for T of Config.Tasks loop + if To_String (T.Name) = Task_Name then + return True; + end if; + end loop; + return False; + end Task_Exists; + + function Get_Task + (Config : Mustfile_Config; + Task_Name : String) return Task_Def + is + begin + for T of Config.Tasks loop + if To_String (T.Name) = Task_Name then + return T; + end if; + end loop; + raise Task_Error with "Task not found: " & Task_Name; + end Get_Task; + + -- Internal: depth-first search for topological sort + procedure DFS + (Config : Mustfile_Config; + Task_Name : String; + Visited : in out String_Vector; + In_Stack : in out String_Vector; + Result : in out String_Vector) + is + T : Task_Def; + begin + -- Check for circular dependency + if Contains (In_Stack, Task_Name) then + raise Circular_Dependency with + "Circular dependency detected involving: " & Task_Name; + end if; + + -- Already processed + if Contains (Visited, Task_Name) then + return; + end if; + + -- Add to current path + In_Stack.Append (Task_Name); + + -- Get task and process dependencies + T := Get_Task (Config, Task_Name); + for Dep of T.Dependencies loop + if not Task_Exists (Config, Dep) then + raise Task_Error with + "Task '" & Task_Name & "' depends on unknown task: " & Dep; + end if; + DFS (Config, Dep, Visited, In_Stack, Result); + end loop; + + -- Remove from current path + declare + New_Stack : String_Vector; + begin + for Item of In_Stack loop + if Item /= Task_Name then + New_Stack.Append (Item); + end if; + end loop; + In_Stack := New_Stack; + end; + + -- Mark as visited and add to result + Visited.Append (Task_Name); + Result.Append (Task_Name); + end DFS; + + function Resolve_Dependencies + (Config : Mustfile_Config; + Task_Name : String) return String_Vector + is + Visited : String_Vector; + In_Stack : String_Vector; + Result : String_Vector; + begin + if not Task_Exists (Config, Task_Name) then + raise Task_Error with "Task not found: " & Task_Name; + end if; + + DFS (Config, Task_Name, Visited, In_Stack, Result); + return Result; + end Resolve_Dependencies; + + -- Execute a shell command + function Execute_Command + (Command : String; + Verbose : Boolean) return Integer + is + use GNAT.OS_Lib; + Args : Argument_List_Access; + Success : Boolean; + Status : Integer; + begin + -- Use shell to execute command + Args := new Argument_List (1 .. 2); + Args (1) := new String'("-c"); + Args (2) := new String'(Command); + + Spawn + (Program_Name => "/bin/sh", + Args => Args.all, + Success => Success); + + -- Free arguments + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + + if Success then + return 0; + else + return 1; + end if; + end Execute_Command; + + -- Execute a single task (without dependencies) + procedure Execute_Task + (Config : Mustfile_Config; + T : Task_Def; + Dry_Run : Boolean; + Verbose : Boolean) + is + Original_Dir : constant String := Ada.Directories.Current_Directory; + begin + -- Change to working directory if specified + if Length (T.Working_Dir) > 0 then + if Verbose then + Put_Line (" cd " & To_String (T.Working_Dir)); + end if; + if not Dry_Run then + Ada.Directories.Set_Directory (To_String (T.Working_Dir)); + end if; + end if; + + -- Execute commands or script + if Length (T.Script) > 0 then + -- Execute script + if Verbose or Dry_Run then + Put_Line (" [script]"); + end if; + if not Dry_Run then + declare + Status : Integer; + begin + Status := Execute_Command (To_String (T.Script), Verbose); + if Status /= 0 then + raise Task_Error with + "Script failed with exit code:" & Integer'Image (Status); + end if; + end; + end if; + else + -- Execute commands + for Cmd of T.Commands loop + if Verbose or Dry_Run then + Put_Line (" " & Cmd); + end if; + if not Dry_Run then + declare + Status : Integer; + begin + Status := Execute_Command (Cmd, Verbose); + if Status /= 0 then + raise Task_Error with + "Command failed: " & Cmd; + end if; + end; + end if; + end loop; + end if; + + -- Restore original directory + if Length (T.Working_Dir) > 0 and then not Dry_Run then + Ada.Directories.Set_Directory (Original_Dir); + end if; + end Execute_Task; + + procedure Run_Task + (Config : Mustfile_Config; + Task_Name : String; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Execution_Order : String_Vector; + begin + -- Get execution order (dependencies first) + Execution_Order := Resolve_Dependencies (Config, Task_Name); + + -- Execute tasks in order + for Name of Execution_Order loop + declare + T : constant Task_Def := Get_Task (Config, Name); + begin + if Name = Task_Name then + Put_Line ("Running: " & Name); + else + Put_Line ("Running dependency: " & Name); + end if; + + Execute_Task (Config, T, Dry_Run, Verbose); + end; + end loop; + + if Dry_Run then + Put_Line ("(dry run - no commands executed)"); + else + Put_Line ("Done."); + end if; + end Run_Task; + + procedure List_Tasks (Config : Mustfile_Config) is + Max_Len : Natural := 0; + begin + if Config.Tasks.Is_Empty then + Put_Line ("No tasks defined in mustfile.toml"); + return; + end if; + + -- Find max task name length for alignment + for T of Config.Tasks loop + if Length (T.Name) > Max_Len then + Max_Len := Length (T.Name); + end if; + end loop; + + Put_Line ("Available tasks:"); + Put_Line (""); + + for T of Config.Tasks loop + declare + Name : constant String := To_String (T.Name); + Desc : constant String := To_String (T.Description); + Padding : constant String (1 .. Max_Len - Name'Length + 2) := + (others => ' '); + begin + if Desc'Length > 0 then + Put_Line (" " & Name & Padding & "# " & Desc); + else + Put_Line (" " & Name); + end if; + end; + end loop; + end List_Tasks; + +end Task_Runner; diff --git a/mustfile/src/tasks/task_runner.ads b/mustfile/src/tasks/task_runner.ads new file mode 100644 index 0000000..7d883b8 --- /dev/null +++ b/mustfile/src/tasks/task_runner.ads @@ -0,0 +1,43 @@ +-- task_runner.ads +-- Task runner with dependency resolution for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Task_Runner is + + -- Run a task by name + procedure Run_Task + (Config : Mustfile_Config; + Task_Name : String; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- List all available tasks + procedure List_Tasks (Config : Mustfile_Config); + + -- Check if a task exists + function Task_Exists + (Config : Mustfile_Config; + Task_Name : String) return Boolean; + + -- Get task definition by name + function Get_Task + (Config : Mustfile_Config; + Task_Name : String) return Task_Def; + + -- Resolve dependencies (topological sort) + function Resolve_Dependencies + (Config : Mustfile_Config; + Task_Name : String) return String_Vector; + + -- Task execution error + Task_Error : exception; + + -- Circular dependency error + Circular_Dependency : exception; + +end Task_Runner; diff --git a/mustfile/src/templates/mustache_engine.adb b/mustfile/src/templates/mustache_engine.adb new file mode 100644 index 0000000..cd1159c --- /dev/null +++ b/mustfile/src/templates/mustache_engine.adb @@ -0,0 +1,431 @@ +-- mustache_engine.adb +-- Mustache template engine for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; +with Ada.Strings.Fixed; +with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; + +package body Mustache_Engine is + + -- Read entire file content + function Read_File (Path : String) return String is + F : File_Type; + Content : Unbounded_String; + begin + Open (F, In_File, Path); + while not End_Of_File (F) loop + Append (Content, Get_Line (F)); + if not End_Of_File (F) then + Append (Content, ASCII.LF); + end if; + end loop; + Close (F); + return To_String (Content); + exception + when Name_Error => + raise Template_Error with "Template file not found: " & Path; + when others => + if Is_Open (F) then + Close (F); + end if; + raise; + end Read_File; + + -- Write content to file + procedure Write_File (Path : String; Content : String) is + F : File_Type; + begin + -- Create parent directories if needed + declare + Dir : constant String := Ada.Directories.Containing_Directory (Path); + begin + if Dir'Length > 0 and then not Ada.Directories.Exists (Dir) then + Ada.Directories.Create_Path (Dir); + end if; + end; + + Create (F, Out_File, Path); + Put (F, Content); + Close (F); + exception + when others => + if Is_Open (F) then + Close (F); + end if; + raise; + end Write_File; + + -- Find closing tag for section + function Find_Section_End + (Template : String; + Tag_Name : String; + Start : Positive) return Natural + is + Close_Tag : constant String := "{{/" & Tag_Name & "}}"; + Open_Tag : constant String := "{{#" & Tag_Name & "}}"; + Pos : Natural := Start; + Depth : Natural := 1; + begin + while Pos <= Template'Last - Close_Tag'Length + 1 loop + if Template (Pos .. Pos + Close_Tag'Length - 1) = Close_Tag then + Depth := Depth - 1; + if Depth = 0 then + return Pos; + end if; + elsif Template (Pos .. Pos + Open_Tag'Length - 1) = Open_Tag then + Depth := Depth + 1; + end if; + Pos := Pos + 1; + end loop; + return 0; + end Find_Section_End; + + function Render + (Template : String; + Variables : String_Map) return String + is + Result : Unbounded_String; + I : Positive := Template'First; + Tag_Start : Natural; + Tag_End : Natural; + begin + while I <= Template'Last loop + -- Look for opening tag + Tag_Start := Ada.Strings.Fixed.Index (Template (I .. Template'Last), "{{"); + + if Tag_Start = 0 then + -- No more tags, append rest of template + Append (Result, Template (I .. Template'Last)); + exit; + end if; + + -- Append text before tag + if Tag_Start > I then + Append (Result, Template (I .. Tag_Start - 1)); + end if; + + -- Find closing tag + Tag_End := Ada.Strings.Fixed.Index + (Template (Tag_Start .. Template'Last), "}}"); + + if Tag_End = 0 then + raise Template_Error with "Unclosed tag at position" & + Positive'Image (Tag_Start); + end if; + + -- Process tag + declare + Tag_Content : constant String := + Template (Tag_Start + 2 .. Tag_End - 1); + begin + if Tag_Content'Length = 0 then + -- Empty tag + null; + + elsif Tag_Content (Tag_Content'First) = '#' then + -- Section start {{#name}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last); + Sec_End : constant Natural := + Find_Section_End (Template, Name, Tag_End + 2); + Close_Tag : constant String := "{{/" & Name & "}}"; + begin + if Sec_End = 0 then + raise Template_Error with + "Unclosed section: " & Name; + end if; + + -- Check if variable is truthy + if Variables.Contains (Name) then + declare + Value : constant String := Variables (Name); + begin + if Value'Length > 0 and then Value /= "false" then + -- Render section content + Append (Result, Render + (Template (Tag_End + 2 .. Sec_End - 1), Variables)); + end if; + end; + end if; + + I := Sec_End + Close_Tag'Length; + end; + + elsif Tag_Content (Tag_Content'First) = '^' then + -- Inverted section {{^name}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last); + Sec_End : constant Natural := + Find_Section_End (Template, Name, Tag_End + 2); + Close_Tag : constant String := "{{/" & Name & "}}"; + begin + if Sec_End = 0 then + raise Template_Error with + "Unclosed inverted section: " & Name; + end if; + + -- Render if variable is falsy + if not Variables.Contains (Name) or else + Variables (Name)'Length = 0 or else + Variables (Name) = "false" + then + Append (Result, Render + (Template (Tag_End + 2 .. Sec_End - 1), Variables)); + end if; + + I := Sec_End + Close_Tag'Length; + end; + + elsif Tag_Content (Tag_Content'First) = '/' then + -- Section end (handled above) + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '!' then + -- Comment {{! comment }} + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '>' then + -- Partial {{> partial_name}} + declare + Partial_Name : constant String := Ada.Strings.Fixed.Trim + (Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last), + Ada.Strings.Both); + Partial_Path : constant String := + "templates/" & Partial_Name & ".mustache"; + begin + if Ada.Directories.Exists (Partial_Path) then + -- Load and render the partial with current variables + declare + Partial_Content : constant String := + Read_File (Partial_Path); + begin + Append (Result, Render (Partial_Content, Variables)); + end; + else + -- Partial not found - silently skip (per Mustache spec) + null; + end if; + end; + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '{' and then + Tag_Content (Tag_Content'Last) = '}' + then + -- Unescaped variable {{{name}}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last - 1); + begin + if Variables.Contains (Name) then + Append (Result, Variables (Name)); + end if; + end; + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '&' then + -- Unescaped variable {{&name}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 2 .. Tag_Content'Last); + begin + if Variables.Contains (Ada.Strings.Fixed.Trim + (Name, Ada.Strings.Both)) + then + Append (Result, Variables (Ada.Strings.Fixed.Trim + (Name, Ada.Strings.Both))); + end if; + end; + I := Tag_End + 2; + + else + -- Regular variable {{name}} + declare + Name : constant String := Ada.Strings.Fixed.Trim + (Tag_Content, Ada.Strings.Both); + begin + if Variables.Contains (Name) then + -- HTML escape the value (basic escaping) + declare + Value : constant String := Variables (Name); + Escaped : Unbounded_String; + begin + for C of Value loop + case C is + when '&' => Append (Escaped, "&"); + when '<' => Append (Escaped, "<"); + when '>' => Append (Escaped, ">"); + when '"' => Append (Escaped, """); + when others => Append (Escaped, C); + end case; + end loop; + Append (Result, To_String (Escaped)); + end; + end if; + end; + I := Tag_End + 2; + end if; + end; + end loop; + + return To_String (Result); + end Render; + + function Render_File + (Template_Path : String; + Variables : String_Map) return String + is + Content : constant String := Read_File (Template_Path); + begin + return Render (Content, Variables); + end Render_File; + + -- Render destination path with variables + function Render_Path + (Path : String; + Variables : String_Map) return String + is + begin + return Render (Path, Variables); + end Render_Path; + + procedure Apply_Template + (Source : String; + Destination : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Rendered_Dest : constant String := Render_Path (Destination, Variables); + Content : constant String := Render_File (Source, Variables); + begin + if Verbose or Dry_Run then + Put_Line (" " & Source & " -> " & Rendered_Dest); + end if; + + if not Dry_Run then + Write_File (Rendered_Dest, Content); + end if; + end Apply_Template; + + procedure Apply_All + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Variables : String_Map := Config.Variables; + begin + if Config.Templates.Is_Empty then + Put_Line ("No templates defined"); + return; + end if; + + Put_Line ("Applying templates:"); + + for T of Config.Templates loop + Apply_Template + (Source => To_String (T.Source), + Destination => To_String (T.Destination), + Variables => Variables, + Dry_Run => Dry_Run, + Verbose => Verbose); + end loop; + + if Dry_Run then + Put_Line ("(dry run - no files written)"); + else + Put_Line ("Done."); + end if; + end Apply_All; + + procedure Apply_Named + (Config : Mustfile_Config; + Template_Name : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Merged_Vars : String_Map := Config.Variables; + begin + -- Merge provided variables with config variables + for C in Variables.Iterate loop + Merged_Vars.Include (String_Maps.Key (C), String_Maps.Element (C)); + end loop; + + -- Find and apply template + for T of Config.Templates loop + if To_String (T.Name) = Template_Name then + Put_Line ("Applying template: " & Template_Name); + Apply_Template + (Source => To_String (T.Source), + Destination => To_String (T.Destination), + Variables => Merged_Vars, + Dry_Run => Dry_Run, + Verbose => Verbose); + + if Dry_Run then + Put_Line ("(dry run - no files written)"); + else + Put_Line ("Done."); + end if; + return; + end if; + end loop; + + raise Template_Error with "Template not found: " & Template_Name; + end Apply_Named; + + procedure List_Templates (Config : Mustfile_Config) is + Max_Len : Natural := 0; + begin + if Config.Templates.Is_Empty then + Put_Line ("No templates defined in mustfile.toml"); + return; + end if; + + -- Find max name length + for T of Config.Templates loop + if Length (T.Name) > Max_Len then + Max_Len := Length (T.Name); + end if; + end loop; + + Put_Line ("Available templates:"); + Put_Line (""); + + for T of Config.Templates loop + declare + Name : constant String := To_String (T.Name); + Desc : constant String := To_String (T.Description); + Padding : constant String (1 .. Max_Len - Name'Length + 2) := + (others => ' '); + begin + if Desc'Length > 0 then + Put_Line (" " & Name & Padding & "# " & Desc); + else + Put_Line (" " & Name); + end if; + end; + end loop; + end List_Templates; + + function Template_Exists + (Config : Mustfile_Config; + Template_Name : String) return Boolean + is + begin + for T of Config.Templates loop + if To_String (T.Name) = Template_Name then + return True; + end if; + end loop; + return False; + end Template_Exists; + +end Mustache_Engine; diff --git a/mustfile/src/templates/mustache_engine.ads b/mustfile/src/templates/mustache_engine.ads new file mode 100644 index 0000000..e6215fd --- /dev/null +++ b/mustfile/src/templates/mustache_engine.ads @@ -0,0 +1,55 @@ +-- mustache_engine.ads +-- Mustache template engine for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Mustache_Engine is + + -- Render a template string with variables + function Render + (Template : String; + Variables : String_Map) return String; + + -- Render a template file with variables + function Render_File + (Template_Path : String; + Variables : String_Map) return String; + + -- Apply a template to generate output file + procedure Apply_Template + (Source : String; + Destination : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Apply all templates from config + procedure Apply_All + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Apply a specific template by name + procedure Apply_Named + (Config : Mustfile_Config; + Template_Name : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- List available templates + procedure List_Templates (Config : Mustfile_Config); + + -- Check if template exists + function Template_Exists + (Config : Mustfile_Config; + Template_Name : String) return Boolean; + + -- Template error + Template_Error : exception; + +end Mustache_Engine; diff --git a/mustfile/templates/ada/package.adb.mustache b/mustfile/templates/ada/package.adb.mustache new file mode 100644 index 0000000..3e34cce --- /dev/null +++ b/mustfile/templates/ada/package.adb.mustache @@ -0,0 +1,26 @@ +-- {{module_name}}.adb +-- {{description}} +-- {{copyright}} +-- SPDX-License-Identifier: {{license}} + +pragma Ada_2022; + +package body {{module_name}} is + +{{#procedures}} + procedure {{name}} is + begin + -- Implementation goes here + raise Program_Error with "{{name}} not yet implemented"; + end {{name}}; + +{{/procedures}} +{{#functions}} + function {{name}} return {{return_type}} is + begin + -- Implementation goes here + return {{default_value}}; + end {{name}}; + +{{/functions}} +end {{module_name}}; diff --git a/mustfile/templates/ada/package.ads.mustache b/mustfile/templates/ada/package.ads.mustache new file mode 100644 index 0000000..c4bdd46 --- /dev/null +++ b/mustfile/templates/ada/package.ads.mustache @@ -0,0 +1,34 @@ +-- {{module_name}}.ads +-- {{description}} +-- {{copyright}} +-- SPDX-License-Identifier: {{license}} + +pragma Ada_2022; + +package {{module_name}} is + + -- Public type declarations +{{#has_type}} + type {{type_name}} is private; +{{/has_type}} + + -- Public subprogram declarations +{{#procedures}} + procedure {{name}}; + -- {{description}} +{{/procedures}} + +{{#functions}} + function {{name}} return {{return_type}}; + -- {{description}} +{{/functions}} + +{{#has_type}} +private + + type {{type_name}} is record + Placeholder : Boolean := False; + end record; +{{/has_type}} + +end {{module_name}}; diff --git a/mustfile/templates/elixir/module.ex.mustache b/mustfile/templates/elixir/module.ex.mustache new file mode 100644 index 0000000..9463496 --- /dev/null +++ b/mustfile/templates/elixir/module.ex.mustache @@ -0,0 +1,32 @@ +# {{module_name}} +# {{description}} +# {{copyright}} +# SPDX-License-Identifier: {{license}} + +defmodule {{app_name}}.{{module_name}} do + @moduledoc """ + {{description}} + + ## Examples + + iex> {{app_name}}.{{module_name}}.example() + :ok + """ + +{{#functions}} + @doc """ + {{description}} + + ## Examples + + iex> {{app_name}}.{{module_name}}.{{name}}() + :not_implemented + + """ + @spec {{name}}() :: term() + def {{name}} do + :not_implemented + end + +{{/functions}} +end diff --git a/policy/policy.json b/policy/policy.json new file mode 100644 index 0000000..bc42df6 --- /dev/null +++ b/policy/policy.json @@ -0,0 +1,66 @@ +{ + "deployment": { + "ban_dockerfile": true, + "require_intent_review": false, + "require_must_check": true, + "require_pinned_images": true, + "require_trust_verify": true + }, + "dust": { + "require_idempotent_annotation": true, + "require_precondition_for_wide_blast": true, + "require_recovery_coverage": false + }, + "intend": { + "require_evidence_probes": false, + "require_owner_for_active": false, + "require_target_for_critical": true + }, + "k9": { + "hunt_requires_signature": true, + "kennel_always_allowed": true, + "yard_signature_recommended": true + }, + "must": { + "critical_checks_required": true, + "minimum_checks": 4, + "warnings_block_deploy": false + }, + "policy": { + "description": "Operational policy for the contractiles project", + "name": "contractiles-policy", + "version": "1.0.0" + }, + "required_contractiles": { + "dustfile": true, + "intentfile": true, + "k9_validators": true, + "mustfile": true, + "trustfile": true + }, + "trust": { + "approved_hash_algorithms": [ + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-256", + "SHA3-512", + "SHAKE256", + "BLAKE3" + ], + "approved_signature_algorithms": [ + "Ed25519", + "Ed448", + "Dilithium5", + "SPHINCS+" + ], + "banned_algorithms": [ + "MD5", + "SHA1", + "RC4", + "DES", + "3DES" + ], + "require_pq_for_long_term": true + } +} diff --git a/policy/policy.ncl b/policy/policy.ncl new file mode 100644 index 0000000..febc324 --- /dev/null +++ b/policy/policy.ncl @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# policy.ncl — Contractile system policy configuration. +# +# Defines the policy rules for the contractiles project: +# - Which contractile types are required +# - What checks must pass before deployment +# - Which algorithms are approved for trust operations +# - K9 security level requirements +# +# Evaluated via: nickel export policy/policy.ncl -f json +# Validated via: nickel check policy/policy.ncl +# +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +{ + # Policy metadata + policy = { + name = "contractiles-policy", + version = "1.0.0", + description = "Operational policy for the contractiles project", + }, + + # Which contractile types are required in this project + required_contractiles = { + mustfile = true, + trustfile = true, + dustfile = true, + intentfile = true, + k9_validators = true, + }, + + # Must check policy + must = { + # All checks with severity: critical must pass + critical_checks_required = true, + # Warnings are advisory only + warnings_block_deploy = false, + # Minimum checks required + minimum_checks = 4, + }, + + # Trust verification policy + trust = { + # Approved hash algorithms (no MD5, no SHA1) + approved_hash_algorithms = [ + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-256", + "SHA3-512", + "SHAKE256", + "BLAKE3", + ], + + # Approved signature algorithms + approved_signature_algorithms = [ + "Ed25519", + "Ed448", + "Dilithium5", + "SPHINCS+", + ], + + # Banned algorithms + banned_algorithms = [ + "MD5", + "SHA1", + "RC4", + "DES", + "3DES", + ], + + # Post-quantum readiness + require_pq_for_long_term = true, + }, + + # Dust recovery policy + dust = { + # Every must check should have a corresponding dust recovery + require_recovery_coverage = false, + # Non-idempotent actions must be explicitly marked + require_idempotent_annotation = true, + # Cluster/global blast radius requires precondition + require_precondition_for_wide_blast = true, + }, + + # Intent tracking policy + intend = { + # In-progress intents should have an owner + require_owner_for_active = false, + # Critical intents must have a target date + require_target_for_critical = true, + # All intents should have evidence probes + require_evidence_probes = false, + }, + + # K9 security policy + k9 = { + # Hunt-level components require signatures + hunt_requires_signature = true, + # Kennel-level is always safe + kennel_always_allowed = true, + # Yard-level recommended but not required to sign + yard_signature_recommended = true, + }, + + # Deployment gates + deployment = { + # All critical must checks must pass + require_must_check = true, + # All critical trust verifications must pass + require_trust_verify = true, + # Intent progress must be reviewed + require_intent_review = false, + # Container images must use pinned digests + require_pinned_images = true, + # No Dockerfiles (Containerfile only) + ban_dockerfile = true, + }, +} diff --git a/runners/bust/README.adoc b/runners/bust/README.adoc new file mode 100644 index 0000000..95be239 --- /dev/null +++ b/runners/bust/README.adoc @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += Bust Runner (Reserved) + +The `bust` runner is a reserved placeholder. The `dust` (recovery/rollback) +runner is provided by the unified Rust CLI at `cli/crates/contractile/`. + +Install with: + +[source,bash] +---- +just install-cli +---- + +This creates a `dust` symlink in `~/.local/bin/` pointing to the `contractile` binary. + +== Usage + +[source,bash] +---- +dust status # List available recovery actions +dust rollback NAME # Execute a named rollback +dust replay NAME # Replay a named handler +dust run NAME # Execute any dust action +---- + +== Specification + +See `dustfile/docs/dust-spec.adoc` for the full Dust specification. diff --git a/runners/intend/README.adoc b/runners/intend/README.adoc new file mode 100644 index 0000000..a4d6a8c --- /dev/null +++ b/runners/intend/README.adoc @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += Intend Runner + +The `intend` runner is now provided by the unified Rust CLI at `cli/crates/contractile/`. + +Install with: + +[source,bash] +---- +just install-cli +---- + +This creates an `intend` symlink in `~/.local/bin/` pointing to the `contractile` binary. + +== Usage + +[source,bash] +---- +intend list # Display all intents with status/priority +intend check # Run evidence probes for realisation +intend progress # Summary of intent lifecycle status +---- + +== Specification + +See `intentfile/docs/intent-spec.adoc` for the full Intent specification. diff --git a/runners/just/README.adoc b/runners/just/README.adoc new file mode 100644 index 0000000..d56453e --- /dev/null +++ b/runners/just/README.adoc @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += Just Integration Runner + +The `just` integration is now handled by `contractile gen-just`, which +auto-generates a `contractile.just` file from all A2ML and K9 sources. + +== Usage + +[source,bash] +---- +# Generate contractile.just +contractile gen-just --dir contractiles --output contractile.just + +# Import in your Justfile +# import "contractile.just" + +# Then use generated recipes +just must-check +just trust-verify +just dust-status +just intend-list +---- + +The generated file contains recipes for every executable entry in every +A2ML file, plus stubs for K9 Hunt-level components. diff --git a/runners/must/.gitignore b/runners/must/.gitignore new file mode 100644 index 0000000..c11bd2d --- /dev/null +++ b/runners/must/.gitignore @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# RSR-compliant .gitignore + +# OS & Editor +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ + +# Build +/target/ +/_build/ +/build/ +/dist/ +/out/ + +# Dependencies +/node_modules/ +/vendor/ +/deps/ +/.elixir_ls/ + +# Rust +# Cargo.lock # Keep for binaries + +# Elixir +/cover/ +/doc/ +*.ez +erl_crash.dump + +# Julia +*.jl.cov +*.jl.mem +/Manifest.toml + +# ReScript +/lib/bs/ +/.bsb.lock + +# Python (SaltStack only) +__pycache__/ +*.py[cod] +.venv/ + +# Ada/SPARK +*.ali +/obj/ +/bin/ + +# Haskell +/.stack-work/ +/dist-newstyle/ + +# Chapel +*.chpl.tmp.* + +# Secrets +.env +.env.* +*.pem +*.key +secrets/ + +# Test/Coverage +/coverage/ +htmlcov/ + +# Logs +*.log +/logs/ + +# Temp +/tmp/ +*.tmp +*.bak diff --git a/runners/must/CHANGELOG.adoc b/runners/must/CHANGELOG.adoc new file mode 100644 index 0000000..773a027 --- /dev/null +++ b/runners/must/CHANGELOG.adoc @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0-or-later += Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +== [Unreleased] + +=== Added +- `must deploy` command for container-based deployment +- Containerfile for self-contained build environment +- INSTALL.adoc with comprehensive installation guide +- docs/must-spec.adoc with formal specification and golden examples +- Physical State contract definition +- `[requirements.content]` for file content verification +- `[deploy]` configuration section + +=== Changed +- Updated README with Quick Start and Physical State documentation +- Enhanced CLI help with deploy options + +== [0.1.0] - 2025-12-27 + +=== Added +- Initial release of Must - task runner + template engine + enforcer +- Core commands: init, list, check, fix, enforce, apply, templates +- TOML-based configuration (mustfile.toml) +- Mustache template engine for code generation +- Requirements enforcement (must_have, must_not_have) +- Task dependency resolution with topological sort +- Circular dependency detection +- Ada 2022 implementation with GNAT project file +- Templates for Ada packages and Elixir modules +- Justfile with 40+ recipes +- GitHub Actions workflows (CodeQL, Scorecard, quality checks) +- GitLab CI/CD pipeline + +=== Security +- SHA-pinned all GitHub Actions for supply chain security +- No MD5/SHA1 for security purposes +- HTTPS-only enforcement +- Secret scanning with TruffleHog + +[Unreleased]: https://github.com/hyperpolymath/mustfile/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/hyperpolymath/mustfile/releases/tag/v0.1.0 diff --git a/runners/must/Cargo.toml b/runners/must/Cargo.toml new file mode 100644 index 0000000..d1aadad --- /dev/null +++ b/runners/must/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "must" +version = "1.0.0" +edition = "2021" +authors = ["Jonathan D.A. Jewell "] +license = "PMPL-1.0-or-later" +description = "Authority-First State Orchestrator with Reversible Contracts" +repository = "https://github.com/hyperpolymath/must" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" # For SHA-512/SHAKE256 +ed25519-dalek = "2.0" # For Ed448-like signatures (post-quantum) +walkdir = "2.3" # Filesystem traversal +thiserror = "1.0" # Error handling +clap = { version = "4.0", features = ["derive"] } # CLI parsing +nickel-lang = { git = "https://github.com/tweag/nickel", optional = true } # For Nickel FFI +tokio = { version = "1.0", features = ["full"] } # Async for Podman/processes + +[features] +default = [] +nickel = ["nickel-lang"] # Enable Nickel manifest support diff --git a/runners/must/Containerfile b/runners/must/Containerfile new file mode 100644 index 0000000..d9481ec --- /dev/null +++ b/runners/must/Containerfile @@ -0,0 +1,60 @@ +# Containerfile for Must - task runner + template engine + enforcer +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell +# +# Build: podman build -t must:latest -f Containerfile . +# Run: podman run --rm -it must:latest --help +# Shell: podman run --rm -it --entrypoint /bin/bash must:latest + +# Stage 1: Build environment with GNAT Ada compiler +FROM docker.io/library/debian:bookworm-slim AS builder + +# Install GNAT Ada compiler and build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gnat \ + gprbuild \ + make \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /build + +# Copy source files +COPY must.gpr . +COPY src/ src/ +COPY templates/ templates/ + +# Build release binary +RUN gprbuild -P must.gpr -XMODE=release -j0 + +# Verify binary works +RUN ./bin/must --version && ./bin/must --help + +# Stage 2: Minimal runtime image +FROM docker.io/library/debian:bookworm-slim AS runtime + +# Install minimal runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN useradd -m -s /bin/bash must + +# Copy binary from builder +COPY --from=builder /build/bin/must /usr/local/bin/must + +# Copy templates (needed for template operations) +COPY --from=builder /build/templates /opt/must/templates + +# Set ownership +RUN chown -R must:must /opt/must + +# Switch to non-root user +USER must +WORKDIR /home/must + +# Default entrypoint +ENTRYPOINT ["/usr/local/bin/must"] +CMD ["--help"] diff --git a/runners/must/INSTALL.adoc b/runners/must/INSTALL.adoc new file mode 100644 index 0000000..49d6691 --- /dev/null +++ b/runners/must/INSTALL.adoc @@ -0,0 +1,269 @@ += Must Installation Guide +:author: Jonathan D.A. Jewell +:revnumber: 0.1.0-Alpha +:toc: macro +:toclevels: 2 + +toc::[] + +== Quick Start (Container) + +The fastest way to try Must is via container: + +[source,bash] +---- +# Build the container image +podman build -t must:latest -f Containerfile . + +# Run must --help +podman run --rm must:latest --help + +# Run must on a project (mount current directory) +podman run --rm -v "$(pwd):/project:Z" -w /project must:latest check +---- + +== Native Installation + +=== Prerequisites + +Must requires the GNAT Ada 2022 compiler: + +==== Debian/Ubuntu + +[source,bash] +---- +sudo apt-get update +sudo apt-get install -y gnat gprbuild +---- + +==== Fedora + +[source,bash] +---- +sudo dnf install -y gcc-gnat gprbuild +---- + +==== Arch Linux + +[source,bash] +---- +sudo pacman -S gcc-ada gprbuild +---- + +==== macOS (Homebrew) + +[source,bash] +---- +brew install gnat gprbuild +---- + +==== Guix (Recommended) + +[source,bash] +---- +guix install gnat gprbuild +---- + +==== Nix + +[source,bash] +---- +nix-shell -p gnat gprbuild +---- + +=== Build from Source + +Clone the repository and build: + +[source,bash] +---- +git clone https://github.com/hyperpolymath/must.git +cd must + +# Build debug version +gprbuild -P must.gpr -XMODE=debug + +# Or build release version (optimized) +gprbuild -P must.gpr -XMODE=release + +# Verify it works +./bin/must --version +./bin/must --help +---- + +=== Install System-Wide + +[source,bash] +---- +# Build release and install +gprbuild -P must.gpr -XMODE=release +sudo cp bin/must /usr/local/bin/ + +# Verify installation +must --version +---- + +== Usage Examples + +=== Initialize a New Project + +[source,bash] +---- +mkdir my-project && cd my-project +must init +---- + +This creates a default `mustfile.toml` configuration. + +=== List Available Tasks + +[source,bash] +---- +must list +---- + +=== Run a Task + +[source,bash] +---- +must build # Run the 'build' task +must test # Run the 'test' task +must build --dry-run # Preview without executing +---- + +=== Check Requirements + +[source,bash] +---- +must check # Check project requirements +must check --strict # Fail on warnings +must check --verbose # Show detailed output +---- + +=== Enforce Requirements (Check + Fix + Verify) + +[source,bash] +---- +must enforce # Full enforcement cycle +must enforce --dry-run # Preview what would be fixed +---- + +=== Deploy (Container-based) + +Must uses Podman for deployment: + +[source,bash] +---- +# Build the must binary in a container +podman build -t must:latest -f Containerfile . + +# Run must in a container against your project +podman run --rm -v "$(pwd):/project:Z" -w /project must:latest enforce +---- + +== CI/CD Integration + +=== GitLab CI + +[source,yaml] +---- +stages: + - build + - check + +build: + stage: build + image: registry.gitlab.com/hyperpolymath/must:latest + script: + - must --version + +check: + stage: check + image: registry.gitlab.com/hyperpolymath/must:latest + script: + - must check --strict +---- + +=== GitHub Actions + +[source,yaml] +---- +jobs: + must-check: + runs-on: ubuntu-latest + container: + image: ghcr.io/hyperpolymath/must:latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - run: must check --strict +---- + +== Troubleshooting + +=== GNAT Not Found + +[source,bash] +---- +# Check if GNAT is installed +gnat --version +gprbuild --version + +# If not found, install via your package manager (see Prerequisites) +---- + +=== Build Fails with Missing Dependencies + +Ensure you have the complete GNAT toolchain: + +[source,bash] +---- +# Debian/Ubuntu +sudo apt-get install -y gnat gprbuild + +# The project has no external Ada library dependencies +---- + +=== Permission Denied on Install + +[source,bash] +---- +# Use sudo for system-wide installation +sudo cp bin/must /usr/local/bin/ + +# Or install to user directory +mkdir -p ~/.local/bin +cp bin/must ~/.local/bin/ +export PATH="$HOME/.local/bin:$PATH" +---- + +== Verify Installation + +After installation, verify must works: + +[source,bash] +---- +# Show version +must --version +# Expected: must 0.1.0-alpha + +# Show help +must --help + +# Initialize and check a test project +cd /tmp +mkdir must-test && cd must-test +must init +must list +must check +---- + +== Uninstall + +[source,bash] +---- +# Remove system-wide installation +sudo rm /usr/local/bin/must + +# Or remove user installation +rm ~/.local/bin/must +---- diff --git a/runners/must/Justfile b/runners/must/Justfile new file mode 100644 index 0000000..971d8a5 --- /dev/null +++ b/runners/must/Justfile @@ -0,0 +1,474 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# mustfile - Mustfile Task Runner for RSR Projects +# https://just.systems/man/en/ +# +# IMPORTANT: This file MUST be named "Justfile" (capital J) for RSR compliance. +# Mustfile files MUST also be named "Mustfile" (capital M). +# +# Run `just` to see all available recipes +# Run `just cookbook` to generate docs/just-cookbook.adoc +# Run `just combinations` to see matrix recipe options + +set shell := ["bash", "-uc"] +set dotenv-load := true +set positional-arguments := true + +# Project metadata +project := "must" +version := "0.1.0" +tier := "infrastructure" # 1 | 2 | infrastructure + +# ═══════════════════════════════════════════════════════════════════════════════ +# DEFAULT & HELP +# ═══════════════════════════════════════════════════════════════════════════════ + +# Show all available recipes with descriptions +default: + @just --list --unsorted + +# Show detailed help for a specific recipe +help recipe="": + #!/usr/bin/env bash + if [ -z "{{recipe}}" ]; then + just --list --unsorted + echo "" + echo "Usage: just help " + echo " just cookbook # Generate full documentation" + echo " just combinations # Show matrix recipes" + else + just --show "{{recipe}}" 2>/dev/null || echo "Recipe '{{recipe}}' not found" + fi + +# Show this project's info +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "RSR Tier: {{tier}}" + @echo "Recipes: $(just --summary | wc -w)" + @[ -f STATE.scm ] && grep -oP '\(phase\s+\.\s+\K[^)]+' STATE.scm | head -1 | xargs -I{} echo "Phase: {}" || true + +# ═══════════════════════════════════════════════════════════════════════════════ +# BUILD & COMPILE +# ═══════════════════════════════════════════════════════════════════════════════ + +# Build the project (debug mode) +build *args: + @echo "Building {{project}} (debug)..." + gprbuild -P must.gpr -XMODE=debug {{args}} + +# Build in release mode with optimizations +build-release *args: + @echo "Building {{project}} (release)..." + gprbuild -P must.gpr -XMODE=release {{args}} + +# Build and watch for changes (requires entr) +build-watch: + @echo "Watching for changes..." + find src -name '*.ad[sb]' | entr -c just build + +# Clean build artifacts [reversible: rebuild with `just build`] +clean: + @echo "Cleaning..." + gnatclean -P must.gpr || true + rm -rf obj/ bin/ + +# Deep clean including caches [reversible: rebuild] +clean-all: clean + rm -rf .cache .tmp + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST & QUALITY +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run all tests +test *args: build + @echo "Running tests..." + bin/must --version + bin/must --help + @echo "Tests passed!" + +# Run tests with verbose output +test-verbose: build + @echo "Running tests (verbose)..." + bin/must --version + bin/must --list || echo "No mustfile in current dir (expected)" + +# Verify the tool works +test-smoke: build + @echo "Smoke test..." + bin/must init || true + bin/must --list + bin/must check || true + rm -f mustfile.toml + +# ═══════════════════════════════════════════════════════════════════════════════ +# LINT & FORMAT +# ═══════════════════════════════════════════════════════════════════════════════ + +# Format all source files [reversible: git checkout] +fmt: + @echo "Formatting Ada source files..." + @if command -v gnatpp > /dev/null 2>&1; then \ + find src -name "*.adb" -o -name "*.ads" | xargs -I{} gnatpp -rnb --max-line-length=120 {} 2>/dev/null || true; \ + echo "Formatting complete"; \ + else \ + echo "gnatpp not found - install GNAT Studio or libadalang-tools for formatting"; \ + fi + +# Check formatting without changes +fmt-check: + #!/usr/bin/env bash + echo "Checking Ada formatting..." + if command -v gnatpp > /dev/null 2>&1; then + diff_files=$(find src -name "*.adb" -o -name "*.ads" | while read -r f; do + gnatpp -rnb --max-line-length=120 --pipe "$f" 2>/dev/null | diff -q "$f" - > /dev/null 2>&1 || echo "$f" + done) + if [ -n "$diff_files" ]; then + echo "Files need formatting:" + echo "$diff_files" + exit 1 + fi + echo "All files properly formatted" + else + echo "gnatpp not found - skipping format check" + fi + +# Run linter +lint: + @echo "Linting Ada source files..." + @echo "Compiling with strict warnings (acts as linter)..." + gprbuild -P must.gpr -XMODE=debug -gnatwa -gnatwe -q || exit 1 + @echo "Lint passed - no warnings" + +# Run all quality checks +quality: fmt-check lint test + @echo "All quality checks passed!" + +# Fix all auto-fixable issues [reversible: git checkout] +fix: fmt + @echo "Fixed all auto-fixable issues" + +# ═══════════════════════════════════════════════════════════════════════════════ +# RUN & EXECUTE +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run the application +run *args: build + bin/must {{args}} + +# Run with verbose output +run-verbose *args: build + bin/must --verbose {{args}} + +# Install to /usr/local/bin +install: build-release + @echo "Installing must to /usr/local/bin..." + sudo cp bin/must /usr/local/bin/ + @echo "Installed: $(which must)" + +# ═══════════════════════════════════════════════════════════════════════════════ +# DEPENDENCIES +# ═══════════════════════════════════════════════════════════════════════════════ + +# Install all dependencies +deps: + @echo "Checking Ada/GNAT dependencies..." + @command -v gnat > /dev/null 2>&1 || { echo "ERROR: gnat not found - install GNAT"; exit 1; } + @command -v gprbuild > /dev/null 2>&1 || { echo "ERROR: gprbuild not found - install gprbuild"; exit 1; } + @echo "GNAT: $(gnat --version | head -1)" + @echo "gprbuild: $(gprbuild --version | head -1)" + @echo "All dependencies satisfied (Ada projects have no external runtime dependencies)" + +# Audit dependencies for vulnerabilities +deps-audit: + @echo "Auditing for vulnerabilities..." + @echo "Ada/GNAT security checks:" + @echo " - No external package dependencies (self-contained)" + @echo " - GNAT compiler version: $(gnat --version | head -1)" + @echo "" + @echo "Running supply chain checks..." + @if command -v trivy > /dev/null 2>&1; then \ + trivy fs --severity HIGH,CRITICAL --quiet . || true; \ + else \ + echo " trivy not installed - skipping container/filesystem scan"; \ + fi + @if command -v gitleaks > /dev/null 2>&1; then \ + gitleaks detect --source . --no-git --quiet || true; \ + else \ + echo " gitleaks not installed - skipping secret scan"; \ + fi + @echo "Audit complete" + +# ═══════════════════════════════════════════════════════════════════════════════ +# DOCUMENTATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Generate all documentation +docs: + @mkdir -p docs/generated docs/man + just cookbook + just man + @echo "Documentation generated in docs/" + +# Generate justfile cookbook documentation +cookbook: + #!/usr/bin/env bash + mkdir -p docs + OUTPUT="docs/just-cookbook.adoc" + echo "= {{project}} Justfile Cookbook" > "$OUTPUT" + echo ":toc: left" >> "$OUTPUT" + echo ":toclevels: 3" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "Generated: $(date -Iseconds)" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "== Recipes" >> "$OUTPUT" + echo "" >> "$OUTPUT" + just --list --unsorted | while read -r line; do + if [[ "$line" =~ ^[[:space:]]+([a-z_-]+) ]]; then + recipe="${BASH_REMATCH[1]}" + echo "=== $recipe" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "[source,bash]" >> "$OUTPUT" + echo "----" >> "$OUTPUT" + echo "just $recipe" >> "$OUTPUT" + echo "----" >> "$OUTPUT" + echo "" >> "$OUTPUT" + fi + done + echo "Generated: $OUTPUT" + +# Generate man page +man: + #!/usr/bin/env bash + mkdir -p docs/man + printf '%s\n' \ + ".TH RSR-TEMPLATE-REPO 1 \"$(date +%Y-%m-%d)\" \"{{version}}\" \"RSR Template Manual\"" \ + ".SH NAME" \ + "{{project}} \\- RSR standard repository template" \ + ".SH SYNOPSIS" \ + ".B just" \ + "[recipe] [args...]" \ + ".SH DESCRIPTION" \ + "Canonical template for RSR (Rhodium Standard Repository) projects." \ + ".SH AUTHOR" \ + "Hyperpolymath " \ + > docs/man/{{project}}.1 + echo "Generated: docs/man/{{project}}.1" + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONTAINERS (nerdctl-first, podman-fallback) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Detect container runtime: nerdctl > podman > docker +[private] +container-cmd: + #!/usr/bin/env bash + if command -v nerdctl >/dev/null 2>&1; then + echo "nerdctl" + elif command -v podman >/dev/null 2>&1; then + echo "podman" + elif command -v docker >/dev/null 2>&1; then + echo "docker" + else + echo "ERROR: No container runtime found (install nerdctl, podman, or docker)" >&2 + exit 1 + fi + +# Build container image +container-build tag="latest": + #!/usr/bin/env bash + CTR=$(just container-cmd) + if [ -f Containerfile ]; then + echo "Building with $CTR..." + $CTR build -t {{project}}:{{tag}} -f Containerfile . + else + echo "No Containerfile found" + fi + +# Run container +container-run tag="latest": + #!/usr/bin/env bash + CTR=$(just container-cmd) + $CTR run --rm -it {{project}}:{{tag}} + +# Push container image +container-push registry="ghcr.io/hyperpolymath" tag="latest": + #!/usr/bin/env bash + CTR=$(just container-cmd) + $CTR tag {{project}}:{{tag}} {{registry}}/{{project}}:{{tag}} + $CTR push {{registry}}/{{project}}:{{tag}} + +# ═══════════════════════════════════════════════════════════════════════════════ +# CI & AUTOMATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run full CI pipeline locally +ci: deps quality + @echo "CI pipeline complete!" + +# Install git hooks +install-hooks: + @mkdir -p .git/hooks + @printf '%s\n' '#!/bin/bash' 'just fmt-check || exit 1' 'just lint || exit 1' > .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "Git hooks installed" + +# ═══════════════════════════════════════════════════════════════════════════════ +# SECURITY +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run security audit +security: deps-audit + @echo "=== Security Audit ===" + @command -v gitleaks >/dev/null && gitleaks detect --source . --verbose || true + @command -v trivy >/dev/null && trivy fs --severity HIGH,CRITICAL . || true + @echo "Security audit complete" + +# Generate SBOM +sbom: + @mkdir -p docs/security + @command -v syft >/dev/null && syft . -o spdx-json > docs/security/sbom.spdx.json || echo "syft not found" + +# ═══════════════════════════════════════════════════════════════════════════════ +# VALIDATION & COMPLIANCE +# ═══════════════════════════════════════════════════════════════════════════════ + +# Validate RSR compliance +validate-rsr: + #!/usr/bin/env bash + echo "=== RSR Compliance Check ===" + MISSING="" + for f in .editorconfig .gitignore Justfile RSR_COMPLIANCE.adoc README.adoc; do + [ -f "$f" ] || MISSING="$MISSING $f" + done + for d in .well-known; do + [ -d "$d" ] || MISSING="$MISSING $d/" + done + for f in .well-known/security.txt .well-known/ai.txt .well-known/humans.txt; do + [ -f "$f" ] || MISSING="$MISSING $f" + done + if [ ! -f "guix.scm" ] && [ ! -f ".guix-channel" ] && [ ! -f "flake.nix" ]; then + MISSING="$MISSING guix.scm/flake.nix" + fi + if [ -n "$MISSING" ]; then + echo "MISSING:$MISSING" + exit 1 + fi + echo "RSR compliance: PASS" + +# Validate STATE.scm syntax +validate-state: + @if [ -f "STATE.scm" ]; then \ + guile -c "(primitive-load \"STATE.scm\")" 2>/dev/null && echo "STATE.scm: valid" || echo "STATE.scm: INVALID"; \ + else \ + echo "No STATE.scm found"; \ + fi + +# Full validation suite +validate: validate-rsr validate-state + @echo "All validations passed!" + +# ═══════════════════════════════════════════════════════════════════════════════ +# STATE MANAGEMENT +# ═══════════════════════════════════════════════════════════════════════════════ + +# Update STATE.scm timestamp +state-touch: + @if [ -f "STATE.scm" ]; then \ + sed -i 's/(updated . "[^"]*")/(updated . "'"$(date -Iseconds)"'")/' STATE.scm && \ + echo "STATE.scm timestamp updated"; \ + fi + +# Show current phase from STATE.scm +state-phase: + @grep -oP '\(phase\s+\.\s+\K[^)]+' STATE.scm 2>/dev/null | head -1 || echo "unknown" + +# ═══════════════════════════════════════════════════════════════════════════════ +# GUIX & NIX +# ═══════════════════════════════════════════════════════════════════════════════ + +# Enter Guix development shell (primary) +guix-shell: + guix shell -D -f guix.scm + +# Build with Guix +guix-build: + guix build -f guix.scm + +# Enter Nix development shell (fallback) +nix-shell: + @if [ -f "flake.nix" ]; then nix develop; else echo "No flake.nix"; fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# HYBRID AUTOMATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Run local automation tasks +automate task="all": + #!/usr/bin/env bash + case "{{task}}" in + all) just fmt && just lint && just test && just docs && just state-touch ;; + cleanup) just clean && find . -name "*.orig" -delete && find . -name "*~" -delete ;; + update) just deps && just validate ;; + *) echo "Unknown: {{task}}. Use: all, cleanup, update" && exit 1 ;; + esac + +# ═══════════════════════════════════════════════════════════════════════════════ +# COMBINATORIC MATRIX RECIPES +# ═══════════════════════════════════════════════════════════════════════════════ + +# Build matrix: [debug|release] × [target] × [features] +build-matrix mode="debug" target="" features="": + @echo "Build matrix: mode={{mode}} target={{target}} features={{features}}" + # Customize for your build system + +# Test matrix: [unit|integration|e2e|all] × [verbosity] × [parallel] +test-matrix suite="unit" verbosity="normal" parallel="true": + @echo "Test matrix: suite={{suite}} verbosity={{verbosity}} parallel={{parallel}}" + +# Container matrix: [build|run|push|shell|scan] × [registry] × [tag] +container-matrix action="build" registry="ghcr.io/hyperpolymath" tag="latest": + @echo "Container matrix: action={{action}} registry={{registry}} tag={{tag}}" + +# CI matrix: [lint|test|build|security|all] × [quick|full] +ci-matrix stage="all" depth="quick": + @echo "CI matrix: stage={{stage}} depth={{depth}}" + +# Show all matrix combinations +combinations: + @echo "=== Combinatoric Matrix Recipes ===" + @echo "" + @echo "Build Matrix: just build-matrix [debug|release] [target] [features]" + @echo "Test Matrix: just test-matrix [unit|integration|e2e|all] [verbosity] [parallel]" + @echo "Container: just container-matrix [build|run|push|shell|scan] [registry] [tag]" + @echo "CI Matrix: just ci-matrix [lint|test|build|security|all] [quick|full]" + @echo "" + @echo "Total combinations: ~10 billion" + +# ═══════════════════════════════════════════════════════════════════════════════ +# VERSION CONTROL +# ═══════════════════════════════════════════════════════════════════════════════ + +# Show git status +status: + @git status --short + +# Show recent commits +log count="20": + @git log --oneline -{{count}} + +# ═══════════════════════════════════════════════════════════════════════════════ +# UTILITIES +# ═══════════════════════════════════════════════════════════════════════════════ + +# Count lines of code +loc: + @find . \( -name "*.rs" -o -name "*.ex" -o -name "*.res" -o -name "*.ncl" -o -name "*.scm" \) 2>/dev/null | xargs wc -l 2>/dev/null | tail -1 || echo "0" + +# Show TODO comments +todos: + @grep -rn "TODO\|FIXME" --include="*.rs" --include="*.ex" --include="*.res" . 2>/dev/null || echo "No TODOs" + +# Open in editor +edit: + ${EDITOR:-code} . diff --git a/runners/must/LICENSE.txt b/runners/must/LICENSE.txt new file mode 100644 index 0000000..37eb991 --- /dev/null +++ b/runners/must/LICENSE.txt @@ -0,0 +1,91 @@ +SPDX-License-Identifier: MIT OR PMPL-1.0-or-later +SPDX-FileCopyrightText: 2024-2025 hyperpolymath + +================================================================================ +DUAL LICENSE: MIT OR PMPL-1.0-or-later +================================================================================ + +This project is dual-licensed under the MIT License OR the GNU Affero General +Public License v3.0 or later. You may choose to use, copy, modify, and +distribute this work under the terms of EITHER license (your choice). + +-------------------------------------------------------------------------------- +MIT LICENSE +-------------------------------------------------------------------------------- + +Copyright (c) 2024-2025 hyperpolymath + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- +GNU AFFERO GENERAL PUBLIC LICENSE v3.0 OR LATER +-------------------------------------------------------------------------------- + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see . + +The full text of the AGPL-3.0 is available at: + https://www.gnu.org/licenses/agpl-3.0.txt + +-------------------------------------------------------------------------------- +CHOOSING YOUR LICENSE +-------------------------------------------------------------------------------- + +You may choose to use this work under: + 1. MIT License - Standard permissive open source license + 2. PMPL-1.0-or-later - Copyleft license requiring source disclosure for + network services + +For most uses, MIT License provides maximum flexibility. +For ensuring open source remains open (especially for SaaS/network services), +PMPL-1.0-or-later provides stronger protections. + +================================================================================ +PALIMPSEST PHILOSOPHICAL OVERLAY (NON-BINDING) +================================================================================ + +This project encourages (but does not legally require) adherence to the +principles of the Palimpsest License - a framework for consent-based digital +interaction and the future web. + +Core Principles (Encouraged): + - Respect for emotional and creative lineage + - Transparent AI training practices with explicit consent + - Preservation of metadata and attribution + - Protection of narrative intent and cultural context + +The Palimpsest principles represent our vision for a consent-based internet. +While not legally binding for use of this software, we encourage all users +and contributors to familiarize themselves with these principles. + +Learn more: https://github.com/hyperpolymath/palimpsest-license + +================================================================================ +END OF LICENSE +================================================================================ diff --git a/runners/must/MAINTAINERS.adoc b/runners/must/MAINTAINERS.adoc new file mode 100644 index 0000000..b5154a0 --- /dev/null +++ b/runners/must/MAINTAINERS.adoc @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0-or-later += Maintainers +:toc: preamble + +This document lists the maintainers of this project and their responsibilities. + +== Current Maintainers + +[cols="2,3,2",options="header"] +|=== +| Name | Role | Contact + +| Jonathan D.A. Jewell +| Lead Maintainer +| https://github.com/hyperpolymath[@hyperpolymath] +|=== + +== Responsibilities + +Maintainers are responsible for: + +* Reviewing and merging pull requests +* Triaging issues and feature requests +* Ensuring code quality and security standards +* Managing releases and versioning +* Upholding the project's code of conduct + +== Becoming a Maintainer + +Contributors who demonstrate: + +* Consistent, high-quality contributions +* Understanding of the project's goals and standards +* Constructive participation in discussions +* Commitment to the project's long-term health + +May be invited to become maintainers at the discretion of existing maintainers. + +== Decision Making + +* Routine decisions (bug fixes, minor improvements) can be made by any maintainer +* Significant changes require discussion and consensus among maintainers +* Breaking changes or major features should be discussed in issues before implementation + +== Contact + +For questions about project governance, open an issue or contact the maintainers listed above. diff --git a/runners/must/Mustfile b/runners/must/Mustfile new file mode 100644 index 0000000..bd54bd5 --- /dev/null +++ b/runners/must/Mustfile @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# Mustfile - hyperpolymath mandatory checks +# See: https://github.com/hyperpolymath/mustfile + +version: 1 + +checks: + - name: security + run: just lint + - name: tests + run: just test + - name: format + run: just fmt + diff --git a/runners/must/README.adoc b/runners/must/README.adoc new file mode 100644 index 0000000..2fd652c --- /dev/null +++ b/runners/must/README.adoc @@ -0,0 +1,215 @@ += must - Mustfile Execution Engine +image:https://img.shields.io/badge/License-MPL_2.0-blue.svg[MPL-2.0-or-later,link="https://opensource.org/licenses/MPL-2.0"] + +[NOTE] +==== +**THIS IS THE IMPLEMENTATION** - the `must` binary that executes Mustfiles. + +For the **format specification** (what a Mustfile is), see https://github.com/hyperpolymath/mustfile[hyperpolymath/mustfile]. + +Think of it like: **must:Mustfile :: just:Justfile** +==== + +== License & Philosophy + +This project must declare **MPL-2.0-or-later** for platform/tooling compatibility. + +Philosophy: **Palimpsest**. The Palimpsest-MPL (PMPL) text is provided in `license/PMPL-1.0.txt`, and the canonical source is the palimpsest-license repository. + + + + +:author: Hyperpolymath +:revnumber: 0.2.0-beta +:toc: macro +:toclevels: 3 +:icons: font + +toc::[] + +--- + +== Status: v0.1.0-alpha + +This is the **Ada 2022 implementation** of the Mustfile execution engine. It enforces the **Contract of Physical State** defined in the https://github.com/hyperpolymath/mustfile[Mustfile specification]. + +The v1.0.0 release will stabilize the CLI interface and fully implement the must-spec v1.0. + +=== Implementation vs Specification + +[cols="1,3,3",options="header"] +|=== +|Component |This Repo (must) |Sister Repo (mustfile) + +|**Purpose** +|Implements the execution engine +|Defines the Mustfile format + +|**Contains** +|Ada 2022 source code, CLI binary, runtime logic +|Specification documents, golden examples, test cases + +|**Relation** +|HOW to execute a Mustfile +|WHAT a Mustfile is + +|**Analogy** +|`just` command +|Justfile format +|=== + +For the **Mustfile format specification**, see https://github.com/hyperpolymath/mustfile[hyperpolymath/mustfile]. + +IMPORTANT: This project strictly forbids the use of `Makefiles`. All local tasks are orchestrated via `just`, and all deployment transitions are managed via `must`. + +=== What is Physical State? + +**Physical State** is the complete set of observable facts: + +* **Files Present**: Which files must exist (`must_have`) +* **Files Absent**: Which files must NOT exist (`must_not_have`) +* **Content**: Required strings in specific files (`requirements.content`) +* **Artifacts**: Compiled binaries, container images + +Every `must` operation enforces this contract. + +== Design Philosophy +The deployment architecture is environment-agnostic and prioritises **Just Route** logic. Every operation is treated as a state transition where resources are consumed to produce outputs, ensuring no orphaned state or unvalidated side effects. + +* **Podman First**: Containerisation is the primary deployment vector. +* **Universal Shell Support**: 17 shell variants (bash, oil, nushell, etc.) are supported via referenced scripts. +* **Configuration**: Handled via `nickel` for validated, type-safe manifests. + +== Quick Start + +See link:INSTALL.adoc[INSTALL.adoc] for detailed installation instructions. + +=== Container (Fastest Way) + +[source,bash] +---- +# Build and run must in a container +podman build -t must:latest -f Containerfile . +podman run --rm must:latest --help +---- + +=== Native Build + +[source,bash] +---- +# Install GNAT (Debian/Ubuntu) +sudo apt-get install -y gnat gprbuild + +# Build and install +gprbuild -P must.gpr -XMODE=release +sudo cp bin/must /usr/local/bin/ +---- + +== Prerequisites + +To maintain the integrity of the build and deployment pipeline, ensure the following are installed: + +1. **gnat**: GNAT Ada 2022 compiler. +2. **gprbuild**: GNAT project build system. +3. **just**: Command runner for local development. +4. **podman**: Primary container engine (for deployment). + +[source,bash] +---- +# Debian/Ubuntu +sudo apt-get install -y gnat gprbuild + +# Or use the Containerfile for a self-contained build environment +podman build -t must:latest -f Containerfile . +---- + +== Usage + +=== Local Development +Local tasks are documented in the `cookbook.adoc`. Use `just` to view available recipes: + +[source,bash] +---- +just --list +---- + +=== Deployment + +The `must deploy` command builds and deploys via Containerfile: + +[source,bash] +---- +# Build container image +must deploy + +# Build with specific tag +must deploy --tag v0.1.0 + +# Build and push to registry +must deploy --push + +# Preview without executing +must deploy --dry-run --verbose +---- + +For local (non-container) builds: + +[source,bash] +---- +# Build locally with release optimizations +must deploy --target local +---- + +== Shell Support +This project provides native wrappers for a comprehensive list of shells to ensure compatibility across Linux, Minix, macOS, iOS, Android, and PC (ASIC/Edge-specific environments included). + +Only the **bash** entry point is shown here. All other 16 shell implementations (cmd, oil, ash, csh, dash, elvish, fish, ion, ksh, murex, ngs, nushell, powershell-core, tcsh, tsh, zsh, and minix shell) are located in the link:scripts/shells/[scripts/shells/] directory. + +.scripts/entrypoint.sh (Bash) +[source,bash] +---- +#!/usr/bin/env bash +set -euo pipefail + +# Ensure just is present +if ! command -v just &> /dev/null; then + echo "Error: 'just' not found. Please install via https://just.systems" + exit 1 +fi + +# Route to just recipe +just "$@" +---- + +== Package Management +Supported package managers and deployment routes are managed through `nicagu` (Nickel-Augmented) manifests: + +| OS / Area | Tooling | +| :--- | :--- | +| **Fedora/Silverblue** | `rpm-ostree`, `dnf` | +| **Debian/Ubuntu** | `apt`, `nala` | +| **macOS** | `brew`, `macports` | +| **Windows** | `winget`, `scoop` | +| **Arch** | `pacman` | +| **Cross-platform** | `asdf`, `cargo`, `pwa` | + +== Documentation +* link:INSTALL.adoc[**Installation Guide**]: Build and install instructions for all platforms. +* link:cookbook.adoc[**Cookbook**]: Detailed recipes for all `just` commands. +* link:docs/must-spec.adoc[**must-spec**]: Technical specification for the Mustfile transitions. +* link:docs/man/[**Man Pages**]: CLI help and man documents for all custom binaries. + +== Release & Publishing +The v1.0.0 release will publish both **GitHub Releases** artifacts and a **GHCR** container image. + +Targets: + +* GitHub Releases: tarball + checksums +* GHCR: `ghcr.io/hyperpolymath/must:` + +Example: +[source,bash] +---- +# Build and publish GHCR image +must deploy --tag v1.0.0 --push +---- diff --git a/runners/must/ROADMAP.adoc b/runners/must/ROADMAP.adoc new file mode 100644 index 0000000..4286f2f --- /dev/null +++ b/runners/must/ROADMAP.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0-or-later += must Implementation Roadmap + +[NOTE] +==== +This roadmap tracks the **implementation** development (the `must` binary). + +For the **specification** roadmap (Mustfile format), see https://github.com/hyperpolymath/mustfile/ROADMAP.adoc[mustfile/ROADMAP.adoc]. +==== + +== Current Status + +Implementation v0.1.0-alpha released (Ada 2022). Targeting v1.0.0 with frozen CLI interface and dual publishing (GitHub Releases + GHCR). + +== Milestones + +=== v0.1.0 - Foundation +* [x] Core functionality +* [x] Basic documentation +* [x] CI/CD pipeline + +=== v0.2.0 - Beta Stabilization +* [x] Content requirement checks +* [x] Fixtures for pass/fail/fix/content +* [ ] Spec + CLI drift audit +* [ ] Release checklist + version stamping + +=== v1.0.0 - Stable Release +* [ ] Spec + CLI contract frozen +* [ ] Comprehensive tests + fixtures green +* [ ] GitHub Releases artifacts + checksums +* [ ] GHCR publish pipeline +* [ ] Production-ready docs + +== Future Directions + +_To be determined based on community feedback._ diff --git a/runners/must/SPARK-CLI-PARSER-COMPLETE.md b/runners/must/SPARK-CLI-PARSER-COMPLETE.md new file mode 100644 index 0000000..67b58a4 --- /dev/null +++ b/runners/must/SPARK-CLI-PARSER-COMPLETE.md @@ -0,0 +1,225 @@ +# CLI_Parser Conversion Complete + +**Date:** 2026-02-05 +**Session:** Phase 2 - CLI_Parser & must.adb +**Status:** ✅ COMPLETE + +--- + +## Summary + +Successfully converted CLI_Parser and must.adb to use bounded strings. The argument parsing and main entry point now use memory-safe bounded types. + +--- + +## Files Modified + +### cli_parser.ads +**Changes:** +- Fixed license header (`PMPL-1.0-or-later` → `MPL-2.0`) +- Removed `Ada.Strings.Unbounded` dependency +- Updated `Parsed_Args` record: + ```ada + type Parsed_Args is record + Command : Command_Type := Cmd_None; + Task_Name : Bounded_String; -- was Unbounded_String + Template_Name : Bounded_String; -- was Unbounded_String + Variables : String_Map; -- now uses Bounded_String keys/values + Vars_File : Bounded_Path; -- was Unbounded_String, now long paths + Deploy_Target : Bounded_String; -- was Unbounded_String + Deploy_Tag : Bounded_String; -- was Unbounded_String + -- ... other fields + end record; + ``` + +### cli_parser.adb +**Changes:** +- Fixed license header +- Updated `Get_Arguments` to return `String_Vector` of `Bounded_String`: + ```ada + function Get_Arguments return String_Vector is + Args : String_Vector; + begin + for I in 1 .. Ada.Command_Line.Argument_Count loop + declare + Arg : constant String := Ada.Command_Line.Argument (I); + begin + if Arg'Length > Max_String_Length then + raise Parse_Error with "Argument too long..."; + end if; + Args.Append (Must_Types.To_Bounded (Arg)); + end; + end loop; + return Args; + end Get_Arguments; + ``` + +- Updated `Parse_Var` to use `Bounded_String`: + ```ada + procedure Parse_Var (Arg : Bounded_String) is + Arg_Str : constant String := Must_Types.To_String (Arg); + ... + begin + ... + Result.Variables.Include (Must_Types.To_Bounded (Key), + Must_Types.To_Bounded (Value)); + end Parse_Var; + ``` + +- Updated all `To_Unbounded (Args (I))` calls to just `Args (I)` (already bounded) +- Added length checks for file paths using `To_Bounded_Path` + +### must.adb +**Changes:** +- Removed `with Ada.Strings.Unbounded;` import +- Fixed line 97: + ```ada + -- Before: + if Ada.Strings.Unbounded.Length (Args.Template_Name) > 0 then + + -- After: + if Bounded_Strings.Length (Args.Template_Name) > 0 then + ``` + +--- + +## Compilation Results + +### ✅ Success +```bash +$ gprbuild -P must.gpr -c -u cli_parser.adb +Compile + [Ada] cli_parser.adb +# SUCCESS + +$ gprbuild -P must.gpr -c -u must.adb +Compile + [Ada] must.adb +# SUCCESS +``` + +### ❌ Next Module: task_runner.adb +```bash +$ gprbuild -P must.gpr -XMODE=debug +... +task_runner.adb:20:18: error: operator for private type "Bounded_String" is not directly visible +task_runner.adb:75:24: error: expected Bounded_String, found String +... +# 32+ errors total +``` + +--- + +## Safety Improvements + +### Argument Length Validation +```ada +if Arg'Length > Max_String_Length then + raise Parse_Error with + "Argument too long (max " & Max_String_Length'Image & " chars)"; +end if; +``` + +### Variable Key/Value Bounds +```ada +if Key'Length > Max_String_Length then + raise Parse_Error with "Variable key too long: " & Key; +end if; +if Value'Length > Max_String_Length then + raise Parse_Error with "Variable value too long: " & Value; +end if; +``` + +### Path Length Checks +```ada +if File_Path'Length > Max_Path_Length then + raise Parse_Error with "File path too long"; +end if; +Result.Vars_File := Must_Types.To_Bounded_Path (File_Path); +``` + +--- + +## Benefits Achieved + +1. **No Buffer Overflows** + - All command-line arguments bounded to Max_String_Length (1024) + - File paths bounded to Max_Path_Length (4096) + - Validated at parse time, not execution time + +2. **Type Safety** + - Compiler prevents mixing String and Bounded_String + - Explicit conversions make boundaries clear + - Path types distinct from general strings + +3. **Fail-Fast Behavior** + - Invalid input rejected immediately during parsing + - Clear error messages for oversized arguments + - No silent truncation or undefined behavior + +4. **Memory Predictability** + - `Parsed_Args` size is now constant and known at compile-time + - No heap allocations for argument storage + - Better cache locality + +--- + +## Next Steps + +### Phase 3: Convert task_runner.adb + +**32+ errors to fix:** +1. String comparisons need operator visibility +2. String concatenation needs use clauses +3. Unbounded_String → Bounded_String conversions +4. Bounded_Path for Working_Dir +5. Bounded_Command for commands + +**Strategy:** +```ada +-- Add to task_runner.adb: +use type Bounded_String; +use type Bounded_Path; +use type Bounded_Command; + +-- Or selectively: +use Bounded_Strings; +use Bounded_Paths; +use Bounded_Commands; +``` + +### Remaining Modules + +After task_runner: +- mustfile_loader.adb +- toml_parser.adb +- requirement_checker.adb +- mustache_engine.adb +- deployer.adb + +--- + +## Modules Complete + +| Module | Status | Notes | +|--------|--------|-------| +| **must_types** | ✅ COMPLETE | Foundation with bounded strings | +| **cli_parser** | ✅ COMPLETE | Safe argument parsing | +| **must** | ✅ COMPLETE | Main entry point compiles | +| **task_runner** | 🔄 NEXT | 32+ errors, needs conversion | +| **mustfile_loader** | ⏳ PENDING | After task_runner | +| **toml_parser** | ⏳ PENDING | After mustfile_loader | +| **requirement_checker** | ⏳ PENDING | After toml_parser | +| **mustache_engine** | ⏳ PENDING | After requirement_checker | +| **deployer** | ⏳ PENDING | After mustache_engine | + +--- + +## Session Summary + +✅ **2 modules converted** (cli_parser, must) +✅ **Main program compiles** (must.adb) +✅ **Safe argument parsing** implemented +🎯 **Next:** task_runner conversion (~32 errors to fix) + +**Progress:** ~30% of codebase converted (3/9 modules) diff --git a/runners/must/SPARK-CONVERSION-COMPLETE.md b/runners/must/SPARK-CONVERSION-COMPLETE.md new file mode 100644 index 0000000..f65917c --- /dev/null +++ b/runners/must/SPARK-CONVERSION-COMPLETE.md @@ -0,0 +1,397 @@ +# SPARK Bounded String Conversion - COMPLETE + +**Date:** 2026-02-05 +**Status:** ✅ **ALL MODULES COMPLETE** +**Result:** ZERO ERRORS, FULL BUILD SUCCESS + +--- + +## Executive Summary + +Successfully converted the entire `must` codebase from `Ada.Strings.Unbounded` to bounded strings with fixed maximum lengths. All 9 modules now compile with zero errors and zero warnings, and the final binary builds successfully. + +**Final Build Output:** +```bash +$ gprbuild -P must.gpr +Bind + [gprbind] must.bexch + [Ada] must.ali +Link + [link] must.adb +``` + +--- + +## Modules Converted (9/9 - 100%) + +| # | Module | Status | Lines | Errors | Notes | +|---|--------|--------|-------|--------|-------| +| 1 | **must_types** | ✅ COMPLETE | ~200 | 0 | Foundation - bounded string type system | +| 2 | **cli_parser** | ✅ COMPLETE | ~150 | 0 | Command-line argument parsing | +| 3 | **must** | ✅ COMPLETE | ~100 | 0 | Main program entry point | +| 4 | **task_runner** | ✅ COMPLETE | ~300 | 0 | Task execution engine | +| 5 | **requirement_checker** | ✅ COMPLETE | ~200 | 0 | File system validation | +| 6 | **mustfile_loader** | ✅ COMPLETE | ~250 | 0 | TOML configuration loading | +| 7 | **mustache_engine** | ✅ COMPLETE | ~450 | 0 | Template rendering engine | +| 8 | **toml_parser** | ✅ COMPLETE | ~500 | 0 | TOML parsing library | +| 9 | **deployer** | ✅ COMPLETE | ~220 | 0 | Container deployment | + +**Total:** ~2,370 lines of memory-safe Ada code + +--- + +## Safety Improvements + +### 1. Bounded String Type System + +```ada +-- must_types.ads +Max_Path_Length : constant := 4096; -- File paths +Max_String_Length : constant := 1024; -- General strings +Max_Command_Length : constant := 8192; -- Shell commands +Max_Description_Length : constant := 2048; -- Descriptions + +subtype Bounded_Path is Ada.Strings.Bounded.Bounded_String; -- 4096 +subtype Bounded_String is Ada.Strings.Bounded.Bounded_String; -- 1024 +subtype Bounded_Command is Ada.Strings.Bounded.Bounded_String; -- 8192 +subtype Bounded_Description is Ada.Strings.Bounded.Bounded_String; -- 2048 +``` + +**Benefits:** +- No heap allocations +- Predictable memory usage +- Cache-friendly data structures +- Buffer overflow protection +- Stack-only allocation + +### 2. Type Safety + +All string types are explicitly bounded and checked: + +```ada +type Task_Def is record + Name : Bounded_String; -- Task name (1024 max) + Description : Bounded_Description; -- Description (2048 max) + Commands : Command_Vector; -- Bounded commands (8192 max each) + Working_Dir : Bounded_Path; -- Path (4096 max) + ... +end record with + Predicate => Bounded_Strings.Length (Task_Def.Name) > 0; +``` + +**Type predicates enforce invariants at compile-time:** +- Task names must be non-empty +- Paths must be valid +- Commands must fit in bounds + +### 3. Conversion Safety + +Explicit conversion functions for all bounded types: + +```ada +-- String → Bounded +function To_Bounded (S : String) return Bounded_String; +function To_Bounded_Path (S : String) return Bounded_Path; +function To_Bounded_Command (S : String) return Bounded_Command; +function To_Bounded_Description (S : String) return Bounded_Description; + +-- Bounded → String +function To_String (B : Bounded_String) return String; +function To_Path_String (B : Bounded_Path) return String; +function To_Command_String (B : Bounded_Command) return String; +function To_Description_String (B : Bounded_Description) return String; +``` + +**No implicit conversions** - compiler enforces correct usage + +### 4. Automatic Truncation + +Error messages automatically truncated to prevent buffer overflows: + +```ada +-- requirement_checker.adb +function Make_Message (Msg : String) return Bounded_Description is +begin + if Msg'Length > Max_Description_Length then + return Must_Types.To_Bounded_Description + (Msg (Msg'First .. Msg'First + Max_Description_Length - 4) & "..."); + else + return Must_Types.To_Bounded_Description (Msg); + end if; +end Make_Message; +``` + +### 5. Container Safety + +All containers use bounded string elements: + +```ada +package String_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Bounded_String); +type String_Vector is new String_Vectors.Vector with null record; + +package String_Maps is new Ada.Containers.Ordered_Maps + (Key_Type => Bounded_String, + Element_Type => Bounded_String); +type String_Map is new String_Maps.Map with null record; +``` + +**No unbounded strings in data structures** - all memory bounded + +--- + +## Key Technical Achievements + +### Phase 1-2: cli_parser +- Argument parsing with length validation +- Bounded string vector for arguments +- Explicit conversions for all parameters + +### Phase 3: task_runner +- Task execution with bounded commands +- Dependency resolution with bounded task names +- Shell command construction with bounded strings + +### Phase 4: requirement_checker +- File system validation with bounded paths +- Auto-truncating error messages +- Memory-safe requirement checking + +### Phase 5: mustfile_loader +- TOML parsing with bounded result types +- Explicit conversions for all config fields +- Type-safe configuration loading + +### Phase 6: mustache_engine +- Template rendering with bounded variables +- Helper functions for String→Bounded_String key lookups +- HTML escaping with bounded result buffers + +### Phase 7: toml_parser +- Internal Unbounded_String for parsing (arbitrary TOML values) +- External API returns Bounded_String vectors +- Explicit conversions at API boundary + +### Phase 8: deployer +- Container deployment with bounded paths +- License header updates +- Warning cleanup + +--- + +## Compilation Statistics + +### Before Conversion +- Multiple Unbounded_String heap allocations +- Unpredictable memory usage +- No bounds checking on strings +- Potential buffer overflows + +### After Conversion +```bash +$ gprbuild -P must.gpr -v 2>&1 | grep -E "(Compile|Bind|Link)" +Compile + [Ada] must.adb + [Ada] deployer.adb + [Ada] toml_parser.adb +Bind + [gprbind] must.bexch + [Ada] must.ali +Link + [link] must.adb +``` + +**Result:** +- ✅ 0 errors +- ✅ 0 warnings +- ✅ 100% bounded strings +- ✅ All memory on stack +- ✅ SPARK-ready (when GNATprove installed) + +--- + +## SPARK Readiness + +### Current Status +All code is now SPARK-compatible bounded strings. Ready for formal verification once GNATprove is installed: + +```bash +$ gnatprove -P must.gpr --mode=check +# Will verify all contracts, predicates, and bounds +``` + +### Contracts Added +- Type predicates on record types +- Pre/Post conditions on key functions +- Bounds checking on all conversions +- Memory safety guarantees + +### Next Steps for Full SPARK +1. Install SPARK 23+ toolchain +2. Add `pragma SPARK_Mode` to packages +3. Run GNATprove on codebase +4. Add additional contracts as needed +5. Prove absence of runtime errors + +--- + +## Performance Benefits + +### Memory Usage +- **Before:** Unbounded heap allocations, fragmentation, GC pressure +- **After:** Fixed-size stack allocations, predictable memory footprint + +### Execution Speed +- **Before:** Heap allocation overhead, pointer indirection +- **After:** Direct stack access, cache-friendly data structures + +### Safety +- **Before:** Potential buffer overflows, unbounded growth +- **After:** Compile-time bounds checking, guaranteed memory safety + +--- + +## License Updates + +All files updated to correct license: + +```ada +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) +``` + +**Changed from:** `PMPL-1.0-or-later` (old license) +**Changed to:** `MPL-2.0` (GNAT ecosystem requirement) +**Preferred:** `PMPL-1.0-or-later` (Palimpsest License) + +--- + +## Testing Verification + +### Build Test +```bash +$ gprbuild -P must.gpr +Bind + [gprbind] must.bexch + [Ada] must.ali +Link + [link] must.adb + +$ ./must --version +must 0.1.0 +``` + +### Unit Test Ready +All modules compile and link successfully. Ready for: +- Unit testing +- Integration testing +- SPARK formal verification +- Production deployment + +--- + +## Files Changed Summary + +### Source Files Modified (18 files) +``` +src/must_types.ads ✅ Foundation types +src/must_types.adb ✅ Type conversions +src/cli/cli_parser.ads ✅ API update +src/cli/cli_parser.adb ✅ Bounded parsing +src/must.adb ✅ Main program +src/tasks/task_runner.ads ✅ API update +src/tasks/task_runner.adb ✅ Bounded execution +src/enforcement/requirement_checker.ads ✅ API update +src/enforcement/requirement_checker.adb ✅ Bounded validation +src/config/mustfile_loader.ads ✅ API update +src/config/mustfile_loader.adb ✅ Bounded config +src/templates/mustache_engine.ads ✅ API update +src/templates/mustache_engine.adb ✅ Bounded rendering +src/config/toml_parser.ads ✅ API update +src/config/toml_parser.adb ✅ Bounded parsing +src/deploy/deployer.ads ✅ API update +src/deploy/deployer.adb ✅ License fix +must.gpr ✅ Project file +``` + +### Documentation Added (4 files) +``` +SPARK-REQUIREMENT-CHECKER-COMPLETE.md ✅ Phase 4 completion +SPARK-MUSTACHE-ENGINE-COMPLETE.md ✅ Phase 6 completion +SPARK-CONVERSION-COMPLETE.md ✅ This document +``` + +--- + +## Conclusion + +The entire `must` codebase has been successfully converted to use bounded strings with fixed maximum lengths. All 9 modules compile with zero errors and zero warnings. The code is now: + +✅ **Memory-safe** - No heap allocations, all bounds checked +✅ **Type-safe** - Explicit conversions, no implicit casts +✅ **SPARK-ready** - Compatible with formal verification +✅ **Production-ready** - Full build success, ready for deployment +✅ **Maintainable** - Clear type system, explicit bounds + +**Next steps:** Install SPARK toolchain and begin formal verification! + +--- + +## Session Details + +**Date:** 2026-02-05 +**Duration:** ~8 phases across continuation session +**Lines Changed:** ~2,370 lines across 18 files +**Commits Ready:** All changes staged and ready to commit + +**Session Phases:** +1. ✅ cli_parser conversion +2. ✅ task_runner conversion +3. ✅ requirement_checker conversion +4. ✅ mustfile_loader conversion +5. ✅ mustache_engine conversion +6. ✅ toml_parser conversion +7. ✅ deployer cleanup +8. ✅ Full build verification + +**Final Status:** 🎉 **COMPLETE - 100% SUCCESS** + +--- + +## Post-Build Fix + +### Predicate Issue +After initial successful build, runtime testing revealed a predicate failure in `must_types.ads`: + +```ada +type Mustfile_Config is record + ... +end record with + Predicate => Bounded_Strings.Length (Mustfile_Config.Project.Name) > 0; +``` + +**Problem:** Predicate checked at declaration time, before Config was loaded from file. + +**Solution:** Removed predicate from type declaration. Validation now happens at load time in `mustfile_loader`. + +**Fix Applied:** +```ada +type Mustfile_Config is record + ... +end record; + -- Config validation happens at load time in mustfile_loader +``` + +**Result:** Binary now runs correctly: +```bash +$ ./bin/must --help +Must v0.1.0 +Task runner + template engine + project enforcer +... + +$ ./bin/must --version +must 0.1.0 +``` + +**Final Status:** 🎉 **COMPLETE - 100% SUCCESS - VERIFIED WORKING** diff --git a/runners/must/SPARK-CONVERSION-SESSION-2026-02-05.md b/runners/must/SPARK-CONVERSION-SESSION-2026-02-05.md new file mode 100644 index 0000000..ac8aab6 --- /dev/null +++ b/runners/must/SPARK-CONVERSION-SESSION-2026-02-05.md @@ -0,0 +1,254 @@ +# SPARK Conversion Session Summary + +**Date:** 2026-02-05 +**Session:** SPARK formal verification conversion of must binary +**Status:** Phase 1 Complete (must_types module) + +--- + +## What Was Done + +### 1. License Headers Fixed ✅ +- Changed `PMPL-1.0-or-later` → `MPL-2.0` (GNAT ecosystem requirement) +- Added note: `(PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem)` +- Applied to: `must_types.ads`, `must_types.adb` + +### 2. Type System Converted to Bounded Strings ✅ +**File:** `src/must_types.ads` + +Replaced unbounded strings with bounded variants for memory safety: + +```ada +-- Maximum lengths defined +Max_Path_Length : constant := 4096; +Max_String_Length : constant := 1024; +Max_Command_Length : constant := 8192; +Max_Description_Length : constant := 2048; + +-- Bounded string types +subtype Bounded_String is Bounded_Strings.Bounded_String; +subtype Bounded_Path is Bounded_Paths.Bounded_String; +subtype Bounded_Command is Bounded_Commands.Bounded_String; +subtype Bounded_Description is Bounded_Descriptions.Bounded_String; +``` + +### 3. Type Predicates Added ✅ +Added compile-time invariants to core types: + +```ada +type Task_Def is record + Name : Bounded_String; + Description : Bounded_Description; + Commands : Command_Vector; + Dependencies : String_Vector; + Script : Bounded_Command; + Working_Dir : Bounded_Path; +end record with + Predicate => Bounded_Strings.Length (Task_Def.Name) > 0; + +type Requirement_Def is record + Kind : Requirement_Kind; + Path : Bounded_Path; + Pattern : Bounded_String; +end record with + Predicate => Bounded_Paths.Length (Requirement_Def.Path) > 0; + +type Template_Def is record + Name : Bounded_String; + Source : Bounded_Path; + Destination : Bounded_Path; + Description : Bounded_Description; +end record with + Predicate => Bounded_Strings.Length (Template_Def.Name) > 0 and then + Bounded_Paths.Length (Template_Def.Source) > 0 and then + Bounded_Paths.Length (Template_Def.Destination) > 0; + +type Enforcement_Config is record + License : Bounded_String; + Copyright_Holder : Bounded_String; + Podman_Not_Docker : Boolean := True; + Gitlab_Not_Github : Boolean := True; + No_Trailing_Whitespace : Boolean := True; + No_Tabs : Boolean := True; + Unix_Line_Endings : Boolean := True; + Max_Line_Length : Natural := 100; +end record with + Predicate => Enforcement_Config.Max_Line_Length > 0 and then + Enforcement_Config.Max_Line_Length <= 500; +``` + +### 4. Conversion Helper Functions ✅ +Added type-safe conversion functions with Pre/Post conditions: + +```ada +function To_String (S : Bounded_String) return String with + Post => To_String'Result'Length <= Max_String_Length; + +function To_Bounded (S : String) return Bounded_String with + Pre => S'Length <= Max_String_Length, + Post => Bounded_Strings.To_String (To_Bounded'Result) = S; + +-- Similar functions for: Bounded_Path, Bounded_Command, Bounded_Description +``` + +### 5. GNATprove Configuration Added ✅ +**File:** `must.gpr` + +Added SPARK verification package: + +```ada +package Prove is + for Proof_Switches ("Ada") use + ("--level=4", -- Maximum proof level + "--timeout=60", -- 60 second timeout per proof + "--steps=10000", -- Maximum proof steps + "--counterexamples=on", -- Show counterexamples + "--warnings=error", -- Treat warnings as errors + "--pedantic"); -- Pedantic checking +end Prove; +``` + +### 6. Documentation Created ✅ +- **SPARK-STATUS.md** - Comprehensive conversion roadmap +- **This file** - Session summary + +--- + +## Compilation Status + +### ✅ Success: must_types Module +```bash +$ gprbuild -P must.gpr -c -u must_types.ads +# Compiles without errors +``` + +The core type system now provides: +- **Memory safety** via bounded strings +- **Type invariants** enforced at compile-time +- **Safe conversions** with preconditions +- **SPARK-ready** contracts for formal verification + +### ❌ Blocked: Rest of Codebase +```bash +$ gprbuild -P must.gpr -XMODE=debug +# 4 errors in must.adb (lines 72, 101, 184, 185) +# Still uses Ada.Strings.Unbounded +``` + +--- + +## What's Left + +### Immediate (must.adb - 4 lines) +```ada +-- Line 72: Convert Task_Name +Task_Name : constant String := Must_Types.To_String (Args.Task_Name); + +-- Line 101: Convert Template_Name +Template_Name => Must_Types.To_String (Args.Template_Name), + +-- Line 184: Convert Deploy_Target +Target => Must_Types.To_String (Args.Deploy_Target), + +-- Line 185: Convert Deploy_Tag +Tag => Must_Types.To_String (Args.Deploy_Tag), +``` + +**BUT:** `Args` record (from CLI_Parser) still uses `Unbounded_String`, so need to convert CLI_Parser first. + +### Next Modules (in order) +1. **cli_parser.ads/adb** - Update `Parsed_Args` to use `Bounded_String` +2. **must.adb** - Convert the 4 error lines after CLI_Parser done +3. **mustfile_loader** - File I/O with bounded strings +4. **toml_parser** - TOML parsing with bounded strings +5. **task_runner** - Task execution with contracts +6. **requirement_checker** - Requirement validation +7. **mustache_engine** - Template rendering +8. **deployer** - Deployment orchestration + +--- + +## Benefits Already Gained + +Even without full SPARK verification yet: + +1. **Bounded strings prevent buffer overflows** - All strings have maximum lengths +2. **Type predicates catch errors at compile-time** - Invalid configurations rejected early +3. **Explicit contracts document intent** - Pre/Post conditions serve as documentation +4. **Memory usage is predictable** - No unbounded growth +5. **Safe string conversions** - Preconditions prevent length violations + +--- + +## SPARK Tools Required + +**Status:** GNATprove not installed on this system + +To enable formal verification: +```bash +# Install SPARK Community Edition +# https://www.adacore.com/download + +# Or use Alire: +alr get spark2014 +``` + +Once installed: +```bash +gnatprove -P must.gpr --level=4 +``` + +--- + +## Recommendations + +### Option A: Complete the Conversion (Recommended) +1. Convert `cli_parser` to bounded strings (will fix must.adb errors) +2. Systematically convert remaining modules +3. Install SPARK tools and run verification +4. Document proof obligations +5. Add GNATprove to CI/CD + +**Timeline:** ~1-2 sessions for full conversion + +### Option B: Hybrid Approach +Keep `must_types` as-is (bounded, safe) but add compatibility wrappers for rest of codebase: + +```ada +-- In must_types.ads +function To_Unbounded (S : Bounded_String) return Unbounded_String; +function From_Unbounded (S : Unbounded_String) return Bounded_String; +``` + +**Pros:** Immediate compilation +**Cons:** Loses some safety benefits, conversion overhead + +### Option C: Revert to Unbounded +Revert must_types back to `Unbounded_String`. + +**Pros:** Quick fix +**Cons:** Loses all safety gains, back to square one + +--- + +## Files Modified + +``` +/var$REPOS_DIR/must/ +├── src/ +│ ├── must_types.ads [MODIFIED] Bounded strings + type predicates +│ └── must_types.adb [MODIFIED] License header updated +├── must.gpr [MODIFIED] Added Prove package +├── SPARK-STATUS.md [CREATED] Conversion roadmap +└── SPARK-CONVERSION-SESSION-2026-02-05.md [CREATED] This file +``` + +--- + +## Summary + +✅ **Phase 1 Complete:** Core type system converted to bounded strings with SPARK-ready contracts +⏳ **Next:** Convert CLI_Parser to unblock must.adb +🎯 **Goal:** Full formal verification of must binary for deployment safety assurance + +**Key Achievement:** The foundation (must_types) is now memory-safe and ready for formal verification. diff --git a/runners/must/SPARK-MUSTACHE-ENGINE-COMPLETE.md b/runners/must/SPARK-MUSTACHE-ENGINE-COMPLETE.md new file mode 100644 index 0000000..a8eef95 --- /dev/null +++ b/runners/must/SPARK-MUSTACHE-ENGINE-COMPLETE.md @@ -0,0 +1,244 @@ +# mustache_engine Conversion Complete + +**Date:** 2026-02-05 +**Session:** Phase 6 - mustache_engine +**Status:** ✅ COMPLETE + +--- + +## Summary + +Successfully converted mustache_engine to use bounded strings. The template rendering engine now uses memory-safe bounded types for all variable lookups and string operations. + +--- + +## Files Modified + +### mustache_engine.ads +**Changes:** +- Fixed license header (`PMPL-1.0-or-later` → `MPL-2.0`) +- No API changes needed (already used String_Map from must_types) + +### mustache_engine.adb +**Changes:** +- Fixed license header +- Added `use Ada.Strings.Unbounded` for HTML escaping buffer +- Created helper functions for String→Bounded_String key lookups: + ```ada + function Get_Var (Variables : String_Map; Key : String) return String is + Bounded_Key : Bounded_String; + begin + if Key'Length > Max_String_Length then + return ""; + end if; + Bounded_Key := Must_Types.To_Bounded (Key); + if Variables.Contains (Bounded_Key) then + return Must_Types.To_String (Variables.Element (Bounded_Key)); + else + return ""; + end if; + end Get_Var; + + function Has_Var (Variables : String_Map; Key : String) return Boolean is + Bounded_Key : Bounded_String; + begin + if Key'Length > Max_String_Length then + return False; + end if; + Bounded_Key := Must_Types.To_Bounded (Key); + return Variables.Contains (Bounded_Key); + end Has_Var; + ``` + +- **Render function updates:** + - Line 171: `Variables.Contains (Name)` → `Has_Var (Variables, Name)` + - Line 173: `Variables (Name)` → `Get_Var (Variables, Name)` + - Line 201: `Variables.Contains (Name)` → `Has_Var (Variables, Name)` + - Line 202-203: `Variables (Name)` → `Get_Var (Variables, Name)` + - Line 252: `Variables.Contains (Name)` → `Has_Var (Variables, Name)` + - Line 253: `Variables (Name)` → `Get_Var (Variables, Name)` + - Line 264-268: Simplified and used `Has_Var`/`Get_Var` + - Line 279: `Variables.Contains (Name)` → `Has_Var (Variables, Name)` + - Line 282: `Variables (Name)` → `Get_Var (Variables, Name)` + +- **Apply_All procedure:** + - Line 349: Added `constant` qualifier to Variables + - Line 360-361: `To_String (T.Source/Destination)` → `To_Path_String (T.Source/Destination)` + +- **Apply_Named procedure:** + - Line 393-394: `To_String (T.Source/Destination)` → `To_Path_String (T.Source/Destination)` + +- **List_Templates procedure:** + - Line 421-422: `Length (T.Name)` → `Must_Types.Bounded_Strings.Length (T.Name)` + - Line 432: `To_String (T.Description)` → `To_Description_String (T.Description)` + - Line 433: Array aggregate `(others => ' ')` → `[others => ' ']` + +--- + +## Safety Improvements + +### String Key Lookup Safety +```ada +function Get_Var (Variables : String_Map; Key : String) return String is + Bounded_Key : Bounded_String; +begin + if Key'Length > Max_String_Length then + return ""; -- Safe default for oversized keys + end if; + Bounded_Key := Must_Types.To_Bounded (Key); + if Variables.Contains (Bounded_Key) then + return Must_Types.To_String (Variables.Element (Bounded_Key)); + else + return ""; -- Safe default for missing keys + end if; +end Get_Var; +``` +- Keys bounded to `Max_String_Length` (1024 chars) +- Oversized keys return empty string instead of raising exception +- Missing keys return empty string (graceful degradation) +- No direct string access to map (all through helper functions) + +### Type Safety +- Template source/destination paths: `Bounded_Path` (4096 chars) +- Template names: `Bounded_String` (1024 chars) +- Template descriptions: `Bounded_Description` (2048 chars) +- Variable keys/values: `Bounded_String` (1024 chars) +- All conversions explicit and checked + +### HTML Escaping +```ada +-- HTML escape the value (basic escaping) +declare + Value : constant String := Get_Var (Variables, Name); + Escaped : Unbounded_String; -- Still used for internal buffer +begin + for C of Value loop + case C is + when '&' => Append (Escaped, "&"); + when '<' => Append (Escaped, "<"); + when '>' => Append (Escaped, ">"); + when '"' => Append (Escaped, """); + when others => Append (Escaped, C); + end case; + end loop; + Append (Result, Ada.Strings.Unbounded.To_String (Escaped)); +end; +``` +- HTML entities properly escaped +- Temporary buffer uses Unbounded_String (not exposed in API) +- Final result appended to bounded result buffer + +--- + +## Compilation Results + +### ✅ Success +```bash +$ gprbuild -P must.gpr -c -u src/templates/mustache_engine.adb +Compile + [Ada] mustache_engine.adb +# SUCCESS - zero errors, zero warnings! +``` + +--- + +## Modules Complete + +| Module | Status | Errors | Notes | +|--------|--------|--------|-------| +| **must_types** | ✅ COMPLETE | 0 | Foundation | +| **cli_parser** | ✅ COMPLETE | 0 | Argument parsing | +| **must** | ✅ COMPLETE | 0 | Main program | +| **task_runner** | ✅ COMPLETE | 0 | Task execution | +| **requirement_checker** | ✅ COMPLETE | 0 | File system checks | +| **mustfile_loader** | ✅ COMPLETE | 0 | TOML parsing | +| **mustache_engine** | ✅ COMPLETE | 0 | **Just completed!** | +| **deployer** | ⏳ TODO | ~4 warnings | Container deployment | +| **toml_parser** | ⏳ TODO | ? | Likely included in mustfile_loader | + +--- + +## Progress + +**Complete:** 7/9 modules (~78%) +- must_types ✅ +- cli_parser ✅ +- must.adb ✅ +- task_runner ✅ +- requirement_checker ✅ +- mustfile_loader ✅ +- mustache_engine ✅ + +**Remaining:** deployer, toml_parser + +--- + +## Key Accomplishments + +### 1. Template Variable Lookup Safety +All variable lookups go through helper functions that: +- Validate key length before lookup +- Return safe defaults for missing/oversized keys +- Convert between String and Bounded_String transparently +- Maintain template parsing logic without major refactoring + +### 2. Path Type Safety +```ada +Apply_Template + (Source => Must_Types.To_Path_String (T.Source), + Destination => Must_Types.To_Path_String (T.Destination), + Variables => Variables, + Dry_Run => Dry_Run, + Verbose => Verbose); +``` +- Source and destination paths properly typed as `Bounded_Path` +- Explicit conversion to String at usage point +- No implicit conversions between path and string types + +### 3. Description Type Safety +```ada +Desc : constant String := Must_Types.To_Description_String (T.Description); +``` +- Template descriptions properly typed as `Bounded_Description` +- Explicit conversion functions for each bounded type +- Compiler enforces correct type usage + +--- + +## Next Steps + +### Phase 7: Convert deployer (Optional) + +**~4 warnings to fix:** +- Likely similar issues: type conversions, array aggregates +- Container deployment functionality + +**Strategy:** +1. Fix license header +2. Update to use bounded string types +3. Fix any type conversion warnings +4. Fix array aggregate syntax + +### Phase 8: Verify full build + +**Test complete build:** +```bash +gprbuild -P must.gpr +``` + +**Expected:** +- All modules compile successfully +- Zero errors across codebase +- Ready for SPARK verification (when GNATprove installed) + +--- + +## Session Summary + +✅ **mustache_engine converted** (0 errors, 0 warnings!) +✅ **Template rendering** now memory-safe +✅ **Variable lookups** bounds-checked +✅ **Path operations** type-safe +🎯 **Next:** deployer conversion (optional) + +**Progress:** ~78% of codebase converted (7/9 modules) diff --git a/runners/must/SPARK-REQUIREMENT-CHECKER-COMPLETE.md b/runners/must/SPARK-REQUIREMENT-CHECKER-COMPLETE.md new file mode 100644 index 0000000..895e816 --- /dev/null +++ b/runners/must/SPARK-REQUIREMENT-CHECKER-COMPLETE.md @@ -0,0 +1,232 @@ +# requirement_checker Conversion Complete + +**Date:** 2026-02-05 +**Session:** Phase 4 - requirement_checker +**Status:** ✅ COMPLETE + +--- + +## Summary + +Successfully converted requirement_checker to use bounded strings. The file system requirement validation engine now uses memory-safe bounded types for all paths and messages. + +--- + +## Files Modified + +### requirement_checker.ads +**Changes:** +- Fixed license header (`PMPL-1.0-or-later` → `MPL-2.0`) +- Removed `Ada.Strings.Unbounded` dependency +- Updated `Check_Result` to use `Bounded_Description`: + ```ada + type Check_Result is record + Passed : Boolean; + Message : Bounded_Description; -- was Unbounded_String + Requirement : Requirement_Def; + end record; + ``` +- Changed from `Indefinite_Vectors` to `Vectors` with definite types + +### requirement_checker.adb +**Changes:** +- Fixed license header +- Removed redundant `with Must_Types;` clause +- Kept `Ada.Strings.Unbounded` only for file reading buffer (internal use) +- Updated `Check_Requirement` to use bounded strings: + ```ada + function Check_Requirement (Req : Requirement_Def) return Check_Result is + Path : constant String := Must_Types.To_Path_String (Req.Path); + Pattern : constant String := Must_Types.To_String (Req.Pattern); + + function Make_Message (Msg : String) return Bounded_Description is + begin + if Msg'Length > Max_Description_Length then + return Must_Types.To_Bounded_Description + (Msg (Msg'First .. Msg'First + Max_Description_Length - 4) & "..."); + else + return Must_Types.To_Bounded_Description (Msg); + end if; + end Make_Message; + ``` + +- Updated `Check_All` to remove `Requirements_Content` support: + ```ada + -- TODO: Re-add Requirements_Content support when map type is added + -- This was used for dynamic content requirements (file → patterns mapping) + -- For now, only static requirements from Requirements vector are checked + ``` + +- Updated `Check` procedure: + - Removed `Requirements_Content.Is_Empty` check + - Changed `To_String (R.Message)` → `To_Description_String (R.Message)` + +- Updated `Fix` procedure: + - Changed `To_String (R.Requirement.Path)` → `To_Path_String (R.Requirement.Path)` + - Changed `To_String (R.Message)` → `To_Description_String (R.Message)` + +--- + +## Safety Improvements + +### Message Truncation +```ada +function Make_Message (Msg : String) return Bounded_Description is +begin + if Msg'Length > Max_Description_Length then + return Must_Types.To_Bounded_Description + (Msg (Msg'First .. Msg'First + Max_Description_Length - 4) & "..."); + else + return Must_Types.To_Bounded_Description (Msg); + end if; +end Make_Message; +``` +- Messages bounded to `Max_Description_Length` (2048 chars) +- Long messages automatically truncated with "..." +- No buffer overflows on error messages + +### Path Safety +- File paths bounded to `Max_Path_Length` (4096 chars) +- Pattern strings bounded to `Max_String_Length` (1024 chars) +- All conversions explicit and checked + +### Type Safety +```ada +type Check_Result is record + Passed : Boolean; + Message : Bounded_Description; -- Fixed size + Requirement : Requirement_Def; -- Contains Bounded_Path, Bounded_String +end record; +``` +- Check results have predictable memory size +- No heap allocations for error messages +- Vector of check results is cache-friendly + +--- + +## Known Limitations + +### Requirements_Content Removed +The `Requirements_Content` field (mapping file paths to content patterns) was temporarily removed during SPARK conversion. This feature allowed dynamic content requirements: + +```ada +-- Old feature (removed): +Requirements_Content: Map> +"src/main.rs" → ["SPDX-License", "Copyright"] +``` + +**Impact:** Only static requirements from `Config.Requirements` are checked +**TODO:** Add back as proper bounded string map type + +**Workaround:** Use static `Requirement_Def` records: +```ada +Requirement_Def'( + Kind => Must_Contain, + Path => To_Bounded_Path ("src/main.rs"), + Pattern => To_Bounded ("SPDX-License") +) +``` + +--- + +## Compilation Results + +### ✅ Success +```bash +$ gprbuild -P must.gpr -c -u requirement_checker.adb +Compile + [Ada] requirement_checker.adb +# SUCCESS - zero errors! +``` + +--- + +## Modules Complete + +| Module | Status | Errors | Notes | +|--------|--------|--------|-------| +| **must_types** | ✅ COMPLETE | 0 | Foundation | +| **cli_parser** | ✅ COMPLETE | 0 | Argument parsing | +| **must** | ✅ COMPLETE | 0 | Main program | +| **task_runner** | ✅ COMPLETE | 0 | Task execution | +| **requirement_checker** | ✅ COMPLETE | 0 | **Just completed!** | +| **mustache_engine** | 🔄 NEXT | ~20 | Template rendering | +| **mustfile_loader** | ⏳ TODO | ~15 | TOML parsing | +| **deployer** | ⏳ TODO | ~4 warnings | Container deployment | +| **toml_parser** | ⏳ TODO | ? | Likely included in mustfile_loader | + +--- + +## Progress + +**Complete:** 5/9 modules (~56%) +- must_types ✅ +- cli_parser ✅ +- must.adb ✅ +- task_runner ✅ +- requirement_checker ✅ + +**In Progress:** mustache_engine (~20 errors) + +**Remaining:** mustfile_loader, deployer, toml_parser + +--- + +## Key Accomplishments + +### 1. Safe Message Generation +Messages automatically truncated to prevent buffer overflows: +```ada +Result.Message := Make_Message ("MISSING CONTENT: " & Path & " should contain: " & Pattern); +-- If too long, automatically truncated with "..." +``` + +### 2. Path Validation +All file system operations use bounded paths: +```ada +Path : constant String := Must_Types.To_Path_String (R.Requirement.Path); +if Path_Exists (Path) then + Ada.Directories.Create_Path (Path); +``` + +### 3. Type-Safe Results +```ada +type Check_Result is record + Passed : Boolean; + Message : Bounded_Description; -- Fixed max size + Requirement : Requirement_Def; -- Contains bounded types +end record; +``` + +--- + +## Next Steps + +### Phase 5: Convert mustache_engine + +**~20 errors to fix:** +- String/Bounded_String type mismatches +- Container indexing with String keys needs Bounded_String +- Bounded_Path vs Bounded_String type confusion +- Unbounded_String usage in template variables +- Array aggregate syntax (warning) + +**Strategy:** +1. Fix license header +2. Remove `Ada.Strings.Unbounded` +3. Add `use type` clauses for operator visibility +4. Convert template variable map to use `Bounded_String` keys +5. Update template parsing to use bounded strings +6. Fix array aggregate syntax: `()` → `[]` + +--- + +## Session Summary + +✅ **requirement_checker converted** (0 errors!) +✅ **File system checks** now memory-safe +✅ **Error messages** bounded and auto-truncated +✅ **Path operations** bounds-checked +🎯 **Next:** mustache_engine conversion + +**Progress:** ~56% of codebase converted (5/9 modules) diff --git a/runners/must/SPARK-STATUS.md b/runners/must/SPARK-STATUS.md new file mode 100644 index 0000000..7cbb6ba --- /dev/null +++ b/runners/must/SPARK-STATUS.md @@ -0,0 +1,319 @@ +# SPARK Conversion Status + +**Date:** 2026-02-05 +**Project:** must - Task runner + template engine + enforcer +**Goal:** Convert Ada 2022 codebase to SPARK for formal verification + +--- + +## Overview + +SPARK is a formally verifiable subset of Ada that enables mathematical proof of program correctness. By converting the must binary to SPARK, we gain: + +- **Formal verification** of contracts (preconditions, postconditions) +- **Proven absence** of runtime errors (buffer overflows, null pointers, etc.) +- **Type invariants** enforced at compile-time +- **Memory safety** guarantees +- **Higher assurance** for critical deployment operations + +--- + +## Conversion Strategy + +### Phase 1: Type System (COMPLETE ✅) + +**Files converted:** +- `src/must_types.ads` - Type definitions with SPARK contracts +- `src/must_types.adb` - Body (minimal, all in spec) +- `must.gpr` - Added GNATprove configuration + +**Key changes:** + +1. **Bounded Strings** - Replaced `Unbounded_String` with bounded variants: + ```ada + Max_Path_Length : constant := 4096; + Max_String_Length : constant := 1024; + Max_Command_Length : constant := 8192; + Max_Description_Length : constant := 2048; + + package Bounded_Strings is new Ada.Strings.Bounded.Generic_Bounded_Length + (Max => Max_String_Length); + subtype Bounded_String is Bounded_Strings.Bounded_String; + ``` + +2. **Formal Containers** - Replaced indefinite containers with formal versions: + ```ada + -- Before (Ada 2022): + package String_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, Element_Type => String); + + -- After (SPARK): + package String_Vectors is new Ada.Containers.Formal_Vectors + (Index_Type => Positive, Element_Type => Bounded_String); + ``` + +3. **Type Predicates** - Added invariants to all major types: + ```ada + type Task_Def is record + Name : Bounded_String; + Description : Bounded_Description; + Commands : Command_Vector; + Dependencies : String_Vector; + Script : Bounded_Command; + Working_Dir : Bounded_Path; + end record with + Predicate => Bounded_Strings.Length (Task_Def.Name) > 0; + -- Task must have a non-empty name + ``` + +4. **Pre/Post Conditions** - Added contracts to conversion functions: + ```ada + function To_Bounded (S : String) return Bounded_String is + (Bounded_Strings.To_Bounded_String (S)) with + Pre => S'Length <= Max_String_Length, + Post => Bounded_Strings.To_String (To_Bounded'Result) = S; + ``` + +5. **SPARK_Mode** - Enabled SPARK checking: + ```ada + pragma SPARK_Mode (On); + package Must_Types with SPARK_Mode => On is + ``` + +### Phase 2: Main Program (TODO) + +**Files to convert:** +- `src/must.adb` - Main entry point + +**Required changes:** +- Add `pragma SPARK_Mode (On);` +- Convert exception handlers to precondition checks where possible +- Add pre/postconditions to main logic blocks +- Ensure all paths are provably safe + +**Challenges:** +- Exception handling in SPARK (limited support) +- I/O operations require careful contracts +- Command_Line operations need safety proofs + +### Phase 3: CLI Parser (TODO) + +**Files to convert:** +- `src/cli/cli_parser.ads` +- `src/cli/cli_parser.adb` + +**Required changes:** +- Convert argument parsing to bounded strings +- Add preconditions for valid argument counts +- Prove no buffer overflows in string operations +- Add invariants for Parsed_Args type + +### Phase 4: Mustfile Loader (TODO) + +**Files to convert:** +- `src/config/mustfile_loader.ads` +- `src/config/mustfile_loader.adb` +- `src/config/toml_parser.ads` +- `src/config/toml_parser.adb` + +**Required changes:** +- File I/O with SPARK contracts +- TOML parsing with bounded strings +- Prove correctness of parser state machine +- Handle parse errors without exceptions + +### Phase 5: Task Runner (TODO) + +**Files to convert:** +- `src/tasks/task_runner.ads` +- `src/tasks/task_runner.adb` + +**Required changes:** +- Dependency resolution with formal verification +- Prove absence of circular dependencies +- Safe command execution with contracts +- Process spawning with error handling + +### Phase 6: Requirement Checker (TODO) + +**Files to convert:** +- `src/requirements/requirement_checker.ads` +- `src/requirements/requirement_checker.adb` + +**Required changes:** +- File system operations with SPARK contracts +- Pattern matching with proofs +- Requirement validation logic +- Safe fix operations + +### Phase 7: Template Engine (TODO) + +**Files to convert:** +- `src/templates/mustache_engine.ads` +- `src/templates/mustache_engine.adb` + +**Required changes:** +- Mustache template parsing +- Variable substitution with bounds checking +- Template rendering with formal verification +- File writing with safety proofs + +### Phase 8: Deployer (TODO) + +**Files to convert:** +- `src/deploy/deployer.ads` +- `src/deploy/deployer.adb` + +**Required changes:** +- Deployment orchestration contracts +- Container/package manager integration +- Safe command execution +- Rollback safety proofs + +--- + +## Verification Commands + +### Build with SPARK +```bash +gprbuild -P must.gpr -XMODE=debug +``` + +### Run GNATprove +```bash +gnatprove -P must.gpr +``` + +### Full verification (maximum level) +```bash +gnatprove -P must.gpr --level=4 --timeout=60 +``` + +### Check specific file +```bash +gnatprove -P must.gpr -u must_types.ads --level=4 +``` + +--- + +## Current Status + +| Module | Status | Compiles | Errors | Notes | +|--------|--------|----------|--------|-------| +| **must_types** | ✅ COMPLETE | ✅ YES | 0 | Bounded strings, type predicates | +| **cli_parser** | ✅ COMPLETE | ✅ YES | 0 | Safe argument parsing | +| **must** | ✅ COMPLETE | ✅ YES | 0 | Main program with bounded strings | +| **task_runner** | 🔄 IN PROGRESS | ❌ NO | 32+ | String/Unbounded conversions needed | +| **mustfile_loader** | ⏳ TODO | ❓ Unknown | ? | After task_runner | +| **toml_parser** | ⏳ TODO | ❓ Unknown | ? | After mustfile_loader | +| **requirement_checker** | ⏳ TODO | ❓ Unknown | ? | After toml_parser | +| **mustache_engine** | ⏳ TODO | ❓ Unknown | ? | After requirement_checker | +| **deployer** | ⏳ TODO | ❓ Unknown | ? | After mustache_engine | + +### Compilation Status + +```bash +# Phase 1 & 2 Complete! ✅ +gprbuild -P must.gpr -c -u must_types.ads # ✅ SUCCESS +gprbuild -P must.gpr -c -u cli_parser.adb # ✅ SUCCESS +gprbuild -P must.gpr -c -u must.adb # ✅ SUCCESS + +# Phase 3: task_runner (IN PROGRESS) +gprbuild -P must.gpr -XMODE=debug +# task_runner.adb: 32+ errors +# - String operator visibility issues +# - Unbounded_String → Bounded_String conversions needed +# - Bounded_Path, Bounded_Command usage needed +``` + +**Progress:** 3/9 modules complete (~33%) + +--- + +## Benefits Once Complete + +### Proven Properties + +1. **Memory Safety** + - No buffer overflows + - No null pointer dereferences + - No use-after-free errors + - Bounds checked array access + +2. **Type Safety** + - All type invariants maintained + - No invalid discriminant values + - Controlled variant record access + - Safe type conversions + +3. **Logical Correctness** + - Preconditions always met before function calls + - Postconditions always satisfied after function returns + - Loop invariants maintained across iterations + - No integer overflow/underflow + +4. **Absence of Runtime Errors** + - Mathematically proven absence of: + - Constraint_Error + - Storage_Error + - Program_Error + - All exceptions are intentional and documented + +### Deployment Confidence + +With SPARK verification: +- **High assurance** deployment operations won't corrupt systems +- **Proven** requirement checking won't miss violations +- **Guaranteed** task dependencies are correctly resolved +- **Verified** template rendering won't produce malformed output + +--- + +## SPARK Tools Installation + +**Current Status:** GNATprove not installed on system + +To enable full SPARK verification: + +```bash +# Option 1: Install SPARK Community Edition +# Download from: https://www.adacore.com/download + +# Option 2: Use Alire package manager +alr get spark2014 +alr with spark2014 + +# Option 3: Install via system package manager (if available) +# Fedora: sudo dnf install spark2014 +``` + +Once SPARK tools are installed, formal verification can begin: +```bash +gnatprove -P must.gpr --level=4 +``` + +## Next Steps + +1. **Install SPARK tools** (gnatprove, why3, alt-ergo) +2. **Convert must.adb**: Fix 4 Unbounded_String → Bounded_String conversions + - Line 72: `Must_Types.To_String (Args.Task_Name)` + - Line 101: `Must_Types.To_String (Args.Template_Name)` + - Line 184: `Must_Types.To_String (Args.Deploy_Target)` + - Line 185: `Must_Types.To_String (Args.Deploy_Tag)` +3. **Convert CLI_Parser**: Update Parsed_Args to use Bounded_String +4. **Incremental conversion**: Convert remaining modules systematically +5. **Run GNATprove**: Verify each module as converted +6. **Document proofs**: Explain complex proof obligations +7. **CI integration**: Add GNATprove to RSR workflows + +--- + +## Resources + +- **SPARK Documentation**: https://docs.adacore.com/live/wave/spark2014/html/spark2014_ug/ +- **SPARK by Example**: https://github.com/AdaCore/spark-by-example +- **Learn SPARK**: https://learn.adacore.com/courses/SPARK_for_the_MISRA_C_Developer/ + +--- + +**Status**: Phase 1 complete (must_types). Ready for verification and Phase 2 (must.adb). diff --git a/runners/must/SPARK-TASK-RUNNER-COMPLETE.md b/runners/must/SPARK-TASK-RUNNER-COMPLETE.md new file mode 100644 index 0000000..b6f754d --- /dev/null +++ b/runners/must/SPARK-TASK-RUNNER-COMPLETE.md @@ -0,0 +1,263 @@ +# task_runner Conversion Complete + +**Date:** 2026-02-05 +**Session:** Phase 3 - task_runner +**Status:** ✅ COMPLETE + +--- + +## Summary + +Successfully converted task_runner to use bounded strings. The task execution and dependency resolution engine now uses memory-safe bounded types throughout. + +--- + +## Files Modified + +### task_runner.ads +**Changes:** +- Fixed license header (`PMPL-1.0-or-later` → `MPL-2.0`) +- Updated all API functions to use `Bounded_String` instead of `String`: + ```ada + procedure Run_Task + (Config : Mustfile_Config; + Task_Name : Bounded_String; -- was String + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + function Task_Exists + (Config : Mustfile_Config; + Task_Name : Bounded_String) return Boolean; -- was String + + function Get_Task + (Config : Mustfile_Config; + Task_Name : Bounded_String) return Task_Def; -- was String + + function Resolve_Dependencies + (Config : Mustfile_Config; + Task_Name : Bounded_String) return String_Vector; -- was String + ``` + +### task_runner.adb +**Changes:** +- Fixed license header +- Removed `Ada.Strings.Unbounded` dependency +- Added `use type Bounded_String` for operator visibility +- Updated `Contains` function to use `Bounded_String` +- Updated all functions to use bounded strings: + - `Task_Exists`: Direct comparison of `T.Name = Task_Name` (both Bounded_String) + - `Get_Task`: Direct comparison, no string conversions needed + - `DFS`: Uses `Bounded_String` for all task names + - `Resolve_Dependencies`: Uses `Bounded_String` parameter + +- Updated `Execute_Command` to use `Bounded_Command`: + ```ada + function Execute_Command + (Command : Bounded_Command; -- was String + Verbose : Boolean) return Integer + is + Cmd_String : constant String := Must_Types.To_Command_String (Command); + ``` + +- Updated `Execute_Task` to use bounded string Length functions: + ```ada + if Bounded_Paths.Length (T.Working_Dir) > 0 then + Put_Line (" cd " & Must_Types.To_Path_String (T.Working_Dir)); + Ada.Directories.Set_Directory (Must_Types.To_Path_String (T.Working_Dir)); + end if; + + if Bounded_Commands.Length (T.Script) > 0 then + Status := Execute_Command (T.Script, Verbose); + ``` + +- Updated `Run_Task` to use `Bounded_String`: + ```ada + for Name of Execution_Order loop + if Name = Task_Name then + Put_Line ("Running: " & Must_Types.To_String (Name)); + else + Put_Line ("Running dependency: " & Must_Types.To_String (Name)); + end if; + ``` + +- Updated `List_Tasks` to use bounded string Length: + ```ada + if Bounded_Strings.Length (T.Name) > Max_Len then + Max_Len := Bounded_Strings.Length (T.Name); + end if; + + Desc : constant String := Must_Types.To_Description_String (T.Description); + ``` + +- Fixed Ada 2022 syntax: `(others => ' ')` → `[others => ' ']` +- Added `pragma Unreferenced` for unused parameters + +### must.adb +**Changes:** +- Updated to pass `Args.Task_Name` directly (already `Bounded_String`): + ```ada + -- Before: + declare + Task_Name : constant String := Must_Types.To_String (Args.Task_Name); + begin + if not Task_Runner.Task_Exists (Config, Task_Name) then + ... + end if; + Task_Runner.Run_Task (Config, Task_Name, ...); + end; + + -- After: + if not Task_Runner.Task_Exists (Config, Args.Task_Name) then + Put_Line ("Error: Unknown task '" & Must_Types.To_String (Args.Task_Name) & "'"); + ... + end if; + Task_Runner.Run_Task (Config, Args.Task_Name, ...); + ``` + +--- + +## Safety Improvements + +### Type Safety +- Task names now bounded at `Max_String_Length` (1024 chars) +- Commands bounded at `Max_Command_Length` (8192 chars) +- Working directories bounded at `Max_Path_Length` (4096 chars) +- No implicit string conversions - all conversions explicit and checked + +### Dependency Resolution Safety +```ada +function Contains (Vec : String_Vector; Name : Bounded_String) return Boolean +``` +- Direct comparison of bounded strings +- No string copying or conversions in hot path +- Circular dependency detection uses bounded strings + +### Command Execution Safety +```ada +function Execute_Command + (Command : Bounded_Command; + Verbose : Boolean) return Integer +``` +- Command length validated before execution +- No buffer overflows possible +- Shell command string generation checked + +--- + +## Compilation Results + +### ✅ Success +```bash +$ gprbuild -P must.gpr -c -u task_runner.adb +Compile + [Ada] task_runner.adb +# SUCCESS - no errors, no warnings! +``` + +### ✅ Integration +```bash +$ gprbuild -P must.gpr -c -u must.adb +Compile + [Ada] must.adb +# SUCCESS - must.adb updated and compiles! +``` + +### ❌ Next Modules +Full build reveals remaining modules need conversion: +- **requirement_checker** - 19+ errors +- **mustache_engine** - 20+ errors +- **mustfile_loader** - 15+ errors +- **deployer** - warnings only (minor issues) + +--- + +## Modules Complete + +| Module | Status | Errors | Notes | +|--------|--------|--------|-------| +| **must_types** | ✅ COMPLETE | 0 | Foundation | +| **cli_parser** | ✅ COMPLETE | 0 | Argument parsing | +| **must** | ✅ COMPLETE | 0 | Main program | +| **task_runner** | ✅ COMPLETE | 0 | **Just completed!** | +| **requirement_checker** | 🔄 NEXT | 19+ | File system checks | +| **mustache_engine** | ⏳ TODO | 20+ | Template rendering | +| **mustfile_loader** | ⏳ TODO | 15+ | TOML parsing | +| **deployer** | ⏳ TODO | ~5 warnings | Container deployment | +| **toml_parser** | ⏳ TODO | ? | Likely included in mustfile_loader | + +--- + +## Progress + +**Complete:** 4/9 modules (~44%) +- must_types ✅ +- cli_parser ✅ +- must.adb ✅ +- task_runner ✅ + +**In Progress:** requirement_checker (~19 errors) + +**Remaining:** mustache_engine, mustfile_loader, deployer, toml_parser + +--- + +## Key Accomplishments + +### 1. Zero String Conversions in Hot Path +Dependency resolution and task lookup now use direct `Bounded_String` comparisons: +```ada +for T of Config.Tasks loop + if T.Name = Task_Name then -- Direct comparison, no conversion! + return T; + end if; +end loop; +``` + +### 2. Safe Command Execution +```ada +Status := Execute_Command (T.Script, Verbose); +-- T.Script is Bounded_Command, validated at parse time +``` + +### 3. Type-Safe Dependencies +```ada +for Dep of T.Dependencies loop + if not Task_Exists (Config, Dep) then -- Dep is Bounded_String + raise Task_Error with + "Task '" & Must_Types.To_String (Task_Name) & + "' depends on unknown task: " & Must_Types.To_String (Dep); + end if; +``` + +--- + +## Next Steps + +### Phase 4: Convert requirement_checker + +**19+ errors to fix:** +- Bounded_Path vs Bounded_String type mismatches +- `To_Unbounded` calls → `To_Bounded` conversions +- `Requirements_Content` field removed (needs to be added back or handled differently) +- `String_Vector_Maps` usage +- String concatenation operator visibility + +**Strategy:** +1. Fix license header +2. Remove `Ada.Strings.Unbounded` +3. Add `use type` clauses +4. Convert file path operations to `Bounded_Path` +5. Update pattern matching to use `Bounded_String` +6. Fix `Requirements_Content` (if needed, add back to Mustfile_Config) + +--- + +## Session Summary + +✅ **task_runner converted** (0 errors!) +✅ **must.adb updated** (0 errors!) +✅ **Dependency resolution** now memory-safe +✅ **Command execution** bounds-checked +🎯 **Next:** requirement_checker conversion + +**Progress:** ~44% of codebase converted (4/9 modules) diff --git a/runners/must/fixtures/content-project/README.adoc b/runners/must/fixtures/content-project/README.adoc new file mode 100644 index 0000000..dd5d3c1 --- /dev/null +++ b/runners/must/fixtures/content-project/README.adoc @@ -0,0 +1,3 @@ += Content Fixture + +This fixture should fail content checks until the required line is present. diff --git a/runners/must/fixtures/content-project/mustfile.toml b/runners/must/fixtures/content-project/mustfile.toml new file mode 100644 index 0000000..c2e07df --- /dev/null +++ b/runners/must/fixtures/content-project/mustfile.toml @@ -0,0 +1,19 @@ +schema = "0.1" + +[project] +name = "must-content" +version = "0.1.0" +license = "MPL-2.0-or-later" +author = "Hyperpolymath" + +[requirements] +must_have = [ + "README.adoc", +] + +must_not_have = [ + "Makefile", +] + +[requirements.content] +"README.adoc" = ["Content Fixture", "Required Line"] diff --git a/runners/must/fixtures/failing-project/README.adoc b/runners/must/fixtures/failing-project/README.adoc new file mode 100644 index 0000000..67fc81c --- /dev/null +++ b/runners/must/fixtures/failing-project/README.adoc @@ -0,0 +1,3 @@ += Failing Fixture + +This fixture should fail must check because MISSING.txt is not present. diff --git a/runners/must/fixtures/failing-project/mustfile.toml b/runners/must/fixtures/failing-project/mustfile.toml new file mode 100644 index 0000000..de6438e --- /dev/null +++ b/runners/must/fixtures/failing-project/mustfile.toml @@ -0,0 +1,20 @@ +schema = "0.1" + +[project] +name = "must-failing" +version = "0.1.0" +license = "MPL-2.0-or-later" +author = "Hyperpolymath" + +[requirements] +must_have = [ + "README.adoc", + "MISSING.txt", +] + +must_not_have = [ + "Makefile", +] + +[requirements.content] +"README.adoc" = ["Failing Fixture"] diff --git a/runners/must/fixtures/fix-project/README.adoc b/runners/must/fixtures/fix-project/README.adoc new file mode 100644 index 0000000..d3477cc --- /dev/null +++ b/runners/must/fixtures/fix-project/README.adoc @@ -0,0 +1,3 @@ += Fix Fixture + +This fixture should fail must check until must fix removes Makefile. diff --git a/runners/must/fixtures/fix-project/mustfile.toml b/runners/must/fixtures/fix-project/mustfile.toml new file mode 100644 index 0000000..95679d4 --- /dev/null +++ b/runners/must/fixtures/fix-project/mustfile.toml @@ -0,0 +1,19 @@ +schema = "0.1" + +[project] +name = "must-fix" +version = "0.1.0" +license = "MPL-2.0-or-later" +author = "Hyperpolymath" + +[requirements] +must_have = [ + "README.adoc", +] + +must_not_have = [ + "Makefile", +] + +[requirements.content] +"README.adoc" = ["Fix Fixture"] diff --git a/runners/must/fixtures/sample-project/README.adoc b/runners/must/fixtures/sample-project/README.adoc new file mode 100644 index 0000000..673cb29 --- /dev/null +++ b/runners/must/fixtures/sample-project/README.adoc @@ -0,0 +1,3 @@ += Sample Project + +This is a minimal fixture for running `must check` in CI. diff --git a/runners/must/fixtures/sample-project/mustfile.toml b/runners/must/fixtures/sample-project/mustfile.toml new file mode 100644 index 0000000..edcedf2 --- /dev/null +++ b/runners/must/fixtures/sample-project/mustfile.toml @@ -0,0 +1,19 @@ +schema = "0.1" + +[project] +name = "must-sample" +version = "0.1.0" +license = "MPL-2.0-or-later" +author = "Hyperpolymath" + +[requirements] +must_have = [ + "README.adoc", +] + +must_not_have = [ + "Makefile", +] + +[requirements.content] +"README.adoc" = ["Sample Project"] diff --git a/runners/must/license/PMPL-1.0.txt b/runners/must/license/PMPL-1.0.txt new file mode 100644 index 0000000..13d072a --- /dev/null +++ b/runners/must/license/PMPL-1.0.txt @@ -0,0 +1,162 @@ +SPDX-License-Identifier: MPL-2.0-or-later +SPDX-FileCopyrightText: 2025 Palimpsest Stewardship Council + +================================================================================ +PALIMPSEST-MPL LICENSE VERSION 1.0 +================================================================================ + +File-level copyleft with ethical use and quantum-safe provenance + +Based on Mozilla Public License 2.0 + +-------------------------------------------------------------------------------- +PREAMBLE +-------------------------------------------------------------------------------- + +This License extends the Mozilla Public License 2.0 (MPL-2.0) with provisions +for ethical use, post-quantum cryptographic provenance, and emotional lineage +protection. The base MPL-2.0 terms apply except where explicitly modified by +the Exhibits below. + +Like a palimpsest manuscript where each layer builds upon what came before, +this license recognizes that creative works carry history, context, and meaning +that transcend mere code or text. + +-------------------------------------------------------------------------------- +SECTION 1: BASE LICENSE +-------------------------------------------------------------------------------- + +This License incorporates the full text of Mozilla Public License 2.0 by +reference. The complete MPL-2.0 text is available at: +https://www.mozilla.org/en-US/MPL/2.0/ + +All terms, conditions, and definitions from MPL-2.0 apply except where +explicitly modified by the Exhibits in this License. + +-------------------------------------------------------------------------------- +SECTION 2: ADDITIONAL DEFINITIONS +-------------------------------------------------------------------------------- + +2.1. "Emotional Lineage" + means the narrative, cultural, symbolic, and contextual meaning embedded + in Covered Software, including but not limited to: protest traditions, + cultural heritage, trauma narratives, and community stories. + +2.2. "Provenance Metadata" + means cryptographically signed attribution information attached to or + associated with Covered Software, including author identities, timestamps, + modification history, and lineage references. + +2.3. "Non-Interpretive System" + means any automated system that processes Covered Software without + preserving or considering its Emotional Lineage, including but not + limited to: AI training pipelines, content aggregators, and automated + summarization tools. + +2.4. "Quantum-Safe Signature" + means a cryptographic signature using algorithms resistant to attacks + by quantum computers, as specified in Exhibit B. + +-------------------------------------------------------------------------------- +SECTION 3: ETHICAL USE REQUIREMENTS +-------------------------------------------------------------------------------- + +In addition to the rights and obligations under MPL-2.0: + +3.1. Emotional Lineage Preservation + You must make reasonable efforts to preserve and communicate the + Emotional Lineage of Covered Software when distributing or creating + derivative works. This includes maintaining narrative context, cultural + attributions, and symbolic meaning where documented. + +3.2. Non-Interpretive System Notice + If You use Covered Software as input to a Non-Interpretive System, You + must: + (a) document such use in a publicly accessible manner; and + (b) not claim that outputs of such systems carry the Emotional Lineage + of the original work without explicit permission from Contributors. + +3.3. Ethical Use Declaration + Commercial use of Covered Software requires acknowledgment that You have + read and understood Exhibit A (Ethical Use Guidelines) and agree to act + in good faith accordance with its principles. + +See Exhibit A for complete Ethical Use Guidelines. + +-------------------------------------------------------------------------------- +SECTION 4: PROVENANCE REQUIREMENTS +-------------------------------------------------------------------------------- + +4.1. Metadata Preservation + You must not strip, alter, or obscure Provenance Metadata from Covered + Software except where technically necessary and with clear documentation + of any changes. + +4.2. Quantum-Safe Provenance (Optional) + Contributors may sign their Contributions using Quantum-Safe Signatures. + If Quantum-Safe Signatures are present, You must preserve them in all + distributions. + +4.3. Lineage Chain + When creating derivative works, You should extend the provenance chain + to include Your own contributions, maintaining cryptographic linkage to + prior Contributors where feasible. + +See Exhibit B for Quantum-Safe Provenance specifications. + +-------------------------------------------------------------------------------- +SECTION 5: GOVERNANCE +-------------------------------------------------------------------------------- + +5.1. Stewardship Council + This License is maintained by the Palimpsest Stewardship Council, which + may issue clarifications, interpretive guidance, and future versions. + +5.2. Version Selection + You may use Covered Software under this version of the License or any + later version published by the Palimpsest Stewardship Council. + +5.3. Dispute Resolution + Disputes regarding interpretation of Ethical Use Requirements (Section 3) + should first be submitted to the Palimpsest Stewardship Council for + non-binding guidance before pursuing legal remedies. + +-------------------------------------------------------------------------------- +SECTION 6: COMPATIBILITY +-------------------------------------------------------------------------------- + +6.1. MPL-2.0 Compatibility + Covered Software under this License may be combined with software under + MPL-2.0. The combined work must comply with both licenses. + +6.2. Secondary Licenses + The Secondary License provisions of MPL-2.0 Section 3.3 apply to this + License. + +-------------------------------------------------------------------------------- +EXHIBITS +-------------------------------------------------------------------------------- + +Exhibit A - Ethical Use Guidelines +Exhibit B - Quantum-Safe Provenance Specification + +See separate files: +- EXHIBIT-A-ETHICAL-USE.txt +- EXHIBIT-B-QUANTUM-SAFE.txt + +-------------------------------------------------------------------------------- +END OF PALIMPSEST-MPL-1.0 LICENSE TEXT +-------------------------------------------------------------------------------- + +For exhibits, specifications, provenance rules, and governance: +https://github.com/hyperpolymath/palimpsest-license + +For legal frameworks and jurisdictional analysis: +See /legal/frameworks/ + +For provenance and audit tooling: +See /tools/ and /spec/PROVENANCE-SPEC.adoc + +For questions about this License: +- Repository: https://github.com/hyperpolymath/palimpsest-license +- Council: contact via repository Issues diff --git a/runners/must/must.gpr b/runners/must/must.gpr new file mode 100644 index 0000000..ba09f9e --- /dev/null +++ b/runners/must/must.gpr @@ -0,0 +1,59 @@ +-- must.gpr +-- GNAT project file for Must - task runner + template engine + enforcer +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0-or-later + +project Must is + + for Source_Dirs use ("src", "src/**"); + for Object_Dir use "obj"; + for Exec_Dir use "bin"; + for Main use ("must.adb"); + + type Build_Mode is ("debug", "release"); + Mode : Build_Mode := external ("MODE", "debug"); + + package Compiler is + case Mode is + when "debug" => + for Switches ("Ada") use + ("-g", -- Debug info + "-gnata", -- Enable assertions + "-gnatwa", -- All warnings + "-gnatwj", -- Warnings for obsolescent features + "-gnatwe", -- Warnings as errors + "-gnat2022", -- Ada 2022 mode + "-fstack-check"); -- Stack overflow checking + when "release" => + for Switches ("Ada") use + ("-O3", -- Maximum optimization + "-gnatp", -- Suppress all checks (for speed) + "-gnatn", -- Enable inlining + "-gnat2022"); -- Ada 2022 mode + end case; + end Compiler; + + package Binder is + for Switches ("Ada") use ("-E"); -- Store traceback in exceptions + end Binder; + + package Linker is + for Switches ("Ada") use ("-g"); + end Linker; + + package Builder is + for Executable ("must.adb") use "must"; + for Switches ("Ada") use ("-j0"); -- Use all available CPUs + end Builder; + + package Prove is + for Proof_Switches ("Ada") use + ("--level=4", -- Maximum proof level + "--timeout=60", -- 60 second timeout per proof + "--steps=10000", -- Maximum proof steps + "--counterexamples=on", -- Show counterexamples + "--warnings=error", -- Treat warnings as errors + "--pedantic"); -- Pedantic checking + end Prove; + +end Must; diff --git a/runners/must/mustfile.ncl b/runners/must/mustfile.ncl new file mode 100644 index 0000000..fb746f8 --- /dev/null +++ b/runners/must/mustfile.ncl @@ -0,0 +1,26 @@ +{ + must_have = [ + { path = "LICENSE-AGPL", hash = "sha512-..." }, + { path = "README.adoc", contains = ["SPDX-License-Identifier: PMPL-1.0-or-later"] }, + { path = "Cargo.toml" }, + { path = "target/debug/must", mode = "0755", hash = "shake256-..." } + ], + must_not_have = ["Makefile", "node_modules/", "*.rs.bk"], + tasks = { + build = { + steps = ["cargo build"], + must_have = [{ path = "target/debug/must", hash = "shake256-..." }] + }, + test = { + steps = ["cargo test"], + must_have = [] + }, + deploy = { + steps = ["podman build -t must:latest ."], + must_have = [{ path = "Containerfile" }] + } + }, + artifacts = { + "must:latest": { hash_alg = "shake256", hash = "..." } + } +} diff --git a/runners/must/mustfile.toml b/runners/must/mustfile.toml new file mode 100644 index 0000000..4bf64ca --- /dev/null +++ b/runners/must/mustfile.toml @@ -0,0 +1,135 @@ +# mustfile.toml +# Configuration for Must - task runner + template engine + enforcer +# https://github.com/hyperpolymath/must +# Spec lives in the mustfile repo: https://github.com/hyperpolymath/mustfile + +schema = "0.1" + +[project] +name = "must" +version = "0.1.0" +license = "MPL-2.0-or-later" +author = "Jonathan D.A. Jewell" + +[variables] +copyright = "Copyright (C) 2025 Jonathan D.A. Jewell" +license = "MPL-2.0-or-later" + +# Task definitions +[tasks] + +[tasks.build] +description = "Build the project (debug mode)" +commands = ["gprbuild -P must.gpr -XMODE=debug"] + +[tasks.build-release] +description = "Build the project (release mode)" +commands = ["gprbuild -P must.gpr -XMODE=release"] + +[tasks.test] +description = "Run tests" +dependencies = ["build"] +commands = ["echo 'Running tests...'", "bin/must --version", "bin/must --help"] + +[tasks.clean] +description = "Clean build artifacts" +commands = ["gnatclean -P must.gpr", "rm -rf obj/ bin/"] + +[tasks.install] +description = "Install to /usr/local/bin" +dependencies = ["build-release"] +commands = ["sudo cp bin/must /usr/local/bin/"] + +[tasks.docs] +description = "Generate documentation" +commands = ["echo 'Documentation generated'"] + +[tasks.fmt] +description = "Format Ada code using gnatpp" +commands = ["just fmt"] + +[tasks.lint] +description = "Lint Ada code (compile with strict warnings)" +commands = ["just lint"] + +[tasks.all] +description = "Build, test, and check" +dependencies = ["build", "test", "check"] +commands = ["echo 'All checks passed'"] + +[tasks.check] +description = "Check requirements" +commands = ["bin/must check"] + +[tasks.deploy] +description = "Build and deploy container" +dependencies = ["build-release"] +commands = ["bin/must deploy"] + +[tasks.deploy-push] +description = "Build, deploy, and push container" +dependencies = ["build-release"] +commands = ["bin/must deploy --push"] + +# Requirements enforcement - defines Physical State contract +[requirements] +must_have = [ + "LICENSE.txt", + "README.adoc", + "INSTALL.adoc", + "Containerfile", + "justfile", + "mustfile.toml", + "must.gpr", + "src/must.adb", + "src/deploy/deployer.ads", + "src/deploy/deployer.adb", +] + +must_not_have = [ + "Makefile", + "Dockerfile", + ".env", +] + +# Content requirements - strings that must appear in files +[requirements.content] +"LICENSE.txt" = ["MPL", "Version 2.0"] +"src/must.adb" = ["SPDX-License-Identifier: MPL-2.0-or-later"] + +# Templates +[templates] + +[templates.ada_package] +source = "templates/ada/package.ads.mustache" +destination = "src/{{module_name}}.ads" +description = "Generate Ada package specification" + +[templates.ada_body] +source = "templates/ada/package.adb.mustache" +destination = "src/{{module_name}}.adb" +description = "Generate Ada package body" + +[templates.elixir_module] +source = "templates/elixir/module.ex.mustache" +destination = "lib/{{app_name}}/{{module_name}}.ex" +description = "Generate Elixir module" + +# Enforcement rules +[enforcement] +license = "MPL-2.0-or-later" +copyright_holder = "Jonathan D.A. Jewell" +podman_not_docker = true +gitlab_not_github = false + +[enforcement.checks] +no_trailing_whitespace = true +no_tabs = false +unix_line_endings = true +max_line_length = 120 + +# Deployment configuration +[deploy] +containerfile = "Containerfile" +registry = "ghcr.io/hyperpolymath" +default_tag = "latest" diff --git a/runners/must/scripts/bootstrap.sh b/runners/must/scripts/bootstrap.sh new file mode 100755 index 0000000..4672f8c --- /dev/null +++ b/runners/must/scripts/bootstrap.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# bootstrap.sh - Quick bootstrap for Must development environment +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +set -euo pipefail + +echo "=== Must Bootstrap ===" +echo "" + +# Detect OS +OS="$(uname -s)" +case "$OS" in + Linux) + if [ -f /etc/debian_version ]; then + DISTRO="debian" + elif [ -f /etc/fedora-release ]; then + DISTRO="fedora" + elif [ -f /etc/arch-release ]; then + DISTRO="arch" + else + DISTRO="linux" + fi + ;; + Darwin) + DISTRO="macos" + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +echo "Detected: $DISTRO" +echo "" + +# Install GNAT if not present +if ! command -v gnat &> /dev/null; then + echo "Installing GNAT Ada compiler..." + case "$DISTRO" in + debian) + sudo apt-get update + sudo apt-get install -y gnat gprbuild + ;; + fedora) + sudo dnf install -y gcc-gnat gprbuild + ;; + arch) + sudo pacman -S --noconfirm gcc-ada gprbuild + ;; + macos) + if command -v brew &> /dev/null; then + brew install gnat gprbuild + else + echo "Please install Homebrew first: https://brew.sh" + exit 1 + fi + ;; + *) + echo "Please install GNAT manually for your distribution" + exit 1 + ;; + esac +else + echo "GNAT already installed: $(gnat --version | head -1)" +fi + +# Install just if not present +if ! command -v just &> /dev/null; then + echo "Installing just..." + case "$DISTRO" in + debian) + sudo apt-get install -y just || { + # Fallback to cargo if not in repos + if command -v cargo &> /dev/null; then + cargo install just + else + echo "Please install 'just' manually: https://just.systems" + exit 1 + fi + } + ;; + fedora) + sudo dnf install -y just + ;; + arch) + sudo pacman -S --noconfirm just + ;; + macos) + brew install just + ;; + *) + if command -v cargo &> /dev/null; then + cargo install just + else + echo "Please install 'just' manually: https://just.systems" + exit 1 + fi + ;; + esac +else + echo "just already installed: $(just --version)" +fi + +# Install podman if not present (optional) +if ! command -v podman &> /dev/null; then + echo "" + echo "Note: podman not found. Install it for container deployment:" + case "$DISTRO" in + debian) + echo " sudo apt-get install podman" + ;; + fedora) + echo " sudo dnf install podman" + ;; + arch) + echo " sudo pacman -S podman" + ;; + macos) + echo " brew install podman" + ;; + esac +fi + +echo "" +echo "=== Bootstrap Complete ===" +echo "" +echo "Next steps:" +echo " just build # Build must" +echo " just test # Run tests" +echo " just install # Install to /usr/local/bin" +echo "" diff --git a/runners/must/src/cli/cli_parser.adb b/runners/must/src/cli/cli_parser.adb new file mode 100644 index 0000000..e650c0a --- /dev/null +++ b/runners/must/src/cli/cli_parser.adb @@ -0,0 +1,228 @@ +-- cli_parser.adb +-- Command-line argument parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Command_Line; +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Strings.Fixed; + +package body CLI_Parser is + + Version_String : constant String := "0.1.0"; + + function Get_Arguments return String_Vector is + Args : String_Vector; + begin + for I in 1 .. Ada.Command_Line.Argument_Count loop + declare + Arg : constant String := Ada.Command_Line.Argument (I); + begin + if Arg'Length > Max_String_Length then + raise Parse_Error with + "Argument too long (max " & + Max_String_Length'Image & " chars): " & Arg (Arg'First .. Arg'First + 50) & "..."; + end if; + Args.Append (Must_Types.To_Bounded (Arg)); + end; + end loop; + return Args; + end Get_Arguments; + + function Parse return Parsed_Args is + Result : Parsed_Args; + Args : constant String_Vector := Get_Arguments; + I : Positive := 1; + + procedure Parse_Var (Arg : Bounded_String) is + Arg_Str : constant String := Must_Types.To_String (Arg); + Eq_Pos : constant Natural := + Ada.Strings.Fixed.Index (Arg_Str, "="); + begin + if Eq_Pos = 0 then + raise Parse_Error with "Invalid --var format: " & Arg_Str; + end if; + + declare + Key : constant String := Arg_Str (Arg_Str'First .. Eq_Pos - 1); + Value : constant String := Arg_Str (Eq_Pos + 1 .. Arg_Str'Last); + begin + if Key'Length > Max_String_Length then + raise Parse_Error with "Variable key too long: " & Key; + end if; + if Value'Length > Max_String_Length then + raise Parse_Error with "Variable value too long: " & Value; + end if; + Result.Variables.Include (Must_Types.To_Bounded (Key), + Must_Types.To_Bounded (Value)); + end; + end Parse_Var; + + begin + if Args.Is_Empty then + Result.Command := Cmd_None; + return Result; + end if; + + while I <= Natural (Args.Length) loop + declare + Arg_Bounded : constant Bounded_String := Args (I); + Arg : constant String := Must_Types.To_String (Arg_Bounded); + begin + if Arg = "--help" or else Arg = "-h" then + Result.Command := Cmd_Help; + return Result; + + elsif Arg = "--version" or else Arg = "-v" then + Result.Command := Cmd_Version; + return Result; + + elsif Arg = "--list" or else Arg = "-l" then + Result.Command := Cmd_List; + + elsif Arg = "--strict" then + Result.Strict := True; + + elsif Arg = "--dry-run" then + Result.Dry_Run := True; + + elsif Arg = "--verbose" or else Arg = "-V" then + Result.Verbose := True; + + elsif Arg = "--template" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--template requires an argument"; + end if; + Result.Template_Name := Args (I); + + elsif Arg = "--var" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--var requires KEY=VALUE argument"; + end if; + Parse_Var (Args (I)); + + elsif Arg = "--vars" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--vars requires a file argument"; + end if; + declare + File_Path : constant String := Must_Types.To_String (Args (I)); + begin + if File_Path'Length > Max_Path_Length then + raise Parse_Error with "File path too long"; + end if; + Result.Vars_File := Must_Types.To_Bounded_Path (File_Path); + end; + + elsif Arg = "init" then + Result.Command := Cmd_Init; + + elsif Arg = "apply" then + Result.Command := Cmd_Apply; + + elsif Arg = "check" then + Result.Command := Cmd_Check; + + elsif Arg = "fix" then + Result.Command := Cmd_Fix; + + elsif Arg = "enforce" then + Result.Command := Cmd_Enforce; + + elsif Arg = "templates" then + Result.Command := Cmd_Templates; + + elsif Arg = "deploy" then + Result.Command := Cmd_Deploy; + + elsif Arg = "--target" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--target requires an argument"; + end if; + Result.Deploy_Target := Args (I); + + elsif Arg = "--tag" then + I := I + 1; + if I > Natural (Args.Length) then + raise Parse_Error with "--tag requires an argument"; + end if; + Result.Deploy_Tag := Args (I); + + elsif Arg = "--push" then + Result.Deploy_Push := True; + + elsif Arg'Length > 0 and then Arg (Arg'First) = '-' then + raise Parse_Error with "Unknown option: " & Arg; + + else + -- Task name or extra argument + if Result.Command = Cmd_None then + Result.Command := Cmd_Run_Task; + Result.Task_Name := Arg_Bounded; + else + Result.Extra_Args.Append (Arg_Bounded); + end if; + end if; + + I := I + 1; + end; + end loop; + + return Result; + end Parse; + + procedure Show_Help is + begin + Put_Line ("Must v" & Version_String); + Put_Line ("Task runner + template engine + project enforcer"); + Put_Line (""); + Put_Line ("Usage: must [COMMAND] [OPTIONS]"); + Put_Line (""); + Put_Line ("Commands:"); + Put_Line (" Run a task from mustfile.toml"); + Put_Line (" --list, -l List all available tasks"); + Put_Line (" apply Apply templates"); + Put_Line (" check Check requirements"); + Put_Line (" fix Fix violations automatically"); + Put_Line (" enforce Check + apply + verify"); + Put_Line (" deploy Build and deploy via Containerfile"); + Put_Line (" init Create default mustfile.toml"); + Put_Line (" templates List available templates"); + Put_Line (" --help, -h Show this help"); + Put_Line (" --version, -v Show version"); + Put_Line (""); + Put_Line ("Options:"); + Put_Line (" --strict Fail on requirement violations"); + Put_Line (" --dry-run Show what would be executed"); + Put_Line (" --verbose, -V Verbose output"); + Put_Line (" --template NAME Apply specific template"); + Put_Line (" --var KEY=VALUE Set template variable"); + Put_Line (" --vars FILE Load variables from TOML file"); + Put_Line (""); + Put_Line ("Deploy Options:"); + Put_Line (" --target TARGET Target (container, local)"); + Put_Line (" --tag TAG Container image tag (default: latest)"); + Put_Line (" --push Push image to registry after build"); + Put_Line (""); + Put_Line ("Examples:"); + Put_Line (" must build Run the 'build' task"); + Put_Line (" must --list List all tasks"); + Put_Line (" must apply --template ada_package --var module=Test"); + Put_Line (" must check --strict"); + Put_Line (" must deploy Build container from Containerfile"); + Put_Line (" must deploy --tag v1.0 --push"); + end Show_Help; + + procedure Show_Version is + begin + Put_Line ("must " & Version_String); + end Show_Version; + +end CLI_Parser; diff --git a/runners/must/src/cli/cli_parser.ads b/runners/must/src/cli/cli_parser.ads new file mode 100644 index 0000000..db64c45 --- /dev/null +++ b/runners/must/src/cli/cli_parser.ads @@ -0,0 +1,60 @@ +-- cli_parser.ads +-- Command-line argument parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package CLI_Parser is + + -- Command types + type Command_Type is + (Cmd_None, + Cmd_Help, + Cmd_Version, + Cmd_List, + Cmd_Init, + Cmd_Run_Task, + Cmd_Apply, + Cmd_Check, + Cmd_Fix, + Cmd_Enforce, + Cmd_Templates, + Cmd_Deploy); + + -- Parsed arguments (now using bounded strings for safety) + type Parsed_Args is record + Command : Command_Type := Cmd_None; + Task_Name : Bounded_String; + Template_Name : Bounded_String; + Variables : String_Map; + Vars_File : Bounded_Path; -- File paths can be long + Strict : Boolean := False; + Dry_Run : Boolean := False; + Verbose : Boolean := False; + Extra_Args : String_Vector; + -- Deploy-specific options + Deploy_Target : Bounded_String; -- Target OS/container + Deploy_Push : Boolean := False; -- Push to registry + Deploy_Tag : Bounded_String; -- Container tag + end record; + + -- Parse command-line arguments + function Parse return Parsed_Args; + + -- Get raw arguments as a vector + function Get_Arguments return String_Vector; + + -- Show help text + procedure Show_Help; + + -- Show version + procedure Show_Version; + + -- Parse error exception + Parse_Error : exception; + +end CLI_Parser; diff --git a/runners/must/src/config/mustfile_loader.adb b/runners/must/src/config/mustfile_loader.adb new file mode 100644 index 0000000..157bf70 --- /dev/null +++ b/runners/must/src/config/mustfile_loader.adb @@ -0,0 +1,250 @@ +-- mustfile_loader.adb +-- Mustfile configuration loader for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Directories; +with Ada.Text_IO; use Ada.Text_IO; +with TOML_Parser; use TOML_Parser; + +package body Mustfile_Loader is + + function Mustfile_Exists return Boolean is + begin + return Ada.Directories.Exists (Mustfile_Name); + end Mustfile_Exists; + + function Load return Mustfile_Config is + begin + return Load (Mustfile_Name); + end Load; + + function Load (Path : String) return Mustfile_Config is + Doc : TOML_Document; + Config : Mustfile_Config; + begin + Doc := Parse_File (Path); + + -- Load project section + Config.Project.Name := Must_Types.To_Bounded (Get_String (Doc, "project.name", "")); + Config.Project.Version := Must_Types.To_Bounded (Get_String (Doc, "project.version", "")); + Config.Project.License := Must_Types.To_Bounded (Get_String (Doc, "project.license", "")); + Config.Project.Author := Must_Types.To_Bounded (Get_String (Doc, "project.author", "")); + + -- Load tasks section + declare + Task_Keys : constant String_Vector := Get_Table_Keys (Doc, "tasks"); + begin + for Key of Task_Keys loop + declare + Task_Path : constant String := "tasks." & Must_Types.To_String (Key); + T : Task_Def; + Cmd_Strings : constant String_Vector := + Get_String_Array (Doc, Task_Path & ".commands"); + begin + T.Name := Key; -- Already Bounded_String + T.Description := Must_Types.To_Bounded_Description + (Get_String (Doc, Task_Path & ".description", "")); + + -- Convert String_Vector to Command_Vector + for Cmd of Cmd_Strings loop + T.Commands.Append + (Must_Types.To_Bounded_Command (Must_Types.To_String (Cmd))); + end loop; + + T.Dependencies := Get_String_Array (Doc, Task_Path & ".dependencies"); + T.Script := Must_Types.To_Bounded_Command + (Get_String (Doc, Task_Path & ".script", "")); + T.Working_Dir := Must_Types.To_Bounded_Path + (Get_String (Doc, Task_Path & ".working_dir", "")); + Config.Tasks.Append (T); + end; + end loop; + end; + + -- Load variables section + declare + Var_Keys : constant String_Vector := Get_Table_Keys (Doc, "variables"); + begin + for Key of Var_Keys loop + declare + Key_Str : constant String := Must_Types.To_String (Key); + Val_Str : constant String := Get_String (Doc, "variables." & Key_Str, ""); + begin + Config.Variables.Insert + (Key, Must_Types.To_Bounded (Val_Str)); + end; + end loop; + end; + + -- Load requirements section + declare + Must_Have : constant String_Vector := + Get_String_Array (Doc, "requirements.must_have"); + Must_Not_Have : constant String_Vector := + Get_String_Array (Doc, "requirements.must_not_have"); + begin + for Path of Must_Have loop + declare + Path_Str : constant String := Must_Types.To_String (Path); + begin + if Path_Str'Length <= Max_Path_Length then + Config.Requirements.Append + (Must_Types.Requirement_Def' + (Kind => Must_Types.Must_Have, + Path => Must_Types.To_Bounded_Path (Path_Str), + Pattern => Must_Types.To_Bounded (""))); + end if; + end; + end loop; + + for Path of Must_Not_Have loop + declare + Path_Str : constant String := Must_Types.To_String (Path); + begin + if Path_Str'Length <= Max_Path_Length then + Config.Requirements.Append + (Must_Types.Requirement_Def' + (Kind => Must_Types.Must_Not_Have, + Path => Must_Types.To_Bounded_Path (Path_Str), + Pattern => Must_Types.To_Bounded (""))); + end if; + end; + end loop; + + -- TODO: Re-add requirements.content support when Requirements_Content + -- field is added back to Mustfile_Config + -- For now, content requirements must be added as static Requirement_Def records + end; + + -- Load templates section + declare + Template_Keys : constant String_Vector := Get_Table_Keys (Doc, "templates"); + begin + for Key of Template_Keys loop + declare + Tpl_Path : constant String := "templates." & Must_Types.To_String (Key); + T : Template_Def; + begin + T.Name := Key; -- Already Bounded_String + T.Source := Must_Types.To_Bounded_Path + (Get_String (Doc, Tpl_Path & ".source", "")); + T.Destination := Must_Types.To_Bounded_Path + (Get_String (Doc, Tpl_Path & ".destination", "")); + T.Description := Must_Types.To_Bounded_Description + (Get_String (Doc, Tpl_Path & ".description", "")); + Config.Templates.Append (T); + end; + end loop; + end; + + -- Load enforcement section + Config.Enforcement.License := Must_Types.To_Bounded + (Get_String (Doc, "enforcement.license", "")); + Config.Enforcement.Copyright_Holder := Must_Types.To_Bounded + (Get_String (Doc, "enforcement.copyright_holder", "")); + Config.Enforcement.Podman_Not_Docker := + Get_Boolean (Doc, "enforcement.podman_not_docker", True); + Config.Enforcement.Gitlab_Not_Github := + Get_Boolean (Doc, "enforcement.gitlab_not_github", True); + Config.Enforcement.No_Trailing_Whitespace := + Get_Boolean (Doc, "enforcement.checks.no_trailing_whitespace", True); + Config.Enforcement.No_Tabs := + Get_Boolean (Doc, "enforcement.checks.no_tabs", True); + Config.Enforcement.Unix_Line_Endings := + Get_Boolean (Doc, "enforcement.checks.unix_line_endings", True); + Config.Enforcement.Max_Line_Length := Natural + (Get_Integer (Doc, "enforcement.checks.max_line_length", 100)); + + return Config; + exception + when TOML_Parser.Parse_Error => + raise Load_Error with "Failed to parse mustfile: " & Path; + end Load; + + procedure Create_Default_Mustfile is + begin + Create_Default_Mustfile (Mustfile_Name); + end Create_Default_Mustfile; + + procedure Create_Default_Mustfile (Path : String) is + F : File_Type; + begin + Create (F, Out_File, Path); + Put_Line (F, "# mustfile.toml"); + Put_Line (F, "# Configuration for Must - task runner + template engine + enforcer"); + Put_Line (F, "# https://gitlab.com/hyperpolymath/must"); + Put_Line (F, ""); + Put_Line (F, "[project]"); + Put_Line (F, "name = ""my-project"""); + Put_Line (F, "version = ""0.1.0"""); + Put_Line (F, "license = ""PMPL-1.0-or-later"""); + Put_Line (F, "author = ""Your Name"""); + Put_Line (F, ""); + Put_Line (F, "# Variables available in tasks and templates"); + Put_Line (F, "[variables]"); + Put_Line (F, "# server = ""production.example.com"""); + Put_Line (F, ""); + Put_Line (F, "# Task definitions"); + Put_Line (F, "[tasks]"); + Put_Line (F, ""); + Put_Line (F, "[tasks.build]"); + Put_Line (F, "description = ""Build the project"""); + Put_Line (F, "commands = [""echo 'Building...'""]"); + Put_Line (F, ""); + Put_Line (F, "[tasks.test]"); + Put_Line (F, "description = ""Run tests"""); + Put_Line (F, "dependencies = [""build""]"); + Put_Line (F, "commands = [""echo 'Testing...'""]"); + Put_Line (F, ""); + Put_Line (F, "[tasks.clean]"); + Put_Line (F, "description = ""Clean build artifacts"""); + Put_Line (F, "commands = [""rm -rf bin/ obj/""]"); + Put_Line (F, ""); + Put_Line (F, "# Requirements enforcement"); + Put_Line (F, "[requirements]"); + Put_Line (F, "must_have = ["); + Put_Line (F, " ""LICENSE"","); + Put_Line (F, " ""README.md"","); + Put_Line (F, "]"); + Put_Line (F, ""); + Put_Line (F, "must_not_have = ["); + Put_Line (F, " ""Makefile"","); + Put_Line (F, " ""Dockerfile"","); + Put_Line (F, "]"); + Put_Line (F, ""); + Put_Line (F, "# Templates"); + Put_Line (F, "[templates]"); + Put_Line (F, ""); + Put_Line (F, "# [templates.ada_package]"); + Put_Line (F, "# source = ""templates/ada/package.ads.mustache"""); + Put_Line (F, "# destination = ""src/{{module_name}}.ads"""); + Put_Line (F, "# description = ""Generate Ada package specification"""); + Put_Line (F, ""); + Put_Line (F, "# Enforcement rules"); + Put_Line (F, "[enforcement]"); + Put_Line (F, "license = ""PMPL-1.0-or-later"""); + Put_Line (F, "copyright_holder = ""Your Name"""); + Put_Line (F, "podman_not_docker = true"); + Put_Line (F, "gitlab_not_github = true"); + Put_Line (F, ""); + Put_Line (F, "[enforcement.checks]"); + Put_Line (F, "no_trailing_whitespace = true"); + Put_Line (F, "no_tabs = true"); + Put_Line (F, "unix_line_endings = true"); + Put_Line (F, "max_line_length = 100"); + Close (F); + + Put_Line ("Created " & Path); + exception + when others => + if Is_Open (F) then + Close (F); + end if; + raise; + end Create_Default_Mustfile; + +end Mustfile_Loader; diff --git a/runners/must/src/config/mustfile_loader.ads b/runners/must/src/config/mustfile_loader.ads new file mode 100644 index 0000000..26a87ba --- /dev/null +++ b/runners/must/src/config/mustfile_loader.ads @@ -0,0 +1,34 @@ +-- mustfile_loader.ads +-- Mustfile configuration loader for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Mustfile_Loader is + + -- Default mustfile name + Mustfile_Name : constant String := "mustfile.toml"; + + -- Check if mustfile exists in current directory + function Mustfile_Exists return Boolean; + + -- Load mustfile configuration + function Load return Mustfile_Config; + + -- Load mustfile from specific path + function Load (Path : String) return Mustfile_Config; + + -- Create default mustfile + procedure Create_Default_Mustfile; + + -- Create default mustfile at specific path + procedure Create_Default_Mustfile (Path : String); + + -- Load error exception + Load_Error : exception; + +end Mustfile_Loader; diff --git a/runners/must/src/config/toml_parser.adb b/runners/must/src/config/toml_parser.adb new file mode 100644 index 0000000..6e388cf --- /dev/null +++ b/runners/must/src/config/toml_parser.adb @@ -0,0 +1,516 @@ +-- toml_parser.adb +-- TOML parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Text_IO; +with Ada.Strings.Fixed; +with Ada.Strings.Maps; + +package body TOML_Parser is + + -- Character sets + Whitespace : constant Ada.Strings.Maps.Character_Set := + Ada.Strings.Maps.To_Set (" " & ASCII.HT); + + function Trim (S : String) return String is + begin + return Ada.Strings.Fixed.Trim (S, Whitespace, Whitespace); + end Trim; + + function Make_String (S : String) return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_String, + Str_Val => To_Unbounded_String (S)); + end Make_String; + + function Make_Boolean (B : Boolean) return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Boolean, Bool_Val => B); + end Make_Boolean; + + function Make_Integer (I : Long_Integer) return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Integer, Int_Val => I); + end Make_Integer; + + function Make_Table return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Table, Table_Val => Value_Maps.Empty_Map); + end Make_Table; + + function Make_Array return TOML_Value_Access is + begin + return new TOML_Value'(Kind => Val_Array, Arr_Val => Value_Vectors.Empty_Vector); + end Make_Array; + + -- Parse a quoted string value + function Parse_String_Value (S : String) return String is + Result : Unbounded_String; + I : Positive := S'First; + Quote : Character; + begin + if S'Length < 2 then + raise Parse_Error with "Invalid string: " & S; + end if; + + Quote := S (I); + if Quote /= '"' and then Quote /= ''' then + raise Parse_Error with "String must start with quote: " & S; + end if; + + I := I + 1; + while I <= S'Last loop + if S (I) = Quote then + return To_String (Result); + elsif S (I) = '\' and then I < S'Last then + I := I + 1; + case S (I) is + when 'n' => Append (Result, ASCII.LF); + when 't' => Append (Result, ASCII.HT); + when 'r' => Append (Result, ASCII.CR); + when '\' => Append (Result, '\'); + when '"' => Append (Result, '"'); + when ''' => Append (Result, '''); + when others => Append (Result, S (I)); + end case; + else + Append (Result, S (I)); + end if; + I := I + 1; + end loop; + + raise Parse_Error with "Unterminated string: " & S; + end Parse_String_Value; + + -- Parse a value (string, integer, boolean, array) + function Parse_Value (S : String) return TOML_Value_Access is + Trimmed : constant String := Trim (S); + begin + if Trimmed'Length = 0 then + return Make_String (""); + end if; + + -- Boolean + if Trimmed = "true" then + return Make_Boolean (True); + elsif Trimmed = "false" then + return Make_Boolean (False); + end if; + + -- String + if Trimmed (Trimmed'First) = '"' or else Trimmed (Trimmed'First) = ''' then + return Make_String (Parse_String_Value (Trimmed)); + end if; + + -- Array + if Trimmed (Trimmed'First) = '[' then + declare + Arr : constant TOML_Value_Access := Make_Array; + Inner : constant String := + Trim (Trimmed (Trimmed'First + 1 .. Trimmed'Last - 1)); + Start : Positive := Inner'First; + I : Positive := Inner'First; + Depth : Natural := 0; + In_Str : Boolean := False; + begin + if Inner'Length = 0 then + return Arr; + end if; + + while I <= Inner'Last loop + if not In_Str then + if Inner (I) = '"' then + In_Str := True; + elsif Inner (I) = '[' then + Depth := Depth + 1; + elsif Inner (I) = ']' then + Depth := Depth - 1; + elsif Inner (I) = ',' and Depth = 0 then + Arr.Arr_Val.Append + (Parse_Value (Inner (Start .. I - 1))); + Start := I + 1; + end if; + else + if Inner (I) = '"' and then + (I = Inner'First or else Inner (I - 1) /= '\') + then + In_Str := False; + end if; + end if; + I := I + 1; + end loop; + + -- Last element + if Start <= Inner'Last then + Arr.Arr_Val.Append (Parse_Value (Inner (Start .. Inner'Last))); + end if; + + return Arr; + end; + end if; + + -- Integer + declare + Val : Long_Integer; + begin + Val := Long_Integer'Value (Trimmed); + return Make_Integer (Val); + exception + when others => + -- Not a valid integer, treat as bareword/string + return Make_String (Trimmed); + end; + end Parse_Value; + + function Split_Path (Path : String) return String_Vector is + Segments : String_Vector; + Current : Unbounded_String := To_Unbounded_String (""); + In_Quotes : Boolean := False; + I : Positive := Path'First; + begin + while I <= Path'Last loop + declare + C : constant Character := Path (I); + begin + if C = '"' then + In_Quotes := not In_Quotes; + elsif C = '.' and then not In_Quotes then + Segments.Append (Must_Types.To_Bounded (To_String (Current))); + Current := To_Unbounded_String (""); + elsif C = '\' and then In_Quotes and then I < Path'Last then + I := I + 1; + Append (Current, Path (I)); + else + Append (Current, C); + end if; + end; + I := I + 1; + end loop; + + if Length (Current) > 0 or else Segments.Is_Empty then + Segments.Append (Must_Types.To_Bounded (To_String (Current))); + end if; + + return Segments; + end Split_Path; + + -- Navigate to or create path in document + procedure Navigate_Or_Create + (Doc : in out TOML_Document; + Path : String; + Target : out TOML_Value_Access; + Parent : out TOML_Value_Access; + Last_Key : out Unbounded_String) + is + Current : TOML_Value_Access := null; + Segments : constant String_Vector := Split_Path (Path); + Key : Unbounded_String; + Key_Str : String := ""; + begin + Parent := null; + Last_Key := To_Unbounded_String (""); + + -- Start with doc root as implicit table + for Segment of Segments loop + declare + Seg_Str : constant String := Must_Types.To_String (Segment); + begin + Key := To_Unbounded_String (Seg_Str); + Key_Str := To_String (Key); + if Current = null then + -- At root level + if not Doc.Contains (Key_Str) then + Doc.Insert (Key_Str, Make_Table); + end if; + Parent := null; + Current := Doc (Key_Str); + else + -- Inside a table + if Current.Kind /= Val_Table then + raise Parse_Error with "Cannot navigate into non-table: " & Path; + end if; + if not Current.Table_Val.Contains (Key_Str) then + Current.Table_Val.Insert (Key_Str, Make_Table); + end if; + Parent := Current; + Current := Current.Table_Val (Key_Str); + end if; + + Last_Key := Key; + end; + end loop; + + Target := Current; + end Navigate_Or_Create; + + function Parse_String (Content : String) return TOML_Document is + Doc : TOML_Document; + Current_Table : Unbounded_String := To_Unbounded_String (""); + Lines : String_Vector; + Line_Start : Positive := Content'First; + I : Positive := Content'First; + begin + -- Split into lines + while I <= Content'Last loop + if Content (I) = ASCII.LF then + if I > Line_Start then + Lines.Append (Must_Types.To_Bounded (Content (Line_Start .. I - 1))); + else + Lines.Append (Must_Types.To_Bounded ("")); + end if; + Line_Start := I + 1; + end if; + I := I + 1; + end loop; + if Line_Start <= Content'Last then + Lines.Append (Must_Types.To_Bounded (Content (Line_Start .. Content'Last))); + end if; + + -- Process each line + declare + Idx : Natural := Lines.First_Index; + begin + while Idx <= Lines.Last_Index loop + declare + Line : constant String := Must_Types.To_String (Lines (Idx)); + Trimmed : constant String := Trim (Line); + Target : TOML_Value_Access; + Parent : TOML_Value_Access; + Last_Key : Unbounded_String; + begin + -- Skip empty lines and comments + if Trimmed'Length = 0 or else Trimmed (Trimmed'First) = '#' then + null; + + -- Table header [table.name] + elsif Trimmed (Trimmed'First) = '[' then + if Trimmed'Length > 1 and then + Trimmed (Trimmed'First + 1) = '[' + then + -- Array of tables [[table.name]] + Current_Table := To_Unbounded_String + (Trim (Trimmed (Trimmed'First + 2 .. Trimmed'Last - 2))); + Navigate_Or_Create (Doc, To_String (Current_Table), + Target, Parent, Last_Key); + if Target.Kind /= Val_Array then + -- Convert to array + declare + Arr : constant TOML_Value_Access := Make_Array; + begin + Arr.Arr_Val.Append (Make_Table); + if Parent = null then + Doc.Include (To_String (Last_Key), Arr); + else + Parent.Table_Val.Include (To_String (Last_Key), Arr); + end if; + end; + else + Target.Arr_Val.Append (Make_Table); + end if; + else + -- Regular table [table.name] + Current_Table := To_Unbounded_String + (Trim (Trimmed (Trimmed'First + 1 .. Trimmed'Last - 1))); + Navigate_Or_Create (Doc, To_String (Current_Table), + Target, Parent, Last_Key); + end if; + + -- Key = value + else + declare + Eq_Pos : constant Natural := + Ada.Strings.Fixed.Index (Trimmed, "="); + begin + if Eq_Pos > 0 then + declare + Key : constant String := + Trim (Trimmed (Trimmed'First .. Eq_Pos - 1)); + Value : Unbounded_String := + To_Unbounded_String + (Trim (Trimmed (Eq_Pos + 1 .. Trimmed'Last))); + Full_Path : constant String := + (if Length (Current_Table) > 0 + then To_String (Current_Table) & "." & Key + else Key); + Val : TOML_Value_Access; + begin + -- Support multi-line arrays (collect until closing ]) + if Length (Value) > 0 and then + To_String (Value) (1) = '[' and then + Ada.Strings.Fixed.Index (To_String (Value), "]") = 0 + then + declare + Acc : Unbounded_String := Value; + begin + while Ada.Strings.Fixed.Index + (To_String (Acc), "]") = 0 + and then Idx < Lines.Last_Index + loop + Idx := Idx + 1; + Acc := Acc & " " & Trim (Must_Types.To_String (Lines (Idx))); + end loop; + Value := Acc; + end; + end if; + + Val := Parse_Value (To_String (Value)); + Navigate_Or_Create (Doc, Full_Path, Target, Parent, Last_Key); + if Parent = null then + Doc.Include (To_String (Last_Key), Val); + else + Parent.Table_Val.Include (To_String (Last_Key), Val); + end if; + end; + end if; + end; + end if; + end; + Idx := Idx + 1; + end loop; + end; + + return Doc; + end Parse_String; + + function Parse_File (Filename : String) return TOML_Document is + use Ada.Text_IO; + File : File_Type; + Content : Unbounded_String; + begin + Open (File, In_File, Filename); + while not End_Of_File (File) loop + Append (Content, Get_Line (File)); + Append (Content, ASCII.LF); + end loop; + Close (File); + + return Parse_String (To_String (Content)); + exception + when Name_Error => + raise Parse_Error with "File not found: " & Filename; + when others => + if Is_Open (File) then + Close (File); + end if; + raise; + end Parse_File; + + function Get (Doc : TOML_Document; Path : String) return TOML_Value_Access is + Current : TOML_Value_Access := null; + Segments : constant String_Vector := Split_Path (Path); + Key_Str : String := ""; + begin + for Segment of Segments loop + Key_Str := Must_Types.To_String (Segment); + if Current = null then + if not Doc.Contains (Key_Str) then + return null; + end if; + Current := Doc (Key_Str); + else + if Current.Kind /= Val_Table then + return null; + end if; + if not Current.Table_Val.Contains (Key_Str) then + return null; + end if; + Current := Current.Table_Val (Key_Str); + end if; + end loop; + + return Current; + end Get; + + function Has (Doc : TOML_Document; Path : String) return Boolean is + begin + return Get (Doc, Path) /= null; + end Has; + + function Get_String (Doc : TOML_Document; Path : String; + Default : String := "") return String is + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null then + return Default; + end if; + if Val.Kind /= Val_String then + return Default; + end if; + return To_String (Val.Str_Val); + end Get_String; + + function Get_Boolean (Doc : TOML_Document; Path : String; + Default : Boolean := False) return Boolean is + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null then + return Default; + end if; + if Val.Kind /= Val_Boolean then + return Default; + end if; + return Val.Bool_Val; + end Get_Boolean; + + function Get_Integer (Doc : TOML_Document; Path : String; + Default : Long_Integer := 0) return Long_Integer is + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null then + return Default; + end if; + if Val.Kind /= Val_Integer then + return Default; + end if; + return Val.Int_Val; + end Get_Integer; + + function Get_String_Array (Doc : TOML_Document; Path : String) + return String_Vector + is + Result : String_Vector; + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null or else Val.Kind /= Val_Array then + return Result; + end if; + + declare + Arr : constant Value_Vectors.Vector := Val.Arr_Val; + begin + for Item of Arr loop + if Item.all.Kind = Val_String then + Result.Append (Must_Types.To_Bounded (To_String (Item.all.Str_Val))); + end if; + end loop; + end; + + return Result; + end Get_String_Array; + + function Get_Table_Keys (Doc : TOML_Document; Path : String) + return String_Vector + is + Result : String_Vector; + Val : constant TOML_Value_Access := Get (Doc, Path); + begin + if Val = null or else Val.Kind /= Val_Table then + return Result; + end if; + + declare + Tbl : constant Value_Maps.Map := Val.Table_Val; + begin + for C in Tbl.Iterate loop + Result.Append (Must_Types.To_Bounded (Value_Maps.Key (C))); + end loop; + end; + + return Result; + end Get_Table_Keys; + +end TOML_Parser; diff --git a/runners/must/src/config/toml_parser.ads b/runners/must/src/config/toml_parser.ads new file mode 100644 index 0000000..857559f --- /dev/null +++ b/runners/must/src/config/toml_parser.ads @@ -0,0 +1,109 @@ +-- toml_parser.ads +-- TOML parser for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Containers.Indefinite_Ordered_Maps; +with Ada.Containers.Indefinite_Vectors; +with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; +with Must_Types; use Must_Types; + +package TOML_Parser is + + -- TOML value types + type Value_Kind is + (Val_String, + Val_Integer, + Val_Float, + Val_Boolean, + Val_Array, + Val_Table); + + type TOML_Value; + type TOML_Value_Access is access all TOML_Value; + + -- Forward declarations for containers + package Value_Vectors is new Ada.Containers.Indefinite_Vectors + (Index_Type => Positive, + Element_Type => TOML_Value_Access); + + package Value_Maps is new Ada.Containers.Indefinite_Ordered_Maps + (Key_Type => String, + Element_Type => TOML_Value_Access); + + -- TOML value type (discriminated record) + type TOML_Value (Kind : Value_Kind := Val_String) is record + case Kind is + when Val_String => + Str_Val : Unbounded_String; + when Val_Integer => + Int_Val : Long_Integer; + when Val_Float => + Float_Val : Long_Float; + when Val_Boolean => + Bool_Val : Boolean; + when Val_Array => + Arr_Val : Value_Vectors.Vector; + when Val_Table => + Table_Val : Value_Maps.Map; + end case; + end record; + + -- Root TOML document (table) + type TOML_Document is new Value_Maps.Map with null record; + + -- Parse a TOML file + function Parse_File (Filename : String) return TOML_Document; + + -- Parse a TOML string + function Parse_String (Content : String) return TOML_Document; + + -- Get a value by path (e.g., "project.name") + function Get (Doc : TOML_Document; Path : String) + return TOML_Value_Access; + + -- Get string value + function Get_String (Doc : TOML_Document; Path : String; + Default : String := "") return String; + + -- Get boolean value + function Get_Boolean (Doc : TOML_Document; Path : String; + Default : Boolean := False) return Boolean; + + -- Get integer value + function Get_Integer (Doc : TOML_Document; Path : String; + Default : Long_Integer := 0) return Long_Integer; + + -- Get string array + function Get_String_Array (Doc : TOML_Document; Path : String) + return String_Vector; + + -- Check if path exists + function Has (Doc : TOML_Document; Path : String) return Boolean; + + -- Get table keys at path + function Get_Table_Keys (Doc : TOML_Document; Path : String) + return String_Vector; + + -- Parse error exception + Parse_Error : exception; + + -- Helper: create string value + function Make_String (S : String) return TOML_Value_Access; + + -- Helper: create boolean value + function Make_Boolean (B : Boolean) return TOML_Value_Access; + + -- Helper: create integer value + function Make_Integer (I : Long_Integer) return TOML_Value_Access; + + -- Helper: create empty table + function Make_Table return TOML_Value_Access; + + -- Helper: create empty array + function Make_Array return TOML_Value_Access; + +end TOML_Parser; diff --git a/runners/must/src/deploy/deployer.adb b/runners/must/src/deploy/deployer.adb new file mode 100644 index 0000000..bf13291 --- /dev/null +++ b/runners/must/src/deploy/deployer.adb @@ -0,0 +1,223 @@ +-- deployer.adb +-- Container deployment for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; use Ada.Directories; +with GNAT.OS_Lib; use GNAT.OS_Lib; + +package body Deployer is + + Containerfile_Name : constant String := "Containerfile"; + + function Containerfile_Exists return Boolean is + begin + return Exists (Containerfile_Name); + end Containerfile_Exists; + + function Get_Containerfile_Path return String is + begin + return Containerfile_Name; + end Get_Containerfile_Path; + + procedure Run_Command + (Command : String; + Args : Argument_List_Access; + Dry_Run : Boolean; + Verbose : Boolean) + is + Success : Boolean; + begin + if Verbose or Dry_Run then + Put (" $ " & Command); + for I in Args'Range loop + Put (" " & Args (I).all); + end loop; + New_Line; + end if; + + if Dry_Run then + return; + end if; + + Spawn + (Program_Name => Command, + Args => Args.all, + Success => Success); + + if not Success then + raise Deploy_Error with "Command failed: " & Command; + end if; + end Run_Command; + + procedure Build_Container + (Project_Name : String; + Tag : String; + Dry_Run : Boolean; + Verbose : Boolean) + is + Actual_Tag : constant String := + (if Tag'Length > 0 then Tag else "latest"); + Image_Name : constant String := + Project_Name & ":" & Actual_Tag; + Args : Argument_List_Access; + begin + Put_Line ("Building container image: " & Image_Name); + + if not Containerfile_Exists then + raise Deploy_Error with "Containerfile not found"; + end if; + + -- podman build -t : -f Containerfile . + Args := new Argument_List (1 .. 5); + Args (1) := new String'("build"); + Args (2) := new String'("-t"); + Args (3) := new String'(Image_Name); + Args (4) := new String'("-f"); + Args (5) := new String'(Containerfile_Name); + + declare + Dot_Args : Argument_List_Access := new Argument_List (1 .. 6); + begin + Dot_Args (1 .. 5) := Args (1 .. 5); + Dot_Args (6) := new String'("."); + + Run_Command ("podman", Dot_Args, Dry_Run, Verbose); + + -- Free memory + for I in Dot_Args'Range loop + Free (Dot_Args (I)); + end loop; + Free (Dot_Args); + end; + + if not Dry_Run then + Put_Line ("Container image built successfully: " & Image_Name); + end if; + end Build_Container; + + procedure Push_Container + (Project_Name : String; + Tag : String; + Registry : String; + Dry_Run : Boolean; + Verbose : Boolean) + is + Actual_Tag : constant String := + (if Tag'Length > 0 then Tag else "latest"); + Local_Image : constant String := Project_Name & ":" & Actual_Tag; + Remote_Image : constant String := + Registry & "/" & Project_Name & ":" & Actual_Tag; + Args : Argument_List_Access; + begin + Put_Line ("Pushing container image to registry..."); + + -- Tag for registry: podman tag + Args := new Argument_List (1 .. 3); + Args (1) := new String'("tag"); + Args (2) := new String'(Local_Image); + Args (3) := new String'(Remote_Image); + + Run_Command ("podman", Args, Dry_Run, Verbose); + + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + + -- Push: podman push + Args := new Argument_List (1 .. 2); + Args (1) := new String'("push"); + Args (2) := new String'(Remote_Image); + + Run_Command ("podman", Args, Dry_Run, Verbose); + + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + + if not Dry_Run then + Put_Line ("Image pushed: " & Remote_Image); + end if; + end Push_Container; + + procedure Deploy + (Config : Mustfile_Config; + Target : String; + Tag : String; + Push : Boolean; + Dry_Run : Boolean; + Verbose : Boolean) + is + Project_Name : constant String := Must_Types.To_String (Config.Project.Name); + Actual_Target : Deploy_Target_Type := Target_Container; + begin + -- Parse target + if Target'Length > 0 then + if Target = "local" then + Actual_Target := Target_Local; + elsif Target = "container" then + Actual_Target := Target_Container; + else + raise Deploy_Error with "Unknown target: " & Target & + " (use 'container' or 'local')"; + end if; + end if; + + case Actual_Target is + when Target_Container => + if not Containerfile_Exists then + raise Deploy_Error with + "Containerfile not found. Create one or use --target local"; + end if; + + Put_Line ("Deploying via container..."); + Build_Container (Project_Name, Tag, Dry_Run, Verbose); + + if Push then + -- Use default registry from config or environment + Push_Container + (Project_Name => Project_Name, + Tag => Tag, + Registry => "ghcr.io/hyperpolymath", + Dry_Run => Dry_Run, + Verbose => Verbose); + end if; + + if not Dry_Run then + Put_Line ("Deployment complete!"); + New_Line; + Put_Line ("Run the container:"); + Put_Line (" podman run --rm -it " & Project_Name & ":" & + (if Tag'Length > 0 then Tag else "latest")); + end if; + + when Target_Local => + Put_Line ("Building locally..."); + declare + Args : Argument_List_Access := new Argument_List (1 .. 3); + begin + Args (1) := new String'("-P"); + Args (2) := new String'("must.gpr"); + Args (3) := new String'("-XMODE=release"); + + Run_Command ("gprbuild", Args, Dry_Run, Verbose); + + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + end; + + if not Dry_Run then + Put_Line ("Local build complete: bin/must"); + end if; + end case; + end Deploy; + +end Deployer; diff --git a/runners/must/src/deploy/deployer.ads b/runners/must/src/deploy/deployer.ads new file mode 100644 index 0000000..4d2865a --- /dev/null +++ b/runners/must/src/deploy/deployer.ads @@ -0,0 +1,48 @@ +-- deployer.ads +-- Container deployment for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: PMPL-1.0-or-later + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Deployer is + + -- Deploy target types + type Deploy_Target_Type is (Target_Container, Target_Local); + + -- Check if Containerfile exists + function Containerfile_Exists return Boolean; + + -- Get the Containerfile path + function Get_Containerfile_Path return String; + + -- Deploy the project + procedure Deploy + (Config : Mustfile_Config; + Target : String; + Tag : String; + Push : Boolean; + Dry_Run : Boolean; + Verbose : Boolean); + + -- Build container image + procedure Build_Container + (Project_Name : String; + Tag : String; + Dry_Run : Boolean; + Verbose : Boolean); + + -- Push container image to registry + procedure Push_Container + (Project_Name : String; + Tag : String; + Registry : String; + Dry_Run : Boolean; + Verbose : Boolean); + + -- Deploy error exception + Deploy_Error : exception; + +end Deployer; diff --git a/runners/must/src/manifest.rs b/runners/must/src/manifest.rs new file mode 100644 index 0000000..1f3f98b --- /dev/null +++ b/runners/must/src/manifest.rs @@ -0,0 +1,7 @@ +// src/manifest.rs +#[cfg(feature = "nickel")] +pub fn parse_nickel_manifest(path: &str) -> Result { + use nickel_lang::eval::eval_file; + let value = eval_file(path).map_err(|e| format!("Nickel error: {}", e))?; + serde_json::from_value(value.into()).map_err(|e| e.to_string()) +} diff --git a/runners/must/src/must.adb b/runners/must/src/must.adb new file mode 100644 index 0000000..7dff768 --- /dev/null +++ b/runners/must/src/must.adb @@ -0,0 +1,222 @@ +-- must.adb +-- Main entry point for Must - task runner + template engine + enforcer +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0-or-later + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Command_Line; +with Ada.Exceptions; + +with Must_Types; use Must_Types; +with CLI_Parser; use CLI_Parser; +with Mustfile_Loader; +with Task_Runner; +with Mustache_Engine; +with Requirement_Checker; +with Deployer; + +procedure Must is +begin + declare + Args : constant Parsed_Args := Parse; + Config : Mustfile_Config; + begin + case Args.Command is + when Cmd_Help => + Show_Help; + + when Cmd_Version => + Show_Version; + + when Cmd_Init => + if Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml already exists"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + else + Mustfile_Loader.Create_Default_Mustfile; + end if; + + when Cmd_None => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Put_Line ("Run 'must init' to create one"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + else + Put_Line ("Error: No command specified"); + Put_Line ("Run 'must --help' for usage"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end if; + + when Cmd_List => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + Task_Runner.List_Tasks (Config); + + when Cmd_Run_Task => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + + if not Task_Runner.Task_Exists (Config, Args.Task_Name) then + Put_Line ("Error: Unknown task '" & Must_Types.To_String (Args.Task_Name) & "'"); + Put_Line ("Run 'must --list' to see available tasks"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Task_Runner.Run_Task + (Config => Config, + Task_Name => Args.Task_Name, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + + when Cmd_Apply => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + + if Bounded_Strings.Length (Args.Template_Name) > 0 then + -- Apply specific template + Mustache_Engine.Apply_Named + (Config => Config, + Template_Name => Must_Types.To_String (Args.Template_Name), + Variables => Args.Variables, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + else + -- Apply all templates + Mustache_Engine.Apply_All + (Config => Config, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + end if; + + when Cmd_Check => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + begin + Requirement_Checker.Check + (Config => Config, + Strict => Args.Strict, + Verbose => Args.Verbose); + exception + when Requirement_Checker.Requirement_Failed => + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end; + + when Cmd_Fix => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + Requirement_Checker.Fix + (Config => Config, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + + when Cmd_Enforce => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + begin + Requirement_Checker.Enforce + (Config => Config, + Strict => Args.Strict, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + exception + when Requirement_Checker.Requirement_Failed => + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end; + + when Cmd_Templates => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + Mustache_Engine.List_Templates (Config); + + when Cmd_Deploy => + if not Mustfile_Loader.Mustfile_Exists then + Put_Line ("Error: mustfile.toml not found"); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + return; + end if; + + Config := Mustfile_Loader.Load; + begin + Deployer.Deploy + (Config => Config, + Target => Must_Types.To_String (Args.Deploy_Target), + Tag => Must_Types.To_String (Args.Deploy_Tag), + Push => Args.Deploy_Push, + Dry_Run => Args.Dry_Run, + Verbose => Args.Verbose); + exception + when Deployer.Deploy_Error => + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + end; + end case; + end; + +exception + when E : CLI_Parser.Parse_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Mustfile_Loader.Load_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Task_Runner.Task_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Task_Runner.Circular_Dependency => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Mustache_Engine.Template_Error => + Put_Line ("Error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : Deployer.Deploy_Error => + Put_Line ("Deploy error: " & Ada.Exceptions.Exception_Message (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + + when E : others => + Put_Line ("Unexpected error: " & Ada.Exceptions.Exception_Message (E)); + Put_Line ("Exception: " & Ada.Exceptions.Exception_Name (E)); + Ada.Command_Line.Set_Exit_Status (Ada.Command_Line.Failure); + +end Must; diff --git a/runners/must/src/must_types.adb b/runners/must/src/must_types.adb new file mode 100644 index 0000000..d65f661 --- /dev/null +++ b/runners/must/src/must_types.adb @@ -0,0 +1,12 @@ +-- must_types.adb +-- Common type definitions for Must (body) +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +package body Must_Types is + -- All functions are expression functions defined in spec + -- No implementation needed in body +end Must_Types; diff --git a/runners/must/src/must_types.ads b/runners/must/src/must_types.ads new file mode 100644 index 0000000..33d9c43 --- /dev/null +++ b/runners/must/src/must_types.ads @@ -0,0 +1,205 @@ +-- must_types.ads +-- Common type definitions for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Containers.Vectors; +with Ada.Containers.Ordered_Maps; +with Ada.Strings.Bounded; + +package Must_Types is + pragma Elaborate_Body; + + -- SPARK-compatible bounded strings with reasonable max lengths + -- These limits are chosen to balance memory usage with practical needs + Max_Path_Length : constant := 4096; -- Maximum path length + Max_String_Length : constant := 1024; -- General string max + Max_Command_Length : constant := 8192; -- Command lines can be long + Max_Description_Length : constant := 2048; -- Descriptions + + package Bounded_Paths is new Ada.Strings.Bounded.Generic_Bounded_Length + (Max => Max_Path_Length); + subtype Bounded_Path is Bounded_Paths.Bounded_String; + use type Bounded_Path; -- Make = and < visible + + package Bounded_Strings is new Ada.Strings.Bounded.Generic_Bounded_Length + (Max => Max_String_Length); + subtype Bounded_String is Bounded_Strings.Bounded_String; + use type Bounded_String; -- Make = and < visible + + package Bounded_Commands is new Ada.Strings.Bounded.Generic_Bounded_Length + (Max => Max_Command_Length); + subtype Bounded_Command is Bounded_Commands.Bounded_String; + use type Bounded_Command; -- Make = and < visible + + package Bounded_Descriptions is new Ada.Strings.Bounded.Generic_Bounded_Length + (Max => Max_Description_Length); + subtype Bounded_Description is Bounded_Descriptions.Bounded_String; + use type Bounded_Description; -- Make = and < visible + + -- Standard containers with bounded strings for safety + -- Note: When SPARK tools are available, these can be verified + -- String vector type (using bounded strings) + package String_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Bounded_String); + + subtype String_Vector is String_Vectors.Vector; + + -- Command vector type (commands can be longer) + package Command_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Bounded_Command); + + subtype Command_Vector is Command_Vectors.Vector; + + -- Path vector type + package Path_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Bounded_Path); + + subtype Path_Vector is Path_Vectors.Vector; + + -- Task definition with SPARK contracts + type Task_Def is record + Name : Bounded_String; + Description : Bounded_Description; + Commands : Command_Vector; + Dependencies : String_Vector; + Script : Bounded_Command; + Working_Dir : Bounded_Path; + end record with + Predicate => Bounded_Strings.Length (Task_Def.Name) > 0; + -- Task must have a non-empty name + + -- Task vector + package Task_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Task_Def); + + subtype Task_Vector is Task_Vectors.Vector; + + -- Requirement kind + type Requirement_Kind is (Must_Have, Must_Not_Have, Must_Contain) with + Default_Value => Must_Have; + + -- Requirement definition with SPARK contracts + type Requirement_Def is record + Kind : Requirement_Kind; + Path : Bounded_Path; + Pattern : Bounded_String; -- For Must_Contain + end record with + Predicate => Bounded_Paths.Length (Requirement_Def.Path) > 0; + -- Requirement must have a non-empty path + + -- Requirement vector + package Requirement_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Requirement_Def); + + subtype Requirement_Vector is Requirement_Vectors.Vector; + + -- Template definition with SPARK contracts + type Template_Def is record + Name : Bounded_String; + Source : Bounded_Path; + Destination : Bounded_Path; + Description : Bounded_Description; + end record with + Predicate => Bounded_Strings.Length (Template_Def.Name) > 0 and then + Bounded_Paths.Length (Template_Def.Source) > 0 and then + Bounded_Paths.Length (Template_Def.Destination) > 0; + -- Template must have non-empty name, source, and destination + + -- Template vector + package Template_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Template_Def); + + subtype Template_Vector is Template_Vectors.Vector; + + -- Project configuration with SPARK contracts + type Project_Config is record + Name : Bounded_String; + Version : Bounded_String; + License : Bounded_String; + Author : Bounded_String; + end record with + Predicate => Bounded_Strings.Length (Project_Config.Name) > 0 and then + Bounded_Strings.Length (Project_Config.Version) > 0; + -- Project must have non-empty name and version + + -- Enforcement configuration with SPARK contracts + type Enforcement_Config is record + License : Bounded_String; + Copyright_Holder : Bounded_String; + Podman_Not_Docker : Boolean := True; + Gitlab_Not_Github : Boolean := True; + No_Trailing_Whitespace : Boolean := True; + No_Tabs : Boolean := True; + Unix_Line_Endings : Boolean := True; + Max_Line_Length : Natural := 100; + end record with + Predicate => Enforcement_Config.Max_Line_Length > 0 and then + Enforcement_Config.Max_Line_Length <= 500; + -- Line length must be reasonable (1-500) + + -- String-to-String map type (for variables) + package String_Maps is new Ada.Containers.Ordered_Maps + (Key_Type => Bounded_String, + Element_Type => Bounded_String); + + subtype String_Map is String_Maps.Map; + + -- Full mustfile configuration with SPARK contracts + type Mustfile_Config is record + Project : Project_Config; + Tasks : Task_Vector; + Variables : String_Map; + Requirements : Requirement_Vector; + Templates : Template_Vector; + Enforcement : Enforcement_Config; + end record; + -- Config validation happens at load time in mustfile_loader + + -- Helper functions for bounded string conversion + function To_String (S : Bounded_String) return String is + (Bounded_Strings.To_String (S)) with + Post => To_String'Result'Length <= Max_String_Length; + + function To_Bounded (S : String) return Bounded_String is + (Bounded_Strings.To_Bounded_String (S)) with + Pre => S'Length <= Max_String_Length, + Post => Bounded_Strings.To_String (To_Bounded'Result) = S; + + function To_Path_String (S : Bounded_Path) return String is + (Bounded_Paths.To_String (S)) with + Post => To_Path_String'Result'Length <= Max_Path_Length; + + function To_Bounded_Path (S : String) return Bounded_Path is + (Bounded_Paths.To_Bounded_String (S)) with + Pre => S'Length <= Max_Path_Length, + Post => Bounded_Paths.To_String (To_Bounded_Path'Result) = S; + + function To_Command_String (S : Bounded_Command) return String is + (Bounded_Commands.To_String (S)) with + Post => To_Command_String'Result'Length <= Max_Command_Length; + + function To_Bounded_Command (S : String) return Bounded_Command is + (Bounded_Commands.To_Bounded_String (S)) with + Pre => S'Length <= Max_Command_Length, + Post => Bounded_Commands.To_String (To_Bounded_Command'Result) = S; + + function To_Description_String (S : Bounded_Description) return String is + (Bounded_Descriptions.To_String (S)) with + Post => To_Description_String'Result'Length <= Max_Description_Length; + + function To_Bounded_Description (S : String) return Bounded_Description is + (Bounded_Descriptions.To_Bounded_String (S)) with + Pre => S'Length <= Max_Description_Length, + Post => Bounded_Descriptions.To_String (To_Bounded_Description'Result) = S; + +end Must_Types; diff --git a/runners/must/src/mustfile.ncl b/runners/must/src/mustfile.ncl new file mode 100644 index 0000000..b4cc417 --- /dev/null +++ b/runners/must/src/mustfile.ncl @@ -0,0 +1,17 @@ +{ + must_have = [ + { path = "LICENSE-AGPL", hash = "sha512-..." }, + { path = "README.adoc", contains = ["SPDX-License-Identifier: PMPL-1.0-or-later"] } + ], + must_not_have = ["Makefile", "node_modules/"], + tasks = { + build = { + steps = ["just build"], + must_have = [{ path = "bin/must", mode = "0755" }] + }, + deploy = { + steps = ["podman build -t must:latest ."], + must_have = [{ path = "Containerfile" }] + } + } +} diff --git a/runners/must/src/requirements/requirement_checker.adb b/runners/must/src/requirements/requirement_checker.adb new file mode 100644 index 0000000..b6aaae3 --- /dev/null +++ b/runners/must/src/requirements/requirement_checker.adb @@ -0,0 +1,271 @@ +-- requirement_checker.adb +-- Requirements checker for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; +with Ada.Strings.Fixed; +with Ada.Strings.Unbounded; -- Only for file reading buffer +use type Ada.Directories.File_Kind; + +package body Requirement_Checker is + + -- Check if a path exists (file or directory) + function Path_Exists (Path : String) return Boolean is + begin + return Ada.Directories.Exists (Path); + end Path_Exists; + + -- Check if file contains pattern + function File_Contains (Path : String; Pattern : String) return Boolean is + F : File_Type; + Content : Ada.Strings.Unbounded.Unbounded_String; + begin + if not Ada.Directories.Exists (Path) then + return False; + end if; + + -- Only check files, not directories + if Ada.Directories.Kind (Path) /= Ada.Directories.Ordinary_File then + return False; + end if; + + Open (F, In_File, Path); + while not End_Of_File (F) loop + Ada.Strings.Unbounded.Append (Content, Get_Line (F)); + Ada.Strings.Unbounded.Append (Content, ASCII.LF); + end loop; + Close (F); + + return Ada.Strings.Fixed.Index + (Ada.Strings.Unbounded.To_String (Content), Pattern) > 0; + exception + when others => + if Is_Open (F) then + Close (F); + end if; + return False; + end File_Contains; + + function Check_Requirement (Req : Requirement_Def) return Check_Result is + Path : constant String := Must_Types.To_Path_String (Req.Path); + Pattern : constant String := Must_Types.To_String (Req.Pattern); + Result : Check_Result; + + function Make_Message (Msg : String) return Bounded_Description is + begin + if Msg'Length > Max_Description_Length then + return Must_Types.To_Bounded_Description + (Msg (Msg'First .. Msg'First + Max_Description_Length - 4) & "..."); + else + return Must_Types.To_Bounded_Description (Msg); + end if; + end Make_Message; + begin + Result.Requirement := Req; + + case Req.Kind is + when Must_Have => + if Path_Exists (Path) then + Result.Passed := True; + Result.Message := Make_Message ("OK: " & Path & " exists"); + else + Result.Passed := False; + Result.Message := Make_Message ("MISSING: " & Path); + end if; + + when Must_Not_Have => + if not Path_Exists (Path) then + Result.Passed := True; + Result.Message := Make_Message ("OK: " & Path & " does not exist"); + else + Result.Passed := False; + Result.Message := Make_Message ("FORBIDDEN: " & Path & " exists"); + end if; + + when Must_Contain => + if File_Contains (Path, Pattern) then + Result.Passed := True; + Result.Message := Make_Message ("OK: " & Path & " contains pattern"); + else + Result.Passed := False; + Result.Message := Make_Message + ("MISSING CONTENT: " & Path & " should contain: " & Pattern); + end if; + end case; + + return Result; + end Check_Requirement; + + function Check_All (Config : Mustfile_Config) return Result_Vector is + Results : Result_Vector; + Req : Requirement_Def; + begin + for R of Config.Requirements loop + Req := R; + Results.Append (Check_Requirement (Req)); + end loop; + + -- TODO: Re-add Requirements_Content support when map type is added to must_types + -- This was used for dynamic content requirements (file → patterns mapping) + -- For now, only static requirements from Requirements vector are checked + + return Results; + end Check_All; + + procedure Check + (Config : Mustfile_Config; + Strict : Boolean := False; + Verbose : Boolean := False) + is + Results : constant Result_Vector := Check_All (Config); + Passed_Count : Natural := 0; + Failed_Count : Natural := 0; + begin + if Config.Requirements.Is_Empty then + Put_Line ("No requirements defined"); + return; + end if; + + Put_Line ("Checking requirements:"); + Put_Line (""); + + for R of Results loop + if R.Passed then + Passed_Count := Passed_Count + 1; + if Verbose then + Put_Line (" [PASS] " & Must_Types.To_Description_String (R.Message)); + end if; + else + Failed_Count := Failed_Count + 1; + Put_Line (" [FAIL] " & Must_Types.To_Description_String (R.Message)); + end if; + end loop; + + Put_Line (""); + Put_Line ("Passed:" & Natural'Image (Passed_Count) & + " / Failed:" & Natural'Image (Failed_Count)); + + if Failed_Count > 0 and then Strict then + raise Requirement_Failed with + "Requirements check failed (" & Natural'Image (Failed_Count) & + " violations)"; + end if; + end Check; + + procedure Fix + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Results : constant Result_Vector := Check_All (Config); + Fixed_Count : Natural := 0; + begin + Put_Line ("Fixing violations:"); + Put_Line (""); + + for R of Results loop + if not R.Passed then + case R.Requirement.Kind is + when Must_Have => + -- Create empty file/directory + declare + Path : constant String := Must_Types.To_Path_String (R.Requirement.Path); + begin + if Verbose or Dry_Run then + Put_Line (" Creating: " & Path); + end if; + + if not Dry_Run then + -- Check if it's a directory (ends with /) + if Path (Path'Last) = '/' then + Ada.Directories.Create_Path (Path); + else + -- Create empty file + declare + F : File_Type; + begin + Create (F, Out_File, Path); + Close (F); + end; + end if; + Fixed_Count := Fixed_Count + 1; + end if; + end; + + when Must_Not_Have => + -- Delete file/directory + declare + Path : constant String := Must_Types.To_Path_String (R.Requirement.Path); + begin + if Verbose or Dry_Run then + Put_Line (" Removing: " & Path); + end if; + + if not Dry_Run then + if Ada.Directories.Exists (Path) then + if Ada.Directories.Kind (Path) = + Ada.Directories.Directory + then + Ada.Directories.Delete_Tree (Path); + else + Ada.Directories.Delete_File (Path); + end if; + Fixed_Count := Fixed_Count + 1; + end if; + end if; + end; + + when Must_Contain => + -- Cannot auto-fix content requirements + Put_Line (" Cannot auto-fix: " & Must_Types.To_Description_String (R.Message)); + end case; + end if; + end loop; + + if Dry_Run then + Put_Line ("(dry run - no changes made)"); + else + Put_Line (""); + Put_Line ("Fixed:" & Natural'Image (Fixed_Count) & " violations"); + end if; + end Fix; + + procedure Enforce + (Config : Mustfile_Config; + Strict : Boolean := True; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + begin + Put_Line ("=== Enforcement Mode ==="); + Put_Line (""); + + -- Step 1: Check requirements + Put_Line ("Step 1: Check requirements"); + begin + Check (Config, Strict => False, Verbose => Verbose); + exception + when others => + null; -- Continue even if check fails + end; + Put_Line (""); + + -- Step 2: Fix violations + Put_Line ("Step 2: Fix violations"); + Fix (Config, Dry_Run, Verbose); + Put_Line (""); + + -- Step 3: Verify + Put_Line ("Step 3: Verify"); + Check (Config, Strict, Verbose); + Put_Line (""); + + Put_Line ("=== Enforcement Complete ==="); + end Enforce; + +end Requirement_Checker; diff --git a/runners/must/src/requirements/requirement_checker.ads b/runners/must/src/requirements/requirement_checker.ads new file mode 100644 index 0000000..235d3de --- /dev/null +++ b/runners/must/src/requirements/requirement_checker.ads @@ -0,0 +1,57 @@ +-- requirement_checker.ads +-- Requirements checker for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Containers.Vectors; +with Must_Types; use Must_Types; + +package Requirement_Checker is + + -- Check result (using bounded strings for safety) + type Check_Result is record + Passed : Boolean; + Message : Bounded_Description; -- Messages can be moderately long + Requirement : Requirement_Def; + end record; + + -- Check result vector + package Result_Vectors is new Ada.Containers.Vectors + (Index_Type => Positive, + Element_Type => Check_Result); + + subtype Result_Vector is Result_Vectors.Vector; + + -- Check all requirements + function Check_All + (Config : Mustfile_Config) return Result_Vector; + + -- Check requirements and report + procedure Check + (Config : Mustfile_Config; + Strict : Boolean := False; + Verbose : Boolean := False); + + -- Fix violations (where possible) + procedure Fix + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Full enforcement (check + apply + verify) + procedure Enforce + (Config : Mustfile_Config; + Strict : Boolean := True; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Check a single requirement + function Check_Requirement (Req : Requirement_Def) return Check_Result; + + -- Requirement check failed + Requirement_Failed : exception; + +end Requirement_Checker; diff --git a/runners/must/src/rollback.rs b/runners/must/src/rollback.rs new file mode 100644 index 0000000..14e9142 --- /dev/null +++ b/runners/must/src/rollback.rs @@ -0,0 +1,11 @@ +// src/rollback.rs +use std::fs; + +pub fn rollback(task_name: &str, mustfile: &MustFile) -> Result<(), String> { + if let Some(task) = mustfile.tasks.get(task_name) { + for rule in &task.must_have { + let _ = fs::remove_file(&rule.path); // Simple rollback + } + } + Ok(()) +} diff --git a/runners/must/src/state.rs b/runners/must/src/state.rs new file mode 100644 index 0000000..a17710c --- /dev/null +++ b/runners/must/src/state.rs @@ -0,0 +1,64 @@ +// src/state.rs +use sha2::{Sha512, Digest}; +use std::path::Path; +use std::fs; + +#[derive(Debug, serde::Deserialize)] +pub struct FileRule { + pub path: String, + pub hash: Option, // SHA-512 or SHAKE256 + pub contains: Option>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct MustFile { + pub must_have: Vec, + pub must_not_have: Vec, + pub tasks: std::collections::HashMap, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Task { + pub steps: Vec, + pub must_have: Vec, +} + +pub fn validate_state(mustfile: &MustFile) -> Result<(), String> { + // Check must_have + for rule in &mustfile.must_have { + let path = Path::new(&rule.path); + if !path.exists() { + return Err(format!("❌ Missing required file: {}", rule.path)); + } + if let Some(hash) = &rule.hash { + let actual_hash = compute_hash(&rule.path)?; + if actual_hash != hash { + return Err(format!("❌ Hash mismatch for {} (expected {}, got {})", rule.path, hash, actual_hash)); + } + } + if let Some(strings) = &rule.contains { + let content = fs::read_to_string(&rule.path).map_err(|e| e.to_string())?; + for s in strings { + if !content.contains(s) { + return Err(format!("❌ File {} missing required string: {}", rule.path, s)); + } + } + } + } + + // Check must_not_have + for path in &mustfile.must_not_have { + if Path::new(path).exists() { + return Err(format!("❌ Forbidden file present: {}", path)); + } + } + + Ok(()) +} + +pub fn compute_hash(path: &str) -> Result { + let mut file = fs::File::open(path).map_err(|e| e.to_string())?; + let mut hasher = Sha512::new(); + std::io::copy(&mut file, &mut hasher).map_err(|e| e.to_string())?; + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/runners/must/src/task.rs b/runners/must/src/task.rs new file mode 100644 index 0000000..b4d8880 --- /dev/null +++ b/runners/must/src/task.rs @@ -0,0 +1,23 @@ +// src/task.rs +use std::process::Command; + +pub fn run_task(task_name: &str, mustfile: &MustFile) -> Result<(), String> { + let task = mustfile.tasks.get(task_name) + .ok_or_else(|| format!("❌ Task {} not found", task_name))?; + + // Pre-task validation + validate_state(mustfile)?; + + // Execute steps + for step in &task.steps { + println!("🛠️ Running: {}", step); + let status = Command::new("sh").arg("-c").arg(step).status().map_err(|e| e.to_string())?; + if !status.success() { + return Err(format!("❌ Step failed: {}", step)); + } + } + + // Post-task validation + validate_state(mustfile)?; + Ok(()) +} diff --git a/runners/must/src/tasks/task_runner.adb b/runners/must/src/tasks/task_runner.adb new file mode 100644 index 0000000..b8aa1b6 --- /dev/null +++ b/runners/must/src/tasks/task_runner.adb @@ -0,0 +1,287 @@ +-- task_runner.adb +-- Task runner with dependency resolution for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; +with GNAT.OS_Lib; + +package body Task_Runner is + + -- Make operators visible for bounded strings + use type Bounded_String; + + -- Check if task name is in vector (now using bounded strings) + function Contains (Vec : String_Vector; Name : Bounded_String) return Boolean is + begin + for Item of Vec loop + if Item = Name then + return True; + end if; + end loop; + return False; + end Contains; + + function Task_Exists + (Config : Mustfile_Config; + Task_Name : Bounded_String) return Boolean + is + begin + for T of Config.Tasks loop + if T.Name = Task_Name then + return True; + end if; + end loop; + return False; + end Task_Exists; + + function Get_Task + (Config : Mustfile_Config; + Task_Name : Bounded_String) return Task_Def + is + begin + for T of Config.Tasks loop + if T.Name = Task_Name then + return T; + end if; + end loop; + raise Task_Error with "Task not found: " & Must_Types.To_String (Task_Name); + end Get_Task; + + -- Internal: depth-first search for topological sort + procedure DFS + (Config : Mustfile_Config; + Task_Name : Bounded_String; + Visited : in out String_Vector; + In_Stack : in out String_Vector; + Result : in out String_Vector) + is + T : Task_Def; + begin + -- Check for circular dependency + if Contains (In_Stack, Task_Name) then + raise Circular_Dependency with + "Circular dependency detected involving: " & Must_Types.To_String (Task_Name); + end if; + + -- Already processed + if Contains (Visited, Task_Name) then + return; + end if; + + -- Add to current path + In_Stack.Append (Task_Name); + + -- Get task and process dependencies + T := Get_Task (Config, Task_Name); + for Dep of T.Dependencies loop + if not Task_Exists (Config, Dep) then + raise Task_Error with + "Task '" & Must_Types.To_String (Task_Name) & + "' depends on unknown task: " & Must_Types.To_String (Dep); + end if; + DFS (Config, Dep, Visited, In_Stack, Result); + end loop; + + -- Remove from current path + declare + New_Stack : String_Vector; + begin + for Item of In_Stack loop + if Item /= Task_Name then + New_Stack.Append (Item); + end if; + end loop; + In_Stack := New_Stack; + end; + + -- Mark as visited and add to result + Visited.Append (Task_Name); + Result.Append (Task_Name); + end DFS; + + function Resolve_Dependencies + (Config : Mustfile_Config; + Task_Name : Bounded_String) return String_Vector + is + Visited : String_Vector; + In_Stack : String_Vector; + Result : String_Vector; + begin + if not Task_Exists (Config, Task_Name) then + raise Task_Error with "Task not found: " & Must_Types.To_String (Task_Name); + end if; + + DFS (Config, Task_Name, Visited, In_Stack, Result); + return Result; + end Resolve_Dependencies; + + -- Execute a shell command (using bounded command for safety) + function Execute_Command + (Command : Bounded_Command; + Verbose : Boolean) return Integer + is + pragma Unreferenced (Verbose); -- Reserved for future verbose output + use GNAT.OS_Lib; + Args : Argument_List_Access; + Success : Boolean; + Cmd_String : constant String := Must_Types.To_Command_String (Command); + begin + -- Use shell to execute command + Args := new Argument_List (1 .. 2); + Args (1) := new String'("-c"); + Args (2) := new String'(Cmd_String); + + Spawn + (Program_Name => "/bin/sh", + Args => Args.all, + Success => Success); + + -- Free arguments + for I in Args'Range loop + Free (Args (I)); + end loop; + Free (Args); + + if Success then + return 0; + else + return 1; + end if; + end Execute_Command; + + -- Execute a single task (without dependencies) + procedure Execute_Task + (Config : Mustfile_Config; + T : Task_Def; + Dry_Run : Boolean; + Verbose : Boolean) + is + pragma Unreferenced (Config); -- Reserved for future config-based execution + Original_Dir : constant String := Ada.Directories.Current_Directory; + begin + -- Change to working directory if specified + if Bounded_Paths.Length (T.Working_Dir) > 0 then + if Verbose then + Put_Line (" cd " & Must_Types.To_Path_String (T.Working_Dir)); + end if; + if not Dry_Run then + Ada.Directories.Set_Directory (Must_Types.To_Path_String (T.Working_Dir)); + end if; + end if; + + -- Execute commands or script + if Bounded_Commands.Length (T.Script) > 0 then + -- Execute script + if Verbose or Dry_Run then + Put_Line (" [script]"); + end if; + if not Dry_Run then + declare + Status : Integer; + begin + Status := Execute_Command (T.Script, Verbose); + if Status /= 0 then + raise Task_Error with + "Script failed with exit code:" & Integer'Image (Status); + end if; + end; + end if; + else + -- Execute commands + for Cmd of T.Commands loop + if Verbose or Dry_Run then + Put_Line (" " & Must_Types.To_Command_String (Cmd)); + end if; + if not Dry_Run then + declare + Status : Integer; + begin + Status := Execute_Command (Cmd, Verbose); + if Status /= 0 then + raise Task_Error with + "Command failed: " & Must_Types.To_Command_String (Cmd); + end if; + end; + end if; + end loop; + end if; + + -- Restore original directory + if Bounded_Paths.Length (T.Working_Dir) > 0 and then not Dry_Run then + Ada.Directories.Set_Directory (Original_Dir); + end if; + end Execute_Task; + + procedure Run_Task + (Config : Mustfile_Config; + Task_Name : Bounded_String; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Execution_Order : String_Vector; + begin + -- Get execution order (dependencies first) + Execution_Order := Resolve_Dependencies (Config, Task_Name); + + -- Execute tasks in order + for Name of Execution_Order loop + declare + T : constant Task_Def := Get_Task (Config, Name); + begin + if Name = Task_Name then + Put_Line ("Running: " & Must_Types.To_String (Name)); + else + Put_Line ("Running dependency: " & Must_Types.To_String (Name)); + end if; + + Execute_Task (Config, T, Dry_Run, Verbose); + end; + end loop; + + if Dry_Run then + Put_Line ("(dry run - no commands executed)"); + else + Put_Line ("Done."); + end if; + end Run_Task; + + procedure List_Tasks (Config : Mustfile_Config) is + Max_Len : Natural := 0; + begin + if Config.Tasks.Is_Empty then + Put_Line ("No tasks defined in mustfile.toml"); + return; + end if; + + -- Find max task name length for alignment + for T of Config.Tasks loop + if Bounded_Strings.Length (T.Name) > Max_Len then + Max_Len := Bounded_Strings.Length (T.Name); + end if; + end loop; + + Put_Line ("Available tasks:"); + Put_Line (""); + + for T of Config.Tasks loop + declare + Name : constant String := Must_Types.To_String (T.Name); + Desc : constant String := Must_Types.To_Description_String (T.Description); + Padding : constant String (1 .. Max_Len - Name'Length + 2) := + [others => ' ']; + begin + if Desc'Length > 0 then + Put_Line (" " & Name & Padding & "# " & Desc); + else + Put_Line (" " & Name); + end if; + end; + end loop; + end List_Tasks; + +end Task_Runner; diff --git a/runners/must/src/tasks/task_runner.ads b/runners/must/src/tasks/task_runner.ads new file mode 100644 index 0000000..912ffc1 --- /dev/null +++ b/runners/must/src/tasks/task_runner.ads @@ -0,0 +1,44 @@ +-- task_runner.ads +-- Task runner with dependency resolution for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Task_Runner is + + -- Run a task by name (using bounded string for safety) + procedure Run_Task + (Config : Mustfile_Config; + Task_Name : Bounded_String; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- List all available tasks + procedure List_Tasks (Config : Mustfile_Config); + + -- Check if a task exists (using bounded string for safety) + function Task_Exists + (Config : Mustfile_Config; + Task_Name : Bounded_String) return Boolean; + + -- Get task definition by name (using bounded string for safety) + function Get_Task + (Config : Mustfile_Config; + Task_Name : Bounded_String) return Task_Def; + + -- Resolve dependencies (topological sort, using bounded string for safety) + function Resolve_Dependencies + (Config : Mustfile_Config; + Task_Name : Bounded_String) return String_Vector; + + -- Task execution error + Task_Error : exception; + + -- Circular dependency error + Circular_Dependency : exception; + +end Task_Runner; diff --git a/runners/must/src/templates/mustache_engine.adb b/runners/must/src/templates/mustache_engine.adb new file mode 100644 index 0000000..58794f2 --- /dev/null +++ b/runners/must/src/templates/mustache_engine.adb @@ -0,0 +1,458 @@ +-- mustache_engine.adb +-- Mustache template engine for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Ada.Text_IO; use Ada.Text_IO; +with Ada.Directories; +with Ada.Strings.Fixed; +with Ada.Strings.Unbounded; + +use Ada.Strings.Unbounded; + +package body Mustache_Engine is + + -- Helper: lookup variable in map with String key + function Get_Var (Variables : String_Map; Key : String) return String is + Bounded_Key : Bounded_String; + begin + if Key'Length > Max_String_Length then + return ""; + end if; + Bounded_Key := Must_Types.To_Bounded (Key); + if Variables.Contains (Bounded_Key) then + return Must_Types.To_String (Variables.Element (Bounded_Key)); + else + return ""; + end if; + end Get_Var; + + function Has_Var (Variables : String_Map; Key : String) return Boolean is + Bounded_Key : Bounded_String; + begin + if Key'Length > Max_String_Length then + return False; + end if; + Bounded_Key := Must_Types.To_Bounded (Key); + return Variables.Contains (Bounded_Key); + end Has_Var; + + -- Read entire file content + function Read_File (Path : String) return String is + F : File_Type; + Content : Unbounded_String; + begin + Open (F, In_File, Path); + while not End_Of_File (F) loop + Append (Content, Get_Line (F)); + if not End_Of_File (F) then + Append (Content, ASCII.LF); + end if; + end loop; + Close (F); + return Ada.Strings.Unbounded.To_String (Content); + exception + when Name_Error => + raise Template_Error with "Template file not found: " & Path; + when others => + if Is_Open (F) then + Close (F); + end if; + raise; + end Read_File; + + -- Write content to file + procedure Write_File (Path : String; Content : String) is + F : File_Type; + begin + -- Create parent directories if needed + declare + Dir : constant String := Ada.Directories.Containing_Directory (Path); + begin + if Dir'Length > 0 and then not Ada.Directories.Exists (Dir) then + Ada.Directories.Create_Path (Dir); + end if; + end; + + Create (F, Out_File, Path); + Put (F, Content); + Close (F); + exception + when others => + if Is_Open (F) then + Close (F); + end if; + raise; + end Write_File; + + -- Find closing tag for section + function Find_Section_End + (Template : String; + Tag_Name : String; + Start : Positive) return Natural + is + Close_Tag : constant String := "{{/" & Tag_Name & "}}"; + Open_Tag : constant String := "{{#" & Tag_Name & "}}"; + Pos : Natural := Start; + Depth : Natural := 1; + begin + while Pos <= Template'Last - Close_Tag'Length + 1 loop + if Template (Pos .. Pos + Close_Tag'Length - 1) = Close_Tag then + Depth := Depth - 1; + if Depth = 0 then + return Pos; + end if; + elsif Template (Pos .. Pos + Open_Tag'Length - 1) = Open_Tag then + Depth := Depth + 1; + end if; + Pos := Pos + 1; + end loop; + return 0; + end Find_Section_End; + + function Render + (Template : String; + Variables : String_Map) return String + is + Result : Unbounded_String; + I : Positive := Template'First; + Tag_Start : Natural; + Tag_End : Natural; + begin + while I <= Template'Last loop + -- Look for opening tag + Tag_Start := Ada.Strings.Fixed.Index (Template (I .. Template'Last), "{{"); + + if Tag_Start = 0 then + -- No more tags, append rest of template + Append (Result, Template (I .. Template'Last)); + exit; + end if; + + -- Append text before tag + if Tag_Start > I then + Append (Result, Template (I .. Tag_Start - 1)); + end if; + + -- Find closing tag + Tag_End := Ada.Strings.Fixed.Index + (Template (Tag_Start .. Template'Last), "}}"); + + if Tag_End = 0 then + raise Template_Error with "Unclosed tag at position" & + Positive'Image (Tag_Start); + end if; + + -- Process tag + declare + Tag_Content : constant String := + Template (Tag_Start + 2 .. Tag_End - 1); + begin + if Tag_Content'Length = 0 then + -- Empty tag + null; + + elsif Tag_Content (Tag_Content'First) = '#' then + -- Section start {{#name}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last); + Sec_End : constant Natural := + Find_Section_End (Template, Name, Tag_End + 2); + Close_Tag : constant String := "{{/" & Name & "}}"; + begin + if Sec_End = 0 then + raise Template_Error with + "Unclosed section: " & Name; + end if; + + -- Check if variable is truthy + if Has_Var (Variables, Name) then + declare + Value : constant String := Get_Var (Variables, Name); + begin + if Value'Length > 0 and then Value /= "false" then + -- Render section content + Append (Result, Render + (Template (Tag_End + 2 .. Sec_End - 1), Variables)); + end if; + end; + end if; + + I := Sec_End + Close_Tag'Length; + end; + + elsif Tag_Content (Tag_Content'First) = '^' then + -- Inverted section {{^name}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last); + Sec_End : constant Natural := + Find_Section_End (Template, Name, Tag_End + 2); + Close_Tag : constant String := "{{/" & Name & "}}"; + begin + if Sec_End = 0 then + raise Template_Error with + "Unclosed inverted section: " & Name; + end if; + + -- Render if variable is falsy + if not Has_Var (Variables, Name) or else + Get_Var (Variables, Name) = "" or else + Get_Var (Variables, Name) = "false" + then + Append (Result, Render + (Template (Tag_End + 2 .. Sec_End - 1), Variables)); + end if; + + I := Sec_End + Close_Tag'Length; + end; + + elsif Tag_Content (Tag_Content'First) = '/' then + -- Section end (handled above) + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '!' then + -- Comment {{! comment }} + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '>' then + -- Partial {{> partial_name}} + declare + Partial_Name : constant String := Ada.Strings.Fixed.Trim + (Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last), + Ada.Strings.Both); + Partial_Path : constant String := + "templates/" & Partial_Name & ".mustache"; + begin + if Ada.Directories.Exists (Partial_Path) then + -- Load and render the partial with current variables + declare + Partial_Content : constant String := + Read_File (Partial_Path); + begin + Append (Result, Render (Partial_Content, Variables)); + end; + else + -- Partial not found - silently skip (per Mustache spec) + null; + end if; + end; + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '{' and then + Tag_Content (Tag_Content'Last) = '}' + then + -- Unescaped variable {{{name}}} + declare + Name : constant String := + Tag_Content (Tag_Content'First + 1 .. Tag_Content'Last - 1); + begin + if Has_Var (Variables, Name) then + Append (Result, Get_Var (Variables, Name)); + end if; + end; + I := Tag_End + 2; + + elsif Tag_Content (Tag_Content'First) = '&' then + -- Unescaped variable {{&name}} + declare + Name : constant String := + Ada.Strings.Fixed.Trim + (Tag_Content (Tag_Content'First + 2 .. Tag_Content'Last), + Ada.Strings.Both); + begin + if Has_Var (Variables, Name) then + Append (Result, Get_Var (Variables, Name)); + end if; + end; + I := Tag_End + 2; + + else + -- Regular variable {{name}} + declare + Name : constant String := Ada.Strings.Fixed.Trim + (Tag_Content, Ada.Strings.Both); + begin + if Has_Var (Variables, Name) then + -- HTML escape the value (basic escaping) + declare + Value : constant String := Get_Var (Variables, Name); + Escaped : Unbounded_String; + begin + for C of Value loop + case C is + when '&' => Append (Escaped, "&"); + when '<' => Append (Escaped, "<"); + when '>' => Append (Escaped, ">"); + when '"' => Append (Escaped, """); + when others => Append (Escaped, C); + end case; + end loop; + Append (Result, Ada.Strings.Unbounded.To_String (Escaped)); + end; + end if; + end; + I := Tag_End + 2; + end if; + end; + end loop; + + return Ada.Strings.Unbounded.To_String (Result); + end Render; + + function Render_File + (Template_Path : String; + Variables : String_Map) return String + is + Content : constant String := Read_File (Template_Path); + begin + return Render (Content, Variables); + end Render_File; + + -- Render destination path with variables + function Render_Path + (Path : String; + Variables : String_Map) return String + is + begin + return Render (Path, Variables); + end Render_Path; + + procedure Apply_Template + (Source : String; + Destination : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Rendered_Dest : constant String := Render_Path (Destination, Variables); + Content : constant String := Render_File (Source, Variables); + begin + if Verbose or Dry_Run then + Put_Line (" " & Source & " -> " & Rendered_Dest); + end if; + + if not Dry_Run then + Write_File (Rendered_Dest, Content); + end if; + end Apply_Template; + + procedure Apply_All + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Variables : constant String_Map := Config.Variables; + begin + if Config.Templates.Is_Empty then + Put_Line ("No templates defined"); + return; + end if; + + Put_Line ("Applying templates:"); + + for T of Config.Templates loop + Apply_Template + (Source => Must_Types.To_Path_String (T.Source), + Destination => Must_Types.To_Path_String (T.Destination), + Variables => Variables, + Dry_Run => Dry_Run, + Verbose => Verbose); + end loop; + + if Dry_Run then + Put_Line ("(dry run - no files written)"); + else + Put_Line ("Done."); + end if; + end Apply_All; + + procedure Apply_Named + (Config : Mustfile_Config; + Template_Name : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False) + is + Merged_Vars : String_Map := Config.Variables; + begin + -- Merge provided variables with config variables + for C in Variables.Iterate loop + Merged_Vars.Include (String_Maps.Key (C), String_Maps.Element (C)); + end loop; + + -- Find and apply template + for T of Config.Templates loop + if Must_Types.To_String (T.Name) = Template_Name then + Put_Line ("Applying template: " & Template_Name); + Apply_Template + (Source => Must_Types.To_Path_String (T.Source), + Destination => Must_Types.To_Path_String (T.Destination), + Variables => Merged_Vars, + Dry_Run => Dry_Run, + Verbose => Verbose); + + if Dry_Run then + Put_Line ("(dry run - no files written)"); + else + Put_Line ("Done."); + end if; + return; + end if; + end loop; + + raise Template_Error with "Template not found: " & Template_Name; + end Apply_Named; + + procedure List_Templates (Config : Mustfile_Config) is + Max_Len : Natural := 0; + begin + if Config.Templates.Is_Empty then + Put_Line ("No templates defined in mustfile.toml"); + return; + end if; + + -- Find max name length + for T of Config.Templates loop + if Must_Types.Bounded_Strings.Length (T.Name) > Max_Len then + Max_Len := Must_Types.Bounded_Strings.Length (T.Name); + end if; + end loop; + + Put_Line ("Available templates:"); + Put_Line (""); + + for T of Config.Templates loop + declare + Name : constant String := Must_Types.To_String (T.Name); + Desc : constant String := Must_Types.To_Description_String (T.Description); + Padding : constant String (1 .. Max_Len - Name'Length + 2) := + [others => ' ']; + begin + if Desc'Length > 0 then + Put_Line (" " & Name & Padding & "# " & Desc); + else + Put_Line (" " & Name); + end if; + end; + end loop; + end List_Templates; + + function Template_Exists + (Config : Mustfile_Config; + Template_Name : String) return Boolean + is + begin + for T of Config.Templates loop + if Must_Types.To_String (T.Name) = Template_Name then + return True; + end if; + end loop; + return False; + end Template_Exists; + +end Mustache_Engine; diff --git a/runners/must/src/templates/mustache_engine.ads b/runners/must/src/templates/mustache_engine.ads new file mode 100644 index 0000000..f983c3c --- /dev/null +++ b/runners/must/src/templates/mustache_engine.ads @@ -0,0 +1,56 @@ +-- mustache_engine.ads +-- Mustache template engine for Must +-- Copyright (C) 2025 Jonathan D.A. Jewell +-- SPDX-License-Identifier: MPL-2.0 +-- (PMPL-1.0-or-later preferred; MPL-2.0 required for GNAT ecosystem) + +pragma Ada_2022; + +with Must_Types; use Must_Types; + +package Mustache_Engine is + + -- Render a template string with variables + function Render + (Template : String; + Variables : String_Map) return String; + + -- Render a template file with variables + function Render_File + (Template_Path : String; + Variables : String_Map) return String; + + -- Apply a template to generate output file + procedure Apply_Template + (Source : String; + Destination : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Apply all templates from config + procedure Apply_All + (Config : Mustfile_Config; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- Apply a specific template by name + procedure Apply_Named + (Config : Mustfile_Config; + Template_Name : String; + Variables : String_Map; + Dry_Run : Boolean := False; + Verbose : Boolean := False); + + -- List available templates + procedure List_Templates (Config : Mustfile_Config); + + -- Check if template exists + function Template_Exists + (Config : Mustfile_Config; + Template_Name : String) return Boolean; + + -- Template error + Template_Error : exception; + +end Mustache_Engine; diff --git a/runners/trust/README.adoc b/runners/trust/README.adoc new file mode 100644 index 0000000..32338ca --- /dev/null +++ b/runners/trust/README.adoc @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += Trust Runner + +The `trust` runner is now provided by the unified Rust CLI at `cli/crates/contractile/`. + +Install with: + +[source,bash] +---- +just install-cli +---- + +This creates a `trust` symlink in `~/.local/bin/` pointing to the `contractile` binary. + +== Usage + +[source,bash] +---- +trust verify # Run all Trustfile.a2ml verifications +trust verify NAME # Run a single verification +trust list # List available verifications +trust hash FILE # Compute SHA-256 of a file +trust sign FILE # Sign a file (placeholder) +---- + +== Specification + +See `trustfile/docs/trust-spec.adoc` for the full Trust specification. diff --git a/schema/version.txt b/schema/version.txt new file mode 100644 index 0000000..0ec25f7 --- /dev/null +++ b/schema/version.txt @@ -0,0 +1 @@ +v1.0.0 diff --git a/scripts/check-6scm.sh b/scripts/check-6scm.sh new file mode 100755 index 0000000..ee6b1e6 --- /dev/null +++ b/scripts/check-6scm.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +missing=0 +for f in AGENTIC.scm ECOSYSTEM.scm META.scm NEUROSYM.scm PLAYBOOK.scm STATE.scm; do + src=".machine_readable/$f" + dst=".machine_readable/6scm/$f" + if [ ! -f "$src" ]; then + continue + fi + if [ ! -f "$dst" ]; then + echo "Missing mirror: $dst" >&2 + missing=1 + continue + fi + if ! diff -u "$src" "$dst" >/dev/null; then + echo "Out of sync: $src -> $dst" >&2 + missing=1 + fi +done + +exit $missing diff --git a/trustfile/docs/trust-spec.adoc b/trustfile/docs/trust-spec.adoc new file mode 100644 index 0000000..31e2d85 --- /dev/null +++ b/trustfile/docs/trust-spec.adoc @@ -0,0 +1,759 @@ += Trust Specification +:author: Jonathan D.A. Jewell +:revnumber: 0.1.0 +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +toc::[] + +== Overview + +Trust is a **contract-driven integrity tool** that enforces **Provenance State** — the verifiable chain of cryptographic evidence binding every artifact to its origin, its author, and its expected content. + +Where Must declares _what exists_, Trust declares _what to believe_ — and provides the commands to verify those beliefs mechanically. + +== Provenance State: Definition + +**Provenance State** is the complete set of verifiable integrity facts about a project: + +[cols="1,3"] +|=== +| Dimension | What It Captures + +| **Content Hashes** +| Cryptographic digests binding file contents to known-good values (SHA-256, SHA3-512, SHAKE256, BLAKE3) + +| **Digital Signatures** +| Author attestations over artifacts using classical (Ed448, Ed25519) or post-quantum (Dilithium5, SPHINCS+) algorithms + +| **Provenance Chains** +| Signed records linking each artifact to the commit, pipeline, and author that produced it + +| **Key Material** +| Public keys, key IDs, rotation history, and trust anchors used for verification + +| **Formal Proofs** +| Machine-checked theorems (LEAN4, Idris2) that the integrity model itself is sound + +| **Transparency Logs** +| Append-only logs (RFC 9162) recording every integrity-relevant event for auditability +|=== + +=== Trust Hierarchy + +Provenance State follows a **trust hierarchy** from strongest to weakest evidence: + +1. **Formal proof**: A LEAN4/Idris2 theorem proves a property holds for all inputs +2. **Cryptographic signature**: A key holder attests to a specific artifact +3. **Content hash**: A digest matches the expected value +4. **Policy assertion**: A Rego/Nickel rule evaluates to true + +Higher levels subsume lower: a formal proof that a signing scheme is correct makes its signatures trustworthy, which in turn makes the hashes it covers trustworthy. + +== Relationship to Other Contractiles + +[cols="1,3"] +|=== +| Contractile | Trust's Relationship + +| **Must** +| Must declares _what must exist_. Trust declares _how to verify those things are genuine_. A Mustfile says `LICENSE` must exist; a Trustfile says `LICENSE` must hash to a specific SHA-256. + +| **Dust** +| Dust declares _how to recover_. Trust provides _integrity evidence for recovery targets_. Before rolling back, dust actions can invoke trust verifications to confirm the rollback target is genuine. + +| **Intend** +| Intend declares _what we plan to do_. Trust can track _whether crypto-related intents have been realised_ (e.g., "migrate to post-quantum" → check if PQ keys are deployed). + +| **K9** +| K9 Yard-level components can validate Trustfile entries using Nickel contracts. K9 Hunt-level components require trust verification (signature check) before execution. +|=== + +== Contract Format: A2ML + +The canonical format for Trustfiles is A2ML. The TOML format used by Mustfiles is not used for Trust — A2ML's flexibility better serves the heterogeneous verification landscape. + +=== Header + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Cryptographic and provenance verification steps for this project. +@end + +@requires: +- section: Inputs +- section: Verifications +@end +---- + +=== Inputs Section (Required) + +Declares all files, keys, and artifacts that the Trustfile references. This serves as both documentation and a pre-flight check — the runner can verify inputs exist before attempting verification. + +[source,a2ml] +---- +## Inputs + +- policy_path: policy/policy.ncl +- policy_hash_path: policy/policy.ncl.sha256 +- schema_path: schema/schema.json +- schema_sig_path: schema/schema.sig +- schema_pub_path: schema/schema.pub +---- + +Recognised input key conventions: + +[cols="1,3"] +|=== +| Key suffix | Meaning + +| `_path` +| Path to the artifact being verified + +| `_hash_path` +| Path to the file containing the expected hash + +| `_sig_path` +| Path to the detached signature file + +| `_pub_path` +| Path to the public key for signature verification + +| `_cert_path` +| Path to the certificate for TLS/mTLS verification +|=== + +=== Verifications Section (Required) + +Each verification is a named subsection under `## Verifications` (or any section name declared in `@requires:`). A verification MUST have a `command:` entry containing the shell command that performs the check. It SHOULD have a `description:` entry. + +[source,a2ml] +---- +## Verifications + +### policy-hash +- description: SHA-256 of policy matches expected value +- command: sha256sum -c policy/policy.ncl.sha256 +- algorithm: SHA-256 +- severity: critical + +### schema-signature +- description: Schema signature verifies against the project signing key +- command: openssl dgst -sha256 -verify schema/schema.pub -signature schema/schema.sig schema/schema.json +- algorithm: Ed25519 +- severity: critical + +### driver-signatures +- description: Post-quantum signatures verify for compiled drivers +- command: kyber-verify --pub drivers/gateway-driver.bin.pub --sig drivers/gateway-driver.bin.sig --file drivers/gateway-driver.bin +- algorithm: Kyber1024 +- severity: critical + +### license-content +- description: LICENSE file contains expected SPDX identifier +- command: grep -q "PMPL-1.0-or-later" LICENSE +- severity: warning +---- + +=== Verification Entry Fields + +[cols="1,1,3"] +|=== +| Field | Required | Meaning + +| `description` +| SHOULD +| Human-readable description of what is verified + +| `command` +| MUST +| Shell command to execute; exit 0 = verified, non-zero = failed + +| `algorithm` +| OPTIONAL +| Cryptographic algorithm used (for documentation and K9 contract validation) + +| `severity` +| OPTIONAL +| `critical` (default) or `warning`; controls whether failure aborts the verification run + +| `proof` +| OPTIONAL +| Path to a LEAN4/Idris2 formal proof of this verification's soundness + +| `ci_cd` +| OPTIONAL +| CI/CD automation hint (e.g., "re-verify-on-push", "rotate-keys-on-compromise") +|=== + +=== Keys Section (Optional) + +Declares the public keys and algorithms used for verification. This section is informational for the A2ML runner but critical for K9 validation contracts and LEAN4 proofs. + +[source,a2ml] +---- +## Keys + +### primary-sig +- type: HybridKey2024 +- classical_algorithm: Ed448 +- pq_algorithm: Dilithium5 +- fallback_algorithm: SPHINCS+ +- usage: authentication, assertion +- rotation: annual +- proof: Crypto.HybridKey.lean + +### build-sig +- type: Ed25519 +- usage: artifact-signing +- rotation: per-release +---- + +=== Policies Section (Optional) + +Declares verification policies written in Rego, Nickel, or formal logic. These are not directly executed by `trust verify` but are referenced by K9 Yard-level validators and CI/CD pipelines. + +[source,a2ml] +---- +## Policies + +### dns-policy +- language: rego +- file: policies/dns.rego +- description: All DNS records must have integrity proofs and ZONEMD hashes +- proof: Policies.DNS.lean + +### http-policy +- language: nickel +- file: policies/http.ncl +- description: All HTTP endpoints require capability tokens and consent headers +---- + +=== Proofs Section (Optional) + +Declares formal proofs (LEAN4, Idris2) that verify the soundness of the trust model itself. These are the strongest form of evidence — they prove properties hold for _all_ inputs, not just specific artifacts. + +[source,a2ml] +---- +## Proofs + +### dns-correctness +- file: proofs/DNS.Core.lean +- description: All DNS records are conflict-free and correctly signed +- status: proven +- ci_cd: reprove-on-dns-change + +### tls-handshake-safety +- file: proofs/TLS.Handshake.lean +- description: TLS 1.3 + Kyber1024 handshake resists downgrade attacks +- status: proven + +### capability-gateway-correctness +- file: proofs/HTTP.Capabilities.lean +- description: OCAP-style capability gateway enforces minimal authority +- status: proven +---- + +Recognised `status` values: `proven`, `in-progress`, `axiom`, `conjecture`. + +== Extended A2ML: Cyberwar-Ready Trustfile + +The canonical Trustfile also supports extended sections for infrastructure-level trust. These sections are processed by specialised validators (Hypatia, GitBot-Fleet) rather than the `trust` CLI directly, but they live in the same A2ML file to keep all trust declarations in one place. + +[cols="1,3"] +|=== +| Section | Purpose + +| `DNS` +| DNS records with ZONEMD integrity, DNSSEC, CAA, DMARC, TLSA + +| `SSL/TLS` +| Cipher suites, protocol versions, OCSP, certificate transparency + +| `Threat Model` +| Adversary types and mitigations (APT, ransomware, quantum) + +| `Capability Gateway` +| Resource-level access policies with formal proofs + +| `SDP Rules` +| Software-Defined Perimeter access policies + +| `Transparency Log` +| RFC 9162 append-only log entries + +| `Cloudflare` +| WAF rules, Workers scripts, route bindings + +| `PHP Hardening` +| Aegis + Sanctify directives (for PHP-serving infrastructure) + +| `Automation` +| GitBot-Fleet hashing, diffing, and integrity automation + +| `Hypatia Scans` +| Neurosymbolic scan configurations +|=== + +== Golden Examples + +=== Example 1: Minimal Rust Library + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Integrity verification for a minimal Rust library. +@end + +@requires: +- section: Inputs +- section: Verifications +@end + +## Inputs + +- cargo_lock: Cargo.lock +- license: LICENSE +- source_dir: src/ + +## Verifications + +### cargo-lock-hash +- description: Cargo.lock matches committed hash (no supply-chain tampering) +- command: sha256sum -c Cargo.lock.sha256 +- algorithm: SHA-256 +- severity: critical + +### license-present +- description: LICENSE file contains SPDX identifier +- command: grep -q "SPDX-License-Identifier" LICENSE +- severity: warning + +### source-spdx-headers +- description: All source files have SPDX headers +- command: find src/ -name '*.rs' -exec grep -L 'SPDX-License-Identifier' {} + | wc -l | grep -q '^0$' +- severity: warning +---- + +=== Example 2: Gateway with Post-Quantum Crypto + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Cryptographic and provenance verification for a policy gateway. +Covers hash integrity, hybrid PQ signatures, and formal proofs. +@end + +@requires: +- section: Inputs +- section: Keys +- section: Verifications +- section: Proofs +@end + +## Inputs + +- policy_path: policy/policy.ncl +- policy_hash_path: policy/policy.ncl.sha256 +- schema_path: schema/schema.json +- schema_sig_path: schema/schema.sig +- schema_pub_path: keys/schema.pub +- driver_path: drivers/gateway-driver.bin +- driver_sig_path: drivers/gateway-driver.bin.sig +- driver_pub_path: keys/driver.pub +- migrations_path: migrations/provenance.json +- migrations_sig_path: migrations/provenance.sig +- migrations_pub_path: keys/migrations.pub + +## Keys + +### schema-signing-key +- type: Ed25519 +- file: keys/schema.pub +- usage: schema-signing +- rotation: per-release + +### driver-signing-key +- type: Dilithium5 +- file: keys/driver.pub +- usage: driver-signing +- rotation: per-build + +### migration-signing-key +- type: Ed448 +- file: keys/migrations.pub +- usage: provenance-attestation +- rotation: annual + +## Verifications + +### policy-hash +- description: SHA-256 of policy matches committed hash +- command: sha256sum -c policy/policy.ncl.sha256 +- algorithm: SHA-256 +- severity: critical + +### schema-signature +- description: Schema signature verifies against schema signing key +- command: openssl dgst -sha256 -verify keys/schema.pub -signature schema/schema.sig schema/schema.json +- algorithm: Ed25519 +- severity: critical + +### driver-signatures +- description: Post-quantum signature verifies for compiled driver +- command: kyber-verify --pub keys/driver.pub --sig drivers/gateway-driver.bin.sig --file drivers/gateway-driver.bin +- algorithm: Dilithium5 +- severity: critical + +### migration-provenance +- description: Migration provenance chain is intact +- command: openssl dgst -sha256 -verify keys/migrations.pub -signature migrations/provenance.sig migrations/provenance.json +- algorithm: Ed448 +- severity: critical + +### no-banned-algorithms +- description: No MD5 or SHA1 used anywhere in the project +- command: grep -rn 'md5\|sha1\|MD5\|SHA1' --include='*.toml' --include='*.json' --include='*.yaml' --include='*.yml' . | grep -v 'sha1sum' | wc -l | grep -q '^0$' +- severity: warning + +## Proofs + +### crypto-algorithm-safety +- file: proofs/Crypto.Algorithms.lean +- description: All hash and signature algorithms meet post-quantum resistance thresholds +- status: proven + +### key-rotation-liveness +- file: proofs/Crypto.KeyRotation.lean +- description: Key rotation schedule ensures no key is used beyond its safe lifetime +- status: in-progress +---- + +=== Example 3: Monorepo with Per-Component Verification + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Per-component integrity verification for a polyglot monorepo. +Each component has its own signing key and hash file. +@end + +@requires: +- section: Verifications +@end + +## Verifications + +### api-lock-hash +- description: API dependencies unchanged (Gleam lock file) +- command: sha256sum -c api/manifest.toml.sha256 +- severity: critical + +### cli-lock-hash +- description: CLI dependencies unchanged (Cargo lock) +- command: sha256sum -c cli/Cargo.lock.sha256 +- severity: critical + +### web-lock-hash +- description: Web dependencies unchanged (deno lock) +- command: sha256sum -c web/deno.lock.sha256 +- severity: critical + +### container-image-digests +- description: All Containerfile base images use pinned digests +- command: grep -rn 'FROM ' */Containerfile | grep -v '@sha256:' | wc -l | grep -q '^0$' +- severity: critical + +### workflow-action-pins +- description: All GitHub Actions are SHA-pinned +- command: grep -rn 'uses:' .github/workflows/*.yml | grep -v '@[a-f0-9]\{40\}' | grep -v '#' | wc -l | grep -q '^0$' +- severity: warning +---- + +=== Example 4: Ada Safety-Critical with SPARK Proofs + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Integrity and formal verification for a safety-critical Ada system. +Combines cryptographic hashing with SPARK proof obligations. +@end + +@requires: +- section: Verifications +- section: Proofs +@end + +## Verifications + +### source-hash +- description: Source tree hash matches release manifest +- command: find src/ -name '*.ad[bs]' -exec sha256sum {} + | sha256sum | cut -d' ' -f1 | diff - RELEASE_HASH +- algorithm: SHA-256 +- severity: critical + +### gpr-integrity +- description: GNAT project file unchanged since last audit +- command: sha256sum -c flight.gpr.sha256 +- severity: critical + +### spark-proof-obligations +- description: All SPARK proof obligations discharged +- command: gnatprove -P flight.gpr --level=2 --report=all 2>&1 | grep -q "0 unproved" +- severity: critical + +### no-unsafe-pragmas +- description: No unsafe pragmas present in source +- command: grep -rn 'pragma Suppress\|pragma Import.*System' src/ | wc -l | grep -q '^0$' +- severity: critical + +## Proofs + +### memory-safety +- file: proofs/Memory.lean +- description: No buffer overflow, use-after-free, or dangling pointer possible +- status: proven + +### timing-analysis +- file: proofs/Timing.lean +- description: All critical paths complete within WCET bounds +- status: in-progress +---- + +=== Example 5: Infrastructure with Transparency Log + +[source,a2ml] +---- +# SPDX-License-Identifier: PMPL-1.0-or-later +# Trustfile (A2ML Canonical) + +@abstract: +Infrastructure trust verification with transparency logging. +@end + +@requires: +- section: Verifications +- section: Transparency +@end + +## Verifications + +### config-hash +- description: Infrastructure config hashes match +- command: sha256sum -c config/infra.sha256 +- severity: critical + +### tls-cert-valid +- description: TLS certificate is valid and not expiring within 30 days +- command: openssl x509 -in certs/server.pem -checkend 2592000 -noout +- severity: critical + +### tls-no-weak-ciphers +- description: No weak cipher suites configured +- command: openssl ciphers -v 'ALL' | grep -i 'RC4\|DES\|MD5\|NULL' | wc -l | grep -q '^0$' +- severity: critical + +## Transparency + +### deployment-log +- uri: https://example.com/log/deployments.json +- format: RFC 9162 +- description: All deployments recorded in append-only transparency log +- verify: curl -s https://example.com/log/deployments.json | jq -e '.entries | length > 0' +---- + +== Command Reference + +=== Verification Commands + +[source,bash] +---- +trust verify # Run all verifications +trust verify NAME # Run a single named verification +trust verify --strict # Fail on warnings (default: warnings are non-fatal) +trust verify --verbose # Show command output for each verification +trust verify --dry-run # Preview commands without executing + +trust list # List available verifications with descriptions +---- + +=== Hash Commands + +[source,bash] +---- +trust hash FILE # Compute and display SHA-256 hash of a file +trust hash FILE --algo sha3-512 # Use a specific algorithm +trust hash FILE --check FILE.sha256 # Verify against a hash file +---- + +=== Signing Commands + +[source,bash] +---- +trust sign FILE # Sign a file (uses default key) +trust sign FILE --key KEY_PATH # Sign with a specific key +trust sign FILE --algo dilithium5 # Use a specific algorithm +trust sign --verify FILE # Verify a file's signature +---- + +=== Integration with Just + +[source,bash] +---- +contractile gen-just # Generates trust-verify, trust- recipes +just trust-verify # Run all trust verifications via Just +just trust-policy-hash # Run a single verification via Just +---- + +== Exit Codes + +[cols="1,3"] +|=== +| Code | Meaning + +| 0 +| All verifications passed + +| 1 +| General error (command failed to execute) + +| 2 +| Verification failed (integrity violation detected) + +| 3 +| Missing Trustfile.a2ml (no contract found) + +| 4 +| Invalid Trustfile.a2ml syntax (parse error) + +| 5 +| Missing input (a file referenced in the Inputs section does not exist) + +| 6 +| Signature verification failed (distinct from hash failure) + +| 7 +| Formal proof invalid or not found +|=== + +== K9 Integration + +=== Yard-Level: Trustfile Validation + +A K9 Yard-level component can validate the _structure_ of a Trustfile — ensuring all verifications use approved algorithms, all critical checks have formal proofs, etc. + +[source,nickel] +---- +# trust-validator.k9.ncl (Yard level) +{ + pedigree = { security = { leash = 'Yard } }, + validation = { + # All verifications must specify an algorithm + check_algorithms = std.array.all + (fun v => v.algorithm != null) + trustfile.verifications, + + # Critical verifications must have a proof reference + check_proofs = std.array.all + (fun v => v.severity != "critical" || v.proof != null) + trustfile.verifications, + + # No banned algorithms + check_no_weak = std.array.all + (fun v => !std.string.contains "MD5" v.algorithm + && !std.string.contains "SHA1" v.algorithm) + trustfile.verifications, + }, +} +---- + +=== Hunt-Level: Automated Trust Operations + +A K9 Hunt-level component can automate trust operations like key rotation, hash regeneration, and transparency log updates. + +[source,nickel] +---- +# trust-ops.k9.ncl (Hunt level — requires signature) +{ + pedigree = { security = { leash = 'Hunt, signature_required = true } }, + recipes = { + "regenerate-hashes" = { + description = "Recompute all hash files from current artifacts", + commands = [ + "sha256sum policy/policy.ncl > policy/policy.ncl.sha256", + "sha256sum Cargo.lock > Cargo.lock.sha256", + ], + }, + "rotate-keys" = { + description = "Generate new signing keys and re-sign all artifacts", + commands = [ + "openssl genpkey -algorithm Ed25519 -out keys/schema.key", + "openssl pkey -in keys/schema.key -pubout -out keys/schema.pub", + "trust sign schema/schema.json --key keys/schema.key", + ], + }, + }, +} +---- + +== Automation Integration + +=== GitBot-Fleet + +The Trustfile declares automation hooks that GitBot-Fleet acts on: + +- `ci_cd: "auto-hash-on-push"` → GitBot-Fleet recomputes hashes on every push +- `ci_cd: "rotate-keys-on-compromise"` → GitBot-Fleet triggers key rotation on security advisory +- `ci_cd: "reprove-on-dns-change"` → GitBot-Fleet re-runs LEAN4 proofs when DNS records change + +=== Hypatia + +Hypatia neurosymbolic scans reference the Trustfile for: + +- Crypto algorithm validation (no weak algorithms) +- Key rotation compliance +- DNS integrity (ZONEMD, DNSSEC, CAA) +- Certificate transparency monitoring + +=== CI/CD Pipeline + +[source,bash] +---- +# In GitHub Actions / CI pipeline: +trust verify --strict # Fail the build on any integrity violation +trust hash --check *.sha256 # Verify all hash files +---- + +== Best Practices + +1. **Hash everything that matters**: Cargo.lock, deno.lock, key config files +2. **Sign release artifacts**: Binaries, containers, and tarballs +3. **Use post-quantum algorithms**: Dilithium5 or SPHINCS+ for long-lived signatures +4. **Pin all base images**: Use `@sha256:` digests in Containerfiles +5. **SHA-pin all actions**: Use full 40-char SHA in GitHub Actions +6. **Record provenance**: Use transparency logs for deployment history +7. **Prove what you can**: Formal proofs are the strongest evidence +8. **Automate hash updates**: GitBot-Fleet prevents manual hash management +9. **Separate signing keys per use**: Schema keys, driver keys, migration keys +10. **CI integration**: Run `trust verify --strict` in every pipeline