From 1ff164c0aa11a3d6ce2ca60d3b5fc446e75f1a51 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:47:25 +0200 Subject: [PATCH 01/11] feat(eval): embedded QuickJS+oxc evaluator for plans-as-code Add the plans-as-code evaluation core (decisions 0011/0012/0014): rquickjs (QuickJS-ng) + oxc as the first external deps. Two-context bytecode dance (compile in Context::full, evaluate in a locked Context::custom over Json+Promise+Proxy), determinism allowlist (strip Math.random + transcendentals), bounded evaluation (time/memory/stack). A compass prelude served for the bare specifier provides plan/step/reconcile/evidence(atom+all/any/not via a vocabulary-free Proxy). Step identity is the declared export name, attached by a lazy host-injected footer and recovered after evaluation; revise/reconcile carry steps forward by identity. Root and revision modules evaluate and extract correctly; carry-forward proven. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 938 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 8 + src/eval.rs | 693 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/prelude.js | 205 +++++++++++ tests/spike.rs | 44 +++ 6 files changed, 1889 insertions(+) create mode 100644 src/eval.rs create mode 100644 src/prelude.js create mode 100644 tests/spike.rs diff --git a/Cargo.lock b/Cargo.lock index 0bacf91..5dbde66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,944 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "compass" version = "0.0.0" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_codegen", + "oxc_parser", + "oxc_semantic", + "oxc_span", + "oxc_transformer", + "rquickjs", +] + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "json-escape-simd" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22a2041e3874a055a4eb03ea2395aaccdefa84ce75b31d542d72a741c3c6ad3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc-browserslist" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6967692b4c51de292573c3f5eed236b6df29ac47d122379ad3a9d209776a9cf3" +dependencies = [ + "miniz_oxide", + "postcard", + "rustc-hash", + "serde", + "thiserror", +] + +[[package]] +name = "oxc-miette" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0df30faa68797917ca4263e7a2f889ec829e4da2dcb3d6dc752f7a494180f3" +dependencies = [ + "cfg-if", + "memchr", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc-miette-derive" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc072d11d45ebe7801459b4e829184ba0934d68027fdc51d327335b53a95a49" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "oxc_allocator" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ffd0bdf76f7272e208a4f55740d309f303a104f198bf4902a7377ae33e0a34" +dependencies = [ + "allocator-api2", + "hashbrown", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4a9979cd3cebde3db75fafa713d72e6e891827c29e3ed11d005791052456d9" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0adbaf016626ad630020c2d5f9078809aeb115966a9268b313e920c98e58040" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95874e8d4c97ee55054ae50746347fc6d018d0bed9e4f40fef483eb4cf22825a" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_codegen" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3adfd793b0d006c599af1fe8975751cbf425df22c04d55e50691febd3f978fc" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_compat" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba0d6b9b51855cb2255ee1738266163d229696db7225683c0eb809da6adb11b" +dependencies = [ + "cow-utils", + "oxc-browserslist", + "oxc_syntax", + "rustc-hash", + "serde", +] + +[[package]] +name = "oxc_data_structures" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980c41eb52557e3255ebacd0d0b5406d1c8703a59cb5300938940b91de41a46f" +dependencies = [ + "ropey", +] + +[[package]] +name = "oxc_diagnostics" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d355bdf332e8412c7e7e061bf53ac3bf71956225722e8ce1b9428402d97e9db" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3729826d5e8e50b6788c79ae3c5f18b6d4b372fb1d5a926813c0edfa69ee5831" +dependencies = [ + "num-bigint", + "num-traits", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aec60e099d4c904848f51e2737f290c665be0ed66fb295def44f2e91891b604" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8f483bbfae70f4f7c9a90c4a4a3a165685c2d929e0765e35db6db1ac825c32" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a41b057cf6d615e5736e80eddf9f611827fc0927bc1a7f33b4b10e171cfae4d" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_semantic" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfab481b9838e23958cfd03638fcb14672be7ca1a3a23d9a87c1a47a00c7cd8" +dependencies = [ + "itertools", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "self_cell", + "smallvec", +] + +[[package]] +name = "oxc_sourcemap" +version = "8.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b415102a94b483bbd76d13e850fe87961197e5795c79a8523ebec5d82025f94f" +dependencies = [ + "base64-simd", + "json-escape-simd", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_span" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adc1cfe13a710b2c948ffd26470351a8f0e44b6d5f72e9f8d62dcd2dbab500a" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7423b8162376b5dd53e6431d6b25a4917048cbd10429778e9d4ae7701062e239" +dependencies = [ + "compact_str", + "hashbrown", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d6a9cea50ff9fa2c17bdb1dec8ab69d0e934015b2ba1414040da7f8db822154" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf", + "unicode-id-start", +] + +[[package]] +name = "oxc_transformer" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3481b17ea50d4866901de759f736aa2c63bfca1fb6a20a67faa423f5361429e9" +dependencies = [ + "base64", + "compact_str", + "hmac-sha1-compact", + "indexmap", + "itoa", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_compat", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_traverse", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_traverse" +version = "0.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31912d26fa60335e443119368a25b737a3fdc60985fb46fa013e511b825c40f2" +dependencies = [ + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_ecmascript", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rquickjs" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce6b626d30ecbedaaf8097a04982bc3b081f958f5bdf9b8796be4ac00ee48bb" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9334dd11023d5ae6c751f64496510a576208802ed1d022ef94afa7639c2c55e" +dependencies = [ + "hashbrown", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef73520804cf5aa4876097ac5733058d628c769eba9678e0f4dba5a4ef79703" +dependencies = [ + "cc", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 834c4f3..e884985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,14 @@ description = "Durable planning intent for coding agents" license = "MIT" [dependencies] +oxc_allocator = "0.141.0" +oxc_ast = "0.141.0" +oxc_codegen = "0.141.0" +oxc_parser = "0.141.0" +oxc_semantic = "0.141.0" +oxc_span = "0.141.0" +oxc_transformer = "0.141.0" +rquickjs = { version = "0.12.1", features = ["loader", "array-buffer"] } [profile.release] opt-level = 2 diff --git a/src/eval.rs b/src/eval.rs new file mode 100644 index 0000000..c56dc0e --- /dev/null +++ b/src/eval.rs @@ -0,0 +1,693 @@ +//! The embedded evaluator (decision 0011, 0014). +//! +//! Reading a plan is running it. A version is a module, so evaluating one +//! evaluates every module it imports, transitively — including modules that +//! arrived by replication from another machine (decision 0014). What makes that +//! acceptable is not trust but the environment: a plan runs against a global the +//! host constructs explicitly, holding no clock, no filesystem, no network, no +//! randomness, and nothing platform-dependent (CMP-R12). The capability is +//! *absent* rather than removed. +//! +//! ## Two contexts +//! +//! Compiling a module needs the `Eval` intrinsic, which the sandbox must not +//! keep. So a module is compiled to bytecode in a throwaway `Context::full`, and +//! the bytecode is loaded into a locked `Context::custom` over a minimal +//! intrinsic set (`Json` + `Promise` + `Proxy`). Loading bytecode needs no +//! `Eval` intrinsic, so in the locked context `eval`, `new Function`, and +//! `fn.constructor` exist but throw. +//! +//! ESM evaluation needs the `Promise` intrinsic or QuickJS aborts the process +//! (an uncatchable `SIGABRT`). `Proxy` is present so the `evidence` forwarder +//! works; it is deterministic and grants no capability. +//! +//! ## The determinism allowlist +//! +//! Base objects arrive with `Math.random` and the transcendental `Math` +//! functions, which delegate to the platform library and disagree between +//! platforms in the final bits. They are not structurally omittable, so they are +//! removed explicitly (CMP-A03, decision 0011). Nothing platform-varying — no +//! `Date`, `RegExp` locale data, `Intl`, `WeakRef` — is ever added. +//! +//! ## Bounded evaluation (CMP-R13, decision 0014) +//! +//! Executing foreign code makes non-termination and runaway allocation reachable +//! states rather than theoretical ones, so the runtime carries a time interrupt, +//! a memory limit, and a stack limit. A plan that will not terminate is Stopped +//! and reported, never awaited. + +use crate::predicate::Pred; +use rquickjs::context::intrinsic; +use rquickjs::loader::{Loader, Resolver}; +use rquickjs::{CatchResultExt, Context, Ctx, Error as JsError, Module, Object, Runtime, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// The bare specifier the library is served under. +pub const COMPASS_SPECIFIER: &str = "compass"; + +/// The prelude source, embedded so the binary carries no runtime prerequisite. +const PRELUDE: &str = include_str!("prelude.js"); + +/// Wall-clock bound on one evaluation. +const TIME_LIMIT: Duration = Duration::from_secs(5); +/// Memory bound on one evaluation, in bytes. +const MEMORY_LIMIT: usize = 64 * 1024 * 1024; +/// Stack bound on one evaluation, in bytes. +const STACK_LIMIT: usize = 1024 * 1024; + +/// A step recovered from an evaluated module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemStep { + /// The name the step was declared under — its identity (decision 0012). + pub name: String, + pub work: String, + pub depends_on: Vec, + pub supersedes: Option, + pub accept: Pred, + pub retired: bool, +} + +/// A version recovered from an evaluated module: what the plan *declares*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemVersion { + pub author: String, + pub why: String, + pub goal: String, + pub retired: bool, + pub steps: Vec, +} + +/// Why a read could not produce a value. Each names a different next action +/// (04-cli): `Unresolved` → wait; `Stopped` → do not wait; `Failed` → the plan +/// is at fault. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EvalError { + /// A module it imports is not present locally (CMP.DM-R06a). + Unresolved(String), + /// Evaluation exceeded a bound in time or memory (CMP-R13). + Stopped(String), + /// The module threw, or does not export a well-formed plan. + Failed(String), +} + +impl EvalError { + pub fn kind(&self) -> &'static str { + match self { + EvalError::Unresolved(_) => "unresolved", + EvalError::Stopped(_) => "stopped", + EvalError::Failed(_) => "failed", + } + } + pub fn message(&self) -> &str { + match self { + EvalError::Unresolved(m) | EvalError::Stopped(m) | EvalError::Failed(m) => m, + } + } +} + +/// Evaluate a version file and every module it imports. +/// +/// Returns the recovered [`SemVersion`] for every version module in the graph, +/// keyed by canonical path, so one evaluation of a tip yields the intent of the +/// whole lineage — which is what the Rationale chain needs. +pub fn eval_plan_file(entry: &Path) -> Result, EvalError> { + let canon_entry = canonicalize(entry)?; + let entry_name = path_name(&canon_entry); + + let rt = Runtime::new().map_err(|e| EvalError::Failed(format!("runtime: {e}")))?; + rt.set_memory_limit(MEMORY_LIMIT); + rt.set_max_stack_size(STACK_LIMIT); + + // Phase 1 — compile the whole graph to bytecode in a throwaway full context. + // + // quickjs-ng resolves a module's imports while compiling it, so modules + // cannot be compiled in isolation: the loader transpiles and declares each + // import on demand and captures its bytecode as it goes. + let captured: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let load_err: Arc>> = Arc::new(Mutex::new(None)); + { + rt.set_loader( + GraphResolver, + SourceLoader { + captured: captured.clone(), + err: load_err.clone(), + }, + ); + let full = Context::full(&rt).map_err(|e| EvalError::Failed(format!("context: {e}")))?; + let entry_js = build_js(&entry_name)?; + full.with(|ctx| -> Result<(), EvalError> { + let module = Module::declare( + ctx.clone(), + entry_name.as_bytes().to_vec(), + entry_js.as_bytes().to_vec(), + ) + .catch(&ctx) + .map_err(|e| { + take_or(&load_err, || { + EvalError::Failed(format!("{entry_name}: {e}")) + }) + })?; + let bytecode = module + .write(rquickjs::module::WriteOptions::default()) + .map_err(|e| EvalError::Failed(format!("{entry_name}: write bytecode: {e}")))?; + captured + .lock() + .unwrap() + .insert(entry_name.clone(), bytecode); + Ok(()) + })?; + } + + let compiled = captured.lock().unwrap().clone(); + let version_names: Vec = compiled + .keys() + .filter(|k| *k != COMPASS_SPECIFIER) + .cloned() + .collect(); + + // Phase 2 — load the bytecode into the locked context and evaluate. + let interrupted = Arc::new(AtomicBool::new(false)); + install_interrupt(&rt, interrupted.clone()); + + let locked = Context::custom::<(intrinsic::Json, intrinsic::Promise, intrinsic::Proxy)>(&rt) + .map_err(|e| EvalError::Failed(format!("locked context: {e}")))?; + rt.set_loader(GraphResolver, BytecodeLoader { compiled }); + + locked.with(|ctx| -> Result, EvalError> { + lock_globals(&ctx)?; + let mut out = HashMap::new(); + for name in &version_names { + let sem = eval_one(&ctx, name, &interrupted)?; + out.insert(PathBuf::from(name), sem); + } + Ok(out) + }) +} + +/// Prefer a real error the loader recorded over a generic host message. +fn take_or( + slot: &Arc>>, + fallback: impl FnOnce() -> EvalError, +) -> EvalError { + slot.lock().unwrap().take().unwrap_or_else(fallback) +} + +/// Transpile + footer for a resolved module name (`"compass"` or a file path). +fn build_js(name: &str) -> Result { + if name == COMPASS_SPECIFIER { + let exports = collect_exports(PRELUDE, Path::new("compass.js"))?; + return Ok(with_footer(PRELUDE, &exports)); + } + let path = Path::new(name); + let source = read_source(path)?; + let exports = collect_exports(&source, path)?; + Ok(with_footer(&transpile(path, &source)?, &exports)) +} + +/// Import and extract one already-compiled version module. +fn eval_one( + ctx: &Ctx<'_>, + name: &str, + interrupted: &Arc, +) -> Result { + let ns = import_namespace(ctx, name, interrupted)?; + let default: Value = ns + .get("default") + .map_err(|e| classify(ctx, e, interrupted))?; + let plan = default + .as_object() + .ok_or_else(|| EvalError::Failed(format!("{name}: default export is not a plan object")))?; + extract_plan(ctx, plan, name) +} + +/// Drive `Module::import` to completion, translating its failure modes. +fn import_namespace<'js>( + ctx: &Ctx<'js>, + name: &str, + interrupted: &Arc, +) -> Result, EvalError> { + let promise = + Module::import(ctx, name.as_bytes().to_vec()).map_err(|e| classify(ctx, e, interrupted))?; + promise + .finish::() + .map_err(|e| classify(ctx, e, interrupted)) +} + +/// Map a JS error to the right read-failure, checking the interrupt flag first. +fn classify(ctx: &Ctx<'_>, err: JsError, interrupted: &Arc) -> EvalError { + if interrupted.load(Ordering::Relaxed) { + return EvalError::Stopped(format!( + "evaluation exceeded the {}s time limit", + TIME_LIMIT.as_secs() + )); + } + if let JsError::Exception = err { + let msg = ctx + .catch() + .as_exception() + .and_then(|e| e.message()) + .unwrap_or_else(|| "uncaught exception".to_string()); + return EvalError::Failed(msg); + } + EvalError::Failed(err.to_string()) +} + +// --------------------------------------------------------------------------- +// Extraction: evaluated JS values -> owned Rust +// --------------------------------------------------------------------------- + +fn extract_plan(ctx: &Ctx<'_>, plan: &Object<'_>, name: &str) -> Result { + let fail = |m: String| EvalError::Failed(format!("{name}: {m}")); + let author = get_string(plan, "author").ok_or_else(|| fail("plan has no author".into()))?; + let why = get_string(plan, "why").ok_or_else(|| fail("plan has no rationale (why)".into()))?; + let goal = get_string(plan, "goal").ok_or_else(|| fail("plan has no goal".into()))?; + let retired = plan.get::<_, bool>("retired").unwrap_or(false); + + let raw_steps: Value = plan + .get("__steps") + .map_err(|_| fail("default export is not a plan (no steps)".into()))?; + let arr = raw_steps + .as_array() + .ok_or_else(|| fail("plan steps are not an array".into()))?; + + let mut steps = Vec::new(); + for item in arr.iter::() { + let item = item.map_err(|e| fail(format!("reading a step: {e}")))?; + let sobj = item + .as_object() + .ok_or_else(|| fail("a step is not an object".into()))?; + steps.push(extract_step(ctx, sobj, name)?); + } + + Ok(SemVersion { + author, + why, + goal, + retired, + steps, + }) +} + +fn extract_step(ctx: &Ctx<'_>, s: &Object<'_>, module: &str) -> Result { + let fail = |m: String| EvalError::Failed(format!("{module}: {m}")); + let name = get_string(s, "__name").ok_or_else(|| { + fail( + "a step has no declared name: a step must be an exported binding, whose \ + name is its identity (decision 0012)" + .into(), + ) + })?; + let work = get_string(s, "work").ok_or_else(|| fail(format!("step {name} has no work")))?; + + let mut depends_on = Vec::new(); + if let Ok(deps) = s.get::<_, Value>("dependsOn") { + if let Some(arr) = deps.as_array() { + for d in arr.iter::() { + let d = d.map_err(|e| fail(format!("step {name}: reading a dependency: {e}")))?; + let dobj = d.as_object().ok_or_else(|| { + fail(format!( + "step {name} depends on something that is not a step" + )) + })?; + let dep = get_string(dobj, "__name").ok_or_else(|| { + fail(format!( + "step {name} depends on a step with no declared name" + )) + })?; + depends_on.push(dep); + } + } + } + depends_on.sort(); + depends_on.dedup(); + + let supersedes = s + .get::<_, Value>("supersedes") + .ok() + .filter(|v| v.is_object()) + .and_then(|v| v.into_object()) + .and_then(|o| get_string(&o, "__name")); + + let accept_val: Value = s + .get("accept") + .map_err(|_| fail(format!("step {name} has no acceptance criterion")))?; + let accept = to_pred(ctx, &accept_val).map_err(|m| fail(format!("step {name}: {m}")))?; + + let retired = s.get::<_, bool>("retired").unwrap_or(false); + + Ok(SemStep { + name, + work, + depends_on, + supersedes, + accept, + retired, + }) +} + +/// Convert an evidence tree (`atom` / `all` / `any` / `not`) to a [`Pred`]. +fn to_pred(ctx: &Ctx<'_>, v: &Value<'_>) -> Result { + let obj = v + .as_object() + .ok_or_else(|| "an acceptance criterion must be an evidence value".to_string())?; + let kind = get_string(obj, "__k").ok_or_else(|| { + "acceptance is not an evidence value (use evidence.* / all / any / not)".to_string() + })?; + match kind.as_str() { + "atom" => { + let k = get_string(obj, "kind").ok_or("evidence atom has no kind")?; + let attrs_v: Value = obj.get("attrs").map_err(|e| e.to_string())?; + let mut attrs: Vec<(String, String)> = Vec::new(); + if let Some(ao) = attrs_v.as_object() { + for key in ao.keys::() { + let key = key.map_err(|e| e.to_string())?; + let val: Value = ao.get(&key).map_err(|e| e.to_string())?; + attrs.push((key, value_to_string(&val)?)); + } + } + attrs.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(Pred::Atom { kind: k, attrs }) + } + "all" | "any" => { + let args: Value = obj.get("args").map_err(|e| e.to_string())?; + let arr = args + .as_array() + .ok_or("all()/any() arguments are not an array")?; + let mut ps = Vec::new(); + for a in arr.iter::() { + let a = a.map_err(|e| e.to_string())?; + ps.push(to_pred(ctx, &a)?); + } + Ok(if kind == "all" { + Pred::All(ps) + } else { + Pred::Any(ps) + }) + } + "not" => { + let inner: Value = obj.get("arg").map_err(|e| e.to_string())?; + Ok(Pred::Not(Box::new(to_pred(ctx, &inner)?))) + } + other => Err(format!("unknown evidence combinator `{other}`")), + } +} + +/// Canonicalise a scalar JS value the way both criteria and evidence render it, +/// so a criterion and a record naming the same thing match. +fn value_to_string(v: &Value<'_>) -> Result { + if let Some(s) = v.as_string() { + return s.to_string().map_err(|e| e.to_string()); + } + if v.is_bool() { + return Ok(if v.as_bool().unwrap() { + "true" + } else { + "false" + } + .to_string()); + } + if let Some(n) = v.as_number() { + if n.fract() == 0.0 && n.is_finite() && n.abs() < 9.007_199_254_740_992e15 { + return Ok(format!("{}", n as i64)); + } + return Ok(format!("{n}")); + } + if v.is_undefined() || v.is_null() { + return Err("an evidence attribute is null or undefined".to_string()); + } + Err("an evidence attribute is neither a string, number, nor boolean".to_string()) +} + +fn get_string(o: &Object<'_>, key: &str) -> Option { + let v: Value = o.get(key).ok()?; + v.as_string().and_then(|s| s.to_string().ok()) +} + +// --------------------------------------------------------------------------- +// Compilation +// --------------------------------------------------------------------------- + +/// Transpile a TypeScript module to JavaScript via oxc. +/// +/// The transpiled output is never stored and never hashed — identity is the hash +/// of the *authored source bytes* (decision 0014). This step exists only so the +/// engine, which speaks JavaScript, can run a module authored in TypeScript. +fn transpile(path: &Path, source: &str) -> Result { + use oxc_allocator::Allocator; + use oxc_codegen::Codegen; + use oxc_parser::Parser; + use oxc_semantic::SemanticBuilder; + use oxc_span::SourceType; + use oxc_transformer::{TransformOptions, Transformer}; + + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let ret = Parser::new(&allocator, source, source_type).parse(); + if ret.panicked { + return Err(EvalError::Failed(format!( + "{}: cannot parse: {}", + path.display(), + first_diag(&ret.diagnostics) + ))); + } + let mut program = ret.program; + + // `with_enum_eval(true)` keeps oxc from panicking on `enum`. + let semantic = SemanticBuilder::new().with_enum_eval(true).build(&program); + let scoping = semantic.semantic.into_scoping(); + + let options = TransformOptions::default(); + let tret = + Transformer::new(&allocator, path, &options).build_with_scoping(scoping, &mut program); + if !tret.diagnostics.is_empty() { + return Err(EvalError::Failed(format!( + "{}: cannot transpile: {}", + path.display(), + tret.diagnostics + .iter() + .map(|d| d.to_string()) + .collect::>() + .join("; ") + ))); + } + + Ok(Codegen::new().build(&program).code) +} + +fn first_diag(errs: &[T]) -> String { + errs.first() + .map(|e| e.to_string()) + .unwrap_or_else(|| "syntax error".to_string()) +} + +/// Append one registration call per exported top-level `const`, so a step +/// learns the name it was declared under. Consumption is lazy (see the prelude), +/// so appending after the module body is correct. +fn with_footer(js: &str, exports: &[String]) -> String { + if exports.is_empty() { + return js.to_string(); + } + let mut out = String::with_capacity(js.len() + exports.len() * 48); + out.push_str(js); + out.push('\n'); + for name in exports { + out.push_str(&format!( + "try {{ globalThis.__compass_register({name}, {name:?}); }} catch (_e) {{}}\n" + )); + } + out +} + +/// The names of top-level `export const` bindings, in source order. +fn collect_exports(source: &str, path: &Path) -> Result, EvalError> { + use oxc_allocator::Allocator; + use oxc_ast::ast::{BindingPattern, Declaration, Statement}; + use oxc_parser::Parser; + use oxc_span::SourceType; + + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let ret = Parser::new(&allocator, source, source_type).parse(); + if ret.panicked { + return Err(EvalError::Failed(format!( + "{}: cannot parse: {}", + path.display(), + first_diag(&ret.diagnostics) + ))); + } + let mut names = Vec::new(); + for stmt in &ret.program.body { + if let Statement::ExportNamedDeclaration(export) = stmt { + if let Some(Declaration::VariableDeclaration(var)) = &export.declaration { + for decl in &var.declarations { + if let BindingPattern::BindingIdentifier(id) = &decl.id { + names.push(id.name.to_string()); + } + } + } + } + } + Ok(names) +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +enum Resolved { + Compass, + File(PathBuf), +} + +/// Resolve an import specifier against the importing file. +fn resolve_spec(base: &Path, spec: &str) -> Result { + if spec == COMPASS_SPECIFIER { + return Ok(Resolved::Compass); + } + let dir = base.parent().unwrap_or_else(|| Path::new(".")); + let joined = dir.join(spec); + let canon = canonicalize(&joined)?; + Ok(Resolved::File(canon)) +} + +fn canonicalize(path: &Path) -> Result { + std::fs::canonicalize(path).map_err(|_| { + EvalError::Unresolved(format!( + "{} has not arrived (or was never committed)", + path.display() + )) + }) +} + +fn read_source(path: &Path) -> Result { + let bytes = std::fs::read(path) + .map_err(|_| EvalError::Unresolved(format!("{} has not arrived", path.display())))?; + String::from_utf8(bytes) + .map_err(|e| EvalError::Failed(format!("{}: not valid UTF-8: {e}", path.display()))) +} + +fn path_name(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +// --------------------------------------------------------------------------- +// The loader over precompiled bytecode +// --------------------------------------------------------------------------- + +struct GraphResolver; + +impl Resolver for GraphResolver { + fn resolve<'js>( + &mut self, + _ctx: &Ctx<'js>, + base: &str, + name: &str, + _attrs: Option>, + ) -> rquickjs::Result { + if name == COMPASS_SPECIFIER { + return Ok(COMPASS_SPECIFIER.to_string()); + } + match resolve_spec(Path::new(base), name) { + Ok(Resolved::Compass) => Ok(COMPASS_SPECIFIER.to_string()), + Ok(Resolved::File(p)) => Ok(p.to_string_lossy().to_string()), + Err(_) => Err(JsError::new_resolving(base.to_string(), name.to_string())), + } + } +} + +/// Phase 1 loader: transpile + declare each imported module on demand, and +/// capture its bytecode. Records the first real underlying failure so the caller +/// can distinguish an unresolved import from a plan fault. +struct SourceLoader { + captured: Arc>>>, + err: Arc>>, +} + +impl Loader for SourceLoader { + fn load<'js>( + &mut self, + ctx: &Ctx<'js>, + name: &str, + _attrs: Option>, + ) -> rquickjs::Result> { + let js = match build_js(name) { + Ok(js) => js, + Err(e) => { + *self.err.lock().unwrap() = Some(e); + return Err(JsError::new_loading(name.to_string())); + } + }; + let module = Module::declare( + ctx.clone(), + name.as_bytes().to_vec(), + js.as_bytes().to_vec(), + )?; + if let Ok(bytecode) = module.write(rquickjs::module::WriteOptions::default()) { + self.captured + .lock() + .unwrap() + .insert(name.to_string(), bytecode); + } + Ok(module) + } +} + +/// Phase 2 loader: serve already-compiled bytecode into the locked context. +struct BytecodeLoader { + compiled: HashMap>, +} + +impl Loader for BytecodeLoader { + fn load<'js>( + &mut self, + ctx: &Ctx<'js>, + name: &str, + _attrs: Option>, + ) -> rquickjs::Result> { + match self.compiled.get(name) { + Some(bytes) => unsafe { Module::load(ctx.clone(), bytes) }, + None => Err(JsError::new_loading(name.to_string())), + } + } +} + +// --------------------------------------------------------------------------- +// The determinism allowlist + bounds +// --------------------------------------------------------------------------- + +/// Remove `Math.random` and the transcendental `Math` functions, and assert the +/// platform-varying globals are absent. Determinism requires the global be an +/// explicit allowlist (decision 0011). +fn lock_globals(ctx: &Ctx<'_>) -> Result<(), EvalError> { + let globals = ctx.globals(); + if let Ok(math) = globals.get::<_, Object>("Math") { + // Everything that delegates to the platform libm, plus randomness. + const REMOVE: &[&str] = &[ + "random", "sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", + "asinh", "acosh", "atanh", "exp", "expm1", "log", "log1p", "log2", "log10", "pow", + "sqrt", "cbrt", "hypot", + ]; + for name in REMOVE { + let _ = math.remove(*name); + } + } + Ok(()) +} + +/// Install a time interrupt on the runtime, recording that it fired so a Stopped +/// read is not misreported as a plan fault. +fn install_interrupt(rt: &Runtime, flag: Arc) { + let deadline = Instant::now() + TIME_LIMIT; + rt.set_interrupt_handler(Some(Box::new(move || { + if Instant::now() >= deadline { + flag.store(true, Ordering::Relaxed); + true + } else { + false + } + }))); +} diff --git a/src/lib.rs b/src/lib.rs index a0f890b..d125e9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod change; pub mod cli; pub mod cmd; pub mod convergence; +pub mod eval; pub mod event; pub mod json; pub mod model; diff --git a/src/prelude.js b/src/prelude.js new file mode 100644 index 0000000..07e1236 --- /dev/null +++ b/src/prelude.js @@ -0,0 +1,205 @@ +// The `compass` prelude (decision 0011, 0013). +// +// A plan module does `import { plan, step, reconcile, evidence } from "compass"`. +// This library provides construction only: nothing here reads a clock, a file, +// the network, or the environment, so a plan authored with it is pure by +// construction rather than by policy. +// +// The evidence *vocabulary* (test, review, measurement, waiver, …) is NOT +// shipped: it belongs to the use case. `evidence` is a thin forwarder that turns +// `evidence.anything(attrs)` into `atom("anything", attrs)`, and exposes the +// combinators `all` / `any` / `not`. Compass fixes the *structure* of a +// criterion and never its vocabulary (decision 0013). + +function atom(kind, attrs) { + return { __k: "atom", kind: String(kind), attrs: attrs || {} }; +} +function all() { + return { __k: "all", args: Array.prototype.slice.call(arguments) }; +} +function any() { + return { __k: "any", args: Array.prototype.slice.call(arguments) }; +} +function not(p) { + return { __k: "not", arg: p }; +} + +// `evidence.test({...})` === `atom("test", {...})`. No vocabulary is declared; +// the domain word is just a string handed to `atom`. +const evidence = new Proxy( + { atom: atom, all: all, any: any, not: not }, + { + get: function (target, prop) { + if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop]; + if (typeof prop !== "string") return undefined; + return function (attrs) { + return atom(prop, attrs); + }; + }, + }, +); + +function makeStep(fields) { + const s = { + __k: "step", + __name: undefined, + __origin: undefined, + work: fields.work, + dependsOn: fields.dependsOn || [], + accept: fields.accept, + supersedes: fields.supersedes, + retired: false, + }; + s.with = function (patch) { + patch = patch || {}; + const n = makeStep({ + work: "work" in patch ? patch.work : s.work, + dependsOn: "dependsOn" in patch ? patch.dependsOn : s.dependsOn, + accept: "accept" in patch ? patch.accept : s.accept, + supersedes: "supersedes" in patch ? patch.supersedes : s.supersedes, + }); + // Identity is the binding; `.with` never changes it. A carried-forward + // step keeps the identity it was born with, and records where it came from. + n.__name = s.__name; + n.__origin = s.__origin || s; + n.retired = s.retired; + return n; + }; + return s; +} + +function step(fields) { + return makeStep(fields); +} + +// Attach a step's declared name. The host appends one call per exported binding +// after the module body, so by the time a successor imports this module every +// declared step already knows the name it was declared under. Never overwrites +// a name a step was already born with (a carried-forward step keeps its origin). +globalThis.__compass_register = function (val, name) { + if (val && val.__k === "step" && val.__name === undefined) { + val.__name = name; + } +}; + +function stepsMap(arr) { + const m = {}; + for (let i = 0; i < arr.length; i++) { + const s = arr[i]; + if (s && s.__k === "step" && s.__name !== undefined) m[s.__name] = s; + } + return m; +} + +function cloneCarry(s) { + const n = makeStep({ + work: s.work, + dependsOn: s.dependsOn, + accept: s.accept, + supersedes: s.supersedes, + }); + n.__name = s.__name; + n.__origin = s.__origin || s; + n.retired = s.retired; + return n; +} + +function makePlan(spec) { + const steps = (spec.steps || []).slice(); + const p = { + __k: "plan", + author: spec.author, + why: spec.why, + goal: spec.goal, + retired: spec.retired || false, + __steps: steps, + }; + Object.defineProperty(p, "steps", { + get: function () { + return stepsMap(p.__steps); + }, + }); + p.revise = function (rev) { + return reviseOne(p, rev); + }; + return p; +} + +function plan(spec) { + return makePlan(spec); +} + +// Apply the edit / add / retire triad against a carried-forward step list. +// There is no fourth operation: a step of a predecessor is carried forward +// unless it is edited or retired, so dropping a step has no spelling. +function applyOps(steps, rev) { + const edit = rev.edit || []; + for (let i = 0; i < edit.length; i++) { + const e = edit[i]; + const idx = findByName(steps, e.__name); + if (idx < 0) throw new Error("edit targets a step that is not present: " + String(e.__name)); + steps[idx] = e; + } + const retire = rev.retire || []; + for (let i = 0; i < retire.length; i++) { + const r = retire[i]; + const idx = findByName(steps, r.__name); + if (idx < 0) throw new Error("retire targets a step that is not present: " + String(r.__name)); + steps[idx].retired = true; + } + const add = rev.add || []; + for (let i = 0; i < add.length; i++) { + steps.push(add[i]); + } + return steps; +} + +function findByName(steps, name) { + for (let i = 0; i < steps.length; i++) { + if (steps[i].__name === name) return i; + } + return -1; +} + +function reviseOne(base, rev) { + let steps = base.__steps.map(cloneCarry); + steps = applyOps(steps, rev); + return makePlan({ + author: rev.author, + why: rev.why, + goal: "goal" in rev ? rev.goal : base.goal, + retired: "retired" in rev ? rev.retired : base.retired, + steps: steps, + }); +} + +// A reconciliation is a revision with more than one predecessor. Every step of +// every side is carried forward, keyed by identity, so nothing is lost by +// choosing a side; the version states only what it changes. +function reconcile(rev) { + const sides = rev.revises || []; + const seen = {}; + const carried = []; + for (let i = 0; i < sides.length; i++) { + const ss = sides[i].__steps || []; + for (let j = 0; j < ss.length; j++) { + const s = ss[j]; + if (s.__name === undefined) continue; + if (!Object.prototype.hasOwnProperty.call(seen, s.__name)) { + seen[s.__name] = true; + carried.push(cloneCarry(s)); + } + } + } + const steps = applyOps(carried, rev); + const goal = "goal" in rev ? rev.goal : sides.length > 0 ? sides[0].goal : undefined; + return makePlan({ + author: rev.author, + why: rev.why, + goal: goal, + retired: rev.retired || false, + steps: steps, + }); +} + +export { plan, step, reconcile, evidence, atom, all, any, not }; diff --git a/tests/spike.rs b/tests/spike.rs new file mode 100644 index 0000000..1fb819f --- /dev/null +++ b/tests/spike.rs @@ -0,0 +1,44 @@ +use compass::eval::eval_plan_file; +use std::path::Path; + +fn show(p: &str) { + let path = Path::new(p); + println!("\n===== {p}"); + match eval_plan_file(path) { + Ok(map) => { + let canon = std::fs::canonicalize(path).unwrap(); + let v = map.get(&canon).expect("entry in map"); + println!( + "author={} goal={:?} retired={}", + v.author, v.goal, v.retired + ); + println!("why={:?}", v.why); + for s in &v.steps { + println!( + " step {} retired={} deps={:?} supersedes={:?}", + s.name, s.retired, s.depends_on, s.supersedes + ); + println!(" work={:?}", s.work); + println!(" accept={}", s.accept); + } + } + Err(e) => println!("ERR [{}] {}", e.kind(), e.message()), + } +} + +#[test] +fn spike_all_examples() { + for p in [ + "examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/001-cfe4f8d721d2.ts", + "examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/002-043517240262.ts", + "examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/001-634e2a7c458b.ts", + "examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-e10822d3395b.ts", + "examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-549d0e4af2eb.ts", + "examples/two-machines/catalog/plans/pl_nested_groups/versions/001-8e528ff9bc56.ts", + "examples/two-machines/catalog/plans/pl_nested_groups/versions/002-7280a933f7cc.ts", + "examples/two-machines/catalog/plans/pl_nested_groups/versions/002-ff95b74b4e9f.ts", + "examples/two-machines/catalog/plans/pl_nested_groups/versions/003-79f571386a40.ts", + ] { + show(p); + } +} From 184ab896b868ce19af8718e09ae7947192732a52 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:10:44 +0200 Subject: [PATCH 02/11] feat(compass): rebuild storage/authoring/CLI for plans-as-code Reorient the whole tool around evaluation (decisions 0012/0014): - model: a Version is recovered by evaluating a module; identity is the sha256 of source bytes; keep validate (cycle/dup/dep), changes_nothing, filenames. - catalog: admission is byte-hash + filename check, no evaluation; parents are derived from import specifiers; source stored unchanged at 0444; evaluate() runs the module on demand. - chain: head/divergence/orphan now walk import-derived parents. - readiness: computed from an evaluated Version + events (per-head labelling moved to the CLI). - predicate: add decision-0013 self-contradiction detection (refused at commit) and the write-time evidence cross-check (reported, never refused). - CLI: start/commit/show/history/ready/status/verify/repair/progress/evidence/ version, each with --json parity and a convergence line; reads render not-found/unresolved/stopped distinctly. - refs: trimmed to event-id minting (plan/step identity is no longer minted). - drop change.rs (structural-diff rendering) and the KDL @block use for versions. Full e2e verified by hand: start -> edit -> commit -> show/history -> revise -> divergence -> reconcile -> converged, all steps carried forward. 129 lib tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...02-e10822d3395b.ts => 002-68fdda593f4d.ts} | 16 +- ...03-549d0e4af2eb.ts => 003-55ca61867911.ts} | 14 +- ...02-7280a933f7cc.ts => 002-2c922cb978de.ts} | 14 +- ...02-ff95b74b4e9f.ts => 002-e5d27ee538bf.ts} | 14 +- ...03-79f571386a40.ts => 003-037d5ddb9db7.ts} | 4 +- src/catalog.rs | 453 +-- src/chain.rs | 58 +- src/change.rs | 425 --- src/cli.rs | 684 +--- src/cmd.rs | 2844 +++++------------ src/eval.rs | 37 + src/event.rs | 4 +- src/lib.rs | 7 +- src/model.rs | 614 +--- src/predicate.rs | 104 + src/readiness.rs | 202 +- src/refs.rs | 126 +- tests/spike.rs | 44 - 18 files changed, 1543 insertions(+), 4121 deletions(-) rename examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/{002-e10822d3395b.ts => 002-68fdda593f4d.ts} (52%) rename examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/{003-549d0e4af2eb.ts => 003-55ca61867911.ts} (57%) rename examples/two-machines/catalog/plans/pl_nested_groups/versions/{002-7280a933f7cc.ts => 002-2c922cb978de.ts} (67%) rename examples/two-machines/catalog/plans/pl_nested_groups/versions/{002-ff95b74b4e9f.ts => 002-e5d27ee538bf.ts} (67%) rename examples/two-machines/catalog/plans/pl_nested_groups/versions/{003-79f571386a40.ts => 003-037d5ddb9db7.ts} (88%) delete mode 100644 src/change.rs delete mode 100644 tests/spike.rs diff --git a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-e10822d3395b.ts b/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts similarity index 52% rename from examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-e10822d3395b.ts rename to examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts index c29b453..66d904a 100644 --- a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-e10822d3395b.ts +++ b/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts @@ -1,17 +1,19 @@ import { step, evidence } from "compass" import prior from "./001-634e2a7c458b.ts" +// The added step is an exported binding: its name is its identity (decision 0012), +// so a later version can carry it forward and refer to it. +export const mirror = step({ + work: "Put build artifacts behind a regional mirror", + dependsOn: [prior.steps.measure], + accept: evidence.measurement({ of: "artifact-download", below: "120" }), +}) + export default prior.revise({ author: "cos", why: `The measurement kills the hypothesis. 26 of the 34 minutes are artifact download from a single-homed mirror; cache misses cost about 90 seconds total. Retiring the cache work and retargeting at the mirror.`, retire: [prior.steps.fixCache], - add: [ - step({ - work: "Put build artifacts behind a regional mirror", - dependsOn: [prior.steps.measure], - accept: evidence.measurement({ of: "artifact-download", below: "120" }), - }), - ], + add: [mirror], }) diff --git a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-549d0e4af2eb.ts b/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts similarity index 57% rename from examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-549d0e4af2eb.ts rename to examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts index 09e5d4f..2639ed6 100644 --- a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-549d0e4af2eb.ts +++ b/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts @@ -1,15 +1,15 @@ import { step, evidence } from "compass" -import prior from "./002-e10822d3395b.ts" +import prior from "./002-68fdda593f4d.ts" + +export const prewarm = step({ + work: "Pre-warm the mirror on branch create", + accept: evidence.measurement({ of: "cold-build", below: "600" }), +}) export default prior.revise({ author: "cos", why: `A regional mirror still leaves the cold-start case: the first build of a new branch is the slow one. Adding a warm path so branch creation is not also the slow build.`, - add: [ - step({ - work: "Pre-warm the mirror on branch create", - accept: evidence.measurement({ of: "cold-build", below: "600" }), - }), - ], + add: [prewarm], }) diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-7280a933f7cc.ts b/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts similarity index 67% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/002-7280a933f7cc.ts rename to examples/two-machines/catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts index 15b3345..4be3f8d 100644 --- a/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-7280a933f7cc.ts +++ b/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts @@ -2,15 +2,15 @@ import { step, evidence } from "compass" import prior from "./001-8e528ff9bc56.ts" // Authored on machine A, from version 001-8e528ff9bc56.ts, before B's revision had replicated. +export const fuzz = step({ + work: "Fuzz nested-group parsing", + dependsOn: [prior.steps.fix], + accept: evidence.test({ name: "parser::nested_fuzz", status: "pass" }), +}) + export default prior.revise({ author: "cos", why: `One reproduction is not enough. Adding a fuzz step so the fix is checked against generated nestings, not just the one case that started this.`, - add: [ - step({ - work: "Fuzz nested-group parsing", - dependsOn: [prior.steps.fix], - accept: evidence.test({ name: "parser::nested_fuzz", status: "pass" }), - }), - ], + add: [fuzz], }) diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-ff95b74b4e9f.ts b/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts similarity index 67% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/002-ff95b74b4e9f.ts rename to examples/two-machines/catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts index 7cbe314..3678a7b 100644 --- a/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-ff95b74b4e9f.ts +++ b/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts @@ -3,15 +3,15 @@ import prior from "./001-8e528ff9bc56.ts" // Authored on machine B, from the same version 001-8e528ff9bc56.ts. Shares a predecessor with // the machine-A revision, so the two are a Divergence, not a sequence. +export const guard = step({ + work: "Add a grammar guard for unterminated groups", + dependsOn: [prior.steps.fix], + accept: evidence.test({ name: "parser::unterminated_guard", status: "pass" }), +}) + export default prior.revise({ author: "dev", why: `Splitting the fix so each part lands reviewable: the tokenizer change, then a grammar guard that rejects an unterminated group outright.`, - add: [ - step({ - work: "Add a grammar guard for unterminated groups", - dependsOn: [prior.steps.fix], - accept: evidence.test({ name: "parser::unterminated_guard", status: "pass" }), - }), - ], + add: [guard], }) diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/003-79f571386a40.ts b/examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts similarity index 88% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/003-79f571386a40.ts rename to examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts index 2fa51fd..fb0b71f 100644 --- a/examples/two-machines/catalog/plans/pl_nested_groups/versions/003-79f571386a40.ts +++ b/examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts @@ -1,6 +1,6 @@ import { reconcile } from "compass" -import fuzzSide from "./002-7280a933f7cc.ts" -import splitSide from "./002-ff95b74b4e9f.ts" +import fuzzSide from "./002-2c922cb978de.ts" +import splitSide from "./002-e5d27ee538bf.ts" // A Reconciliation is an ordinary revision with more than one predecessor. // Every Step of both sides is carried forward — neither the fuzz step nor the diff --git a/src/catalog.rs b/src/catalog.rs index 3e2f6a5..abe076c 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -1,42 +1,44 @@ -//! The Catalog: on-disk storage and deliberate ingestion. +//! The Catalog: on-disk storage and deliberate admission. //! //! ```text -//! /plans//versions/-.cmp immutable, mode 0444 +//! /plans//versions/-.ts immutable, mode 0444 //! /plans//events/-.cmp append-only //! ``` //! -//! The root is configuration, never compiled in (CMP-R15). +//! ## Admission (02-artifacts) //! -//! ## Admission (CMP-R22) +//! A file becomes a Plan Version when it sits in its expected location and the +//! SHA-256 of its bytes matches the hash embedded in its filename. Admission +//! looks at bytes and nothing else — it does not evaluate the module, and in +//! particular does not require that what the module imports be present, because +//! replication delivers files in no useful order. A mismatch is a rejection with +//! an error, never a warning. //! -//! Under no-delete replication a wrongly-adopted file is permanent, so merely -//! parsing is not enough. A file becomes a Plan Version only when it is in its -//! expected location *and* the SHA-256 of its bytes matches the hash embedded -//! in its filename. A mismatch is a **rejection with an error**, never a -//! warning that a reader might skim past. -//! -//! The hash is taken over the **raw file bytes as they sit on disk** — never -//! over a parse-and-re-render. Re-rendering would make a reformatted file -//! admissible under its old name, which is exactly the corruption the chain -//! exists to catch. (DQ02 asks raw bytes or canonical form; raw bytes, and the -//! canonical writer is what keeps that strictness usable.) -//! -//! Files arriving through replication are treated exactly as local files -//! (CMP.INT-R08). Nothing trusts a file mode: `0444` is a local accident-guard -//! that stops an agent editing in place, not a property of the wire. +//! The identity is the hash of the raw bytes as they sit on disk. Because each +//! version imports its predecessors by filename, and each filename carries a +//! hash of content, the lineage is walkable from the bytes alone: the parents of +//! a version are the versions whose hash-prefix appears in its import +//! specifiers. A prefix that resolves to no local file is a missing predecessor +//! (an orphan), repaired by waiting. use crate::event::Event; -use crate::model::{parse_filename, Version, EXT}; +use crate::model::{filename_for, parse_filename, Version, VERSION_EXT}; use std::fs; use std::path::{Path, PathBuf}; -/// A file admitted as a Plan Version. +/// A file admitted as a Plan Version. Its intent is recovered by evaluation on +/// demand (see [`crate::eval`]); admission keeps only what the bytes determine. #[derive(Debug, Clone)] pub struct Admitted { /// Full content hash — the identity. pub hash: String, pub path: PathBuf, - pub version: Version, + pub plan: String, + /// A reading aid: one past the longest predecessor. Never a key. + pub seq: u64, + /// Predecessor references: a full hash when the predecessor is present, or + /// the raw 12-hex prefix when it has not arrived (an orphan edge). + pub parents: Vec, } /// A file in a versions/ or events/ directory that was not admitted. @@ -77,7 +79,17 @@ impl PlanStore { } } -/// Resolve the catalog root (CMP-R15). +/// One admitted version before its parents are resolved against its siblings. +struct Raw { + hash: String, + path: PathBuf, + plan: String, + seq: u64, + /// 12-hex prefixes of the version files this module imports. + import_prefixes: Vec, +} + +/// Resolve the catalog root (configuration, never compiled in). pub fn root() -> Result { if let Some(v) = env_nonempty("COMPASS_CATALOG") { return Ok(PathBuf::from(v)); @@ -109,15 +121,12 @@ pub fn author() -> String { pub fn plans_dir(root: &Path) -> PathBuf { root.join("plans") } - pub fn plan_dir(root: &Path, plan: &str) -> PathBuf { plans_dir(root).join(plan) } - pub fn versions_dir(root: &Path, plan: &str) -> PathBuf { plan_dir(root, plan).join("versions") } - pub fn events_dir(root: &Path, plan: &str) -> PathBuf { plan_dir(root, plan).join("events") } @@ -161,15 +170,43 @@ pub fn load_plan(root: &Path, plan: &str) -> Result { }; let vdir = versions_dir(root, plan); + let mut raws: Vec = Vec::new(); if vdir.is_dir() { for path in sorted_files(&vdir)? { match admit_version(&path, plan) { - Ok(a) => store.versions.push(a), + Ok(raw) => raws.push(raw), Err(reason) => store.rejected.push(Rejected { path, reason }), } } } + // Resolve import-prefixes to full predecessor hashes among the siblings. + let by_prefix: std::collections::HashMap<&str, &str> = raws + .iter() + .map(|r| (&r.hash[..crate::model::HASH_PREFIX_LEN], r.hash.as_str())) + .collect(); + for r in &raws { + let mut parents: Vec = r + .import_prefixes + .iter() + .map(|p| { + by_prefix + .get(p.as_str()) + .map(|h| h.to_string()) + .unwrap_or_else(|| p.clone()) + }) + .collect(); + parents.sort(); + parents.dedup(); + store.versions.push(Admitted { + hash: r.hash.clone(), + path: r.path.clone(), + plan: r.plan.clone(), + seq: r.seq, + parents, + }); + } + let edir = events_dir(root, plan); if edir.is_dir() { for path in sorted_files(&edir)? { @@ -198,11 +235,9 @@ pub fn load_plan(root: &Path, plan: &str) -> Result { } } - // Lineage depth first, then hash: a total order that needs no recorded - // counter, and that two machines holding the same versions agree on. store .versions - .sort_by(|a, b| (a.version.seq, &a.hash).cmp(&(b.version.seq, &b.hash))); + .sort_by(|a, b| (a.seq, &a.hash).cmp(&(b.seq, &b.hash))); store .events .sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id))); @@ -223,22 +258,20 @@ fn sorted_files(dir: &Path) -> Result, String> { Ok(out) } -/// Decide whether one file may become a Plan Version (CMP-R22). -pub fn admit_version(path: &Path, expected_plan: &str) -> Result { +/// Decide whether one file may become a Plan Version. Bytes only — no evaluation. +fn admit_version(path: &Path, expected_plan: &str) -> Result { let name = path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| "file has no readable name".to_string())?; - if !name.ends_with(&format!(".{EXT}")) { - return Err(format!("not a .{EXT} file")); + if !name.ends_with(&format!(".{VERSION_EXT}")) { + return Err(format!("not a .{VERSION_EXT} file")); } - - let (_seq, want_prefix) = parse_filename(name).ok_or_else(|| { - format!("filename `{name}` is not `-.{EXT}`, so it names no content") + let (seq, want_prefix) = parse_filename(name).ok_or_else(|| { + format!("filename `{name}` is not `-.{VERSION_EXT}`, so it names no content") })?; - // Hash the bytes exactly as they sit on disk. let bytes = fs::read(path).map_err(|e| format!("cannot read: {e}"))?; let actual = crate::sha256::sha256_hex(&bytes); if !actual.starts_with(&want_prefix) { @@ -248,38 +281,53 @@ pub fn admit_version(path: &Path, expected_plan: &str) -> Result Result, String> { + let specs = crate::eval::import_specifiers(source, path) + .map_err(|e| format!("cannot read imports: {}", e.message()))?; + let mut out = Vec::new(); + for spec in specs { + // A predecessor import is a relative path to a version file. + let file = spec.rsplit('/').next().unwrap_or(&spec); + if let Some((_seq, prefix)) = parse_filename(file) { + out.push(prefix); + } + } + Ok(out) +} + +/// Write a Plan Version from its authored source bytes. /// -/// A version whose file already exists is left alone: the name is the content -/// hash, so an identical file is the same version. Since no field is excluded -/// from that hash (decision 0007), this is the whole of Compass's idempotency: -/// there is no caller-supplied key, and none is wanted (CMP-R10). -pub fn write_version(root: &Path, v: &Version) -> Result<(PathBuf, bool), String> { - let dir = versions_dir(root, &v.plan); +/// Returns its path and whether it was newly created. The name is the content +/// hash, so an identical file is the same version and is left untouched — the +/// whole of Compass's idempotency, with no caller-supplied key (CMP-R10). +pub fn write_version( + root: &Path, + plan: &str, + seq: u64, + source: &[u8], +) -> Result<(PathBuf, String, bool), String> { + let hash = crate::sha256::sha256_hex(source); + let dir = versions_dir(root, plan); fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - let path = dir.join(v.filename()); + let path = dir.join(filename_for(seq, &hash)); if path.exists() { - return Ok((path, false)); + return Ok((path, hash, false)); } - write_readonly(&path, v.render().as_bytes())?; - Ok((path, true)) + write_readonly(&path, source)?; + Ok((path, hash, true)) } /// Append a Progress Event. @@ -291,7 +339,6 @@ pub fn write_event(root: &Path, e: &Event) -> Result { Ok(path) } -/// Write a file and drop it to mode 0444 (accident-prevention, decision 0002). fn write_readonly(path: &Path, bytes: &[u8]) -> Result<(), String> { fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?; set_readonly(path) @@ -314,34 +361,41 @@ fn set_readonly(path: &Path) -> Result<(), String> { .map_err(|e| format!("cannot set {} read-only: {e}", path.display())) } +/// Build a domain [`Version`] for an admitted file by evaluating it. +pub fn evaluate(a: &Admitted) -> Result { + let map = crate::eval::eval_plan_file(&a.path)?; + let canon = std::fs::canonicalize(&a.path).unwrap_or_else(|_| a.path.clone()); + let sem = map + .get(&canon) + .or_else(|| map.get(&a.path)) + .ok_or_else(|| crate::eval::EvalError::Failed("evaluation produced no plan".into()))?; + Ok(Version::from_sem(&a.plan, a.seq, a.parents.clone(), sem)) +} + #[cfg(test)] mod tests { use super::*; - use crate::model::Step; - use crate::predicate::parse as pred; - /// A scratch catalog that cleans itself up. struct Scratch { root: PathBuf, } - impl Scratch { fn new(tag: &str) -> Scratch { let base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); - let unique = crate::refs::mint(crate::refs::RefKind::Event).unwrap(); - let root = PathBuf::from(base).join(format!("compass-test-{tag}-{unique}")); + let uniq = format!("{}-{}", std::process::id(), tag); + let root = PathBuf::from(base).join(format!("compass-test-{uniq}")); + let _ = make_writable_recursive(&root); + let _ = fs::remove_dir_all(&root); init(&root).unwrap(); Scratch { root } } } - impl Drop for Scratch { fn drop(&mut self) { let _ = make_writable_recursive(&self.root); let _ = fs::remove_dir_all(&self.root); } } - fn make_writable_recursive(p: &Path) -> std::io::Result<()> { if p.is_dir() { for e in fs::read_dir(p)? { @@ -356,255 +410,54 @@ mod tests { Ok(()) } - fn a_version(plan: &str) -> Version { - Version { - plan: plan.to_string(), - seq: 1, - parents: vec![], - author: "cos".into(), - why: "Initial plan.".into(), - goal: "Ship it".into(), - retired: false, - steps: vec![Step::new( - "st_A000000001", - "Do the work", - pred("test(status=pass)").unwrap(), - )], - } - } - - #[test] - fn root_prefers_explicit_configuration() { - // Verified through the pure helper rather than by mutating process env, - // which would race other tests. - assert!(root().is_ok() || std::env::var("HOME").is_err()); - } + const ROOT_MODULE: &str = r#"import { plan, step, evidence } from "compass" +export const a = step({ work: "do a", accept: evidence.test({ status: "pass" }) }) +export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) +"#; #[test] - fn writes_and_reloads_a_version() { - let s = Scratch::new("roundtrip"); - let v = a_version("pl_1000000000"); - let (path, created) = write_version(&s.root, &v).unwrap(); + fn admits_a_version_by_source_byte_hash() { + let s = Scratch::new("admit"); + let (path, hash, created) = + write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(created); assert!(path.exists()); + assert_eq!(hash, crate::sha256::sha256_hex(ROOT_MODULE.as_bytes())); - let store = load_plan(&s.root, &v.plan).unwrap(); + let store = load_plan(&s.root, "pl_x").unwrap(); assert_eq!(store.versions.len(), 1, "rejected: {:?}", store.rejected); - assert!(store.rejected.is_empty()); - assert_eq!(store.versions[0].hash, v.hash()); - assert_eq!(store.versions[0].version, v); - } - - #[test] - fn versions_are_written_read_only() { - let s = Scratch::new("readonly"); - let v = a_version("pl_2000000000"); - let (path, _) = write_version(&s.root, &v).unwrap(); - assert!(fs::metadata(&path).unwrap().permissions().readonly()); - // An in-place edit becomes a visible error rather than silent damage. - assert!(fs::write(&path, b"tampered").is_err()); + assert_eq!(store.versions[0].hash, hash); + assert!(store.versions[0].parents.is_empty()); } #[test] - fn rewriting_an_identical_version_is_a_no_op() { - let s = Scratch::new("idempotent"); - let v = a_version("pl_3000000000"); - let (_, first) = write_version(&s.root, &v).unwrap(); - let (_, second) = write_version(&s.root, &v).unwrap(); + fn identical_content_is_a_no_op() { + let s = Scratch::new("noop"); + let (_, _, first) = write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); + let (_, _, second) = write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(first); - assert!(!second, "identical content must not be rewritten"); - assert_eq!(load_plan(&s.root, &v.plan).unwrap().versions.len(), 1); + assert!(!second); } #[test] - fn tampered_content_is_rejected_not_warned() { + fn tampered_bytes_are_rejected() { let s = Scratch::new("tamper"); - let v = a_version("pl_4000000000"); - let (path, _) = write_version(&s.root, &v).unwrap(); - - // Simulate corruption in transit: same name, different bytes. + let (path, _, _) = write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); make_writable_recursive(&s.root).unwrap(); - let mut tampered = v.clone(); - tampered.goal = "Something else entirely".into(); - fs::write(&path, tampered.render()).unwrap(); - - let store = load_plan(&s.root, &v.plan).unwrap(); - assert!( - store.versions.is_empty(), - "tampered file must not be admitted" - ); - assert_eq!(store.rejected.len(), 1); - assert!( - store.rejected[0].reason.contains("content hash mismatch"), - "{}", - store.rejected[0].reason - ); - } - - #[test] - fn a_file_that_merely_parses_is_not_adopted() { - let s = Scratch::new("stray"); - let plan = "pl_5000000000"; - let v = a_version(plan); - let dir = versions_dir(&s.root, plan); - fs::create_dir_all(&dir).unwrap(); - // Well-formed content, but the name names no content. - fs::write(dir.join("plan-draft.cmp"), v.render()).unwrap(); - // And a correctly-shaped name carrying the wrong hash. - fs::write(dir.join("001-aaaaaaaaaaaa.cmp"), v.render()).unwrap(); - - let store = load_plan(&s.root, plan).unwrap(); + fs::write(&path, b"import x from 'y'\n").unwrap(); + let store = load_plan(&s.root, "pl_x").unwrap(); assert!(store.versions.is_empty()); - assert_eq!(store.rejected.len(), 2); - } - - #[test] - fn a_version_filed_under_the_wrong_plan_is_rejected() { - let s = Scratch::new("misfiled"); - let v = a_version("pl_6000000000"); - let dir = versions_dir(&s.root, "pl_9999999999"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join(v.filename()), v.render()).unwrap(); - - let store = load_plan(&s.root, "pl_9999999999").unwrap(); - assert!(store.versions.is_empty()); - assert!(store.rejected[0].reason.contains("declares plan")); - } - - #[test] - fn unrelated_files_are_ignored_with_a_reason() { - let s = Scratch::new("foreign"); - let plan = "pl_7000000000"; - let dir = versions_dir(&s.root, plan); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("README.md"), "notes").unwrap(); - - let store = load_plan(&s.root, plan).unwrap(); - assert!(store.versions.is_empty()); - assert_eq!(store.rejected.len(), 1); - } - - /// Two machines that independently make the *same* revision from the same - /// parent converge on one version instead of diverging (decision 0007). - /// - /// Each catalog holds a different amount of unrelated history, which under - /// a recorded `max(seen) + 1` counter would have given the two revisions - /// different bodies, different names, and a permanent false divergence. - /// Nothing is recorded that either machine's local history can influence, - /// so the bytes match and replication unions them into one file. - #[test] - fn the_same_revision_from_the_same_parent_converges() { - let left = Scratch::new("converge-left"); - let right = Scratch::new("converge-right"); - let plan = "pl_8000000000"; - - let root_version = a_version(plan); - write_version(&left.root, &root_version).unwrap(); - write_version(&right.root, &root_version).unwrap(); - - // The left machine has seen more of the plan's history than the right. - let mut unrelated = a_version(plan); - unrelated.seq = 2; - unrelated.parents = vec![root_version.hash()]; - unrelated.goal = "An older direction, since abandoned".into(); - unrelated.why = "History the right machine never saw.".into(); - write_version(&left.root, &unrelated).unwrap(); - - let revision = |base: &Version| { - let mut v = base.clone(); - v.seq = base.seq + 1; - v.parents = vec![base.hash()]; - v.why = "The tokenizer, not the grammar, drops it.".into(); - v.steps[0].work = "Fix the tokenizer".into(); - v - }; - let from_left = revision(&root_version); - let from_right = revision(&root_version); - - assert_eq!( - from_left.hash(), - from_right.hash(), - "identical intent from an identical parent is one version" - ); - - write_version(&left.root, &from_left).unwrap(); - write_version(&right.root, &from_right).unwrap(); - assert_eq!( - from_left.filename(), - from_right.filename(), - "one name, so replication unions rather than accumulating" - ); - - // Replicate right's file into left. It is the file left already holds. - let (path, created) = write_version(&left.root, &from_right).unwrap(); - assert!(!created, "the replicated file is the one already present"); - - let store = load_plan(&left.root, plan).unwrap(); - assert!(store.rejected.is_empty(), "{:?}", store.rejected); - assert_eq!( - store - .versions - .iter() - .filter(|a| a.hash == from_left.hash()) - .count(), - 1, - "the two machines' revisions are one version, not two: {}", - path.display() - ); - } - - #[test] - fn resolves_a_unique_hash_prefix() { - let s = Scratch::new("prefix"); - let v = a_version("pl_9000000000"); - write_version(&s.root, &v).unwrap(); - let store = load_plan(&s.root, &v.plan).unwrap(); - assert!(store.resolve_hash(&v.hash()[..8]).is_some()); - assert!(store.resolve_hash("ffffffffffffff").is_none()); - } - - #[test] - fn lists_plans_and_tolerates_an_empty_catalog() { - let s = Scratch::new("list"); - assert!(list_plans(&s.root).unwrap().is_empty()); - write_version(&s.root, &a_version("pl_A000000000")).unwrap(); - write_version(&s.root, &a_version("pl_B000000000")).unwrap(); - assert_eq!( - list_plans(&s.root).unwrap(), - vec!["pl_A000000000", "pl_B000000000"] - ); - } - - #[test] - fn events_round_trip_through_the_catalog() { - use crate::event::{Event, EventKind}; - let s = Scratch::new("events"); - let plan = "pl_C000000000"; - let e = Event { - id: "ev_0000000001".into(), - at: 1, - wall: 0, - plan: plan.into(), - step: "st_A000000001".into(), - version: "a".repeat(64), - actor: "cos".into(), - kind: EventKind::Start, - note: None, - evidence_kind: None, - attrs: vec![], - }; - write_event(&s.root, &e).unwrap(); - let store = load_plan(&s.root, plan).unwrap(); - assert_eq!(store.events.len(), 1); - assert_eq!(store.events[0], e); - assert_eq!(store.next_event_at(), 2); + assert!(store.rejected[0].reason.contains("content hash mismatch")); } #[test] - fn init_is_idempotent() { - let s = Scratch::new("init"); - assert!(exists(&s.root)); - init(&s.root).unwrap(); - assert!(exists(&s.root)); + fn a_version_evaluates_to_its_intent() { + let s = Scratch::new("eval"); + write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); + let store = load_plan(&s.root, "pl_x").unwrap(); + let v = evaluate(&store.versions[0]).unwrap(); + assert_eq!(v.goal, "Ship"); + assert_eq!(v.steps.len(), 1); + assert_eq!(v.steps[0].id, "a"); } } diff --git a/src/chain.rs b/src/chain.rs index 6817c90..70c8a64 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -109,7 +109,7 @@ pub fn analyze(store: &PlanStore) -> Analysis<'_> { // A version is a successor of each hash it names as parent. let mut has_successor: HashSet<&str> = HashSet::new(); for a in &store.versions { - for p in &a.version.parents { + for p in &a.parents { has_successor.insert(p.as_str()); } } @@ -125,7 +125,6 @@ pub fn analyze(store: &PlanStore) -> Analysis<'_> { .iter() .filter_map(|a| { let missing: Vec = a - .version .parents .iter() .filter(|p| !present.contains(p.as_str())) @@ -144,10 +143,10 @@ pub fn analyze(store: &PlanStore) -> Analysis<'_> { let mut by_parent: BTreeMap<&str, Vec<&Admitted>> = BTreeMap::new(); let mut roots: Vec<&Admitted> = Vec::new(); for a in &store.versions { - if a.version.parents.is_empty() { + if a.parents.is_empty() { roots.push(a); } - for p in &a.version.parents { + for p in &a.parents { if present.contains(p.as_str()) { by_parent.entry(p.as_str()).or_default().push(a); } @@ -157,7 +156,7 @@ pub fn analyze(store: &PlanStore) -> Analysis<'_> { // Forward edges, to decide whether a divergence has been reconciled. let mut children_of: BTreeMap<&str, Vec<&Admitted>> = BTreeMap::new(); for a in &store.versions { - for p in &a.version.parents { + for p in &a.parents { children_of.entry(p.as_str()).or_default().push(a); } } @@ -266,47 +265,35 @@ pub fn lineage<'a>(store: &'a PlanStore, hash: &str) -> Vec<&'a Admitted> { } let Some(a) = store.version(&h) else { continue }; out.push(a); - for p in &a.version.parents { + for p in &a.parents { queue.push(p.clone()); } } - out.sort_by(|a, b| (a.version.seq, &a.hash).cmp(&(b.version.seq, &b.hash))); + out.sort_by(|a, b| (a.seq, &a.hash).cmp(&(b.seq, &b.hash))); out } /// The `seq` a new version should carry: one past the longest predecessor. pub fn next_seq(parents: &[&Admitted]) -> u64 { - parents.iter().map(|p| p.version.seq).max().unwrap_or(0) + 1 + parents.iter().map(|p| p.seq).max().unwrap_or(0) + 1 } #[cfg(test)] mod tests { use super::*; use crate::catalog::Admitted; - use crate::model::{Step, Version}; - use crate::predicate::parse as pred; use std::path::PathBuf; - fn v(plan: &str, seq: u64, why: &str, parents: Vec) -> Admitted { - let version = Version { + /// A stand-in admitted version. `tag` seeds a unique content hash, standing + /// for a version's authored source (chain logic never evaluates). + fn v(plan: &str, seq: u64, tag: &str, parents: Vec) -> Admitted { + Admitted { + hash: crate::sha256::sha256_hex(format!("{plan}:{seq}:{tag}").as_bytes()), + path: PathBuf::from("/dev/null"), plan: plan.into(), seq, parents, - author: "cos".into(), - why: why.into(), - goal: "Goal".into(), - retired: false, - steps: vec![Step::new( - "st_A000000001", - "Work", - pred("test(status=pass)").unwrap(), - )], - }; - Admitted { - hash: version.hash(), - path: PathBuf::from("/dev/null"), - version, } } @@ -500,8 +487,8 @@ mod tests { let chain = lineage(&s, &tip); assert_eq!(chain.len(), 3); - let whys: Vec<&str> = chain.iter().map(|a| a.version.why.as_str()).collect(); - assert_eq!(whys, vec!["first", "second", "third"]); + let seqs: Vec = chain.iter().map(|a| a.seq).collect(); + assert_eq!(seqs, vec![1, 2, 3], "oldest first"); } #[test] @@ -518,10 +505,21 @@ mod tests { let tip = merge.hash.clone(); let s = store(vec![a, left, right, merge]); + let merge_hash = tip.clone(); + let root_hash = a_hash(&s); let chain = lineage(&s, &tip); assert_eq!(chain.len(), 4, "the shared root appears once, not twice"); - assert_eq!(chain[0].version.why, "first"); - assert_eq!(chain[3].version.why, "both"); + assert_eq!(chain[0].hash, root_hash, "the root is oldest"); + assert_eq!(chain[3].hash, merge_hash, "the reconciliation is newest"); + } + + /// The single seq-1 root's hash, for lineage-order assertions. + fn a_hash(s: &PlanStore) -> String { + s.versions + .iter() + .find(|a| a.seq == 1) + .map(|a| a.hash.clone()) + .unwrap() } #[test] diff --git a/src/change.rs b/src/change.rs deleted file mode 100644 index 1195682..0000000 --- a/src/change.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! What each Plan Version changed, derived by comparing it to its parents. -//! -//! The lineage says a version exists and why its author wrote it. It does not -//! say what the version *did*, and a reader left to diff two step lists by eye -//! cannot check the Rationale against the change it claims to explain. This -//! module derives the structural change so the two can be read together. -//! -//! Nothing here is stored. A version is a full snapshot of intent (CMP-R05), -//! so the change is recoverable at any time from the versions themselves, and -//! storing it would create a second record able to disagree with the first. -//! -//! Where the change is *not* derivable, this says so rather than guessing. A -//! reconciliation records which versions it succeeded but not which side's -//! step graph it carried forward, so unless the sides agreed there is no base -//! to diff against — and inventing one would be a false account of what an -//! author did. - -use crate::catalog::PlanStore; -use crate::model::{Step, Version}; - -/// What the change is measured against. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Basis { - /// No predecessor: the version states intent rather than changing it. - Root, - /// Exactly one predecessor. - Parent(String), - /// Several predecessors that carried identical step graphs, so the graph - /// they agreed on is an unambiguous base. - Agreed(usize), - /// Several predecessors that disagreed. Which one was carried forward is - /// not recorded, so no diff exists to report. - Unrecoverable(usize), -} - -/// One structural difference between a version and its base. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StepChange { - Added { - id: String, - work: String, - }, - /// A new step replacing an existing one: intended work changed identity. - Superseded { - old: String, - id: String, - work: String, - }, - Retired { - id: String, - work: String, - }, - /// Same identity, different content. `fields` names what moved. - Edited { - id: String, - work: String, - fields: Vec<&'static str>, - }, - /// Present in the base and absent here. Revision cannot do this; only a - /// reconciliation that carried forward a side lacking the step. - Dropped { - id: String, - work: String, - }, -} - -impl StepChange { - pub fn verb(&self) -> &'static str { - match self { - StepChange::Added { .. } => "added", - StepChange::Superseded { .. } => "superseded", - StepChange::Retired { .. } => "retired", - StepChange::Edited { .. } => "edited", - StepChange::Dropped { .. } => "dropped", - } - } - - pub fn id(&self) -> &str { - match self { - StepChange::Added { id, .. } - | StepChange::Superseded { id, .. } - | StepChange::Retired { id, .. } - | StepChange::Edited { id, .. } - | StepChange::Dropped { id, .. } => id, - } - } - - pub fn work(&self) -> &str { - match self { - StepChange::Added { work, .. } - | StepChange::Superseded { work, .. } - | StepChange::Retired { work, .. } - | StepChange::Edited { work, .. } - | StepChange::Dropped { work, .. } => work, - } - } -} - -/// The full structural change a version introduced. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VersionChange { - pub basis: Basis, - /// The previous goal, present only when this version changed it. - pub goal_before: Option, - pub steps: Vec, - /// The step set this version carries. Reported when there is no base to - /// diff against, so the reader still learns what intent now stands. - pub resulting: Vec, -} - -impl VersionChange { - /// Whether this version altered structural intent at all. - pub fn is_empty(&self) -> bool { - self.steps.is_empty() && self.goal_before.is_none() - } -} - -/// Derive what `version` changed, relative to its recorded parents. -pub fn of(store: &PlanStore, version: &Version) -> VersionChange { - let resulting: Vec = version.steps.iter().map(|s| s.id.clone()).collect(); - - // A parent naming a version that has not arrived is an orphan, not a - // change of nothing. Treat it as no base rather than diffing against an - // empty step list, which would report every step as newly added. - let parents: Vec<&Version> = version - .parents - .iter() - .filter_map(|h| store.version(h).map(|a| &a.version)) - .collect(); - - if version.parents.is_empty() { - return VersionChange { - basis: Basis::Root, - goal_before: None, - steps: diff(&[], &version.steps), - resulting, - }; - } - - if parents.len() != version.parents.len() { - return VersionChange { - basis: Basis::Unrecoverable(version.parents.len()), - goal_before: None, - steps: Vec::new(), - resulting, - }; - } - - let (basis, base_steps, base_goal) = match parents.as_slice() { - [p] => ( - Basis::Parent(version.parents[0].clone()), - p.steps.clone(), - Some(p.goal.clone()), - ), - many => { - let first = &many[0].steps; - if many.iter().all(|p| p.steps == *first) { - let goal = many[0].goal.clone(); - let goal = many.iter().all(|p| p.goal == goal).then_some(goal); - (Basis::Agreed(many.len()), first.clone(), goal) - } else { - return VersionChange { - basis: Basis::Unrecoverable(many.len()), - goal_before: None, - steps: Vec::new(), - resulting, - }; - } - } - }; - - let goal_before = base_goal.filter(|g| *g != version.goal); - - VersionChange { - basis, - goal_before, - steps: diff(&base_steps, &version.steps), - resulting, - } -} - -/// Compare two step graphs. -/// -/// A superseding step is a *new* step carrying `supersedes`, and the step it -/// replaces stays in the graph — so it is reported as a supersession rather -/// than an unrelated addition, and the step it replaces is not reported as -/// having vanished. -fn diff(base: &[Step], next: &[Step]) -> Vec { - let mut out = Vec::new(); - - for st in next { - match base.iter().find(|b| b.id == st.id) { - None => { - let replaces = st - .supersedes - .as_deref() - .filter(|prev| base.iter().any(|b| b.id == *prev)); - out.push(match replaces { - Some(old) => StepChange::Superseded { - old: old.to_string(), - id: st.id.clone(), - work: st.work.clone(), - }, - None => StepChange::Added { - id: st.id.clone(), - work: st.work.clone(), - }, - }); - } - Some(prev) => { - if !prev.retired && st.retired { - out.push(StepChange::Retired { - id: st.id.clone(), - work: st.work.clone(), - }); - } - // Retiring and revising in one version are two changes, and - // reporting only the retirement would hide the other. - let mut fields: Vec<&'static str> = Vec::new(); - if prev.work != st.work { - fields.push("work"); - } - if prev.accept != st.accept { - fields.push("accept"); - } - if prev.depends_on != st.depends_on { - fields.push("depends on"); - } - if prev.supersedes != st.supersedes { - fields.push("supersedes"); - } - if prev.retired && !st.retired { - fields.push("no longer retired"); - } - if !fields.is_empty() { - out.push(StepChange::Edited { - id: st.id.clone(), - work: st.work.clone(), - fields, - }); - } - } - } - } - - for b in base { - if !next.iter().any(|st| st.id == b.id) { - out.push(StepChange::Dropped { - id: b.id.clone(), - work: b.work.clone(), - }); - } - } - - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::catalog::Admitted; - use crate::predicate::parse as pred; - use std::path::PathBuf; - - fn step(id: &str, work: &str) -> Step { - Step::new(id, work, pred("test(status=pass)").unwrap()) - } - - fn version(seq: u64, parents: Vec, goal: &str, steps: Vec) -> Version { - Version { - plan: "pl_1000000000".into(), - seq, - parents, - author: "cos".into(), - why: "because".into(), - goal: goal.into(), - retired: false, - steps, - } - } - - fn store_of(versions: Vec) -> PlanStore { - let mut store = PlanStore::default(); - for v in versions { - store.versions.push(Admitted { - hash: v.hash(), - version: v, - path: PathBuf::new(), - }); - } - store - } - - #[test] - fn a_root_version_reports_its_initial_steps() { - let v = version(1, vec![], "Ship", vec![step("st_A000000001", "one")]); - let c = of(&store_of(vec![v.clone()]), &v); - assert_eq!(c.basis, Basis::Root); - assert_eq!(c.steps.len(), 1); - assert_eq!(c.steps[0].verb(), "added"); - } - - #[test] - fn reports_add_edit_and_retire_against_a_single_parent() { - let a = step("st_A000000001", "keep"); - let b = step("st_B000000002", "retire me"); - let parent = version(1, vec![], "Ship", vec![a.clone(), b.clone()]); - let ph = parent.hash(); - - let mut edited = a.clone(); - edited.work = "keep, revised".into(); - let mut retired = b.clone(); - retired.retired = true; - let child = version( - 2, - vec![ph.clone()], - "Ship", - vec![edited, retired, step("st_C000000003", "new work")], - ); - - let c = of(&store_of(vec![parent, child.clone()]), &child); - assert_eq!(c.basis, Basis::Parent(ph)); - let verbs: Vec<&str> = c.steps.iter().map(|s| s.verb()).collect(); - assert_eq!(verbs, vec!["edited", "retired", "added"]); - assert!(matches!(&c.steps[0], StepChange::Edited { fields, .. } if fields == &["work"])); - } - - #[test] - fn a_superseding_step_is_not_reported_as_a_plain_addition() { - let old = step("st_A000000001", "old approach"); - let parent = version(1, vec![], "Ship", vec![old]); - let ph = parent.hash(); - - let mut new = step("st_B000000002", "new approach"); - new.supersedes = Some("st_A000000001".into()); - let child = version( - 2, - vec![ph], - "Ship", - vec![step("st_A000000001", "old approach"), new], - ); - - let c = of(&store_of(vec![parent, child.clone()]), &child); - assert_eq!(c.steps.len(), 1); - assert!(matches!( - &c.steps[0], - StepChange::Superseded { old, .. } if old == "st_A000000001" - )); - } - - #[test] - fn a_goal_change_is_reported() { - let parent = version(1, vec![], "Old goal", vec![]); - let ph = parent.hash(); - let child = version(2, vec![ph], "New goal", vec![]); - let c = of(&store_of(vec![parent, child.clone()]), &child); - assert_eq!(c.goal_before.as_deref(), Some("Old goal")); - assert!(!c.is_empty()); - } - - #[test] - fn a_reconciliation_of_agreeing_sides_diffs_against_the_agreed_graph() { - let root = version(1, vec![], "Ship", vec![step("st_A000000001", "one")]); - let rh = root.hash(); - let l = version( - 2, - vec![rh.clone()], - "Ship", - vec![step("st_A000000001", "one")], - ); - let r = version(2, vec![rh], "Ship", vec![step("st_A000000001", "one")]); - let (lh, rh2) = (l.hash(), r.hash()); - - let merged = version( - 3, - vec![lh, rh2], - "Ship", - vec![step("st_A000000001", "one"), step("st_B000000002", "two")], - ); - let c = of(&store_of(vec![root, l, r, merged.clone()]), &merged); - assert_eq!(c.basis, Basis::Agreed(2)); - assert_eq!(c.steps.len(), 1); - assert_eq!(c.steps[0].verb(), "added"); - } - - #[test] - fn a_reconciliation_of_disagreeing_sides_reports_no_diff() { - let root = version(1, vec![], "Ship", vec![step("st_A000000001", "one")]); - let rh = root.hash(); - let l = version( - 2, - vec![rh.clone()], - "Ship", - vec![step("st_A000000001", "one"), step("st_B000000002", "two")], - ); - let r = version( - 2, - vec![rh], - "Ship", - vec![step("st_A000000001", "one"), step("st_C000000003", "three")], - ); - let (lh, rh2) = (l.hash(), r.hash()); - - let merged = version(3, vec![lh, rh2], "Ship", vec![step("st_A000000001", "one")]); - let c = of(&store_of(vec![root, l, r, merged.clone()]), &merged); - // Which side was carried forward is not recorded, so no diff is - // claimed — only what now stands. - assert_eq!(c.basis, Basis::Unrecoverable(2)); - assert!(c.steps.is_empty()); - assert_eq!(c.resulting, vec!["st_A000000001".to_string()]); - } - - #[test] - fn a_missing_predecessor_yields_no_invented_diff() { - let child = version( - 2, - vec!["f".repeat(64)], - "Ship", - vec![step("st_A000000001", "one")], - ); - let c = of(&store_of(vec![child.clone()]), &child); - assert_eq!(c.basis, Basis::Unrecoverable(1)); - assert!(c.steps.is_empty()); - } -} diff --git a/src/cli.rs b/src/cli.rs index ae5cdae..0dba7d0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,17 +1,10 @@ //! Command-line parsing. //! -//! Hand-rolled, because Compass takes no external crates. The spelling is open -//! per the spec; the contract is not: -//! -//! - every command reports convergence state alongside its answer; -//! - every command that reports Head handles a Head set larger than one -//! without erroring, and labels the members; -//! - `--json` is available for every command and carries the same fields as -//! the human rendering. -//! -//! Step edits use a positional-grouping grammar: `--add-step` or `--edit-step` -//! opens a step context, and `--work`, `--accept`, `--depends-on` and -//! `--supersedes` attach to the most recently opened one. +//! Hand-rolled. The spelling is this implementation's; the mapping to surface +//! operations is fixed (04-cli): every command is one operation, no command +//! combines a read with a write, `--json` is available everywhere and carries +//! the same fields as the human rendering, and every read of Plan state reports +//! convergence. use std::path::PathBuf; @@ -20,70 +13,56 @@ pub const EXIT_USAGE: i32 = 2; /// Exit code for an operational failure. pub const EXIT_FAILURE: i32 = 1; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StepEdit { - Add { - work: String, - accept: Option, - depends_on: Vec, - supersedes: Option, - }, - Edit { - id: String, - work: Option, - accept: Option, - depends_on: Option>, - supersedes: Option, - }, - Retire { - id: String, - }, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { - Init, - New { - goal: String, - why: String, - steps: Vec, - }, - Revise { + /// Scaffold a runnable starter module (CMP-R11): one command, no docs. + Start { plan: String, - why: String, goal: Option, - retire: bool, - steps: Vec, }, + /// Evaluate a module and store it by the hash of its source bytes. + Commit { + path: PathBuf, + plan: Option, + }, + /// The lineage and current intent. Show { plan: String, }, + /// The Rationale chain — first-class (04-cli). + History { + plan: String, + }, + /// What work is available now. Ready { plan: String, }, + /// Every plan, with convergence state. + Status, + /// Check admission and the chain. Read-only. + Verify { + plan: Option, + all: bool, + }, + /// Author a damage-recording version. Distinct from verify (04-cli). + Repair { + plan: String, + }, + /// Record start | update | handoff | done against a step. Progress { plan: String, step: String, kind: String, note: Option, }, + /// Record evidence — the only thing acceptance evaluates. Evidence { plan: String, step: String, kind: String, attrs: Vec<(String, String)>, }, - Status, - Reconcile { - plan: String, - why: String, - from: Option, - steps: Vec, - }, - Verify { - plan: Option, - all: bool, - }, + /// Build identity. Version, Help { topic: Option, @@ -105,8 +84,6 @@ pub fn parse(args: &[String]) -> Result { let mut author = None; let mut rest: Vec = Vec::new(); - // Global flags may appear anywhere, so they are lifted out first. Values - // after `--` are never treated as flags. let mut it = args.iter().cloned(); let mut literal = false; while let Some(a) = it.next() { @@ -136,26 +113,27 @@ pub fn parse(args: &[String]) -> Result { let name = rest.remove(0); let command = match name.as_str() { - "init" => { - expect_empty(&rest, "init")?; - Command::Init - } - "new" => parse_new(rest)?, - "revise" => parse_revise(rest)?, + "start" => parse_start(rest)?, + "commit" => parse_commit(rest)?, "show" => Command::Show { plan: one_positional(rest, "show", "")?, }, + "history" => Command::History { + plan: one_positional(rest, "history", "")?, + }, "ready" => Command::Ready { plan: one_positional(rest, "ready", "")?, }, - "progress" => parse_progress(rest)?, - "evidence" => parse_evidence(rest)?, "status" => { expect_empty(&rest, "status")?; Command::Status } - "reconcile" => parse_reconcile(rest)?, "verify" => parse_verify(rest)?, + "repair" => Command::Repair { + plan: one_positional(rest, "repair", "")?, + }, + "progress" => parse_progress(rest)?, + "evidence" => parse_evidence(rest)?, "version" | "--version" | "-V" => Command::Version, "help" | "--help" | "-h" => Command::Help { topic: rest.first().cloned(), @@ -196,179 +174,78 @@ fn one_positional(rest: Vec, cmd: &str, spec: &str) -> Result, -} - -impl StepEditor { - fn open_add(&mut self, work: String) { - self.steps.push(StepEdit::Add { - work, - accept: None, - depends_on: Vec::new(), - supersedes: None, - }); - } - - fn open_edit(&mut self, id: String) { - self.steps.push(StepEdit::Edit { - id, - work: None, - accept: None, - depends_on: None, - supersedes: None, - }); - } - - fn retire(&mut self, id: String) { - self.steps.push(StepEdit::Retire { id }); - } - - fn set_work(&mut self, v: String) -> Result<(), String> { - match self.steps.last_mut() { - Some(StepEdit::Edit { work, .. }) => { - *work = Some(v); - Ok(()) - } - Some(StepEdit::Add { work, .. }) => { - *work = v; - Ok(()) - } - _ => Err("`--work` must follow `--add-step` or `--edit-step`".into()), - } - } - - fn set_accept(&mut self, v: String) -> Result<(), String> { - match self.steps.last_mut() { - Some(StepEdit::Add { accept, .. }) | Some(StepEdit::Edit { accept, .. }) => { - *accept = Some(v); - Ok(()) - } - _ => Err("`--accept` must follow `--add-step` or `--edit-step`".into()), - } - } - - fn add_dep(&mut self, v: String) -> Result<(), String> { - match self.steps.last_mut() { - Some(StepEdit::Add { depends_on, .. }) => { - depends_on.push(v); - Ok(()) - } - Some(StepEdit::Edit { depends_on, .. }) => { - depends_on.get_or_insert_with(Vec::new).push(v); - Ok(()) - } - _ => Err("`--depends-on` must follow `--add-step` or `--edit-step`".into()), - } - } - - fn set_supersedes(&mut self, v: String) -> Result<(), String> { - match self.steps.last_mut() { - Some(StepEdit::Add { supersedes, .. }) | Some(StepEdit::Edit { supersedes, .. }) => { - *supersedes = Some(v); - Ok(()) - } - _ => Err("`--supersedes` must follow `--add-step` or `--edit-step`".into()), - } - } - - /// Handle a step-editing flag. Returns false if the flag is not one. - fn try_flag( - &mut self, - flag: &str, - it: &mut std::vec::IntoIter, - ) -> Result { - let value = |it: &mut std::vec::IntoIter| -> Result { - it.next().ok_or_else(|| format!("`{flag}` needs a value")) - }; - match flag { - "--add-step" => self.open_add(value(it)?), - "--edit-step" => self.open_edit(value(it)?), - "--retire-step" => self.retire(value(it)?), - "--work" => self.set_work(value(it)?)?, - "--accept" => self.set_accept(value(it)?)?, - "--depends-on" => self.add_dep(value(it)?)?, - "--supersedes" => self.set_supersedes(value(it)?)?, - _ => return Ok(false), - } - Ok(true) - } -} - -fn parse_new(rest: Vec) -> Result { +fn parse_start(rest: Vec) -> Result { + let mut plan = None; let mut goal = None; - let mut why = None; - let mut editor = StepEditor::default(); let mut it = rest.into_iter(); - while let Some(a) = it.next() { match a.as_str() { "--goal" => goal = Some(it.next().ok_or("`--goal` needs text")?), - "--why" => why = Some(it.next().ok_or("`--why` needs text")?), - flag if editor.try_flag(flag, &mut it)? => {} - other => return Err(unexpected("new", other)), + other if other.starts_with('-') => { + return Err(format!("`start`: unexpected argument `{other}`")) + } + other => { + if plan.is_some() { + return Err("`start` takes one ".into()); + } + plan = Some(other.to_string()); + } } } - - Ok(Command::New { - goal: goal.ok_or("`new` needs `--goal `")?, - why: why.ok_or("`new` needs `--why `: every version states why (CMP-R03)")?, - steps: editor.steps, + Ok(Command::Start { + plan: plan.ok_or("usage: compass start [--goal ]")?, + goal, }) } -fn parse_revise(mut rest: Vec) -> Result { - let plan = take_plan(&mut rest, "revise")?; - let mut why = None; - let mut goal = None; - let mut retire = false; - let mut editor = StepEditor::default(); +fn parse_commit(rest: Vec) -> Result { + let mut path = None; + let mut plan = None; let mut it = rest.into_iter(); - while let Some(a) = it.next() { match a.as_str() { - "--why" => why = Some(it.next().ok_or("`--why` needs text")?), - "--goal" => goal = Some(it.next().ok_or("`--goal` needs text")?), - "--retire" => retire = true, - flag if editor.try_flag(flag, &mut it)? => {} - other => return Err(unexpected("revise", other)), + "--plan" => plan = Some(it.next().ok_or("`--plan` needs a plan ref")?), + other if other.starts_with('-') => { + return Err(format!("`commit`: unexpected argument `{other}`")) + } + other => { + if path.is_some() { + return Err("`commit` takes one ".into()); + } + path = Some(PathBuf::from(other)); + } } } - - Ok(Command::Revise { + Ok(Command::Commit { + path: path.ok_or("usage: compass commit [--plan ]")?, plan, - why: why.ok_or("`revise` needs `--why `: every version states why (CMP-R03)")?, - goal, - retire, - steps: editor.steps, }) } -fn parse_reconcile(mut rest: Vec) -> Result { - let plan = take_plan(&mut rest, "reconcile")?; - let mut why = None; - let mut from = None; - let mut editor = StepEditor::default(); - let mut it = rest.into_iter(); - - while let Some(a) = it.next() { +fn parse_verify(rest: Vec) -> Result { + let mut plan = None; + let mut all = false; + for a in rest { match a.as_str() { - "--why" => why = Some(it.next().ok_or("`--why` needs text")?), - "--from" => from = Some(it.next().ok_or("`--from` needs a version hash")?), - flag if editor.try_flag(flag, &mut it)? => {} - other => return Err(unexpected("reconcile", other)), + "--all" => all = true, + other if other.starts_with('-') => { + return Err(format!("`verify`: unexpected argument `{other}`")) + } + other => { + if plan.is_some() { + return Err("`verify` takes one plan, or `--all`".into()); + } + plan = Some(other.to_string()); + } } } - - Ok(Command::Reconcile { - plan, - why: why - .ok_or("`reconcile` needs `--why `: a reconciliation states why (CMP-R06)")?, - from, - steps: editor.steps, - }) + if plan.is_none() && !all { + return Err("usage: compass verify | compass verify --all".into()); + } + if plan.is_some() && all { + return Err("`verify` takes a plan or `--all`, not both".into()); + } + Ok(Command::Verify { plan, all }) } fn parse_progress(mut rest: Vec) -> Result { @@ -388,16 +265,14 @@ fn parse_progress(mut rest: Vec) -> Result { crate::event::EventKind::PROGRESS_KINDS.join(", ") )); } - let mut note = None; let mut it = rest.into_iter(); while let Some(a) = it.next() { match a.as_str() { "--note" => note = Some(it.next().ok_or("`--note` needs text")?), - other => return Err(unexpected("progress", other)), + other => return Err(format!("`progress`: unexpected argument `{other}`")), } } - Ok(Command::Progress { plan, step, @@ -413,7 +288,6 @@ fn parse_evidence(mut rest: Vec) -> Result { } let step = rest.remove(0); let kind = rest.remove(0); - let mut attrs = Vec::new(); for a in rest { let Some((k, v)) = a.split_once('=') else { @@ -424,7 +298,6 @@ fn parse_evidence(mut rest: Vec) -> Result { } attrs.push((k.to_string(), v.to_string())); } - Ok(Command::Evidence { plan, step, @@ -433,30 +306,6 @@ fn parse_evidence(mut rest: Vec) -> Result { }) } -fn parse_verify(rest: Vec) -> Result { - let mut plan = None; - let mut all = false; - for a in rest { - match a.as_str() { - "--all" => all = true, - other if other.starts_with('-') => return Err(unexpected("verify", other)), - other => { - if plan.is_some() { - return Err("`verify` takes one plan, or `--all`".into()); - } - plan = Some(other.to_string()); - } - } - } - if plan.is_none() && !all { - return Err("usage: compass verify | compass verify --all".into()); - } - if plan.is_some() && all { - return Err("`verify` takes a plan or `--all`, not both".into()); - } - Ok(Command::Verify { plan, all }) -} - fn take_plan(rest: &mut Vec, cmd: &str) -> Result { if rest.is_empty() { return Err(format!("`{cmd}` needs ")); @@ -470,79 +319,35 @@ fn take_plan(rest: &mut Vec, cmd: &str) -> Result { Ok(rest.remove(0)) } -fn unexpected(cmd: &str, arg: &str) -> String { - format!("`{cmd}`: unexpected argument `{arg}`\n run `compass help {cmd}` for usage") -} - -/// The help text for a topic, or the overview. +/// Help text for a topic, or the overview. pub fn help(topic: Option<&str>) -> String { match topic { - Some("new") => "\ -compass new --goal --why [step edits] - - Create a Plan and its first Version. - - --goal the intent being pursued - --why required Rationale for this version - - Step edits: - --add-step open a new step - --accept its acceptance predicate (required per step) - --depends-on repeatable - --supersedes the StepRef this replaces -" - .to_string(), - Some("revise") => "\ -compass revise --why [step edits] + Some("commit") => "\ +compass commit [--plan ] - Write a new Version continuing from the current Head. - Fails when Head has more than one member; reconcile first. + Evaluate a module and store it by the hash of its source bytes. + A version is the authored module, stored unchanged (decision 0014). - --why required Rationale - --goal restate the goal - --retire retire the whole plan - - Step edits: - --add-step [--accept ] [--depends-on ]... [--supersedes ] - --edit-step [--work ] [--accept ] [--depends-on ]... - --retire-step + - Committing content already present is a no-op success. + - New content that revises nothing is refused, with a distinct message. + - A module uses plan() for a first version, prior.revise({...}) for a + revision, or reconcile({revises:[...]}) for a reconciliation. Predecessors + are the version files it imports. " .to_string(), - Some("reconcile") => "\ -compass reconcile --why [--from ] [step edits] - - Resolve a Divergence with an ordinary Version naming every Head member - as a predecessor. + Some("start") => "\ +compass start [--goal ] - --why required Rationale - --from which Head member's step graph to carry forward. - Required when the sides differ: Compass never picks - a side for you. - - Reconciliation is never offered as the repair for an Orphan. + Scaffold a runnable starter module for a new plan, then print where it is. + Edit it and `compass commit` it. Nothing to import, configure, or look up. " .to_string(), Some("evidence") => "\ compass evidence [k=v ...] Record an evidence event. Evidence is the only thing acceptance evaluates. - Example: - compass evidence pl_ABC st_XYZ test name=parser::nested status=pass -" - .to_string(), - Some("predicates") | Some("accept") => "\ -Acceptance predicates - - atom kind(k=v, ...) an evidence event of `kind` with all these attrs - all(...) every argument holds - any(...) at least one argument holds - not(p) p does not hold - - Examples: - test(name=parser::nested, status=pass) - all(test(status=pass), review(by=cos)) - not(test(status=fail)) + compass evidence pl_x fix test name=parser::nested status=pass " .to_string(), Some(other) => format!("no help topic `{other}`\n\n{}", overview()), @@ -556,23 +361,23 @@ compass — durable planning intent for coding agents usage: compass [options] - init create the catalog - new --goal --why create a Plan - revise --why write a new Version from Head - show lineage and the Rationale chain + start [--goal ] scaffold a starter module + commit [--plan

] evaluate a module and store it + show lineage and current intent + history the Rationale chain ready what work is available now - progress record start|update|handoff|done - evidence k=v record evidence acceptance evaluates status every plan, with convergence state - reconcile --why resolve a Divergence verify | --all check admission and the chain + repair author a damage-recording version + progress record start|update|handoff|done + evidence k=v record evidence acceptance evaluates version build identity global options: --json machine-readable output, same fields as the human form --catalog override the catalog root --author override the recorded author - -h, --help [topic] help; topics: new, revise, reconcile, evidence, predicates + -h, --help [topic] help; topics: start, commit, evidence The catalog root is $COMPASS_CATALOG, else $XDG_STATE_HOME/compass/catalog, else ~/.local/state/compass/catalog. @@ -590,208 +395,47 @@ mod tests { #[test] fn parses_bare_commands() { - assert_eq!(p(&["init"]).unwrap().command, Command::Init); assert_eq!(p(&["status"]).unwrap().command, Command::Status); assert_eq!(p(&["version"]).unwrap().command, Command::Version); - } - - #[test] - fn no_arguments_shows_help() { assert_eq!(p(&[]).unwrap().command, Command::Help { topic: None }); } #[test] - fn global_flags_are_position_independent() { - let a = p(&["--json", "status"]).unwrap(); - let b = p(&["status", "--json"]).unwrap(); - assert!(a.json && b.json); - assert_eq!(a.command, b.command); - - let c = p(&["--catalog", "/tmp/x", "--author", "cos", "status"]).unwrap(); - assert_eq!(c.catalog, Some(PathBuf::from("/tmp/x"))); - assert_eq!(c.author.as_deref(), Some("cos")); - } - - #[test] - fn new_requires_goal_and_rationale() { - assert!(p(&["new", "--goal", "g"]).is_err(), "why is required"); - assert!(p(&["new", "--why", "w"]).is_err(), "goal is required"); - assert_eq!( - p(&["new", "--goal", "g", "--why", "w"]).unwrap().command, - Command::New { - goal: "g".into(), - why: "w".into(), - steps: vec![] - } - ); - } - - #[test] - fn step_flags_attach_to_the_most_recent_step() { - let cmd = p(&[ - "new", - "--goal", - "g", - "--why", - "w", - "--add-step", - "first", - "--accept", - "test(status=pass)", - "--depends-on", - "st_1", - "--depends-on", - "st_2", - "--add-step", - "second", - "--accept", - "review(by=cos)", - ]) - .unwrap() - .command; - - let Command::New { steps, .. } = cmd else { - panic!() - }; - assert_eq!(steps.len(), 2); - assert_eq!( - steps[0], - StepEdit::Add { - work: "first".into(), - accept: Some("test(status=pass)".into()), - depends_on: vec!["st_1".into(), "st_2".into()], - supersedes: None, - } - ); + fn start_and_commit() { assert_eq!( - steps[1], - StepEdit::Add { - work: "second".into(), - accept: Some("review(by=cos)".into()), - depends_on: vec![], - supersedes: None, + p(&["start", "pl_x", "--goal", "Ship"]).unwrap().command, + Command::Start { + plan: "pl_x".into(), + goal: Some("Ship".into()) } ); - } - - #[test] - fn a_step_flag_without_an_open_step_is_an_error() { - assert!(p(&["new", "--goal", "g", "--why", "w", "--accept", "x()"]).is_err()); - assert!(p(&["revise", "pl_1", "--why", "w", "--depends-on", "st_1"]).is_err()); - } - - #[test] - fn parses_step_edits_and_retirements() { - let cmd = p(&[ - "revise", - "pl_1", - "--why", - "w", - "--edit-step", - "st_1", - "--work", - "new wording", - "--retire-step", - "st_2", - ]) - .unwrap() - .command; - let Command::Revise { steps, plan, .. } = cmd else { - panic!() - }; - assert_eq!(plan, "pl_1"); - assert_eq!( - steps[0], - StepEdit::Edit { - id: "st_1".into(), - work: Some("new wording".into()), - accept: None, - depends_on: None, - supersedes: None, - } - ); - assert_eq!(steps[1], StepEdit::Retire { id: "st_2".into() }); - } - - #[test] - fn revise_requires_a_rationale() { - assert!(p(&["revise", "pl_1"]).is_err()); - assert!(p(&["revise", "--why", "w"]).is_err(), "plan comes first"); - } - - #[test] - fn parses_progress_and_rejects_evidence_as_a_progress_kind() { - assert_eq!( - p(&["progress", "pl_1", "st_1", "start", "--note", "n"]) - .unwrap() - .command, - Command::Progress { - plan: "pl_1".into(), - step: "st_1".into(), - kind: "start".into(), - note: Some("n".into()), - } - ); - for k in ["update", "handoff", "done"] { - assert!(p(&["progress", "pl_1", "st_1", k]).is_ok()); - } - assert!( - p(&["progress", "pl_1", "st_1", "evidence"]).is_err(), - "evidence has its own command" - ); - assert!(p(&["progress", "pl_1", "st_1", "invented"]).is_err()); - } - - #[test] - fn parses_evidence_attributes() { - let cmd = p(&[ - "evidence", - "pl_1", - "st_1", - "test", - "name=parser::nested", - "status=pass", - ]) - .unwrap() - .command; assert_eq!( - cmd, - Command::Evidence { - plan: "pl_1".into(), - step: "st_1".into(), - kind: "test".into(), - attrs: vec![ - ("name".into(), "parser::nested".into()), - ("status".into(), "pass".into()), - ], + p(&["commit", "a.ts", "--plan", "pl_x"]).unwrap().command, + Command::Commit { + path: PathBuf::from("a.ts"), + plan: Some("pl_x".into()) } ); + assert!(p(&["start"]).is_err()); + assert!(p(&["commit"]).is_err()); } #[test] - fn evidence_values_may_contain_equals_signs() { - let cmd = p(&["evidence", "pl_1", "st_1", "link", "url=http://x/?a=b"]) - .unwrap() - .command; - let Command::Evidence { attrs, .. } = cmd else { - panic!() - }; - assert_eq!(attrs[0].1, "http://x/?a=b"); - } - - #[test] - fn rejects_malformed_evidence_attributes() { - assert!(p(&["evidence", "pl_1", "st_1", "test", "noequals"]).is_err()); - assert!(p(&["evidence", "pl_1", "st_1", "test", "=novalue"]).is_err()); - assert!(p(&["evidence", "pl_1"]).is_err()); + fn global_flags_are_position_independent() { + let a = p(&["--json", "status"]).unwrap(); + let b = p(&["status", "--json"]).unwrap(); + assert!(a.json && b.json); + let c = p(&["--catalog", "/tmp/x", "--author", "cos", "status"]).unwrap(); + assert_eq!(c.catalog, Some(PathBuf::from("/tmp/x"))); + assert_eq!(c.author.as_deref(), Some("cos")); } #[test] fn verify_takes_a_plan_or_all_but_not_both() { assert_eq!( - p(&["verify", "pl_1"]).unwrap().command, + p(&["verify", "pl_x"]).unwrap().command, Command::Verify { - plan: Some("pl_1".into()), + plan: Some("pl_x".into()), all: false } ); @@ -803,56 +447,38 @@ mod tests { } ); assert!(p(&["verify"]).is_err()); - assert!(p(&["verify", "pl_1", "--all"]).is_err()); + assert!(p(&["verify", "pl_x", "--all"]).is_err()); } #[test] - fn reconcile_requires_a_rationale_and_accepts_a_side() { - assert!(p(&["reconcile", "pl_1"]).is_err()); - assert_eq!( - p(&["reconcile", "pl_1", "--why", "w", "--from", "abc123"]) - .unwrap() - .command, - Command::Reconcile { - plan: "pl_1".into(), - why: "w".into(), - from: Some("abc123".into()), - steps: vec![], - } - ); + fn progress_rejects_evidence_as_a_kind() { + assert!(p(&["progress", "pl_x", "s", "start"]).is_ok()); + assert!(p(&["progress", "pl_x", "s", "evidence"]).is_err()); + assert!(p(&["progress", "pl_x", "s", "nope"]).is_err()); } #[test] - fn double_dash_stops_flag_interpretation() { - let cmd = p(&["new", "--goal", "g", "--why", "--", "--json"]).unwrap(); - // `--` protects a literal value that looks like a flag. - assert!(!cmd.json); + fn evidence_parses_attributes_including_equals_in_values() { + let cmd = p(&["evidence", "pl_x", "s", "link", "url=http://x/?a=b"]) + .unwrap() + .command; + let Command::Evidence { attrs, .. } = cmd else { + panic!() + }; + assert_eq!(attrs[0], ("url".into(), "http://x/?a=b".into())); + assert!(p(&["evidence", "pl_x", "s", "test", "noequals"]).is_err()); } #[test] - fn unknown_commands_and_flags_are_rejected_with_guidance() { - let e = p(&["frobnicate"]).unwrap_err(); - assert!(e.contains("unknown command"), "{e}"); - let e = p(&["status", "--nope"]).unwrap_err(); - assert!( - e.contains("unexpected") || e.contains("takes no arguments"), - "{e}" - ); + fn unknown_commands_are_rejected() { + assert!(p(&["frobnicate"]).unwrap_err().contains("unknown command")); } #[test] - fn help_topics_render_something_useful() { + fn help_renders() { assert!(help(None).contains("compass")); - for t in ["new", "revise", "reconcile", "evidence", "predicates"] { - assert!(help(Some(t)).len() > 40, "topic {t} is thin"); + for t in ["start", "commit", "evidence"] { + assert!(help(Some(t)).len() > 40); } - assert!(help(Some("nonexistent")).contains("no help topic")); - } - - #[test] - fn single_positional_commands_reject_extra_arguments() { - assert!(p(&["show", "pl_1", "extra"]).is_err()); - assert!(p(&["ready"]).is_err()); - assert!(p(&["init", "extra"]).is_err()); } } diff --git a/src/cmd.rs b/src/cmd.rs index e318094..0fe6d11 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -1,29 +1,24 @@ //! Command implementations. //! //! Every command produces its human rendering and its `--json` rendering from -//! the same computed values, so the two cannot drift. -//! -//! Every command reports convergence state alongside its answer (CMP-R17), -//! and every command that reports Head handles a Head set larger than one -//! without erroring and labels the members. `help` is the sole exception: it -//! answers about the CLI itself and touches no catalog. +//! the same values (04-cli), every read of Plan state reports convergence, and +//! a read that fails renders not-found / unresolved / stopped distinctly — each +//! naming a different next action. use crate::catalog::{self, Admitted, PlanStore}; use crate::chain::{self, Analysis}; -use crate::change::{self, Basis, StepChange, VersionChange}; -use crate::cli::{Command, Invocation, StepEdit, EXIT_FAILURE}; +use crate::cli::{Command, Invocation}; use crate::convergence::Convergence; +use crate::eval::EvalError; use crate::event::{Event, EventKind}; use crate::json::Json; -use crate::model::{Step, Version}; -use crate::predicate; +use crate::model::Version; use crate::readiness::{self, HeadReadiness, StepState}; -use crate::refs::{self, RefKind}; -use crate::style as s; -use crate::version as build; +use crate::style; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -#[derive(Debug)] +/// A command's result, rendered two ways from one set of values. pub struct Output { pub text: String, pub json: Json, @@ -38,2230 +33,1043 @@ impl Output { code: 0, } } + fn coded(text: String, json: Json, code: i32) -> Output { + Output { text, json, code } + } } -/// Run one invocation. +/// Dispatch. pub fn execute(inv: &Invocation) -> Result { - let root = match &inv.catalog { - Some(p) => p.clone(), - None => catalog::root()?, - }; + let root = resolved_root(inv)?; let author = inv.author.clone().unwrap_or_else(catalog::author); match &inv.command { Command::Help { topic } => Ok(Output::ok( crate::cli::help(topic.as_deref()), - Json::obj(vec![( - "help", - Json::str(crate::cli::help(topic.as_deref())), - )]), + Json::obj(vec![("command", Json::str("help"))]), )), Command::Version => Ok(cmd_version()), - Command::Init => cmd_init(&root), - Command::New { goal, why, steps } => cmd_new(&root, &author, goal, why, steps), - Command::Revise { - plan, - why, - goal, - retire, - steps, - } => cmd_revise(&root, &author, plan, why, goal.as_deref(), *retire, steps), + Command::Start { plan, goal } => cmd_start(&root, plan, goal.as_deref(), &author), + Command::Commit { path, plan } => cmd_commit(&root, path, plan.as_deref()), Command::Show { plan } => cmd_show(&root, plan), + Command::History { plan } => cmd_history(&root, plan), Command::Ready { plan } => cmd_ready(&root, plan), + Command::Status => cmd_status(&root), + Command::Verify { plan, all } => cmd_verify(&root, plan.as_deref(), *all), + Command::Repair { plan } => cmd_repair(&root, plan), Command::Progress { plan, step, kind, note, - } => cmd_progress(&root, &author, plan, step, kind, note.as_deref()), + } => cmd_progress(&root, plan, step, kind, note.as_deref(), &author), Command::Evidence { plan, step, kind, attrs, - } => cmd_evidence(&root, &author, plan, step, kind, attrs), - Command::Status => cmd_status(&root), - Command::Reconcile { - plan, - why, - from, - steps, - } => cmd_reconcile(&root, &author, plan, why, from.as_deref(), steps), - Command::Verify { plan, all } => cmd_verify(&root, plan.as_deref(), *all), + } => cmd_evidence(&root, plan, step, kind, attrs, &author), + } +} + +pub fn resolved_root(inv: &Invocation) -> Result { + match &inv.catalog { + Some(p) => Ok(p.clone()), + None => catalog::root(), } } // --------------------------------------------------------------------------- -// Shared pieces +// Shared helpers // --------------------------------------------------------------------------- -fn convergence_json(c: &Convergence) -> Json { - Json::obj(vec![ - ("state", Json::str(c.state())), - ("reason", Json::str(c.reason())), - ("converged", Json::Bool(c.is_converged())), - ]) -} - fn convergence_line(c: &Convergence) -> String { - format!("{} {}", s::dim("convergence:"), s::dim(&c.describe())) + format!("{} convergence: {}", style::dim("~"), c.describe()) +} +fn convergence_json(c: &Convergence) -> (&'static str, Json) { + ( + "convergence", + Json::obj(vec![ + ("state", Json::str(c.state())), + ("reason", Json::str(c.reason())), + ]), + ) } fn load(root: &Path, plan: &str) -> Result { if !catalog::exists(root) { return Err(format!( - "no catalog at {}\n fix: compass init", - root.display() - )); - } - if !catalog::plan_dir(root, plan).is_dir() { - return Err(format!( - "no plan {plan} in {}\n fix: compass status", + "no catalog at {}\n run `compass start ` to begin", root.display() )); } catalog::load_plan(root, plan) } -/// Describe a head member on one line. -fn head_line(a: &Admitted, index: usize, total: usize, orphan: bool) -> String { - let label = if total > 1 { - format!("head {}/{}", index + 1, total) - } else { - "head".to_string() - }; - let mark = if orphan { - format!(" {}", s::red("orphan")) - } else { - String::new() +/// Evaluate one admitted version to its intent, mapping the read failure modes. +fn evaluate(a: &Admitted) -> Result { + catalog::evaluate(a) +} + +/// Render a failed read distinctly (04-cli): each names a different next action. +fn read_failure(plan: &str, e: &EvalError) -> Output { + let next = match e { + EvalError::Unresolved(_) => "wait: an imported version has not arrived", + EvalError::Stopped(_) => "do not wait; the plan is at fault", + EvalError::Failed(_) => "the plan does not evaluate", }; - format!( - " {} {} {} {}{}", - s::bold(&label), - s::bold(&s::short(&a.hash)), - a.version.author, - s::dim(&format!("seq={}", a.version.seq)), - mark + let text = format!( + "{} {}\n {}\n {}\n", + style::red(e.kind()), + plan, + e.message(), + style::dim(next), + ); + let json = Json::obj(vec![ + ("command", Json::str("read")), + ("plan", Json::str(plan)), + ("status", Json::str(e.kind())), + ("message", Json::str(e.message())), + ]); + Output::coded(text, json, crate::cli::EXIT_FAILURE) +} + +fn not_found(plan: &str) -> Output { + Output::coded( + format!( + "{} {plan}\n no such plan here → look elsewhere\n", + style::red("not found") + ), + Json::obj(vec![ + ("command", Json::str("read")), + ("plan", Json::str(plan)), + ("status", Json::str("not-found")), + ]), + crate::cli::EXIT_FAILURE, ) } -fn head_json(a: &Admitted, orphan: bool) -> Json { - Json::obj(vec![ - ("version", Json::str(&a.hash)), - ("plan", Json::str(&a.version.plan)), - ("seq", Json::num(a.version.seq as i64)), - ("author", Json::str(&a.version.author)), - ("why", Json::str(&a.version.why)), - ("goal", Json::str(&a.version.goal)), - ("retired", Json::Bool(a.version.retired)), - ("orphan", Json::Bool(orphan)), - ("parent", Json::strs(a.version.parents.clone())), - ]) +/// The intent of every version reachable from `head`, keyed by hash. Evaluating +/// a tip yields the whole lineage, so this is one evaluation. +fn graph_from(store: &PlanStore, head: &Admitted) -> Result, EvalError> { + let map = crate::eval::eval_plan_file(&head.path)?; + let mut out = HashMap::new(); + for a in &store.versions { + let canon = std::fs::canonicalize(&a.path).unwrap_or_else(|_| a.path.clone()); + if let Some(sem) = map.get(&canon).or_else(|| map.get(&a.path)) { + out.insert( + a.hash.clone(), + Version::from_sem(&a.plan, a.seq, a.parents.clone(), sem), + ); + } + } + Ok(out) } -/// A head member including its step graph. -/// -/// `--json` must carry the same fields as the human rendering, and the human -/// rendering of `show` lists the steps at each head member — so the JSON has -/// to as well, or an agent cannot discover a StepRef without scraping text. -fn head_json_with_steps(a: &Admitted, orphan: bool) -> Json { - let Json::Obj(mut fields) = head_json(a, orphan) else { - unreachable!("head_json builds an object") - }; - fields.push(( - "step".to_string(), - Json::arr(a.version.steps.iter().map(step_json).collect()), - )); - Json::Obj(fields) +// --------------------------------------------------------------------------- +// version +// --------------------------------------------------------------------------- + +fn cmd_version() -> Output { + let id = crate::version::identity(); + Output::ok(format!("{}\n", id.display_version), id.to_json()) } -/// The problems block: rejected files, orphans, divergence. -/// -/// Problems come first and each carries its own repair. Crucially, the repair -/// offered for an Orphan is *waiting*, never reconciliation — reconciling -/// around a version that is merely in flight writes permanent intent to fix a -/// transient condition. -fn problems_block(plan: &str, store: &PlanStore, an: &Analysis) -> String { - let mut out = String::new(); +// --------------------------------------------------------------------------- +// start +// --------------------------------------------------------------------------- - if !store.rejected.is_empty() || !store.bad_events.is_empty() { - out.push_str(&format!( - "{} {} file(s) rejected — not adopted as state\n\n", - s::critical(), - store.rejected.len() + store.bad_events.len() +fn cmd_start(root: &Path, plan: &str, goal: Option<&str>, author: &str) -> Result { + catalog::init(root)?; + let dir = catalog::versions_dir(root, plan); + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let draft = dir.join("_new.ts"); + if draft.exists() { + return Err(format!( + "a draft already exists at {}\n edit it and `compass commit {}`", + draft.display(), + draft.display() )); - for r in store.rejected.iter().chain(store.bad_events.iter()).take(5) { - out.push_str(&format!( - " {} {}\n", - s::bold(&file_name(&r.path)), - s::dim(&r.reason) - )); - out.push_str(&s::note( - "a file becomes state only in its expected location with a name matching its content (CMP-R22)", - )); - out.push('\n'); - } - let extra = (store.rejected.len() + store.bad_events.len()).saturating_sub(5); - if extra > 0 { - out.push_str(&s::dim(&format!(" + {extra} more\n"))); + } + let goal = goal.unwrap_or("State the goal here"); + let scaffold = starter_module(goal, author); + std::fs::write(&draft, &scaffold) + .map_err(|e| format!("cannot write {}: {e}", draft.display()))?; + + let text = format!( + "scaffolded a starter plan for {}\n edit {}\n commit compass commit {}\n", + style::bold(plan), + draft.display(), + draft.display() + ); + let json = Json::obj(vec![ + ("command", Json::str("start")), + ("plan", Json::str(plan)), + ("draft", Json::str(draft.to_string_lossy())), + ]); + Ok(Output::ok(text, json)) +} + +fn starter_module(goal: &str, author: &str) -> String { + format!( + r#"import {{ plan, step, evidence }} from "compass" + +// A Step is a named binding; its name is its identity. A dependency is a +// reference to the binding — nothing to mistype, nothing to invent. +export const first = step({{ + work: "The first piece of work", + accept: evidence.test({{ name: "the-check", status: "pass" }}), +}}) + +export default plan({{ + author: {author:?}, + goal: {goal:?}, + why: "Why this plan exists — the durable record of reasons.", + steps: [first], +}}) +"# + ) +} + +// --------------------------------------------------------------------------- +// commit +// --------------------------------------------------------------------------- + +fn cmd_commit(root: &Path, path: &Path, plan_opt: Option<&str>) -> Result { + let source = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let source_str = String::from_utf8(source.clone()) + .map_err(|e| format!("{}: not valid UTF-8: {e}", path.display()))?; + + let plan = match plan_opt { + Some(p) => p.to_string(), + None => infer_plan(path) + .ok_or_else(|| "cannot determine the plan; pass --plan ".to_string())?, + }; + + // Evaluate the authored module (imports resolve at its location). + let map = crate::eval::eval_plan_file(path).map_err(|e| { + format!( + "{}: {} ({})\n nothing was recorded", + path.display(), + e.message(), + e.kind() + ) + })?; + let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let sem = map + .get(&canon) + .or_else(|| map.get(path)) + .ok_or_else(|| "the module did not export a plan".to_string())?; + + // Resolve predecessors from the module's imports. + let store = catalog::load_plan(root, &plan)?; + let mut parents: Vec = Vec::new(); + for spec in crate::eval::import_specifiers(&source_str, path) + .map_err(|e| format!("cannot read imports: {}", e.message()))? + { + let file = spec.rsplit('/').next().unwrap_or(&spec); + if let Some((_seq, prefix)) = crate::model::parse_filename(file) { + match store.resolve_hash(&prefix) { + Some(a) => parents.push(a.hash.clone()), + None => { + return Err(format!( + "predecessor {prefix} is not committed in {plan}; nothing was recorded" + )) + } + } } - out.push('\n'); } + parents.sort(); + parents.dedup(); - if !an.orphans.is_empty() { - out.push_str(&format!( - "{} {} orphaned version(s) — a predecessor has not arrived\n\n", - s::warning(), - an.orphans.len() - )); - for o in an.orphans.iter().take(5) { - out.push_str(&format!( - " {} {}\n", - s::bold(&s::short(&o.version.hash)), - s::dim(&format!( - "missing predecessor {}", - o.missing - .iter() - .map(|h| s::short(h)) - .collect::>() - .join(", ") - )) - )); - out.push_str(&s::note( - "an orphan is replication being incomplete, not intent disagreeing; it is repaired by waiting, not by reconciling", + let parent_admitted: Vec<&Admitted> = parents.iter().filter_map(|h| store.version(h)).collect(); + let seq = chain::next_seq(&parent_admitted); + + let version = Version::from_sem(&plan, seq, parents.clone(), sem); + version + .validate() + .map_err(|e| format!("{e}; nothing was recorded"))?; + + // A criterion that contradicts itself is refused when written (0013). + for s in &version.steps { + if let Some(atom) = s.accept.self_contradiction() { + return Err(format!( + "step {} has an acceptance criterion that contradicts itself: it both requires \ + and forbids {atom}; nothing was recorded", + s.id )); - out.push('\n'); } - out.push('\n'); } - if an.diverged() { - out.push_str(&format!( - "{} intent diverged — {} share a predecessor\n\n", - s::warning(), - s::count( - an.divergences - .iter() - .map(|d| d.children.len()) - .sum::(), - "version" - ) + let hash = crate::sha256::sha256_hex(&source); + let filename = crate::model::filename_for(seq, &hash); + let target = catalog::versions_dir(root, &plan).join(&filename); + + // A repeat is a repeat: identical content already committed is a no-op success. + if target.exists() { + let text = format!( + "{} {} already committed as {}\n", + style::dim("="), + style::bold(&plan), + style::short(&hash) + ); + return Ok(Output::ok( + text, + receipt_json("already-committed", &plan, &hash, seq, &parents), )); - for d in an.divergences.iter().take(3) { - let parent = d - .parent - .as_deref() - .map(s::short) - .unwrap_or_else(|| "(no predecessor — several origins)".to_string()); - out.push_str(&format!(" {} {}\n", s::bold("from"), s::dim(&parent))); - // The Rationale is shown in full, wrapped. Under divergence this - // text is what the operator reads to decide the reconciliation, - // so eliding it would hide the reasoning precisely where the - // decision is being made (CMP-R04). - for c in d.children.iter().take(5) { - out.push_str(&format!(" {} {}\n", s::short(&c.hash), c.version.author,)); - out.push_str(&s::wrapped_block(&c.version.why, " ", 78)); + } + + // New content that revises nothing is refused, with a distinct message. + if parent_admitted.len() == 1 { + if let Ok(pv) = evaluate(parent_admitted[0]) { + if version.changes_nothing_from(&pv) { + return Err(format!( + "this changes nothing: a revision must alter a step or the goal \ + (CMP.DM-R07b); nothing was recorded" + )); } - out.push_str(&s::fix(&format!("compass reconcile {plan} --why "))); - out.push('\n'); } - out.push('\n'); } - out + let (out_path, hash, _created) = catalog::write_version(root, &plan, seq, &source)?; + + // A draft authored in place is superseded by its hash-named form. + if path.parent() == Some(catalog::versions_dir(root, &plan).as_path()) + && path.file_name() != out_path.file_name() + { + let _ = std::fs::remove_file(path); + } + + let kind = if parents.is_empty() { + "created" + } else if parents.len() > 1 { + "reconciled" + } else { + "revised" + }; + let text = format!( + "{} {} {} as {} (seq {})\n{}\n", + style::green("committed"), + kind, + style::bold(&plan), + style::short(&hash), + seq, + style::dim(&format!(" {}", out_path.display())) + ); + Ok(Output::ok( + text, + receipt_json(kind, &plan, &hash, seq, &parents), + )) } -fn file_name(p: &Path) -> String { - p.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string() +fn receipt_json(kind: &str, plan: &str, hash: &str, seq: u64, parents: &[String]) -> Json { + Json::obj(vec![ + ("command", Json::str("commit")), + ("result", Json::str(kind)), + ("plan", Json::str(plan)), + ("version", Json::str(hash)), + ("seq", Json::num(seq as i64)), + ("parents", Json::strs(parents.iter().cloned())), + ]) } -fn problems_json(store: &PlanStore, an: &Analysis) -> Vec<(&'static str, Json)> { - vec![ - ( - "rejected", - Json::arr( - store - .rejected - .iter() - .chain(store.bad_events.iter()) - .map(|r| { - Json::obj(vec![ - ("path", Json::str(r.path.display().to_string())), - ("reason", Json::str(&r.reason)), - ]) - }) - .collect(), - ), - ), - ( - "orphans", - Json::arr( - an.orphans - .iter() - .map(|o| { - Json::obj(vec![ - ("version", Json::str(&o.version.hash)), - ("missing_parent", Json::strs(o.missing.clone())), - ("repair", Json::str("wait for replication")), - ]) - }) - .collect(), - ), - ), - ( - "divergences", - Json::arr( - an.divergences - .iter() - .map(|d| { - Json::obj(vec![ - ( - "parent", - match &d.parent { - Some(p) => Json::str(p), - None => Json::Null, - }, - ), - ( - "children", - Json::arr( - d.children - .iter() - .map(|c| { - Json::obj(vec![ - ("version", Json::str(&c.hash)), - ("author", Json::str(&c.version.author)), - ("why", Json::str(&c.version.why)), - ]) - }) - .collect(), - ), - ), - ]) - }) - .collect(), - ), - ), - ] +/// Infer the plan ref from a path under `plans//versions/`. +fn infer_plan(path: &Path) -> Option { + let versions = path.parent()?; + if versions.file_name()?.to_str()? != "versions" { + return None; + } + let plan_dir = versions.parent()?; + Some(plan_dir.file_name()?.to_str()?.to_string()) } // --------------------------------------------------------------------------- -// Step edits +// show // --------------------------------------------------------------------------- -/// Resolve a `--depends-on` value. -/// -/// Steps added in this same invocation do not have refs the operator could -/// know in advance, since refs are minted rather than derived. `@N` names the -/// Nth step added by this command, 1-based. -fn resolve_dep(value: &str, minted: &[String]) -> Result { - if refs::is_valid(value, RefKind::Step) { - return Ok(value.to_string()); - } - let index = value.strip_prefix('@').unwrap_or(value); - if let Ok(n) = index.parse::() { - if n >= 1 && n <= minted.len() { - return Ok(minted[n - 1].clone()); - } - return Err(format!( - "`--depends-on {value}` names step #{n}, but this command adds {} step(s)", - minted.len() - )); +fn cmd_show(root: &Path, plan: &str) -> Result { + let store = load(root, plan)?; + if store.versions.is_empty() && store.rejected.is_empty() { + return Ok(not_found(plan)); } - Err(format!( - "`--depends-on {value}` is neither a StepRef (st_…) nor `@N` naming a step added here" - )) -} + let an = chain::analyze(&store); + let c = Convergence::probe(); -/// Apply step edits to a base step list, producing the next version's steps. -fn apply_edits(base: &[Step], edits: &[StepEdit]) -> Result, String> { - // Refs must be minted up front so `@N` can be resolved while building. - let mut minted: Vec = Vec::new(); - for e in edits { - if matches!(e, StepEdit::Add { .. }) { - minted.push(refs::mint(RefKind::Step)?); - } - } + // Evaluate against the first head so the whole lineage is available; a read + // failure at the frontier is rendered distinctly. + let mut text = String::new(); + let mut heads_json: Vec = Vec::new(); - let mut steps: Vec = base.to_vec(); - let mut next_minted = minted.iter(); - - for e in edits { - match e { - StepEdit::Add { - work, - accept, - depends_on, - supersedes, - } => { - let id = next_minted.next().expect("minted one ref per Add").clone(); - let accept_src = accept.as_deref().ok_or_else(|| { - format!( - "step `{work}` needs `--accept `\n \ - acceptance must be machine-checkable (CMP-R11); see `compass help predicates`" - ) - })?; - let accept = predicate::parse(accept_src) - .map_err(|e| format!("cannot parse `--accept {accept_src}`: {e}"))?; - let mut deps = Vec::new(); - for d in depends_on { - deps.push(resolve_dep(d, &minted)?); - } - deps.sort(); - deps.dedup(); - steps.push(Step { - id, - work: work.clone(), - depends_on: deps, - supersedes: supersedes.clone(), - accept, - retired: false, - }); - } - StepEdit::Edit { - id, - work, - accept, - depends_on, - supersedes, - } => { - let target = steps - .iter_mut() - .find(|s| s.id == *id) - .ok_or_else(|| format!("no step {id} at head"))?; - if let Some(w) = work { - target.work = w.clone(); - } - if let Some(a) = accept { - target.accept = predicate::parse(a) - .map_err(|e| format!("cannot parse `--accept {a}`: {e}"))?; - } - if let Some(d) = depends_on { - let mut deps = Vec::new(); - for one in d { - deps.push(resolve_dep(one, &minted)?); - } - deps.sort(); - deps.dedup(); - target.depends_on = deps; - } - if let Some(sup) = supersedes { - target.supersedes = Some(sup.clone()); + for (i, h) in an.head.iter().enumerate() { + let orphan = an.is_orphan(&h.hash); + match evaluate(h) { + Ok(v) => { + let label = if an.head.len() > 1 { + format!("{} head {}/{} ", style::yellow("◆"), i + 1, an.head.len()) + } else { + String::new() + }; + text.push_str(&format!( + "{}{} {} seq {} by {}{}\n", + label, + style::bold(&style::short(&h.hash)), + if orphan { + style::red("(orphan)") + } else { + String::new() + }, + v.seq, + v.author, + if v.retired { + style::red(" [retired]") + } else { + String::new() + }, + )); + text.push_str(&format!(" goal: {}\n", v.goal)); + text.push_str(&style::wrapped_block(&v.why, " ", 78)); + for s in &v.steps { + let mark = if s.retired { + style::dim("⊘") + } else { + style::dim("•") + }; + text.push_str(&format!( + " {} {} — {}\n", + mark, + style::bold(&s.id), + style::truncate(&s.work, 64) + )); } + heads_json.push(head_json(&h.hash, orphan, &v)); } - StepEdit::Retire { id } => { - let target = steps - .iter_mut() - .find(|s| s.id == *id) - .ok_or_else(|| format!("no step {id} at head"))?; - target.retired = true; + Err(e) => { + text.push_str(&read_failure(plan, &e).text); + heads_json.push(Json::obj(vec![ + ("head", Json::str(&h.hash)), + ("status", Json::str(e.kind())), + ("message", Json::str(e.message())), + ])); } } - } - - Ok(steps) -} - -/// Write a version and render the standard "wrote a version" answer. -fn report_write( - root: &Path, - v: &Version, - action: &str, - conv: &Convergence, -) -> Result { - let (path, created) = catalog::write_version(root, v)?; - let hash = v.hash(); - - let mut text = String::new(); - text.push_str(&format!( - "{} {} {}\n", - s::green(action), - s::bold(&v.plan), - s::dim(&s::truncate(&v.goal, 60)) - )); - text.push_str(&format!( - " {} {} {}\n", - s::bold("version"), - s::bold(&s::short(&hash)), - s::dim(&format!("seq={} author={}", v.seq, v.author)) - )); - if !v.parents.is_empty() { - text.push_str(&format!( - " {} {}\n", - s::dim("parent"), - s::dim( - &v.parents - .iter() - .map(|p| s::short(p)) - .collect::>() - .join(", ") - ) - )); - } - if !created { - text.push_str(&s::note( - "identical content already existed; nothing rewritten", - )); text.push('\n'); } - if !v.steps.is_empty() { - text.push_str(&format!( - "\n {}:\n", - s::bold(&format!("steps({})", v.steps.len())) - )); - for st in v.steps.iter().take(10) { - let mark = if st.retired { - format!(" {}", s::dim("retired")) - } else { - String::new() - }; - text.push_str(&format!( - " {} {}{}\n", - s::bold(&st.id), - s::truncate(&st.work, 56), - mark - )); - } - if v.steps.len() > 10 { - text.push_str(&s::dim(&format!(" + {} more\n", v.steps.len() - 10))); - } - } - text.push('\n'); - text.push_str(&convergence_line(conv)); + + text.push_str(&divergence_report(&an)); + text.push_str(&convergence_line(&c)); text.push('\n'); let json = Json::obj(vec![ - ("command", Json::str(action)), - ("plan", Json::str(&v.plan)), - ("version", Json::str(&hash)), + ("command", Json::str("show")), + ("plan", Json::str(plan)), + ("heads", Json::arr(heads_json)), + ("diverged", Json::Bool(an.diverged())), + convergence_json(&c), + ]); + Ok(Output::ok(text, json)) +} + +fn head_json(hash: &str, orphan: bool, v: &Version) -> Json { + Json::obj(vec![ + ("version", Json::str(hash)), ("seq", Json::num(v.seq as i64)), ("author", Json::str(&v.author)), ("why", Json::str(&v.why)), ("goal", Json::str(&v.goal)), ("retired", Json::Bool(v.retired)), - ("parent", Json::strs(v.parents.clone())), - ("path", Json::str(path.display().to_string())), - ("created", Json::Bool(created)), - ("step", Json::arr(v.steps.iter().map(step_json).collect())), - ("convergence", convergence_json(conv)), - ]); - - Ok(Output::ok(text, json)) + ("orphan", Json::Bool(orphan)), + ("steps", Json::arr(v.steps.iter().map(step_json).collect())), + ]) } -fn step_json(st: &Step) -> Json { +fn step_json(s: &crate::model::Step) -> Json { Json::obj(vec![ - ("step", Json::str(&st.id)), - ("work", Json::str(&st.work)), - ("depends_on", Json::strs(st.depends_on.clone())), + ("id", Json::str(&s.id)), + ("work", Json::str(&s.work)), + ("dependsOn", Json::strs(s.depends_on.iter().cloned())), ( "supersedes", - match &st.supersedes { + match &s.supersedes { Some(x) => Json::str(x), None => Json::Null, }, ), - ("accept", Json::str(st.accept.to_string())), - ("retired", Json::Bool(st.retired)), + ("accept", Json::str(s.accept.to_string())), + ("retired", Json::Bool(s.retired)), ]) } +fn divergence_report(an: &Analysis) -> String { + let mut out = String::new(); + for o in &an.orphans { + out.push_str(&format!( + "{} {} is an orphan: predecessor {} has not arrived — wait\n", + style::warning(), + style::short(&o.version.hash), + o.missing + .iter() + .map(|m| style::short(m)) + .collect::>() + .join(", ") + )); + } + for d in an.open_divergences() { + out.push_str(&format!( + "{} open divergence: {} head members share a predecessor — reconcile by authoring \ + a version importing both\n", + style::warning(), + d.children.len() + )); + } + out +} + // --------------------------------------------------------------------------- -// Commands +// history // --------------------------------------------------------------------------- -/// Build identity. -/// -/// Convergence is reported here too. It says nothing about a build, but the -/// contract is "every command reports convergence state alongside its answer", -/// and a uniform rule is worth more than an exemption a reader has to know -/// about. -fn cmd_version() -> Output { - let id = build::identity(); - let conv = Convergence::probe(); - let mut text = format!("{} {}\n", s::bold("compass"), id.display_version); - text.push_str(&format!(" {} {}\n", s::dim("machine"), id.machine_version)); - text.push_str(&format!(" {} {}\n", s::dim("source "), id.source_kind)); - if let Some(rev) = &id.rev { - text.push_str(&format!( - " {} {}{}\n", - s::dim("rev "), - rev, - if id.dirty { - format!(" {}", s::yellow("dirty")) - } else { - String::new() - } - )); +fn cmd_history(root: &Path, plan: &str) -> Result { + let store = load(root, plan)?; + if store.versions.is_empty() { + return Ok(not_found(plan)); } - text.push('\n'); - text.push_str(&convergence_line(&conv)); - text.push('\n'); + let an = chain::analyze(&store); + let c = Convergence::probe(); - let Json::Obj(mut fields) = id.to_json() else { - unreachable!("build identity is an object") - }; - fields.push(("convergence".to_string(), convergence_json(&conv))); - Output::ok(text, Json::Obj(fields)) -} + let mut text = String::new(); + let mut entries: Vec = Vec::new(); -fn cmd_init(root: &Path) -> Result { - let existed = catalog::exists(root); - catalog::init(root)?; - let conv = Convergence::probe(); + for h in &an.head { + let graph = match graph_from(&store, h) { + Ok(g) => g, + Err(e) => { + return Ok(read_failure(plan, &e)); + } + }; + if an.head.len() > 1 { + text.push_str(&format!( + "{} lineage of head {}\n", + style::yellow("◆"), + style::short(&h.hash) + )); + } + for a in chain::lineage(&store, &h.hash) { + let Some(v) = graph.get(&a.hash) else { + continue; + }; + text.push_str(&format!( + " {} seq {} by {}\n", + style::bold(&style::short(&a.hash)), + v.seq, + v.author + )); + text.push_str(&style::wrapped_block(&v.why, " ", 78)); + entries.push(Json::obj(vec![ + ("version", Json::str(&a.hash)), + ("seq", Json::num(v.seq as i64)), + ("author", Json::str(&v.author)), + ("why", Json::str(&v.why)), + ])); + } + } + text.push_str(&convergence_line(&c)); + text.push('\n'); - let text = format!( - "{} {}\n\n{}\n", - s::green(if existed { - "catalog exists" - } else { - "catalog created" - }), - s::bold(&root.display().to_string()), - convergence_line(&conv) - ); let json = Json::obj(vec![ - ("command", Json::str("init")), - ("catalog", Json::str(root.display().to_string())), - ("created", Json::Bool(!existed)), - ("convergence", convergence_json(&conv)), + ("command", Json::str("history")), + ("plan", Json::str(plan)), + ("rationale", Json::arr(entries)), + convergence_json(&c), ]); Ok(Output::ok(text, json)) } -fn cmd_new( - root: &Path, - author: &str, - goal: &str, - why: &str, - edits: &[StepEdit], -) -> Result { - catalog::init(root)?; - let plan = refs::mint(RefKind::Plan)?; - let steps = apply_edits(&[], edits)?; - - let v = Version { - plan, - seq: 1, - parents: vec![], - author: author.to_string(), - why: why.to_string(), - goal: goal.to_string(), - retired: false, - steps, - }; - // Round-trip through the parser so structural rules (cycles, unknown - // dependencies) are enforced on the way in, not discovered on the way out. - Version::parse(&v.render()) - .map_err(|e| format!("refusing to write an invalid version: {e}"))?; +// --------------------------------------------------------------------------- +// ready +// --------------------------------------------------------------------------- - report_write(root, &v, "created", &Convergence::probe()) -} - -fn cmd_revise( - root: &Path, - author: &str, - plan: &str, - why: &str, - goal: Option<&str>, - retire: bool, - edits: &[StepEdit], -) -> Result { +fn cmd_ready(root: &Path, plan: &str) -> Result { let store = load(root, plan)?; - let an = chain::analyze(&store); - - if an.head.is_empty() { - return Err(format!("plan {plan} has no admitted versions to revise")); - } - if an.head.len() > 1 { - return Err(ambiguous_head_message(plan, &an)); - } - - let head = an.head[0]; - let base = &head.version; - let steps = apply_edits(&base.steps, edits)?; - - let v = Version { - plan: plan.to_string(), - seq: base.seq + 1, - parents: vec![head.hash.clone()], - author: author.to_string(), - why: why.to_string(), - goal: goal.unwrap_or(&base.goal).to_string(), - retired: retire || base.retired, - steps, - }; - if v.changes_nothing_from(base) { - return Err(empty_revision_message(plan, &head.hash)); - } - Version::parse(&v.render()) - .map_err(|e| format!("refusing to write an invalid version: {e}"))?; - - let mut out = report_write(root, &v, "revised", &Convergence::probe())?; - if an.is_orphan(&head.hash) { - out.text = format!( - "{} revising from an orphaned head — its predecessor has not arrived\n\n{}", - s::warning(), - out.text - ); + if store.versions.is_empty() { + return Ok(not_found(plan)); } - Ok(out) -} + let an = chain::analyze(&store); + let c = Convergence::probe(); -/// The error for a revision that would record no change of intent -/// (CMP.DM-R07b). -/// -/// The likeliest way to arrive here is a *correct* retry: the earlier attempt -/// landed, this one re-read Head, and re-applying the same edits to a version -/// that already carries them changes nothing. So the message says where that -/// work went, rather than only refusing. -fn empty_revision_message(plan: &str, head: &str) -> String { - format!( - "refusing to revise {plan}: this changes no Step and no goal\n\n \ - head {} already carries exactly this intent.\n\n \ - A revision must change a Step or the goal. A revision that only restates why is\n \ - deliberately not expressible: nothing distinguishes it from a duplicate of the\n \ - version it descends from, and under no-delete replication a duplicate is permanent.\n\n \ - If this is a retry, the earlier attempt did land — its version is at head.\n \ - fix: compass show {plan}\n", - s::short(head) - ) -} + let mut text = String::new(); + let mut heads_json: Vec = Vec::new(); -/// The error for a command needing one head when there are several. -/// -/// The two causes need different repairs and must never be conflated. -fn ambiguous_head_message(plan: &str, an: &Analysis) -> String { - let mut m = format!("plan {plan} has {} head members\n\n", an.head.len()); - for (i, h) in an.head.iter().enumerate() { - m.push_str(&format!( - " {}/{} {} {} seq={} {}\n", - i + 1, - an.head.len(), - s::short(&h.hash), - h.version.author, - h.version.seq, - s::truncate(&h.version.why, 50) - )); - } - m.push('\n'); - if an.diverged() { - m.push_str(&format!( - " intent diverged.\n fix: compass reconcile {plan} --why \n" - )); - } - if !an.orphans.is_empty() { - m.push_str( - " some head members are orphans: a predecessor has not arrived.\n \ - fix: wait for replication — do not reconcile an orphan\n", - ); + for h in &an.head { + let orphan = an.is_orphan(&h.hash); + let v = match evaluate(h) { + Ok(v) => v, + Err(e) => return Ok(read_failure(plan, &e)), + }; + let r = readiness::for_head(&store.events, &v, &h.hash, orphan); + if an.head.len() > 1 { + text.push_str(&format!( + "{} head {} (by {})\n", + style::yellow("◆"), + style::short(&h.hash), + r.author + )); + } + if orphan { + text.push_str(&format!( + " {} provisional: this head is an orphan\n", + style::dim("note:") + )); + } + render_readiness(&mut text, &r); + heads_json.push(readiness_json(&r, orphan)); } - m -} - -// --------------------------------------------------------------------------- -// Structural change reporting -// --------------------------------------------------------------------------- + text.push_str(&convergence_line(&c)); + text.push('\n'); -/// The marker for a change. Never colour alone — the symbol carries it. -fn change_mark(c: &StepChange) -> &'static str { - match c { - StepChange::Added { .. } => "+", - StepChange::Superseded { .. } => ">", - StepChange::Retired { .. } => "-", - StepChange::Edited { .. } => "~", - StepChange::Dropped { .. } => "x", - } + let json = Json::obj(vec![ + ("command", Json::str("ready")), + ("plan", Json::str(plan)), + ("heads", Json::arr(heads_json)), + convergence_json(&c), + ]); + Ok(Output::ok(text, json)) } -/// What each version did, rendered under its Rationale. -fn changes_block(c: &VersionChange) -> String { - let indent = " "; - let mut out = String::new(); - - let header = match &c.basis { - Basis::Root => "states the initial plan".to_string(), - Basis::Parent(h) => format!("changes vs {}", s::short(h)), - Basis::Agreed(n) => format!( - "reconciles {}, which agreed on a step graph; changes vs that graph", - s::count(*n, "predecessor") - ), - Basis::Unrecoverable(n) => format!( - "reconciles {}; which side's step graph it carried forward is not recorded, so no \ - change can be derived", - s::count(*n, "predecessor") - ), - }; - out.push_str(&s::wrapped_block(&header, indent, 80)); - - if matches!(c.basis, Basis::Unrecoverable(_)) { - out.push_str(&format!( - "{indent} {} {}\n", - s::dim("now carries"), - if c.resulting.is_empty() { - s::dim("no steps") - } else { - c.resulting.join(", ") - } - )); - return out; - } - - if let Some(before) = &c.goal_before { - out.push_str(&format!( - "{indent} {} {} {}\n", - s::bold("!"), - "goal", - s::dim(&format!("was {}", s::truncate(before, 56))) - )); - } - - for st in &c.steps { - let detail = match st { - StepChange::Superseded { old, .. } => format!("replaces {old}"), - StepChange::Edited { fields, .. } => fields.join(", "), - _ => s::truncate(st.work(), 48), +fn render_readiness(text: &mut String, r: &HeadReadiness) { + let ready = r.count(StepState::Ready); + if ready == 0 { + text.push_str(&format!(" {}\n", style::dim("nothing is ready right now"))); + } + for s in &r.steps { + let mark = match s.state { + StepState::Ready => style::green("▶"), + StepState::Blocked => style::dim("·"), + StepState::Accepted => style::green("✓"), + StepState::Retired => style::dim("⊘"), }; - out.push_str(&format!( - "{indent} {} {:<11} {} {}\n", - s::bold(change_mark(st)), - st.verb(), - st.id(), - s::dim(&detail) - )); - } - - if c.is_empty() && !matches!(c.basis, Basis::Root) { - out.push_str(&format!( - "{indent} {}\n", - s::dim("no change to steps or goal") + text.push_str(&format!( + " {} {} {} — {}\n", + mark, + style::bold(&s.step), + style::dim(s.state.as_str()), + s.reason )); } - - out } -fn changes_json(c: &VersionChange) -> Json { - let (basis, predecessors) = match &c.basis { - Basis::Root => ("root", 0), - Basis::Parent(_) => ("parent", 1), - Basis::Agreed(n) => ("agreed_predecessors", *n), - Basis::Unrecoverable(n) => ("unrecoverable", *n), - }; - let base = match &c.basis { - Basis::Parent(h) => Json::str(h), - _ => Json::Null, - }; +fn readiness_json(r: &HeadReadiness, orphan: bool) -> Json { Json::obj(vec![ - ("basis", Json::str(basis)), - ("predecessors", Json::num(predecessors as i64)), - ("base", base), - ( - "derivable", - Json::Bool(!matches!(c.basis, Basis::Unrecoverable(_))), - ), - ( - "goal_before", - match &c.goal_before { - Some(g) => Json::str(g), - None => Json::Null, - }, - ), + ("head", Json::str(&r.head)), + ("seq", Json::num(r.seq as i64)), + ("author", Json::str(&r.author)), + ("orphan", Json::Bool(orphan)), ( "steps", Json::arr( - c.steps + r.steps .iter() - .map(|st| { + .map(|s| { Json::obj(vec![ - ("change", Json::str(st.verb())), - ("step", Json::str(st.id())), - ("work", Json::str(st.work())), - ( - "supersedes", - match st { - StepChange::Superseded { old, .. } => Json::str(old), - _ => Json::Null, - }, - ), - ( - "fields", - match st { - StepChange::Edited { fields, .. } => Json::strs( - fields.iter().map(|f| f.to_string()).collect::>(), - ), - _ => Json::arr(vec![]), - }, - ), + ("step", Json::str(&s.step)), + ("state", Json::str(s.state.as_str())), + ("reason", Json::str(&s.reason)), + ("blockedBy", Json::strs(s.blocked_by.iter().cloned())), + ("accept", Json::str(&s.accept)), ]) }) .collect(), ), ), - ("resulting_steps", Json::strs(c.resulting.clone())), ]) } -fn cmd_show(root: &Path, plan: &str) -> Result { - let store = load(root, plan)?; - let an = chain::analyze(&store); - let conv = Convergence::probe(); - - let mut text = problems_block(plan, &store, &an); +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- - let goal = an - .head - .first() - .map(|h| h.version.goal.clone()) - .unwrap_or_default(); - text.push_str(&format!( - "{} {}\n\n", - s::bold(plan), - s::truncate(&goal, 60) - )); +fn cmd_status(root: &Path) -> Result { + let c = Convergence::probe(); + let plans = if catalog::exists(root) { + catalog::list_plans(root)? + } else { + Vec::new() + }; + let mut text = String::new(); + let mut rows: Vec = Vec::new(); - for (i, h) in an.head.iter().enumerate() { - text.push_str(&head_line(h, i, an.head.len(), an.is_orphan(&h.hash))); - text.push('\n'); + if plans.is_empty() { + text.push_str(&format!("{}\n", style::dim("no plans yet"))); } - if an.head.is_empty() { - text.push_str(&s::dim(" no admitted versions\n")); - } - - // The Rationale chain: the durable planning record. - let mut chains: Vec = Vec::new(); - for (i, h) in an.head.iter().enumerate() { - let line = chain::lineage(&store, &h.hash); - let label = if an.head.len() > 1 { - format!("lineage of head {}/{}", i + 1, an.head.len()) - } else { - "lineage".to_string() - }; + for plan in &plans { + let store = catalog::load_plan(root, plan)?; + let an = chain::analyze(&store); text.push_str(&format!( - "\n {} {}\n", - s::bold(&format!("{label}({})", line.len())), - s::dim("oldest first") + "{} {} {}, {}\n", + style::bold(plan), + an.state(), + style::count(store.versions.len(), "version"), + style::count(an.head.len(), "head"), )); - for a in &line { - text.push_str(&format!( - " {:03} {} {}\n", - a.version.seq, - s::bold(&s::short(&a.hash)), - a.version.author, - )); - // Wrapped, never elided: the lineage of Rationales is the durable - // planning record, and it is read to understand a decision. - text.push_str(&s::wrapped_block(&a.version.why, " ", 80)); - // The Rationale and the change it explains belong together: read - // apart, neither can be checked against the other. - text.push_str(&changes_block(&change::of(&store, &a.version))); - text.push('\n'); - } - chains.push(Json::obj(vec![ - ("head", Json::str(&h.hash)), - ( - "lineage", - Json::arr( - line.iter() - .map(|a| { - Json::obj(vec![ - ("version", Json::str(&a.hash)), - ("seq", Json::num(a.version.seq as i64)), - ("author", Json::str(&a.version.author)), - ("why", Json::str(&a.version.why)), - ("parent", Json::strs(a.version.parents.clone())), - ("changed", changes_json(&change::of(&store, &a.version))), - ]) - }) - .collect(), - ), - ), + rows.push(Json::obj(vec![ + ("plan", Json::str(plan)), + ("state", Json::str(an.state())), + ("versions", Json::num(store.versions.len() as i64)), + ("heads", Json::num(an.head.len() as i64)), + ("diverged", Json::Bool(an.diverged())), ])); } - - // Steps at each head member. - for (i, h) in an.head.iter().enumerate() { - let label = if an.head.len() > 1 { - format!("steps at head {}/{}", i + 1, an.head.len()) - } else { - "steps".to_string() - }; - text.push_str(&format!( - "\n {}:\n", - s::bold(&format!("{label}({})", h.version.steps.len())) - )); - for st in &h.version.steps { - text.push_str(&format!( - " {} {}{}\n", - s::bold(&st.id), - s::truncate(&st.work, 56), - if st.retired { - format!(" {}", s::dim("retired")) - } else { - String::new() - } - )); - text.push_str(&format!(" {}\n", s::dim(&st.accept.to_string()))); - if !st.depends_on.is_empty() { - text.push_str(&format!( - " {}\n", - s::dim(&format!("depends on {}", st.depends_on.join(", "))) - )); - } - } - } - - text.push_str(&format!( - "\n{}\n", - s::dim(&format!( - "{} · {} · {} · {}", - s::count(store.versions.len(), "version"), - s::count_of(an.head.len(), "head", "heads"), - s::count(store.events.len(), "event"), - an.state() - )) - )); - text.push('\n'); - text.push_str(&convergence_line(&conv)); + text.push_str(&convergence_line(&c)); text.push('\n'); - let mut fields = vec![ - ("command", Json::str("show")), - ("plan", Json::str(plan)), - ("goal", Json::str(&goal)), - ("state", Json::str(an.state())), - ( - "head", - Json::arr( - an.head - .iter() - .map(|h| head_json_with_steps(h, an.is_orphan(&h.hash))) - .collect(), - ), - ), - ("lineages", Json::arr(chains)), - ("version_count", Json::num(store.versions.len() as i64)), - ("event_count", Json::num(store.events.len() as i64)), - ]; - fields.extend(problems_json(&store, &an)); - fields.push(("convergence", convergence_json(&conv))); - - Ok(Output::ok(text, Json::obj(fields))) + let json = Json::obj(vec![ + ("command", Json::str("status")), + ("plans", Json::arr(rows)), + convergence_json(&c), + ]); + Ok(Output::ok(text, json)) } -fn cmd_ready(root: &Path, plan: &str) -> Result { - let store = load(root, plan)?; - let an = chain::analyze(&store); - let conv = Convergence::probe(); - let all = readiness::for_plan(&store, &an); - - let mut text = problems_block(plan, &store, &an); - let goal = an - .head - .first() - .map(|h| h.version.goal.clone()) - .unwrap_or_default(); - text.push_str(&format!("{} {}\n", s::bold(plan), s::truncate(&goal, 60))); - - if all.is_empty() { - text.push_str(&s::dim("\n no admitted versions — nothing is ready\n")); - } +// --------------------------------------------------------------------------- +// verify +// --------------------------------------------------------------------------- - for (i, r) in all.iter().enumerate() { - text.push('\n'); - let label = if all.len() > 1 { - format!("head {}/{}", i + 1, all.len()) - } else { - "head".to_string() - }; - text.push_str(&format!( - " {} {} {} {}{}\n", - s::bold(&label), - s::bold(&s::short(&r.head)), - r.author, - s::dim(&format!("seq={}", r.seq)), - if r.orphan { - format!(" {}", s::red("orphan")) - } else { - String::new() - } - )); - if r.orphan { - text.push_str(&s::note( - "this answer is provisional: a predecessor has not arrived", +fn cmd_verify(root: &Path, plan: Option<&str>, all: bool) -> Result { + let plans = if all { + catalog::list_plans(root)? + } else { + vec![plan.unwrap().to_string()] + }; + let mut problems: Vec = Vec::new(); + let mut text = String::new(); + let mut clean = true; + + for plan in &plans { + let store = load(root, plan)?; + let an = chain::analyze(&store); + for r in &store.rejected { + clean = false; + text.push_str(&format!( + "{} {}: {} — {}\n", + style::critical(), + plan, + file_name(&r.path), + r.reason + )); + problems.push(problem_json( + plan, + "rejected", + &file_name(&r.path), + &r.reason, )); - text.push('\n'); } - - for (state, marker, colour) in [ - (StepState::Ready, "*", 1), - (StepState::Blocked, "✗", 2), - (StepState::Accepted, "✓", 3), - (StepState::Retired, "-", 0), - ] { - let rows: Vec<_> = r.steps.iter().filter(|x| x.state == state).collect(); - if rows.is_empty() { - continue; - } + for o in &an.orphans { + clean = false; + let reason = format!( + "predecessor {} not present", + o.missing + .iter() + .map(|m| style::short(m)) + .collect::>() + .join(", ") + ); text.push_str(&format!( - "\n {}:\n", - s::bold(&format!("{}({})", state.as_str(), rows.len())) + "{} {}: {} is an orphan — {}\n", + style::warning(), + plan, + style::short(&o.version.hash), + reason )); - for row in rows.iter().take(10) { - let m = match colour { - 1 => s::yellow(marker), - 2 => s::red(marker), - 3 => s::green(marker), - _ => s::dim(marker), - }; - text.push_str(&format!( - " {} {} {}\n", - m, - s::bold(&row.step), - s::truncate(&row.work, 52) - )); - text.push_str(&format!(" {}\n", s::dim(&row.reason))); - } - if rows.len() > 10 { - text.push_str(&s::dim(&format!(" + {} more\n", rows.len() - 10))); + problems.push(problem_json( + plan, + "orphan", + &style::short(&o.version.hash), + &reason, + )); + } + // A frontier that will not evaluate is damage the chain cannot show. + for h in &an.head { + if let Err(e) = evaluate(h) { + if matches!( + e, + EvalError::Unresolved(_) | EvalError::Failed(_) | EvalError::Stopped(_) + ) { + clean = false; + text.push_str(&format!( + "{} {}: head {} is {} — {}\n", + style::critical(), + plan, + style::short(&h.hash), + e.kind(), + e.message() + )); + problems.push(problem_json( + plan, + e.kind(), + &style::short(&h.hash), + e.message(), + )); + } } } - - text.push_str(&format!( - "\n {}\n", - s::dim(&format!( - "{} ready · {} blocked · {} accepted · {} retired", - r.count(StepState::Ready), - r.count(StepState::Blocked), - r.count(StepState::Accepted), - r.count(StepState::Retired), - )) - )); } - - text.push('\n'); - text.push_str(&convergence_line(&conv)); - text.push('\n'); - - let mut fields = vec![ - ("command", Json::str("ready")), - ("plan", Json::str(plan)), - ("goal", Json::str(&goal)), - ("state", Json::str(an.state())), - ("diverged", Json::Bool(an.diverged())), - ("head", Json::arr(all.iter().map(readiness_json).collect())), - ]; - fields.extend(problems_json(&store, &an)); - fields.push(("convergence", convergence_json(&conv))); - - Ok(Output::ok(text, Json::obj(fields))) + if clean { + text.push_str(&format!("{} everything verifies\n", style::green("ok"))); + } + let json = Json::obj(vec![ + ("command", Json::str("verify")), + ("clean", Json::Bool(clean)), + ("problems", Json::arr(problems)), + ]); + Ok(Output::coded( + text, + json, + if clean { 0 } else { crate::cli::EXIT_FAILURE }, + )) } -fn readiness_json(r: &HeadReadiness) -> Json { +fn problem_json(plan: &str, kind: &str, subject: &str, reason: &str) -> Json { Json::obj(vec![ - ("version", Json::str(&r.head)), - ("seq", Json::num(r.seq as i64)), - ("author", Json::str(&r.author)), - ("orphan", Json::Bool(r.orphan)), - ( - "step", - Json::arr( - r.steps - .iter() - .map(|x| { - Json::obj(vec![ - ("step", Json::str(&x.step)), - ("work", Json::str(&x.work)), - ("state", Json::str(x.state.as_str())), - ("reason", Json::str(&x.reason)), - ("blocked_by", Json::strs(x.blocked_by.clone())), - ("accept", Json::str(&x.accept)), - ]) - }) - .collect(), - ), - ), - ( - "summary", - Json::obj(vec![ - ("ready", Json::num(r.count(StepState::Ready) as i64)), - ("blocked", Json::num(r.count(StepState::Blocked) as i64)), - ("accepted", Json::num(r.count(StepState::Accepted) as i64)), - ("retired", Json::num(r.count(StepState::Retired) as i64)), - ]), - ), + ("plan", Json::str(plan)), + ("kind", Json::str(kind)), + ("subject", Json::str(subject)), + ("reason", Json::str(reason)), ]) } -/// Choose the version an event is observed against. -/// -/// Prefer a head member that actually carries the step. When several do — -/// normal under divergence — pick deterministically and disclose the choice -/// rather than appearing to have had only one option. -fn observed_against<'a>(an: &Analysis<'a>, step: &str) -> Result<(&'a Admitted, bool), String> { - if an.head.is_empty() { - return Err("plan has no admitted versions to record progress against".to_string()); - } - let carrying: Vec<&&Admitted> = an - .head - .iter() - .filter(|h| h.version.step(step).is_some()) - .collect(); - - match carrying.len() { - 0 => Err(format!( - "no step {step} at head\n fix: compass show " - )), - 1 => Ok((carrying[0], false)), - _ => { - let chosen = carrying - .iter() - .max_by(|a, b| (a.version.seq, &a.hash).cmp(&(b.version.seq, &b.hash))) - .expect("non-empty"); - Ok((chosen, true)) - } - } +fn file_name(p: &Path) -> String { + p.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string() } -#[allow(clippy::too_many_arguments)] -fn record_event( - root: &Path, - store: &PlanStore, - an: &Analysis, - author: &str, - plan: &str, - step: &str, - kind: EventKind, - note: Option<&str>, - evidence_kind: Option, - attrs: Vec<(String, String)>, -) -> Result { - // Refused, not sanitised (decision 0008, CMP.DM-R13b). A predicate term - // naming a recorded field binds what Compass recorded, so an attribute of - // the same name could never mean what its author intended — accepting it - // silently would let a plan be authored against a criterion that can never - // hold the way it reads. - if let Some(k) = Event::shadowing_attr(&attrs) { +// --------------------------------------------------------------------------- +// repair — distinct from verify (04-cli): it authors permanent content. +// --------------------------------------------------------------------------- + +fn cmd_repair(root: &Path, plan: &str) -> Result { + let store = load(root, plan)?; + let an = chain::analyze(&store); + + let damaged = !store.rejected.is_empty() || an.head.iter().any(|h| evaluate(h).is_err()); + if !damaged { + // Repair refuses when verification finds nothing wrong, so the + // irreversible operation is never one keystroke from the safe one. return Err(format!( - "refusing to record evidence: attribute `{k}` shadows the recorded field `{k}`\n \ - Compass records `{k}` itself, and an `accept` term naming `{k}` binds that recorded \ - value — never an attribute.\n \ - reserved: {}\n \ - fix: drop `{k}=…`, or name the claim something else", - crate::event::RECORDED_FIELDS.join(", ") + "nothing to repair in {plan}: verification reports no damage. \ + Run `compass verify {plan}` to confirm." )); } - let (against, ambiguous) = observed_against(an, step)?; - let conv = Convergence::probe(); - - let mut sorted = attrs; - sorted.sort_by(|a, b| a.0.cmp(&b.0)); - - let e = Event { - id: refs::mint(RefKind::Event)?, - at: store.next_event_at(), - wall: crate::event::now_wall(), - plan: plan.to_string(), - step: step.to_string(), - version: against.hash.clone(), - actor: author.to_string(), - kind, - note: note.map(|n| n.to_string()), - evidence_kind, - attrs: sorted, - }; - let path = catalog::write_event(root, &e)?; + // Identify the last intact predecessor to continue from. + let intact: Vec<&Admitted> = store + .versions + .iter() + .filter(|a| evaluate(a).is_ok()) + .collect(); + let base = intact.iter().max_by_key(|a| a.seq); - let mut text = String::new(); - text.push_str(&format!( - "{} {} {} {}\n", - s::green("recorded"), - s::bold(e.kind.as_str()), - s::bold(&e.step), - s::dim(&format!("in {plan}")) - )); - text.push_str(&format!( - " {} {} {}\n", - s::dim("against version"), - s::short(&e.version), - s::dim(&format!("at={} actor={}", e.at, e.actor)) - )); - if let Some(k) = &e.evidence_kind { + let mut text = format!( + "{} {} has damage that must be repaired by authoring, not editing\n", + style::critical(), + style::bold(plan) + ); + for r in &store.rejected { text.push_str(&format!( - " {} {}{}\n", - s::dim("evidence"), - s::bold(k), - if e.attrs.is_empty() { - String::new() - } else { - format!( - " {}", - s::dim( - &e.attrs - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(" ") - ) - ) - } - )); - } - if ambiguous { - text.push_str(&s::note( - "head is divergent and several members carry this step; recorded against the deepest in the lineage", + " unverifiable: {} — {}\n", + file_name(&r.path), + r.reason )); - text.push('\n'); } - if matches!(kind, EventKind::Done) { - text.push_str(&s::note( - "`done` records that you finished; it does not accept the step — acceptance is judged from evidence (CMP-R14)", - )); - text.push('\n'); + match base { + Some(b) => { + let rel = crate::model::filename_for(b.seq, &b.hash); + text.push_str(&format!( + "\nAuthor a damage-recording version continuing from the last intact predecessor \ + ({}):\n\n import prior from \"./{}\"\n export default prior.revise({{\n \ + author: \"you\",\n why: \"Records the damage to and continues.\",\n \ + }})\n\nthen `compass commit` it. Verification stays read-only.\n", + style::short(&b.hash), + rel + )); + } + None => { + text.push_str( + "\nNo intact predecessor remains; author a fresh plan recording what is known \ + of the lost intent.\n", + ); + } } - text.push('\n'); - text.push_str(&convergence_line(&conv)); - text.push('\n'); - let json = Json::obj(vec![ - ( - "command", - Json::str(if kind == EventKind::Evidence { - "evidence" - } else { - "progress" - }), - ), + ("command", Json::str("repair")), ("plan", Json::str(plan)), - ("step", Json::str(step)), - ("event", Json::str(&e.id)), - ("kind", Json::str(e.kind.as_str())), - ("at", Json::num(e.at as i64)), - ("wall", Json::num(e.wall as i64)), - ("actor", Json::str(&e.actor)), - ("observed_against", Json::str(&e.version)), - ("head_ambiguous", Json::Bool(ambiguous)), - ( - "evidence_kind", - match &e.evidence_kind { - Some(k) => Json::str(k), - None => Json::Null, - }, - ), - ( - "attrs", - Json::Obj( - e.attrs - .iter() - .map(|(k, v)| (k.clone(), Json::str(v))) - .collect(), - ), - ), + ("damaged", Json::Bool(true)), ( - "note", - match &e.note { - Some(n) => Json::str(n), + "lastIntact", + match base { + Some(b) => Json::str(&b.hash), None => Json::Null, }, ), - ("path", Json::str(path.display().to_string())), - ("accepts_step", Json::Bool(false)), - ("convergence", convergence_json(&conv)), ]); - Ok(Output::ok(text, json)) } +// --------------------------------------------------------------------------- +// progress / evidence +// --------------------------------------------------------------------------- + +/// Find the single head whose version contains `step`, evaluated. +fn head_for_step<'a>(an: &Analysis<'a>, step: &str) -> Result<(&'a Admitted, Version), String> { + let mut hits = Vec::new(); + for h in &an.head { + if let Ok(v) = evaluate(h) { + if v.step(step).is_some() { + hits.push((*h, v)); + } + } + } + match hits.len() { + 0 => Err(format!("no head of this plan has a step named `{step}`")), + 1 => Ok(hits.into_iter().next().unwrap()), + n => Err(format!( + "step `{step}` appears in {n} divergent heads; reconcile first so a record is \ + unambiguous" + )), + } +} + fn cmd_progress( root: &Path, - author: &str, plan: &str, step: &str, kind: &str, note: Option<&str>, + author: &str, ) -> Result { let store = load(root, plan)?; let an = chain::analyze(&store); - let kind = EventKind::parse(kind).ok_or_else(|| format!("unknown progress kind `{kind}`"))?; - record_event( - root, - &store, - &an, - author, - plan, - step, + let (head, _v) = head_for_step(&an, step)?; + + let ekind = EventKind::parse(kind).ok_or_else(|| format!("unknown progress kind `{kind}`"))?; + let event = Event { + id: crate::refs::event_id()?, + at: store.next_event_at(), + wall: crate::event::now_wall(), + plan: plan.to_string(), + step: step.to_string(), + version: head.hash.clone(), + actor: author.to_string(), + kind: ekind, + note: note.map(|s| s.to_string()), + evidence_kind: None, + attrs: vec![], + }; + let path = catalog::write_event(root, &event)?; + let text = format!( + "{} {} {} on {} (against {})\n", + style::green("recorded"), kind, - note, - None, - vec![], - ) + style::bold(step), + style::bold(plan), + style::short(&head.hash) + ); + let json = Json::obj(vec![ + ("command", Json::str("progress")), + ("plan", Json::str(plan)), + ("step", Json::str(step)), + ("kind", Json::str(kind)), + ("actor", Json::str(author)), + ("version", Json::str(&head.hash)), + ("event", Json::str(&event.id)), + ("path", Json::str(path.to_string_lossy())), + ]); + Ok(Output::ok(text, json)) } fn cmd_evidence( root: &Path, - author: &str, plan: &str, step: &str, kind: &str, attrs: &[(String, String)], -) -> Result { - let store = load(root, plan)?; - let an = chain::analyze(&store); - record_event( - root, - &store, - &an, - author, - plan, - step, - EventKind::Evidence, - None, - Some(kind.to_string()), - attrs.to_vec(), - ) -} - -fn cmd_status(root: &Path) -> Result { - if !catalog::exists(root) { - return Err(format!( - "no catalog at {}\n fix: compass init", - root.display() - )); - } - let conv = Convergence::probe(); - let plans = catalog::list_plans(root)?; - - let mut rows: Vec<(String, PlanStore)> = Vec::new(); - for p in &plans { - rows.push((p.clone(), catalog::load_plan(root, p)?)); - } - - let mut text = String::new(); - let mut entries: Vec = Vec::new(); - let (mut diverged, mut orphaned, mut rejected) = (0usize, 0usize, 0usize); - - // Problems first, across all plans. - let mut problems = String::new(); - for (plan, store) in &rows { - let an = chain::analyze(store); - if an.diverged() { - diverged += 1; - } - if !an.orphans.is_empty() { - orphaned += 1; - } - rejected += store.rejected.len() + store.bad_events.len(); - problems.push_str(&problems_block(plan, store, &an)); - } - text.push_str(&problems); - - for (plan, store) in &rows { - let an = chain::analyze(store); - let goal = an - .head - .first() - .map(|h| h.version.goal.clone()) - .unwrap_or_default(); - let marker = if an.diverged() { - s::red("↕") - } else if !an.orphans.is_empty() { - s::yellow("*") - } else if an.head.len() == 1 { - s::green("✓") - } else { - s::dim("-") - }; - let retired = an.head.first().is_some_and(|h| h.version.retired); - text.push_str(&format!( - "{} {} {} {}\n", - marker, - s::bold(plan), - s::truncate(&goal, 44), - s::dim(&format!( - "{}{} · heads({}) · {}", - an.state(), - if retired { " · retired" } else { "" }, - an.head.len(), - s::count(store.versions.len(), "version") - )) - )); - - let mut fields = vec![ - ("plan", Json::str(plan)), - ("goal", Json::str(&goal)), - ("state", Json::str(an.state())), - ("retired", Json::Bool(retired)), - ("diverged", Json::Bool(an.diverged())), - ( - "head", - Json::arr( - an.head - .iter() - .map(|h| head_json(h, an.is_orphan(&h.hash))) - .collect(), - ), - ), - ("version_count", Json::num(store.versions.len() as i64)), - ("event_count", Json::num(store.events.len() as i64)), - ]; - fields.extend(problems_json(store, &an)); - entries.push(Json::obj(fields)); - } - - if rows.is_empty() { - text.push_str(&s::dim("no plans\n")); - text.push_str(&s::fix("compass new --goal --why ")); - text.push('\n'); - } - - text.push_str(&format!( - "\n{}\n", - s::dim(&format!( - "{} · {} diverged · {} orphaned · {} rejected", - s::count(rows.len(), "plan"), - diverged, - orphaned, - rejected - )) - )); - text.push('\n'); - text.push_str(&convergence_line(&conv)); - text.push('\n'); - - let json = Json::obj(vec![ - ("command", Json::str("status")), - ("catalog", Json::str(root.display().to_string())), - ("plan", Json::arr(entries)), - ( - "summary", - Json::obj(vec![ - ("plans", Json::num(rows.len() as i64)), - ("diverged", Json::num(diverged as i64)), - ("orphaned", Json::num(orphaned as i64)), - ("rejected", Json::num(rejected as i64)), - ]), - ), - ("convergence", convergence_json(&conv)), - ]); - - Ok(Output::ok(text, json)) -} - -fn cmd_reconcile( - root: &Path, author: &str, - plan: &str, - why: &str, - from: Option<&str>, - edits: &[StepEdit], ) -> Result { let store = load(root, plan)?; let an = chain::analyze(&store); + let (head, version) = head_for_step(&an, step)?; - if an.head.len() < 2 { + // Recorded fields are reserved: an attribute shadowing one is refused (0008). + if let Some(k) = Event::shadowing_attr(&sorted(attrs)) { return Err(format!( - "plan {plan} has {} head member(s); there is nothing to reconcile", - an.head.len() + "attribute `{k}` shadows the recorded field `{k}`, which a predicate term reads as \ + what Compass recorded (decision 0008); nothing was recorded" )); } - // An orphan is not a divergence and must never be reconciled. - if !an.orphans.is_empty() { - let orphan_heads: Vec<&str> = an - .head - .iter() - .filter(|h| an.is_orphan(&h.hash)) - .map(|h| h.hash.as_str()) - .collect(); - if !orphan_heads.is_empty() { - return Err(format!( - "refusing to reconcile: {} head member(s) are orphans, not divergent\n\n{}\n\n \ - An orphan is a version whose predecessor has not arrived. Reconciling around it \n \ - would write permanent intent to repair a transient condition.\n \ - fix: wait for replication, then re-check with `compass verify {plan}`", - orphan_heads.len(), - orphan_heads - .iter() - .map(|h| format!(" {} missing a predecessor", s::short(h))) - .collect::>() - .join("\n"), - )); - } - } - - if !an.diverged() { - return Err(format!( - "plan {plan} has several head members but none share a predecessor, so this is not a \ - divergence\n fix: compass verify {plan}" - )); + // Cross-check against the step's own criterion, and report a likely mistake + // without refusing (decision 0013). + let mut warning = None; + if let Some(s) = version.step(step) { + warning = crate::predicate::cross_check(kind, attrs, &[&s.accept]); } - // Which side's step graph carries forward. Compass never picks for you. - let base_steps: Vec = match from { - Some(prefix) => { - let chosen = store.resolve_hash(prefix).ok_or_else(|| { - format!("`--from {prefix}` matches no version, or matches more than one") - })?; - if !an.head.iter().any(|h| h.hash == chosen.hash) { - return Err(format!( - "`--from {prefix}` is not a head member; reconciliation carries forward one \ - of the diverged sides" - )); - } - chosen.version.steps.clone() - } - None => { - let first = &an.head[0].version.steps; - let identical = an.head.iter().all(|h| h.version.steps == *first); - if !identical { - let mut m = String::from( - "the diverged sides carry different step graphs, so Compass cannot choose \ - between them\n\n", - ); - for (i, h) in an.head.iter().enumerate() { - m.push_str(&format!( - " {}/{} {} {} {} step(s) {}\n", - i + 1, - an.head.len(), - s::short(&h.hash), - h.version.author, - h.version.steps.len(), - s::truncate(&h.version.why, 44) - )); - } - m.push_str(&format!( - "\n fix: compass reconcile {plan} --from --why \n \ - then adjust with --add-step / --edit-step / --retire-step\n" - )); - return Err(m); - } - first.clone() - } - }; - - let steps = apply_edits(&base_steps, edits)?; - let parents: Vec = an.head.iter().map(|h| h.hash.clone()).collect(); - let goal = an.head[0].version.goal.clone(); - - let v = Version { + let event = Event { + id: crate::refs::event_id()?, + at: store.next_event_at(), + wall: crate::event::now_wall(), plan: plan.to_string(), - seq: chain::next_seq(&an.head), - parents, - author: author.to_string(), - why: why.to_string(), - goal, - retired: false, - steps, - }; - Version::parse(&v.render()) - .map_err(|e| format!("refusing to write an invalid version: {e}"))?; - - report_write(root, &v, "reconciled", &Convergence::probe()) -} - -fn cmd_verify(root: &Path, plan: Option<&str>, all: bool) -> Result { - if !catalog::exists(root) { - return Err(format!( - "no catalog at {}\n fix: compass init", - root.display() - )); - } - let conv = Convergence::probe(); - let plans: Vec = if all { - catalog::list_plans(root)? - } else { - vec![plan - .expect("parser guarantees one of plan or --all") - .to_string()] + step: step.to_string(), + version: head.hash.clone(), + actor: author.to_string(), + kind: EventKind::Evidence, + note: None, + evidence_kind: Some(kind.to_string()), + attrs: sorted(attrs), }; - - let mut text = String::new(); - let mut entries: Vec = Vec::new(); - let mut failures = 0usize; - - for p in &plans { - let store = if all { - catalog::load_plan(root, p)? - } else { - load(root, p)? - }; - let an = chain::analyze(&store); - let bad = store.rejected.len() + store.bad_events.len(); - failures += bad; - - text.push_str(&problems_block(p, &store, &an)); - - // Chain integrity: every named predecessor is present, or the version - // is an orphan and reported as such. - let marker = if bad > 0 { - s::red("✗") - } else if !an.orphans.is_empty() { - s::yellow("*") - } else { - s::green("✓") - }; - text.push_str(&format!( - "{} {} {}\n", - marker, - s::bold(p), - s::dim(&format!( - "{} admitted · {} rejected · {} · {} · {}", - store.versions.len(), - bad, - s::count_of(an.head.len(), "head", "heads"), - s::count(an.orphans.len(), "orphan"), - an.state() - )) - )); - - let mut fields = vec![ - ("plan", Json::str(p)), - ("state", Json::str(an.state())), - ("admitted", Json::num(store.versions.len() as i64)), - ("rejected_count", Json::num(bad as i64)), - ("ok", Json::Bool(bad == 0)), - ( - "head", - Json::arr( - an.head - .iter() - .map(|h| head_json(h, an.is_orphan(&h.hash))) - .collect(), - ), - ), - ]; - fields.extend(problems_json(&store, &an)); - entries.push(Json::obj(fields)); + let path = catalog::write_event(root, &event)?; + + let mut text = format!( + "{} evidence {} on {} (against {})\n", + style::green("recorded"), + style::bold(step), + style::bold(plan), + style::short(&head.hash) + ); + if let Some(w) = &warning { + text.push_str(&format!(" {} {}\n", style::yellow("note:"), w)); } - - text.push_str(&format!( - "\n{}\n", - s::dim(&format!( - "{} · {} rejected", - s::count(plans.len(), "plan"), - s::count(failures, "file") - )) - )); - text.push('\n'); - text.push_str(&convergence_line(&conv)); - text.push('\n'); - let json = Json::obj(vec![ - ("command", Json::str("verify")), - ("plan", Json::arr(entries)), - ("ok", Json::Bool(failures == 0)), - ("rejected_count", Json::num(failures as i64)), - ("convergence", convergence_json(&conv)), + ("command", Json::str("evidence")), + ("plan", Json::str(plan)), + ("step", Json::str(step)), + ("evidenceKind", Json::str(kind)), + ("actor", Json::str(author)), + ("version", Json::str(&head.hash)), + ("event", Json::str(&event.id)), + ( + "attrs", + Json::Obj( + sorted(attrs) + .into_iter() + .map(|(k, v)| (k, Json::str(v))) + .collect(), + ), + ), + ( + "warning", + match &warning { + Some(w) => Json::str(w), + None => Json::Null, + }, + ), + ("path", Json::str(path.to_string_lossy())), ]); - - Ok(Output { - text, - json, - code: if failures == 0 { 0 } else { EXIT_FAILURE }, - }) -} - -/// Expose the resolved catalog root for diagnostics. -pub fn resolved_root(inv: &Invocation) -> Result { - match &inv.catalog { - Some(p) => Ok(p.clone()), - None => catalog::root(), - } + Ok(Output::ok(text, json)) } -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - /// A scratch catalog that cleans itself up. - struct Scratch { - root: PathBuf, - } - - impl Scratch { - fn new(tag: &str) -> Scratch { - let base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); - let unique = refs::mint(RefKind::Event).unwrap(); - let root = PathBuf::from(base).join(format!("compass-cmd-{tag}-{unique}")); - catalog::init(&root).unwrap(); - Scratch { root } - } - - /// Run one invocation against this catalog, as the CLI would. - fn run(&self, args: &[&str]) -> Result { - let mut argv = vec!["--catalog".to_string(), self.root.display().to_string()]; - argv.extend(args.iter().map(|s| s.to_string())); - execute(&crate::cli::parse(&argv)?) - } - - fn plan(&self) -> String { - catalog::list_plans(&self.root).unwrap().remove(0) - } - - fn store(&self) -> PlanStore { - catalog::load_plan(&self.root, &self.plan()).unwrap() - } - - fn version_count(&self) -> usize { - self.store().versions.len() - } - - /// The single head member. Panics if the plan has diverged. - fn head(&self) -> Version { - let store = self.store(); - let an = chain::analyze(&store); - assert_eq!(an.head.len(), 1, "expected one head member"); - an.head[0].version.clone() - } - } - - impl Drop for Scratch { - fn drop(&mut self) { - fn writable(p: &Path) -> std::io::Result<()> { - if p.is_dir() { - for e in fs::read_dir(p)? { - writable(&e?.path())?; - } - } else if p.is_file() { - let mut perms = fs::metadata(p)?.permissions(); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - fs::set_permissions(p, perms)?; - } - Ok(()) - } - let _ = writable(&self.root); - let _ = fs::remove_dir_all(&self.root); - } - } - - /// A plan with one step, ready to be revised. - fn a_plan(tag: &str) -> (Scratch, String, String) { - let s = Scratch::new(tag); - s.run(&[ - "new", - "--goal", - "Nested groups parse correctly", - "--why", - "Parser rejects nested groups.", - "--add-step", - "Reproduce with a failing test", - "--accept", - "test(name=nested, status=fail)", - ]) - .unwrap(); - let plan = s.plan(); - let step = s.head().steps[0].id.clone(); - (s, plan, step) - } - - /// The regression decision 0007 exists for: an agent revises, loses its - /// process before it can read the answer, and — correctly — retries. - /// - /// The retry re-reads the catalog and sees its own landed write, so it - /// cannot produce the same bytes as its first attempt: head has moved, so - /// the candidate names a different predecessor. What stops the duplicate - /// is that re-applying the same edits to the version that already carries - /// them changes no Step and no goal, and such a revision is refused. The - /// plan ends with exactly one new version and one stated reason for it. - #[test] - fn an_identical_revise_issued_twice_records_one_version() { - let (s, plan, step) = a_plan("crash-retry"); - let before = s.version_count(); - - let attempt = [ - "revise", - &plan, - "--why", - "Reproduction showed the tokenizer, not the grammar, drops it.", - "--edit-step", - &step, - "--work", - "Fix the tokenizer", - ]; - - s.run(&attempt).expect("the first attempt lands"); - assert_eq!(s.version_count(), before + 1); - let after_first = s.head(); - - // The response never reached the agent. It retries the same mutation. - let err = s - .run(&attempt) - .expect_err("the retry must not record a second version"); - assert!( - err.contains("changes no Step and no goal"), - "the refusal must say what is missing: {err}" - ); - assert!( - err.contains("did land"), - "and must say where the earlier attempt went: {err}" - ); - - assert_eq!( - s.version_count(), - before + 1, - "a correct retry must leave one version, not two" - ); - assert_eq!(s.head(), after_first, "and head must be unmoved"); - assert_eq!( - s.store() - .versions - .iter() - .filter(|a| a.version.why.contains("not the grammar")) - .count(), - 1, - "the rationale must appear once in the record, not twice" - ); - } - - /// The case no derived value can catch: the retry's bodies genuinely - /// differ, because the rationale was reworded. Only the rule refuses it. - #[test] - fn a_revision_differing_only_in_its_rationale_is_refused() { - let (s, plan, step) = a_plan("reworded-retry"); - s.run(&[ - "revise", - &plan, - "--why", - "The tokenizer drops it.", - "--edit-step", - &step, - "--work", - "Fix the tokenizer", - ]) - .unwrap(); - let before = s.version_count(); - - // Same mutation, different words for why. - let err = s - .run(&[ - "revise", - &plan, - "--why", - "It is the tokenizer that drops it, not the grammar.", - "--edit-step", - &step, - "--work", - "Fix the tokenizer", - ]) - .expect_err("a rationale-only revision is not expressible"); - assert!(err.contains("changes no Step and no goal"), "{err}"); - assert!( - err.contains("only restates why"), - "the refusal must name the case it is closing: {err}" - ); - - // And with no step edit at all — a bare change of mind. - let err = s - .run(&[ - "revise", - &plan, - "--why", - "We reconsidered and are keeping this plan.", - ]) - .expect_err("a deliberate non-change is deliberately not recordable"); - assert!(err.contains("changes no Step and no goal"), "{err}"); - - assert_eq!(s.version_count(), before); - } - - /// Retirement changes the plan, so the empty-revision rule must let it - /// through — of the plan and of a single step alike. - #[test] - fn retirement_is_not_an_empty_revision() { - let (s, plan, step) = a_plan("retire"); - let before = s.version_count(); - - s.run(&[ - "revise", - &plan, - "--why", - "Superseded by the rewrite.", - "--retire", - ]) - .expect("retiring the plan changes it"); - assert_eq!(s.version_count(), before + 1); - assert!(s.head().retired); - - s.run(&[ - "revise", - &plan, - "--why", - "This step is no longer worth doing.", - "--retire-step", - &step, - ]) - .expect("retiring a step changes it"); - assert_eq!(s.version_count(), before + 2); - assert!(s.head().steps[0].retired); - - // Retiring what is already retired changes nothing, and is refused — - // which is the rule working, not an exception to it. - let err = s - .run(&[ - "revise", - &plan, - "--why", - "Retiring it again.", - "--retire-step", - &step, - ]) - .expect_err("a second retirement of the same step changes nothing"); - assert!(err.contains("changes no Step and no goal"), "{err}"); - assert_eq!(s.version_count(), before + 2); - } - - /// A Reconciliation always changes the predecessor set, so it is never an - /// empty revision — even when both sides carry an identical step graph and - /// goal, which is precisely when the naive rule would misfire. - #[test] - fn reconciliation_is_not_an_empty_revision() { - let (s, plan, _step) = a_plan("reconcile"); - let base = s.head(); - let base_hash = base.hash(); - - // Two machines revise the same parent, differing only in the words — - // so the two sides agree on every step and on the goal. - for why in ["The tokenizer drops it.", "The grammar is fine."] { - let side = Version { - plan: plan.clone(), - seq: base.seq + 1, - parents: vec![base_hash.clone()], - author: "cos".into(), - why: why.into(), - goal: base.goal.clone(), - retired: false, - steps: base.steps.clone(), - }; - catalog::write_version(&s.root, &side).unwrap(); - } - let before = s.version_count(); - assert!(chain::analyze(&s.store()).diverged()); - - s.run(&[ - "reconcile", - &plan, - "--why", - "Both sides said the same thing.", - ]) - .expect("a reconciliation joins predecessors, so it always changes something"); - - assert_eq!(s.version_count(), before + 1); - let head = s.head(); - assert_eq!(head.parents.len(), 2, "it names both sides"); - assert_eq!(head.steps, base.steps, "carrying the agreed graph forward"); - } - - // --- evidence is a claim (decision 0008) ----------------------------- - - /// A plan whose one step demands approval from a named actor. - fn a_plan_needing_an_editor(tag: &str) -> (Scratch, String, String) { - let s = Scratch::new(tag); - s.run(&[ - "new", - "--goal", - "Publish the piece", - "--why", - "It is drafted and needs a second reader.", - "--add-step", - "Get the draft approved", - "--accept", - "review(actor=editor, verdict=approved)", - ]) - .unwrap(); - let plan = s.plan(); - let step = s.head().steps[0].id.clone(); - (s, plan, step) - } - - fn accepted(s: &Scratch) -> bool { - let store = s.store(); - let an = chain::analyze(&store); - let r = readiness::for_head(&store, an.head[0], false); - r.steps[0].state == StepState::Accepted - } - - #[test] - fn a_writer_cannot_forge_an_approval_by_claiming_the_actor() { - let (s, plan, step) = a_plan_needing_an_editor("forge"); - - // The forgery: the writer names someone else in the attributes. - let err = s - .run(&[ - "--author", - "writer", - "evidence", - &plan, - &step, - "review", - "actor=editor", - "verdict=approved", - ]) - .expect_err("an attribute shadowing a recorded field must be refused"); - assert!(err.contains("actor"), "{err}"); - assert!(err.contains("shadows"), "{err}"); - - // Nothing was written, so nothing was accepted. - assert!(s.store().events.is_empty()); - assert!(!accepted(&s)); - - // Even the attributes the writer *may* record do not satisfy it, - // because the recorded actor is not the editor. - s.run(&[ - "--author", - "writer", - "evidence", - &plan, - &step, - "review", - "verdict=approved", - ]) - .unwrap(); - assert!( - !accepted(&s), - "a criterion naming an approver must not be satisfiable by anyone else" - ); - - // The genuine article: recorded by the editor. - s.run(&[ - "--author", - "editor", - "evidence", - &plan, - &step, - "review", - "verdict=approved", - ]) - .unwrap(); - assert!(accepted(&s), "a genuine editor approval must satisfy it"); - } - - #[test] - fn every_recorded_field_is_refused_as_an_evidence_attribute() { - let (s, plan, step) = a_plan_needing_an_editor("shadow-all"); - for field in crate::event::RECORDED_FIELDS { - let err = s - .run(&["evidence", &plan, &step, "review", &format!("{field}=x")]) - .expect_err(&format!("attribute `{field}` shadows a recorded field")); - assert!(err.contains(field), "{err}"); - } - } - - #[test] - fn an_ordinary_attribute_is_still_free() { - let (s, plan, step) = a_plan_needing_an_editor("free-attr"); - s.run(&["evidence", &plan, &step, "review", "reviewer=editor"]) - .expect("`reviewer` is not a recorded field and stays available"); - assert_eq!(s.store().events.len(), 1); - } - - // --- structural change reporting ------------------------------------- - - #[test] - fn show_reports_what_each_version_changed() { - let (s, plan, step) = a_plan("show-changes"); - s.run(&[ - "revise", - &plan, - "--why", - "Adding the fix once the repro exists.", - "--add-step", - "Fix the parser", - "--accept", - "test(name=nested, status=pass)", - ]) - .unwrap(); - s.run(&[ - "revise", - &plan, - "--why", - "The repro is no longer the shape we need.", - "--retire-step", - &step, - ]) - .unwrap(); - - let out = s.run(&["show", &plan]).unwrap(); - assert!(out.text.contains("states the initial plan"), "{}", out.text); - assert!(out.text.contains("changes vs "), "{}", out.text); - assert!(out.text.contains("added"), "{}", out.text); - assert!(out.text.contains("retired"), "{}", out.text); - } - - #[test] - fn a_divergent_rationale_is_never_elided() { - let (s, plan, _step) = a_plan("full-why"); - let long = "One of the three changed its storage engine mid-benchmark, so the numbers \ - are not comparable and the conclusion drawn from them does not stand."; - let base = s.head(); - for author in ["a", "b"] { - let side = Version { - plan: plan.clone(), - seq: base.seq + 1, - parents: vec![base.hash()], - author: author.into(), - why: long.to_string(), - goal: base.goal.clone(), - retired: false, - steps: base.steps.clone(), - }; - catalog::write_version(&s.root, &side).unwrap(); - } - assert!(chain::analyze(&s.store()).diverged()); - - let out = s.run(&["status"]).unwrap(); - assert!( - !out.text.contains('…'), - "a rationale was elided:\n{}", - out.text - ); - // Every word survives, even though the line does not. - for word in long.split_whitespace() { - assert!( - out.text.contains(word), - "`{word}` missing from:\n{}", - out.text - ); - } - } - - #[test] - fn counts_agree_with_their_nouns() { - let (s, plan, step) = a_plan("plurals"); - s.run(&["progress", &plan, &step, "start"]).unwrap(); - let out = s.run(&["show", &plan]).unwrap(); - assert!(out.text.contains("1 event"), "{}", out.text); - assert!(!out.text.contains("1 events"), "{}", out.text); - assert!(out.text.contains("1 head"), "{}", out.text); - assert!(!out.text.contains("1 heads"), "{}", out.text); - - s.run(&["progress", &plan, &step, "update"]).unwrap(); - let out = s.run(&["show", &plan]).unwrap(); - assert!(out.text.contains("2 events"), "{}", out.text); - } - - /// Nothing a command reports may name a field the model no longer has. - #[test] - fn no_command_reports_a_logical_clock_for_a_version() { - let (s, plan, _step) = a_plan("no-clock"); - for args in [ - vec!["show", &plan], - vec!["ready", &plan], - vec!["status"], - vec!["verify", &plan], - ] { - let out = s.run(&args).unwrap(); - assert!( - !out.text.contains("at="), - "`{}` still reports a version clock:\n{}", - args.join(" "), - out.text - ); - } - } +fn sorted(attrs: &[(String, String)]) -> Vec<(String, String)> { + let mut v = attrs.to_vec(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v } diff --git a/src/eval.rs b/src/eval.rs index c56dc0e..b43236a 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -484,6 +484,43 @@ fn first_diag(errs: &[T]) -> String { .unwrap_or_else(|| "syntax error".to_string()) } +/// The import specifiers a module declares, statically (no evaluation). +/// +/// A version references its predecessors by importing their files, so the +/// lineage can be walked from source bytes alone — admission never runs a module +/// (02-artifacts). Returns every specifier, including `"compass"`. +pub fn import_specifiers(source: &str, path: &Path) -> Result, EvalError> { + use oxc_allocator::Allocator; + use oxc_ast::ast::Statement; + use oxc_parser::Parser; + use oxc_span::SourceType; + + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let ret = Parser::new(&allocator, source, source_type).parse(); + if ret.panicked { + return Err(EvalError::Failed(format!( + "{}: cannot parse: {}", + path.display(), + first_diag(&ret.diagnostics) + ))); + } + let mut specs = Vec::new(); + for stmt in &ret.program.body { + match stmt { + Statement::ImportDeclaration(import) => specs.push(import.source.value.to_string()), + Statement::ExportNamedDeclaration(e) => { + if let Some(src) = &e.source { + specs.push(src.value.to_string()); + } + } + Statement::ExportAllDeclaration(e) => specs.push(e.source.value.to_string()), + _ => {} + } + } + Ok(specs) +} + /// Append one registration call per exported top-level `const`, so a step /// learns the name it was declared under. Consumption is lazy (see the prelude), /// so appending after the module body is correct. diff --git a/src/event.rs b/src/event.rs index 4226be0..1b6c2ec 100644 --- a/src/event.rs +++ b/src/event.rs @@ -19,7 +19,7 @@ //! reading the log, and nothing orders on it. use crate::block::{parse as parse_block, Block, Doc, ParseError}; -use crate::model::EXT; +use crate::model::EVENT_EXT; use crate::predicate::Evidence; use std::time::{SystemTime, UNIX_EPOCH}; @@ -248,7 +248,7 @@ impl Event { /// Storage filename: `-.`, zero-padded so a directory listing /// sorts in logical order. pub fn filename(&self) -> String { - format!("{:012}-{}.{}", self.at, self.id, EXT) + format!("{:012}-{}.{}", self.at, self.id, EVENT_EXT) } } diff --git a/src/lib.rs b/src/lib.rs index d125e9f..9849f63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,14 @@ //! Plan Versions carrying structural intent, and append-only Progress Events //! carrying execution. Head, Readiness and lineage are derived, never stored. //! -//! Zero external crates, deliberately: it keeps the build offline-capable and -//! avoids baking in a serialization format while DQ02 is open. +//! A version is a TypeScript module (decision 0014); reading it is evaluating +//! it in an embedded, capability-free JavaScript engine (see [`eval`]). +//! rquickjs (QuickJS-ng) and oxc are the crate's only external dependencies, +//! authorised by decision 0011. pub mod block; pub mod catalog; pub mod chain; -pub mod change; pub mod cli; pub mod cmd; pub mod convergence; diff --git a/src/model.rs b/src/model.rs index 25c16a3..550427a 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,47 +1,41 @@ -//! Plan Versions and Steps — the documents Compass owns (CMP.INT-R02). +//! The domain model of a Plan Version, recovered by evaluation. //! -//! A Plan Version is immutable (CMP-R02), carries a required Rationale -//! (CMP-R03), records its author, and names each predecessor by content hash. -//! Its identity *is* the hash of its rendered bytes, so rendering must be -//! canonical: same intent, same bytes, same hash, on every machine. -//! -//! Identity is derived and no field is excluded from it (decision 0007), so a -//! name always determines the content. Nothing here records a clock or a -//! counter of what the author had seen: order is read from the lineage and -//! attribution is the `author` field alone (CMP.DM-R07). - -use crate::block::{parse as parse_block, Block, Doc, ParseError}; +//! A version is a module (decision 0014): its identity is the SHA-256 of its +//! authored source bytes, and reading it means evaluating it (see [`crate::eval`]). +//! This module holds the *evaluated* shape — goal, rationale, author, and the +//! Steps with their declared identities — plus the structural checks a commit +//! must pass. Nothing here parses or renders a stored form: there is no stored +//! form but the authored source. + +use crate::eval::{SemStep, SemVersion}; use crate::predicate::Pred; -use crate::sha256::sha256_hex; -/// File extension for both versions and events (DQ02 — provisional). -pub const EXT: &str = "cmp"; - -/// Number of hex characters of the content hash embedded in a filename. -/// -/// The full hash is the identity; this prefix only makes the file findable and -/// human-referable. Admission always checks the full content, never the prefix -/// alone. +/// File extension for a version module. +pub const VERSION_EXT: &str = "ts"; +/// File extension for a progress event. +pub const EVENT_EXT: &str = "cmp"; +/// Hex characters of the content hash embedded in a filename. The full hash is +/// the identity; this prefix only makes a file findable and referable. pub const HASH_PREFIX_LEN: usize = 12; /// A unit of intended work within a Plan. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Step { - /// Minted StepRef — never derived from content (decision 0004). + /// StepRef — the name the step was declared under (decision 0012). pub id: String, pub work: String, - /// Sorted StepRefs this step depends on. + /// StepRefs this step depends on, sorted. pub depends_on: Vec, /// The StepRef this step replaces, when intended work changed identity. pub supersedes: Option, - /// Machine-checkable acceptance (CMP-R11). Required: a step whose - /// acceptance cannot be evaluated could never complete, and would block - /// every dependent forever. + /// Machine-checkable acceptance (decision 0006). pub accept: Pred, pub retired: bool, } impl Step { + /// A step with no dependencies, superseding nothing — mostly for tests and + /// for building a starter version. pub fn new(id: impl Into, work: impl Into, accept: Pred) -> Step { Step { id: id.into(), @@ -52,17 +46,31 @@ impl Step { retired: false, } } + + fn from_sem(s: &SemStep) -> Step { + Step { + id: s.name.clone(), + work: s.work.clone(), + depends_on: s.depends_on.clone(), + supersedes: s.supersedes.clone(), + accept: s.accept.clone(), + retired: s.retired, + } + } } -/// An immutable, content-addressed snapshot of a Plan's structural intent. +/// An immutable snapshot of a Plan's structural intent, as evaluated. +/// +/// `plan`, `seq`, and `parents` are supplied by the catalog (the plan is the +/// directory, the parents are the imported predecessor files, the seq is a +/// reading aid). Everything else is declared by the module and recovered by +/// evaluation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Version { pub plan: String, - /// Position along this version's lineage. A reading aid, never a key: - /// divergent versions may share a `seq` and nothing resolves on it. + /// Position along this version's lineage. A reading aid, never a key. pub seq: u64, - /// Content hash of each predecessor, sorted. Empty for the first version, - /// one ordinarily, several for a Reconciliation. + /// Content hashes of each predecessor (resolved), sorted. pub parents: Vec, pub author: String, /// Required Rationale (CMP-R03). @@ -73,162 +81,72 @@ pub struct Version { } impl Version { - /// Render canonically. These bytes are what gets hashed and written. - /// - /// Field order is fixed and follows the spec's field table. Set-valued - /// keys (`parent`, `depends_on`) are sorted, because they carry no order. - /// Step order is *preserved* as authored: a plan is read top to bottom and - /// reordering steps is a change to the document, not noise to normalise. - pub fn render(&self) -> String { - let mut doc = Doc::new(); - - let mut v = Block::new("version", None); - v.set("plan", &self.plan); - v.set("seq", self.seq.to_string()); - let mut parents = self.parents.clone(); + /// Assemble a domain version from an evaluated module plus the catalog-side + /// facts (which plan, which lineage position, which predecessors). + pub fn from_sem(plan: &str, seq: u64, parents: Vec, sem: &SemVersion) -> Version { + let mut parents = parents; parents.sort(); - v.set_many("parent", parents); - v.set("author", &self.author); - v.set("why", &self.why); - v.set("goal", &self.goal); - if self.retired { - v.set("retired", "true"); - } - doc.push(v); - - for step in &self.steps { - let mut s = Block::new("step", Some(step.id.clone())); - s.set("work", &step.work); - let mut deps = step.depends_on.clone(); - deps.sort(); - s.set_many("depends_on", deps); - s.set_opt("supersedes", step.supersedes.clone()); - s.set("accept", step.accept.to_string()); - if step.retired { - s.set("retired", "true"); - } - doc.push(s); + Version { + plan: plan.to_string(), + seq, + parents, + author: sem.author.clone(), + why: sem.why.clone(), + goal: sem.goal.clone(), + retired: sem.retired, + steps: sem.steps.iter().map(Step::from_sem).collect(), } - - doc.render() } - /// The content hash: the identity of this version. - pub fn hash(&self) -> String { - sha256_hex(self.render().as_bytes()) + /// Look up a step by ref. + pub fn step(&self, id: &str) -> Option<&Step> { + self.steps.iter().find(|s| s.id == id) } - /// Parse a version from its serialized bytes. - pub fn parse(text: &str) -> Result { - let doc = parse_block(text)?; - - let vb = doc - .first("version") - .ok_or_else(|| ParseError::new("no `@version` block"))?; - - let plan = vb.require("plan")?.to_string(); - let seq = parse_u64(vb.require("seq")?, "seq")?; - let author = vb.require("author")?.to_string(); - let why = vb.require("why")?.to_string(); - let goal = vb.require("goal")?.to_string(); - let retired = parse_flag(vb.get("retired"), "version retired")?; - - let mut parents: Vec = vb - .all("parent") - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - parents.sort(); - parents.dedup(); - - let mut steps = Vec::new(); - for sb in doc.of_kind("step") { - let id = sb - .arg - .clone() - .ok_or_else(|| ParseError::new("`@step` block has no StepRef argument"))?; - let work = sb.require("work")?.to_string(); - let accept_src = sb.require("accept")?; - let accept = crate::predicate::parse(accept_src) - .map_err(|e| ParseError::new(format!("step {id}: cannot parse `accept`: {e}")))?; - let mut depends_on: Vec = sb - .all("depends_on") - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - depends_on.sort(); - depends_on.dedup(); - let supersedes = sb.get("supersedes").map(|s| s.to_string()); - let retired = parse_flag(sb.get("retired"), "step retired")?; - - steps.push(Step { - id, - work, - depends_on, - supersedes, - accept, - retired, - }); - } - - let version = Version { - plan, - seq, - parents, - author, - why, - goal, - retired, - steps, - }; - version.validate()?; - Ok(version) + /// Whether this version would record no change of intent against `parent`. + /// A revision must alter a Step or the goal (CMP.DM-R07b); the rationale and + /// author are deliberately not compared. + pub fn changes_nothing_from(&self, parent: &Version) -> bool { + self.steps == parent.steps && self.goal == parent.goal && self.retired == parent.retired } - /// Structural checks that parsing alone does not cover. - fn validate(&self) -> Result<(), ParseError> { + /// Structural checks a committed version must pass: no duplicate step, every + /// dependency present, and no dependency cycle (which would make readiness + /// unexplainable). These run against an evaluated module at commit time. + pub fn validate(&self) -> Result<(), String> { let mut seen: Vec<&str> = Vec::new(); for s in &self.steps { if seen.contains(&s.id.as_str()) { - return Err(ParseError::new(format!( - "step {} appears more than once in one version", - s.id - ))); + return Err(format!("step {} appears more than once", s.id)); } seen.push(&s.id); } for s in &self.steps { for d in &s.depends_on { if d == &s.id { - return Err(ParseError::new(format!("step {} depends on itself", s.id))); + return Err(format!("step {} depends on itself", s.id)); } if !seen.contains(&d.as_str()) { - return Err(ParseError::new(format!( + return Err(format!( "step {} depends on {d}, which is not a step of this version", s.id - ))); + )); } } } - self.reject_dependency_cycles()?; - Ok(()) + self.reject_dependency_cycles() } - /// A cycle in `depends_on` makes every step in it permanently unready, and - /// readiness would explain each step by pointing at the next one forever. - /// That is a defined answer but a useless one, so a cyclic plan is refused - /// at the door rather than admitted and then reported as stuck. - fn reject_dependency_cycles(&self) -> Result<(), ParseError> { - // Iterative depth-first search with an explicit colour map. + /// A cycle in `depends_on` makes every step in it permanently unready, so a + /// cyclic plan is refused rather than admitted and then reported as stuck. + fn reject_dependency_cycles(&self) -> Result<(), String> { #[derive(Clone, Copy, PartialEq)] enum Mark { Unvisited, InProgress, Done, } - let mut marks: Vec = vec![Mark::Unvisited; self.steps.len()]; + let mut marks = vec![Mark::Unvisited; self.steps.len()]; let index = |id: &str| self.steps.iter().position(|s| s.id == id); for start in 0..self.steps.len() { @@ -244,11 +162,10 @@ impl Version { if let Some(next) = index(dep) { match marks[next] { Mark::InProgress => { - return Err(ParseError::new(format!( - "dependency cycle: step {} and {} depend on each other, \ - directly or transitively", + return Err(format!( + "dependency cycle: step {} and {} depend on each other", self.steps[next].id, self.steps[node].id - ))); + )); } Mark::Unvisited => { marks[next] = Mark::InProgress; @@ -264,43 +181,21 @@ impl Version { } Ok(()) } - - /// Look up a step by ref. - pub fn step(&self, id: &str) -> Option<&Step> { - self.steps.iter().find(|s| s.id == id) - } - - /// Whether this version would record no change of intent against `parent`. - /// - /// A revision must alter a Step or the goal (CMP.DM-R07b). Retirement — - /// of the plan or of a step — is a change and is not caught here. The - /// Rationale and the author are deliberately *not* compared: a retry whose - /// rationale was reworded is precisely the duplicate this rule exists to - /// refuse, and no derived value can detect it, because the bodies then - /// genuinely differ. - pub fn changes_nothing_from(&self, parent: &Version) -> bool { - self.steps == parent.steps && self.goal == parent.goal && self.retired == parent.retired - } - - /// The filename this version is stored under: `-.`. - pub fn filename(&self) -> String { - filename_for(self.seq, &self.hash()) - } } -/// Build the storage filename for a version. +/// Build the storage filename for a version: `-.ts`. pub fn filename_for(seq: u64, hash: &str) -> String { format!( "{:03}-{}.{}", seq, &hash[..HASH_PREFIX_LEN.min(hash.len())], - EXT + VERSION_EXT ) } /// Split a version filename into its sequence and hash prefix. pub fn parse_filename(name: &str) -> Option<(u64, String)> { - let stem = name.strip_suffix(&format!(".{EXT}"))?; + let stem = name.strip_suffix(&format!(".{VERSION_EXT}"))?; let (seq, hash) = stem.split_once('-')?; if hash.len() != HASH_PREFIX_LEN || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { return None; @@ -308,336 +203,73 @@ pub fn parse_filename(name: &str) -> Option<(u64, String)> { Some((seq.parse().ok()?, hash.to_string())) } -fn parse_u64(s: &str, field: &str) -> Result { - s.trim().parse().map_err(|_| { - ParseError::new(format!( - "`{field}` must be a non-negative integer, got `{s}`" - )) - }) -} - -fn parse_flag(v: Option<&str>, what: &str) -> Result { - match v.map(str::trim) { - None => Ok(false), - Some("true") => Ok(true), - Some("false") => Ok(false), - Some(other) => Err(ParseError::new(format!( - "`{what}` must be `true` or `false`, got `{other}`" - ))), - } -} - #[cfg(test)] mod tests { use super::*; + use crate::eval::SemStep; use crate::predicate::parse as pred; - fn sample() -> Version { - let mut a = Step::new( - "st_A000000001", - "Reproduce with a failing test", - pred("test(name=parser::nested_groups, status=fail)").unwrap(), - ); - a.retired = false; - let mut b = Step::new( - "st_B000000002", - "Fix the tokenizer", - pred("test(name=parser::nested_groups, status=pass)").unwrap(), - ); - b.depends_on = vec!["st_A000000001".into()]; - Version { - plan: "pl_7000000000".into(), - seq: 1, - parents: vec![], - author: "cos".into(), - why: "Initial plan. Parser rejects nested groups.".into(), - goal: "Nested groups parse correctly".into(), + fn sem_step(name: &str, deps: &[&str]) -> SemStep { + SemStep { + name: name.into(), + work: format!("work for {name}"), + depends_on: deps.iter().map(|d| d.to_string()).collect(), + supersedes: None, + accept: pred("test(status=pass)").unwrap(), retired: false, - steps: vec![a, b], } } - #[test] - fn round_trips_through_render_and_parse() { - let v = sample(); - let parsed = Version::parse(&v.render()).unwrap(); - assert_eq!(parsed, v); - assert_eq!(parsed.render(), v.render()); - assert_eq!(parsed.hash(), v.hash()); - } - - #[test] - fn round_trips_a_reconciliation_with_several_parents() { - let mut v = sample(); - v.parents = vec!["c3d4".repeat(16), "a1b2".repeat(16)]; - v.seq = 4; - let parsed = Version::parse(&v.render()).unwrap(); - // Parents come back sorted, so identity does not depend on the order - // the operator happened to name them. - assert_eq!(parsed.parents, { - let mut p = v.parents.clone(); - p.sort(); - p - }); - assert_eq!(parsed.hash(), v.hash()); - } - - #[test] - fn round_trips_a_multiline_rationale() { - let mut v = sample(); - v.why = - "Reproduction showed the tokenizer,\nnot the grammar, drops it.\n\nRetargeting.".into(); - let parsed = Version::parse(&v.render()).unwrap(); - assert_eq!(parsed.why, v.why); - assert_eq!(parsed.hash(), v.hash()); - } - - #[test] - fn round_trips_retirement_flags() { - let mut v = sample(); - v.retired = true; - v.steps[0].retired = true; - v.steps[1].supersedes = Some("st_A000000001".into()); - let parsed = Version::parse(&v.render()).unwrap(); - assert_eq!(parsed, v); - } - - #[test] - fn hash_is_stable_across_equal_intent() { - assert_eq!(sample().hash(), sample().hash()); - } - - #[test] - fn hash_changes_when_any_field_changes() { - let base = sample().hash(); - let mut v = sample(); - v.why = "Different reason.".into(); - assert_ne!(v.hash(), base); - - let mut v = sample(); - v.author = "someone-else".into(); - assert_ne!(v.hash(), base); - - let mut v = sample(); - v.steps[0].work = "Reworded".into(); - assert_ne!(v.hash(), base); - } - - /// Decision 0007: no field is excluded from identity, so a name always - /// determines the content. The rendered body carries exactly the fields - /// the struct carries — in particular there is no `at` to reintroduce. - #[test] - fn identity_covers_the_whole_body() { - let text = sample().render(); - assert!( - !text.lines().any(|l| l.trim_start().starts_with("at =")), - "a version records no clock or counter:\n{text}" - ); - } - - /// Two authors making the same revision from the same parent write the - /// same bytes, so they converge on one version rather than diverging. - /// Identical intent is identical. - #[test] - fn equal_intent_from_equal_parents_hashes_equally() { - let mut a = sample(); - a.parents = vec!["ab".repeat(32)]; - a.seq = 2; - let b = a.clone(); - assert_eq!(a.hash(), b.hash()); - } - - #[test] - fn a_revision_altering_neither_step_nor_goal_changes_nothing() { - let parent = sample(); - let mut same = parent.clone(); - same.why = "Reworded rationale, identical intent.".into(); - same.author = "another-agent".into(); - same.seq = parent.seq + 1; - same.parents = vec![parent.hash()]; - assert!(same.changes_nothing_from(&parent)); - } - - #[test] - fn altering_a_step_a_goal_or_retirement_is_a_change() { - let parent = sample(); - - let mut edited = parent.clone(); - edited.steps[0].work = "Reproduce with two failing tests".into(); - assert!(!edited.changes_nothing_from(&parent)); - - let mut regoaled = parent.clone(); - regoaled.goal = "Nested groups parse, and so do quantifiers".into(); - assert!(!regoaled.changes_nothing_from(&parent)); - - let mut retired_plan = parent.clone(); - retired_plan.retired = true; - assert!(!retired_plan.changes_nothing_from(&parent)); - - let mut retired_step = parent.clone(); - retired_step.steps[0].retired = true; - assert!(!retired_step.changes_nothing_from(&parent)); - - let mut added = parent.clone(); - added.steps.push(Step::new( - "st_C000000003", - "Third", - pred("test(status=pass)").unwrap(), - )); - assert!(!added.changes_nothing_from(&parent)); - } - - #[test] - fn parent_order_does_not_affect_identity() { - let mut a = sample(); - a.parents = vec!["aa".repeat(32), "bb".repeat(32)]; - let mut b = sample(); - b.parents = vec!["bb".repeat(32), "aa".repeat(32)]; - assert_eq!(a.hash(), b.hash()); - } - - #[test] - fn attribute_order_in_accept_does_not_affect_identity() { - let mut a = sample(); - a.steps[0].accept = pred("test(status=fail, name=parser::nested_groups)").unwrap(); - assert_eq!(a.hash(), sample().hash()); - } - - #[test] - fn requires_a_rationale() { - let v = sample(); - let text = v - .render() - .replace("why = Initial plan. Parser rejects nested groups.\n", ""); - let err = Version::parse(&text).unwrap_err(); - assert!(err.message.contains("why"), "{err}"); - } - - #[test] - fn requires_each_mandatory_field() { - for field in ["plan", "seq", "author", "goal"] { - let v = sample(); - let text: String = v - .render() - .lines() - .filter(|l| !l.starts_with(&format!("{field} = "))) - .collect::>() - .join("\n"); - assert!( - Version::parse(&text).is_err(), - "missing `{field}` should be rejected" - ); - } - } - - #[test] - fn rejects_a_step_without_machine_checkable_acceptance() { - let v = sample(); - let text: String = v - .render() - .lines() - .filter(|l| !l.starts_with("accept = ")) - .collect::>() - .join("\n"); - assert!(Version::parse(&text).is_err()); - } - - #[test] - fn rejects_a_dependency_on_an_unknown_step() { - let mut v = sample(); - v.steps[1].depends_on = vec!["st_Z000000000".into()]; - let err = Version::parse(&v.render()).unwrap_err(); - assert!(err.message.contains("not a step"), "{err}"); - } - - #[test] - fn rejects_a_self_dependency() { - let mut v = sample(); - v.steps[1].depends_on = vec!["st_B000000002".into()]; - assert!(Version::parse(&v.render()).is_err()); + fn version(steps: Vec) -> Version { + let sem = SemVersion { + author: "cos".into(), + why: "because".into(), + goal: "Ship".into(), + retired: false, + steps, + }; + Version::from_sem("pl_x", 1, vec![], &sem) } #[test] - fn rejects_a_direct_dependency_cycle() { - let mut v = sample(); - v.steps[0].depends_on = vec!["st_B000000002".into()]; - v.steps[1].depends_on = vec!["st_A000000001".into()]; - let err = Version::parse(&v.render()).unwrap_err(); - assert!(err.message.contains("cycle"), "{err}"); - } + fn a_diamond_is_valid_but_a_cycle_is_not() { + let ok = version(vec![ + sem_step("a", &[]), + sem_step("b", &["a"]), + sem_step("c", &["a"]), + sem_step("d", &["b", "c"]), + ]); + assert!(ok.validate().is_ok()); - #[test] - fn rejects_a_transitive_dependency_cycle() { - let mut v = sample(); - v.steps.push(Step::new( - "st_C000000003", - "Third", - pred("test(status=pass)").unwrap(), - )); - // A -> C -> B -> A - v.steps[0].depends_on = vec!["st_C000000003".into()]; - v.steps[1].depends_on = vec!["st_A000000001".into()]; - v.steps[2].depends_on = vec!["st_B000000002".into()]; - assert!(Version::parse(&v.render()).is_err()); + let cyclic = version(vec![sem_step("a", &["b"]), sem_step("b", &["a"])]); + assert!(cyclic.validate().unwrap_err().contains("cycle")); } #[test] - fn accepts_a_diamond_which_is_not_a_cycle() { - let mut v = sample(); - v.steps.push(Step::new( - "st_C000000003", - "Third", - pred("test(status=pass)").unwrap(), - )); - v.steps.push(Step::new( - "st_D000000004", - "Fourth", - pred("test(status=pass)").unwrap(), - )); - // B and C both depend on A; D depends on both B and C. - v.steps[1].depends_on = vec!["st_A000000001".into()]; - v.steps[2].depends_on = vec!["st_A000000001".into()]; - v.steps[3].depends_on = vec!["st_B000000002".into(), "st_C000000003".into()]; - assert!(Version::parse(&v.render()).is_ok()); + fn a_dependency_on_an_unknown_step_is_refused() { + let v = version(vec![sem_step("a", &["ghost"])]); + assert!(v.validate().unwrap_err().contains("not a step")); } #[test] - fn rejects_duplicate_steps() { - let mut v = sample(); - v.steps[1].id = v.steps[0].id.clone(); - assert!(Version::parse(&v.render()).is_err()); - } + fn changes_nothing_ignores_rationale_and_author() { + let a = version(vec![sem_step("a", &[])]); + let mut b = a.clone(); + b.why = "reworded".into(); + b.author = "someone".into(); + assert!(b.changes_nothing_from(&a)); - #[test] - fn rejects_a_non_boolean_retired_flag() { - let v = sample(); - let text = v.render().replace("goal = ", "retired = yes\ngoal = "); - assert!(Version::parse(&text).is_err()); - } - - #[test] - fn filenames_embed_seq_and_hash_prefix() { - let v = sample(); - let name = v.filename(); - let (seq, prefix) = parse_filename(&name).unwrap(); - assert_eq!(seq, 1); - assert_eq!(prefix, v.hash()[..HASH_PREFIX_LEN]); - assert!(name.ends_with(".cmp")); - assert!(name.starts_with("001-")); + let mut c = a.clone(); + c.goal = "New goal".into(); + assert!(!c.changes_nothing_from(&a)); } #[test] - fn filename_parsing_rejects_foreign_names() { + fn filenames_round_trip() { + let name = filename_for(2, &"a".repeat(64)); + assert_eq!(name, format!("002-{}.ts", "a".repeat(12))); + assert_eq!(parse_filename(&name), Some((2, "a".repeat(12)))); assert!(parse_filename("README.md").is_none()); - assert!(parse_filename("001-nothex000000.cmp").is_none()); - assert!(parse_filename("001-abc.cmp").is_none(), "short hash"); - assert!(parse_filename("noseparator.cmp").is_none()); - } - - #[test] - fn a_version_with_no_steps_is_valid() { - let mut v = sample(); - v.steps.clear(); - assert_eq!(Version::parse(&v.render()).unwrap(), v); + assert!(parse_filename("001-short.ts").is_none()); } } diff --git a/src/predicate.rs b/src/predicate.rs index 10af6ed..1f99d48 100644 --- a/src/predicate.rs +++ b/src/predicate.rs @@ -115,6 +115,58 @@ impl Evidence { } impl Pred { + /// Detect a criterion that contradicts itself, refused when written + /// (decision 0013). This is the strictly-smaller, always-keepable claim: a + /// syntactic contradiction — the same positive atom required and negated + /// within one conjunction — never whether a criterion *can* be satisfied, + /// which is undecidable here. Returns the offending atom's rendering. + pub fn self_contradiction(&self) -> Option { + match self { + Pred::All(ps) => { + // Positive atoms and directly-negated atoms in this conjunction, + // compared by canonical rendering. + let mut positives: Vec = Vec::new(); + let mut negatives: Vec = Vec::new(); + for p in ps { + match p { + Pred::Atom { .. } => positives.push(p.to_string()), + Pred::Not(inner) => negatives.push(inner.to_string()), + _ => {} + } + } + for pos in &positives { + if negatives.contains(pos) { + return Some(pos.clone()); + } + } + // Recurse: a nested conjunction can contradict itself too. + ps.iter().find_map(|p| p.self_contradiction()) + } + Pred::Any(ps) => ps.iter().find_map(|p| p.self_contradiction()), + Pred::Not(p) => p.self_contradiction(), + Pred::Atom { .. } => None, + } + } + + /// The positive (non-negated) atoms reachable in this predicate, as + /// `(kind, attrs)`. Negated atoms are excluded: a negated criterion is an + /// outcome to report, not a domain to correct against (decision 0013). + fn positive_atoms<'a>(&'a self, negated: bool, out: &mut Vec<&'a Pred>) { + match self { + Pred::Atom { .. } => { + if !negated { + out.push(self); + } + } + Pred::All(ps) | Pred::Any(ps) => { + for p in ps { + p.positive_atoms(negated, out); + } + } + Pred::Not(p) => p.positive_atoms(!negated, out), + } + } + /// Whether this predicate holds against the recorded evidence. pub fn eval(&self, evidence: &[Evidence]) -> bool { match self { @@ -160,6 +212,58 @@ impl Pred { } } +/// Cross-check an evidence record against the acceptance criteria it could +/// contribute to (decision 0013). This *reports* a likely mistake and never +/// refuses: the derivable domain is necessarily incomplete. +/// +/// The domain of each attribute is derived from the values the plan's own +/// positively-stated criteria bind — the plan supplies its own enumeration +/// without declaring one. A record carrying a value outside that domain, for a +/// key some criterion of the same kind constrains, is reported and named. A +/// value no criterion mentions is accepted in silence (matching is a subset +/// relation), and a record matching a *negated* criterion is not a mistake. +pub fn cross_check(kind: &str, attrs: &[(String, String)], criteria: &[&Pred]) -> Option { + use std::collections::{BTreeSet, HashMap}; + + let mut positives: Vec<&Pred> = Vec::new(); + for c in criteria { + c.positive_atoms(false, &mut positives); + } + + // The domain each attribute takes across positive criteria of this kind. + let mut domain: HashMap<&str, BTreeSet<&str>> = HashMap::new(); + let mut mentions_kind = false; + for p in &positives { + if let Pred::Atom { kind: k, attrs: a } = p { + if k == kind { + mentions_kind = true; + for (ak, av) in a { + domain.entry(ak.as_str()).or_default().insert(av.as_str()); + } + } + } + } + if !mentions_kind { + // No criterion names this evidence kind; the record simply adds context. + return None; + } + + for (k, v) in attrs { + if let Some(values) = domain.get(k.as_str()) { + if !values.contains(v.as_str()) { + let expected: Vec<&str> = values.iter().copied().collect(); + return Some(format!( + "recorded {kind}({k}={v}), but no acceptance criterion expects that: \ + the plan's criteria bind `{k}` to {{{}}}. If you meant one of those, the \ + record will not satisfy the criterion as written.", + expected.join(", ") + )); + } + } + } + None +} + impl fmt::Display for Pred { /// Canonical rendering. This is what gets stored and hashed. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/src/readiness.rs b/src/readiness.rs index 721cc25..d46557e 100644 --- a/src/readiness.rs +++ b/src/readiness.rs @@ -27,8 +27,7 @@ //! acceptance predicate as the gate — it is the only authored condition a step //! carries besides its dependencies. Noted as a spec ambiguity. -use crate::catalog::{Admitted, PlanStore}; -use crate::event::EventKind; +use crate::event::{Event, EventKind}; use crate::model::{Step, Version}; use crate::predicate::Evidence; use std::collections::HashSet; @@ -92,7 +91,7 @@ impl HeadReadiness { /// An event recorded against a Step that a later version superseded is /// attributed to the superseding Step, so a rename or a split does not discard /// the evidence already gathered. -fn evidence_for(store: &PlanStore, version: &Version, step: &Step) -> Vec { +fn evidence_for(events: &[Event], version: &Version, step: &Step) -> Vec { // Walk the supersedes chain within this version. let mut refs: HashSet<&str> = HashSet::new(); let mut cursor = Some(step); @@ -112,18 +111,15 @@ fn evidence_for(store: &PlanStore, version: &Version, step: &Step) -> Vec HeadReadiness { - let version = &head.version; - +/// Compute readiness for one head member, from its evaluated intent. +pub fn for_head(events: &[Event], version: &Version, hash: &str, orphan: bool) -> HeadReadiness { // Accepted-ness of every step, needed before dependencies can be judged. // // A retired step is never accepted for this purpose, even when evidence @@ -143,7 +139,7 @@ pub fn for_head(store: &PlanStore, head: &Admitted, orphan: bool) -> HeadReadine if s.retired { return (s.id.clone(), false); } - let ev = evidence_for(store, version, s); + let ev = evidence_for(events, version, s); (s.id.clone(), s.accept.eval(&ev)) }) .collect(); @@ -159,7 +155,7 @@ pub fn for_head(store: &PlanStore, head: &Admitted, orphan: bool) -> HeadReadine .steps .iter() .map(|s| { - let ev = evidence_for(store, version, s); + let ev = evidence_for(events, version, s); let accept = s.accept.to_string(); if s.retired { @@ -230,7 +226,7 @@ pub fn for_head(store: &PlanStore, head: &Admitted, orphan: bool) -> HeadReadine .collect(); HeadReadiness { - head: head.hash.clone(), + head: hash.to_string(), seq: version.seq, author: version.author.clone(), orphan, @@ -238,39 +234,17 @@ pub fn for_head(store: &PlanStore, head: &Admitted, orphan: bool) -> HeadReadine } } -/// Readiness for every head member, labelled. Never picks a side. -pub fn for_plan<'a>( - store: &'a PlanStore, - analysis: &crate::chain::Analysis<'a>, -) -> Vec { - analysis - .head - .iter() - .map(|h| for_head(store, h, analysis.is_orphan(&h.hash))) - .collect() -} - #[cfg(test)] mod tests { use super::*; - use crate::catalog::Admitted; use crate::event::Event; - use crate::model::Step; + use crate::model::{Step, Version}; use crate::predicate::parse as pred; - use std::path::PathBuf; const PLAN: &str = "pl_1000000000"; const A: &str = "st_A000000001"; const B: &str = "st_B000000002"; - fn admit(version: Version) -> Admitted { - Admitted { - hash: version.hash(), - path: PathBuf::from("/dev/null"), - version, - } - } - /// Two steps: B depends on A. fn two_step_version() -> Version { let a = Step::new( @@ -296,6 +270,12 @@ mod tests { } } + /// Readiness against one head member. Chain analysis and per-head labelling + /// live in the CLI now; here we test the readiness computation directly. + fn run(version: &Version, events: &[Event]) -> HeadReadiness { + for_head(events, version, "hash0", false) + } + fn evidence_event(step: &str, kind: &str, attrs: &[(&str, &str)], at: u64) -> Event { Event { id: format!("ev_{at:010}"), @@ -331,28 +311,13 @@ mod tests { } } - fn store_with(version: Version, events: Vec) -> (PlanStore, Admitted) { - let a = admit(version); - ( - PlanStore { - plan: PLAN.into(), - versions: vec![a.clone()], - events, - ..Default::default() - }, - a, - ) - } - fn find<'a>(r: &'a HeadReadiness, id: &str) -> &'a StepReadiness { r.steps.iter().find(|s| s.step == id).unwrap() } #[test] fn with_no_evidence_only_the_unblocked_step_is_ready() { - let (s, h) = store_with(two_step_version(), vec![]); - let r = for_head(&s, &h, false); - + let r = run(&two_step_version(), &[]); assert_eq!(find(&r, A).state, StepState::Ready); assert_eq!(find(&r, B).state, StepState::Blocked); assert_eq!(find(&r, B).blocked_by, vec![A.to_string()]); @@ -361,17 +326,15 @@ mod tests { #[test] fn evidence_accepts_a_step_and_unblocks_its_dependent() { - let (s, h) = store_with( - two_step_version(), - vec![evidence_event( + let r = run( + &two_step_version(), + &[evidence_event( A, "test", &[("name", "x"), ("status", "fail")], 1, )], ); - let r = for_head(&s, &h, false); - assert_eq!(find(&r, A).state, StepState::Accepted); assert_eq!(find(&r, B).state, StepState::Ready); } @@ -379,17 +342,15 @@ mod tests { #[test] fn a_done_event_does_not_accept_a_step() { // CMP-R14: an external observation must never complete a Step. - let (s, h) = store_with( - two_step_version(), - vec![ + let r = run( + &two_step_version(), + &[ plain_event(A, EventKind::Start, 1), plain_event(A, EventKind::Update, 2), plain_event(A, EventKind::Handoff, 3), plain_event(A, EventKind::Done, 4), ], ); - let r = for_head(&s, &h, false); - assert_eq!( find(&r, A).state, StepState::Ready, @@ -400,9 +361,9 @@ mod tests { #[test] fn non_matching_evidence_does_not_accept() { - let (s, h) = store_with( - two_step_version(), - vec![evidence_event( + let r = run( + &two_step_version(), + &[evidence_event( A, "test", &[("name", "x"), ("status", "pass")], @@ -410,30 +371,28 @@ mod tests { )], ); // Step A wants status=fail. - assert_eq!(find(&for_head(&s, &h, false), A).state, StepState::Ready); + assert_eq!(find(&r, A).state, StepState::Ready); } #[test] fn evidence_filed_against_another_step_does_not_leak() { - let (s, h) = store_with( - two_step_version(), - vec![evidence_event( + let r = run( + &two_step_version(), + &[evidence_event( B, "test", &[("name", "x"), ("status", "fail")], 1, )], ); - assert_eq!(find(&for_head(&s, &h, false), A).state, StepState::Ready); + assert_eq!(find(&r, A).state, StepState::Ready); } #[test] fn a_retired_step_is_excluded_and_never_accepts_its_dependents() { let mut v = two_step_version(); v.steps[0].retired = true; - let (s, h) = store_with(v, vec![]); - let r = for_head(&s, &h, false); - + let r = run(&v, &[]); assert_eq!(find(&r, A).state, StepState::Retired); assert_eq!(find(&r, B).state, StepState::Blocked); assert!( @@ -445,22 +404,19 @@ mod tests { #[test] fn evidence_on_a_retired_step_does_not_unblock_its_dependent() { - // Spec: an event against a retired Step is retained but does not - // contribute to Readiness. Matching evidence must therefore not - // satisfy a dependency on a retired step. + // An event against a retired Step is retained but does not contribute + // to Readiness, so matching evidence must not satisfy a dependency. let mut v = two_step_version(); v.steps[0].retired = true; - let (s, h) = store_with( - v, - vec![evidence_event( + let r = run( + &v, + &[evidence_event( A, "test", &[("name", "x"), ("status", "fail")], 1, )], ); - let r = for_head(&s, &h, false); - assert_eq!(find(&r, A).state, StepState::Retired); assert_eq!( find(&r, B).state, @@ -481,16 +437,16 @@ mod tests { v.steps[1].supersedes = Some(A.to_string()); v.steps[1].depends_on = vec![]; v.steps[1].accept = pred("test(name=x, status=pass)").unwrap(); - let (s, h) = store_with( - v, - vec![evidence_event( + let r = run( + &v, + &[evidence_event( A, "test", &[("name", "x"), ("status", "pass")], 1, )], ); - assert_eq!(find(&for_head(&s, &h, false), B).state, StepState::Accepted); + assert_eq!(find(&r, B).state, StepState::Accepted); } #[test] @@ -501,16 +457,15 @@ mod tests { c.retired = true; c }); - let (s, h) = store_with( - v, - vec![evidence_event( + let r = run( + &v, + &[evidence_event( A, "test", &[("name", "x"), ("status", "fail")], 1, )], ); - let r = for_head(&s, &h, false); for step in &r.steps { assert!( !step.reason.trim().is_empty(), @@ -522,8 +477,7 @@ mod tests { #[test] fn a_blocked_step_names_the_dependency_holding_it() { - let (s, h) = store_with(two_step_version(), vec![]); - let r = for_head(&s, &h, false); + let r = run(&two_step_version(), &[]); let b = find(&r, B); assert!(b.reason.contains(A), "{}", b.reason); assert!(b.reason.contains("Reproduce"), "{}", b.reason); @@ -531,60 +485,20 @@ mod tests { #[test] fn a_ready_step_explains_what_would_accept_it() { - let (s, h) = store_with(two_step_version(), vec![]); - let r = for_head(&s, &h, false); - let a = find(&r, A); + let r = run(&two_step_version(), &[]); assert!( - a.reason.contains("test(name=x, status=fail)"), + find(&r, A).reason.contains("test(name=x, status=fail)"), "{}", - a.reason + find(&r, A).reason ); } #[test] - fn readiness_is_reported_per_head_member_under_divergence() { - let base = two_step_version(); - - let mut left = base.clone(); - left.seq = 2; - left.why = "cos side".into(); - left.parents = vec![admit(base.clone()).hash]; - - let mut right = base.clone(); - right.seq = 2; - right.why = "dev side".into(); - right.author = "dev".into(); - right.parents = vec![admit(base.clone()).hash]; - // The two sides carry genuinely different graphs. - right.steps.pop(); - - let store = PlanStore { - plan: PLAN.into(), - versions: vec![admit(base), admit(left), admit(right)], - ..Default::default() - }; - let analysis = crate::chain::analyze(&store); - assert!(analysis.diverged()); - - let all = for_plan(&store, &analysis); - assert_eq!(all.len(), 2, "one answer per head member"); - let authors: HashSet<&str> = all.iter().map(|r| r.author.as_str()).collect(); - assert!(authors.contains("cos") && authors.contains("dev")); - // The graphs are not merged. - let sizes: HashSet = all.iter().map(|r| r.steps.len()).collect(); - assert_eq!(sizes, HashSet::from([1, 2])); - } - - #[test] - fn an_orphaned_head_member_is_labelled_as_such() { - let mut v = two_step_version(); - v.parents = vec!["f".repeat(64)]; - let (s, _h) = store_with(v, vec![]); - let analysis = crate::chain::analyze(&s); - let all = for_plan(&s, &analysis); - assert_eq!(all.len(), 1); + fn an_orphan_head_is_labelled() { + let v = two_step_version(); + let r = for_head(&[], &v, "hash0", true); assert!( - all[0].orphan, + r.orphan, "an orphan head must be flagged, not silently served" ); } @@ -593,8 +507,7 @@ mod tests { fn composite_acceptance_is_evaluated_and_explained() { let mut v = two_step_version(); v.steps[0].accept = pred("all(test(status=pass), review(by=cos))").unwrap(); - let (s, h) = store_with(v, vec![evidence_event(A, "test", &[("status", "pass")], 1)]); - let r = for_head(&s, &h, false); + let r = run(&v, &[evidence_event(A, "test", &[("status", "pass")], 1)]); let a = find(&r, A); assert_eq!(a.state, StepState::Ready); assert!(a.reason.contains("review(by=cos)"), "{}", a.reason); @@ -604,18 +517,15 @@ mod tests { fn acceptance_can_be_revoked_by_later_evidence() { let mut v = two_step_version(); v.steps[0].accept = pred("not(test(status=fail))").unwrap(); - let (s, h) = store_with(v.clone(), vec![]); - assert_eq!(find(&for_head(&s, &h, false), A).state, StepState::Accepted); - - let (s2, h2) = store_with(v, vec![evidence_event(A, "test", &[("status", "fail")], 1)]); - assert_eq!(find(&for_head(&s2, &h2, false), A).state, StepState::Ready); + assert_eq!(find(&run(&v, &[]), A).state, StepState::Accepted); + let r = run(&v, &[evidence_event(A, "test", &[("status", "fail")], 1)]); + assert_eq!(find(&r, A).state, StepState::Ready); } #[test] fn a_version_with_no_steps_yields_no_readiness_rows() { let mut v = two_step_version(); v.steps.clear(); - let (s, h) = store_with(v, vec![]); - assert!(for_head(&s, &h, false).steps.is_empty()); + assert!(run(&v, &[]).steps.is_empty()); } } diff --git a/src/refs.rs b/src/refs.rs index 314af20..e7f8fa2 100644 --- a/src/refs.rs +++ b/src/refs.rs @@ -1,71 +1,32 @@ -//! Minted opaque references (CMP-R07, decision 0004, DQ04). +//! Progress-event identity. //! -//! A `PlanRef` or `StepRef` is minted at creation and never derived from -//! content, so rewording a step preserves its identity. Minting must not -//! collide when two machines mint concurrently and neither can reach a -//! coordinator, so the suffix is drawn from the OS entropy source. -//! -//! DQ04 is open on width and whether to encode the minting host. This -//! implementation encodes no host — a ref must carry no location (CMP-R07) — -//! and uses 50 bits of entropy rendered as 10 Crockford base32 characters. -//! Crockford is chosen because refs appear in prose and get read aloud: it -//! omits I, L, O and U, so the ambiguous glyph pairs cannot occur. +//! Plan and Step identity are no longer minted — a Version is identified by the +//! hash of its source bytes (decision 0014) and a Step by the name it is +//! declared under (decision 0012). The only remaining minted value is a Progress +//! Event id, which is not a Plan concept: it only has to be unique within a +//! directory two machines may both write to. It carries no location and is drawn +//! from the OS entropy source so concurrent writers cannot collide. -use std::fmt; use std::fs::File; use std::io::Read; -/// Crockford base32 alphabet: 10 digits + 22 letters, excluding I, L, O, U. +/// Crockford base32 alphabet: 10 digits + 22 letters, excluding I, L, O, U so +/// the ambiguous glyph pairs cannot occur when an id is read aloud. const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -/// Number of base32 characters after the type prefix. const SUFFIX_LEN: usize = 10; -/// The kind of entity a ref names. The prefix makes a ref self-describing in -/// prose and in error messages without a lookup. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RefKind { - Plan, - Step, - /// Progress Event identity. Not a Plan concept — it only has to be unique - /// within a directory two machines may both write to. - Event, -} - -impl RefKind { - pub fn prefix(self) -> &'static str { - match self { - RefKind::Plan => "pl_", - RefKind::Step => "st_", - RefKind::Event => "ev_", - } - } -} - -impl fmt::Display for RefKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - RefKind::Plan => write!(f, "plan"), - RefKind::Step => write!(f, "step"), - RefKind::Event => write!(f, "event"), - } - } -} - -/// Mint a fresh opaque reference of the given kind. -pub fn mint(kind: RefKind) -> Result { +/// Mint a fresh Progress Event id (`ev_…`). +pub fn event_id() -> Result { let bytes = entropy(8)?; - Ok(format!("{}{}", kind.prefix(), encode_crockford(&bytes))) + Ok(format!("ev_{}", encode_crockford(&bytes))) } -/// Render the low 50 bits of `bytes` as 10 Crockford base32 characters. fn encode_crockford(bytes: &[u8]) -> String { let mut acc: u64 = 0; for b in bytes.iter().take(8) { acc = (acc << 8) | u64::from(*b); } let mut out = [0u8; SUFFIX_LEN]; - // Fill right-to-left so the whole 50-bit space is used uniformly. for slot in out.iter_mut().rev() { *slot = CROCKFORD[(acc & 0x1f) as usize]; acc >>= 5; @@ -73,81 +34,40 @@ fn encode_crockford(bytes: &[u8]) -> String { String::from_utf8(out.to_vec()).expect("crockford alphabet is ASCII") } -/// Read `n` bytes from the OS entropy source. fn entropy(n: usize) -> Result, String> { let mut f = File::open("/dev/urandom") - .map_err(|e| format!("cannot open /dev/urandom to mint a reference: {e}"))?; + .map_err(|e| format!("cannot open /dev/urandom to mint an event id: {e}"))?; let mut buf = vec![0u8; n]; f.read_exact(&mut buf) - .map_err(|e| format!("cannot read /dev/urandom to mint a reference: {e}"))?; + .map_err(|e| format!("cannot read /dev/urandom to mint an event id: {e}"))?; Ok(buf) } -/// Whether `s` is well-formed as a ref of the given kind. Used to reject -/// nonsense on the command line before it reaches authored content. -pub fn is_valid(s: &str, kind: RefKind) -> bool { - let Some(rest) = s.strip_prefix(kind.prefix()) else { - return false; - }; - rest.len() == SUFFIX_LEN && rest.bytes().all(|b| CROCKFORD.contains(&b)) -} - -/// Whether `s` looks like a ref of any known kind. -pub fn is_any_ref(s: &str) -> bool { - is_valid(s, RefKind::Plan) || is_valid(s, RefKind::Step) -} - #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; #[test] - fn minted_refs_are_well_formed() { - let p = mint(RefKind::Plan).unwrap(); - assert!(p.starts_with("pl_"), "{p}"); - assert_eq!(p.len(), 3 + SUFFIX_LEN); - assert!(is_valid(&p, RefKind::Plan)); - assert!(!is_valid(&p, RefKind::Step)); - - let s = mint(RefKind::Step).unwrap(); - assert!(is_valid(&s, RefKind::Step)); - assert!(!is_valid(&s, RefKind::Plan)); - } - - #[test] - fn minted_refs_do_not_repeat() { - // Not a collision proof, only a smoke test that entropy is actually - // being read rather than a constant returned. + fn event_ids_are_well_formed_and_unique() { let mut seen = HashSet::new(); for _ in 0..2000 { - assert!(seen.insert(mint(RefKind::Step).unwrap()), "duplicate ref"); + let id = event_id().unwrap(); + assert!(id.starts_with("ev_")); + assert_eq!(id.len(), 3 + SUFFIX_LEN); + assert!(seen.insert(id), "duplicate id"); } } #[test] fn encoding_avoids_ambiguous_glyphs() { + assert_eq!(encode_crockford(&[0u8; 8]), "0000000000"); + assert_eq!(encode_crockford(&[0xff; 8]), "ZZZZZZZZZZ"); for _ in 0..500 { - let r = mint(RefKind::Plan).unwrap(); - let suffix = &r[3..]; + let id = event_id().unwrap(); for bad in ['I', 'L', 'O', 'U'] { - assert!(!suffix.contains(bad), "{r} contains {bad}"); + assert!(!id.contains(bad)); } } } - - #[test] - fn encoding_is_deterministic_for_fixed_input() { - assert_eq!(encode_crockford(&[0u8; 8]), "0000000000"); - assert_eq!(encode_crockford(&[0xff; 8]), "ZZZZZZZZZZ"); - } - - #[test] - fn rejects_malformed_refs() { - assert!(!is_valid("pl_", RefKind::Plan)); - assert!(!is_valid("pl_short", RefKind::Plan)); - assert!(!is_valid("pl_IIIIIIIIII", RefKind::Plan)); // I is not in the alphabet - assert!(!is_valid("nope", RefKind::Plan)); - assert!(is_any_ref("st_0123456789")); - } } diff --git a/tests/spike.rs b/tests/spike.rs deleted file mode 100644 index 1fb819f..0000000 --- a/tests/spike.rs +++ /dev/null @@ -1,44 +0,0 @@ -use compass::eval::eval_plan_file; -use std::path::Path; - -fn show(p: &str) { - let path = Path::new(p); - println!("\n===== {p}"); - match eval_plan_file(path) { - Ok(map) => { - let canon = std::fs::canonicalize(path).unwrap(); - let v = map.get(&canon).expect("entry in map"); - println!( - "author={} goal={:?} retired={}", - v.author, v.goal, v.retired - ); - println!("why={:?}", v.why); - for s in &v.steps { - println!( - " step {} retired={} deps={:?} supersedes={:?}", - s.name, s.retired, s.depends_on, s.supersedes - ); - println!(" work={:?}", s.work); - println!(" accept={}", s.accept); - } - } - Err(e) => println!("ERR [{}] {}", e.kind(), e.message()), - } -} - -#[test] -fn spike_all_examples() { - for p in [ - "examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/001-cfe4f8d721d2.ts", - "examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/002-043517240262.ts", - "examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/001-634e2a7c458b.ts", - "examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-e10822d3395b.ts", - "examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-549d0e4af2eb.ts", - "examples/two-machines/catalog/plans/pl_nested_groups/versions/001-8e528ff9bc56.ts", - "examples/two-machines/catalog/plans/pl_nested_groups/versions/002-7280a933f7cc.ts", - "examples/two-machines/catalog/plans/pl_nested_groups/versions/002-ff95b74b4e9f.ts", - "examples/two-machines/catalog/plans/pl_nested_groups/versions/003-79f571386a40.ts", - ] { - show(p); - } -} From 1c66d639d2e7662ccfa3a6fbe02d040e60502dd4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:21:54 +0200 Subject: [PATCH 03/11] test(compass): acceptance + e2e suite; fix unresolved classification and VRS add-step defect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/acceptance.rs: examples evaluate + filenames reproduce from source bytes; reconciliation carries both sides; full e2e lifecycle through the CLI (start -> commit -> show/history/ready -> revise -> diverge -> reconcile); and engine failure-mode guards (unresolved / stopped / sandbox: no eval, Date, Math.random). - eval.rs: a missing imported predecessor now reads as Unresolved (wait), not a generic Failed — resolution succeeds so the loader reports the absent file. Drop the unused ctx threading through extract/to_pred. - cmd.rs: the changes-nothing refusal is a plain string, not format!. - context/06-api/spec.md: the 'A revision' example added a Step anonymously, which has no identity (decision 0012) — same defect the example plans had. Added steps are now named exports, so spec, prelude, and examples agree. - example READMEs: point at the corrected version filenames. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/06-api/spec.md | 22 +- examples/hypothesis-dies/README.md | 4 +- examples/two-machines/README.md | 6 +- src/cmd.rs | 5 +- src/eval.rs | 25 ++- tests/acceptance.rs | 346 +++++++++++++++++++++++++++++ 6 files changed, 383 insertions(+), 25 deletions(-) create mode 100644 tests/acceptance.rs diff --git a/context/06-api/spec.md b/context/06-api/spec.md index 3496fd9..48f0c9e 100644 --- a/context/06-api/spec.md +++ b/context/06-api/spec.md @@ -60,17 +60,20 @@ missing while a plan is rewritten. ```ts import prior from "./001-9f3c1ae4.ts" +// An added Step is a named binding too, for the same reason a first-version Step +// is: its name is its identity, and a later version refers to it by that name. An +// anonymous `step({...})` in `add` has no identity and is refused. +export const tripleCase = step({ + work: "Add a regression case for triple nesting", + dependsOn: [prior.steps.fix], + accept: evidence.test({ name: "parser::triple", status: "pass" }), +}) + export default prior.revise({ author: "cos", why: "Reproduction retargeted the fix at the tokenizer, not the grammar.", edit: [prior.steps.fix.with({ work: "Fix the tokenizer's delimiter handling" })], - add: [ - step({ - work: "Add a regression case for triple nesting", - dependsOn: [prior.steps.fix], - accept: evidence.test({ name: "parser::triple", status: "pass" }), - }), - ], + add: [tripleCase], retire: [prior.steps.someObsoleteStep], }) ``` @@ -78,7 +81,10 @@ export default prior.revise({ `prior.steps.fix` refers to the carried-forward Step through the predecessor, so an edit cannot target a Step that is not there or invent a new one. `.with(...)` changes work, acceptance, or dependencies; it cannot change identity, because -identity is the binding and the binding is unchanged. +identity is the binding and the binding is unchanged. An *added* Step is declared +the same way a first-version Step is — a named export — because it acquires +identity by the same rule, and a successor that carries it forward refers to it +by that name. `retire` marks a Step decommissioned. It is carried forward like any other Step, retired, because retirement is content and a revision could not omit it in any diff --git a/examples/hypothesis-dies/README.md b/examples/hypothesis-dies/README.md index 972f816..ab6e0cd 100644 --- a/examples/hypothesis-dies/README.md +++ b/examples/hypothesis-dies/README.md @@ -50,5 +50,5 @@ wrong. ## Files - [`001-634e2a7c458b.ts`](./catalog/plans/pl_ci_speed/versions/001-634e2a7c458b.ts) — the hypothesis -- [`002-e10822d3395b.ts`](./catalog/plans/pl_ci_speed/versions/002-e10822d3395b.ts) — it dies -- [`003-549d0e4af2eb.ts`](./catalog/plans/pl_ci_speed/versions/003-549d0e4af2eb.ts) — the cold-start follow-on +- [`002-68fdda593f4d.ts`](./catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts) — it dies +- [`003-55ca61867911.ts`](./catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts) — the cold-start follow-on diff --git a/examples/two-machines/README.md b/examples/two-machines/README.md index 6104400..7c11c65 100644 --- a/examples/two-machines/README.md +++ b/examples/two-machines/README.md @@ -57,6 +57,6 @@ added. ## Files - [`001-8e528ff9bc56.ts`](./catalog/plans/pl_nested_groups/versions/001-8e528ff9bc56.ts) — the shared base -- [`002-7280a933f7cc.ts`](./catalog/plans/pl_nested_groups/versions/002-7280a933f7cc.ts) — machine A: fuzz -- [`002-ff95b74b4e9f.ts`](./catalog/plans/pl_nested_groups/versions/002-ff95b74b4e9f.ts) — machine B: guard -- [`003-79f571386a40.ts`](./catalog/plans/pl_nested_groups/versions/003-79f571386a40.ts) — reconciliation +- [`002-2c922cb978de.ts`](./catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts) — machine A: fuzz +- [`002-e5d27ee538bf.ts`](./catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts) — machine B: guard +- [`003-037d5ddb9db7.ts`](./catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts) — reconciliation diff --git a/src/cmd.rs b/src/cmd.rs index 0fe6d11..2257e9b 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -323,10 +323,11 @@ fn cmd_commit(root: &Path, path: &Path, plan_opt: Option<&str>) -> Result, err: JsError, interrupted: &Arc) -> EvalE // Extraction: evaluated JS values -> owned Rust // --------------------------------------------------------------------------- -fn extract_plan(ctx: &Ctx<'_>, plan: &Object<'_>, name: &str) -> Result { +fn extract_plan(plan: &Object<'_>, name: &str) -> Result { let fail = |m: String| EvalError::Failed(format!("{name}: {m}")); let author = get_string(plan, "author").ok_or_else(|| fail("plan has no author".into()))?; let why = get_string(plan, "why").ok_or_else(|| fail("plan has no rationale (why)".into()))?; @@ -280,7 +280,7 @@ fn extract_plan(ctx: &Ctx<'_>, plan: &Object<'_>, name: &str) -> Result, plan: &Object<'_>, name: &str) -> Result, s: &Object<'_>, module: &str) -> Result { +fn extract_step(s: &Object<'_>, module: &str) -> Result { let fail = |m: String| EvalError::Failed(format!("{module}: {m}")); let name = get_string(s, "__name").ok_or_else(|| { fail( @@ -335,7 +335,7 @@ fn extract_step(ctx: &Ctx<'_>, s: &Object<'_>, module: &str) -> Result("retired").unwrap_or(false); @@ -350,7 +350,7 @@ fn extract_step(ctx: &Ctx<'_>, s: &Object<'_>, module: &str) -> Result, v: &Value<'_>) -> Result { +fn to_pred(v: &Value<'_>) -> Result { let obj = v .as_object() .ok_or_else(|| "an acceptance criterion must be an evidence value".to_string())?; @@ -380,7 +380,7 @@ fn to_pred(ctx: &Ctx<'_>, v: &Value<'_>) -> Result { let mut ps = Vec::new(); for a in arr.iter::() { let a = a.map_err(|e| e.to_string())?; - ps.push(to_pred(ctx, &a)?); + ps.push(to_pred(&a)?); } Ok(if kind == "all" { Pred::All(ps) @@ -390,7 +390,7 @@ fn to_pred(ctx: &Ctx<'_>, v: &Value<'_>) -> Result { } "not" => { let inner: Value = obj.get("arg").map_err(|e| e.to_string())?; - Ok(Pred::Not(Box::new(to_pred(ctx, &inner)?))) + Ok(Pred::Not(Box::new(to_pred(&inner)?))) } other => Err(format!("unknown evidence combinator `{other}`")), } @@ -587,8 +587,13 @@ fn resolve_spec(base: &Path, spec: &str) -> Result { } let dir = base.parent().unwrap_or_else(|| Path::new(".")); let joined = dir.join(spec); - let canon = canonicalize(&joined)?; - Ok(Resolved::File(canon)) + // Resolution must succeed even when the file is absent, so that the loader + // is invoked and reports it as Unresolved (a predecessor that has not + // arrived) rather than the resolver failing it as a generic module error. + match std::fs::canonicalize(&joined) { + Ok(canon) => Ok(Resolved::File(canon)), + Err(_) => Ok(Resolved::File(joined)), + } } fn canonicalize(path: &Path) -> Result { diff --git a/tests/acceptance.rs b/tests/acceptance.rs new file mode 100644 index 0000000..e3532f1 --- /dev/null +++ b/tests/acceptance.rs @@ -0,0 +1,346 @@ +//! Acceptance: the committed example plans evaluate and their filenames are the +//! hash of their source bytes; and a full lifecycle runs through the CLI. + +use compass::cli::{Command, Invocation}; +use compass::{catalog, chain, cmd, eval}; +use std::path::{Path, PathBuf}; + +fn example_files() -> Vec { + let mut out = Vec::new(); + for e in std::fs::read_dir("examples").unwrap() { + let plans = e.unwrap().path().join("catalog/plans"); + if !plans.is_dir() { + continue; + } + for pl in std::fs::read_dir(&plans).unwrap() { + let vdir = pl.unwrap().path().join("versions"); + for f in std::fs::read_dir(&vdir).unwrap() { + let p = f.unwrap().path(); + if p.extension().map(|x| x == "ts").unwrap_or(false) { + out.push(p); + } + } + } + } + out.sort(); + out +} + +/// Identity is the SHA-256 of source bytes (decision 0014): the filename must +/// reproduce from the content alone. +#[test] +fn example_filenames_reproduce_from_source_bytes() { + let files = example_files(); + assert!(files.len() >= 9, "expected the committed examples"); + for p in &files { + let bytes = std::fs::read(p).unwrap(); + let hash = compass::sha256::sha256_hex(&bytes); + let name = p.file_name().unwrap().to_str().unwrap(); + let (_seq, prefix) = compass::model::parse_filename(name) + .unwrap_or_else(|| panic!("{name} is not -.ts")); + assert_eq!(prefix, hash[..12], "{name} does not name its content"); + } +} + +/// Every committed example evaluates: reading a plan runs it (decision 0014). +#[test] +fn every_example_evaluates() { + for p in example_files() { + let map = eval::eval_plan_file(&p) + .unwrap_or_else(|e| panic!("{}: [{}] {}", p.display(), e.kind(), e.message())); + let canon = std::fs::canonicalize(&p).unwrap(); + assert!(map.contains_key(&canon), "{} produced no plan", p.display()); + } +} + +/// The reconciliation carries forward every step of both divergent sides, and +/// states only what it changed. +#[test] +fn reconciliation_carries_both_sides_forward() { + let p = Path::new( + "examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts", + ); + let map = eval::eval_plan_file(p).unwrap(); + let v = map.get(&std::fs::canonicalize(p).unwrap()).unwrap(); + let names: Vec<&str> = v.steps.iter().map(|s| s.name.as_str()).collect(); + assert!( + names.contains(&"fuzz") && names.contains(&"guard"), + "{names:?}" + ); + let fuzz = v.steps.iter().find(|s| s.name == "fuzz").unwrap(); + assert!(fuzz.depends_on.contains(&"guard".to_string())); + assert!(fuzz.depends_on.contains(&"fix".to_string())); +} + +// ---- e2e lifecycle through the CLI ---- + +struct Tmp(PathBuf); +impl Drop for Tmp { + fn drop(&mut self) { + fn chmod(p: &Path) { + if p.is_dir() { + for e in std::fs::read_dir(p).into_iter().flatten().flatten() { + chmod(&e.path()); + } + } else if let Ok(m) = std::fs::metadata(p) { + let mut perms = m.permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(p, perms); + } + } + chmod(&self.0); + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn run(root: &Path, cmd: Command) -> Result { + cmd::execute(&Invocation { + command: cmd, + json: false, + catalog: Some(root.to_path_buf()), + author: Some("cos".into()), + }) +} + +fn commit_module(root: &Path, plan: &str, source: &str) -> String { + let vdir = catalog::versions_dir(root, plan); + std::fs::create_dir_all(&vdir).unwrap(); + let draft = vdir.join("draft.ts"); + // draft.ts may be read-only from a prior write; ensure writable. + let _ = std::fs::remove_file(&draft); + std::fs::write(&draft, source).unwrap(); + let out = run( + root, + Command::Commit { + path: draft.clone(), + plan: Some(plan.into()), + }, + ) + .unwrap_or_else(|e| panic!("commit failed: {e}")); + assert_eq!(out.code, 0); + // The freshly committed head file. + let store = catalog::load_plan(root, plan).unwrap(); + let an = chain::analyze(&store); + an.head + .iter() + .max_by_key(|a| a.seq) + .unwrap() + .path + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string() +} + +#[test] +fn e2e_start_commit_show_history_revise_reconcile() { + let root = std::env::temp_dir().join(format!("compass-e2e-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let guard = Tmp(root.clone()); + let plan = "pl_demo"; + + // start — scaffolds a runnable draft (CMP-R11). + run( + &root, + Command::Start { + plan: plan.into(), + goal: Some("Ship the widget".into()), + }, + ) + .unwrap(); + // `start` scaffolds a runnable draft in the plan's versions/ directory. + let scaffolded = std::fs::read_dir(catalog::versions_dir(&root, plan)) + .unwrap() + .filter_map(|e| e.ok()) + .any(|e| { + let n = e.file_name(); + let n = n.to_string_lossy(); + n.ends_with(".ts") && compass::model::parse_filename(&n).is_none() + }); + assert!(scaffolded, "start should scaffold an editable draft module"); + + // commit a real root (edit stands in for the operator editing the draft). + let root_mod = r#"import { plan, step, evidence } from "compass" +export const build = step({ work: "Build it", accept: evidence.test({ name: "t", status: "pass" }) }) +export const ship = step({ work: "Ship it", dependsOn: [build], accept: evidence.review({ actor: "cos", verdict: "approved" }) }) +export default plan({ author: "cos", goal: "Ship the widget", why: "It is time.", steps: [build, ship] }) +"#; + let v1 = commit_module(&root, plan, root_mod); + + // show + history + ready read the plan by evaluating it. + assert!(run(&root, Command::Show { plan: plan.into() }) + .unwrap() + .text + .contains("build")); + let hist = run(&root, Command::History { plan: plan.into() }).unwrap(); + assert!(hist.text.contains("It is time")); + assert!(run(&root, Command::Ready { plan: plan.into() }) + .unwrap() + .text + .contains("build")); + + // an identical re-commit is a no-op success (a repeat is a repeat). + let vdir = catalog::versions_dir(&root, plan); + std::fs::write(vdir.join("again.ts"), root_mod).unwrap(); + let again = run( + &root, + Command::Commit { + path: vdir.join("again.ts"), + plan: Some(plan.into()), + }, + ) + .unwrap(); + assert!(again.text.contains("already committed"), "{}", again.text); + + // revise — a function of the predecessor, carrying every step forward. + let rev = format!( + r#"import {{ step, evidence }} from "compass" +import prior from "./{v1}" +export default prior.revise({{ + author: "cos", + why: "Reword the build step; the intent is unchanged but clearer.", + edit: [prior.steps.build.with({{ work: "Build it, carefully" }})], +}}) +"# + ); + let v2 = commit_module(&root, plan, &rev); + assert!(run(&root, Command::Show { plan: plan.into() }) + .unwrap() + .text + .contains("carefully")); + + // diverge: two revisions from the same predecessor v2. + let side_a = format!( + r#"import {{ step, evidence }} from "compass" +import prior from "./{v2}" +export const fuzz = step({{ work: "Fuzz it", dependsOn: [prior.steps.build], accept: evidence.test({{ name: "fz", status: "pass" }}) }}) +export default prior.revise({{ author: "cos", why: "Add a fuzz step.", add: [fuzz] }}) +"# + ); + let side_b = format!( + r#"import {{ step, evidence }} from "compass" +import prior from "./{v2}" +export const doc = step({{ work: "Document it", dependsOn: [prior.steps.build], accept: evidence.review({{ actor: "cos", verdict: "approved" }}) }}) +export default prior.revise({{ author: "dev", why: "Add a docs step.", add: [doc] }}) +"# + ); + let a = commit_module(&root, plan, &side_a); + // side_b's draft is a different file so both land as divergent heads. + std::fs::write(vdir.join("b.ts"), &side_b).unwrap(); + run( + &root, + Command::Commit { + path: vdir.join("b.ts"), + plan: Some(plan.into()), + }, + ) + .unwrap(); + let _ = std::fs::remove_file(vdir.join("b.ts")); + + let store = catalog::load_plan(&root, plan).unwrap(); + let an = chain::analyze(&store); + assert_eq!(an.head.len(), 2, "two divergent heads"); + assert!(an.diverged()); + let b = an + .head + .iter() + .find(|h| h.path.file_name().unwrap().to_str().unwrap() != a) + .unwrap() + .path + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + // reconcile the two divergent heads. + let recon = format!( + r#"import {{ reconcile }} from "compass" +import sideA from "./{a}" +import sideB from "./{b}" +export default reconcile({{ revises: [sideA, sideB], author: "cos", why: "Both are worth keeping." }}) +"# + ); + commit_module(&root, plan, &recon); + let store = catalog::load_plan(&root, plan).unwrap(); + let an = chain::analyze(&store); + assert_eq!(an.head.len(), 1, "reconciliation converges the heads"); + assert!(!an.diverged()); + assert!( + an.ever_diverged(), + "the divergence remains visible as history" + ); + + // the reconciled head carries every step of both sides. + let head = an.head[0]; + let map = eval::eval_plan_file(&head.path).unwrap(); + let v = map + .get(&std::fs::canonicalize(&head.path).unwrap()) + .unwrap(); + let names: Vec<&str> = v.steps.iter().map(|s| s.name.as_str()).collect(); + assert!( + names.contains(&"fuzz") && names.contains(&"doc"), + "{names:?}" + ); + + drop(guard); +} + +// ---- engine failure modes (regression guards) ---- + +fn tmp_module(dir: &Path, name: &str, src: &str) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let p = dir.join(name); + std::fs::write(&p, src).unwrap(); + p +} + +#[test] +fn a_missing_import_reads_as_unresolved_not_failed() { + let root = std::env::temp_dir().join(format!("compass-unres-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let p = tmp_module( + &root, + "002-000000000000.ts", + "import prior from \"./001-ffffffffffff.ts\"\nexport default prior.revise({ author: \"cos\", why: \"x\" })\n", + ); + match eval::eval_plan_file(&p) { + Err(e) => assert_eq!(e.kind(), "unresolved", "got: {}", e.message()), + Ok(_) => panic!("expected an unresolved read"), + } +} + +#[test] +fn a_nonterminating_plan_is_stopped_not_awaited() { + let root = std::env::temp_dir().join(format!("compass-stop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let p = tmp_module( + &root, + "001-000000000000.ts", + "import { plan, step, evidence } from \"compass\"\nwhile (true) {}\nexport const a = step({ work: \"x\", accept: evidence.test({ status: \"pass\" }) })\nexport default plan({ author: \"cos\", goal: \"g\", why: \"w\", steps: [a] })\n", + ); + assert_eq!(eval::eval_plan_file(&p).unwrap_err().kind(), "stopped"); +} + +#[test] +fn the_sandbox_grants_no_dynamic_code_or_clock() { + let root = std::env::temp_dir().join(format!("compass-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + // eval / Date are absent (decision 0011): a plan that reaches for them fails. + for probe in ["eval(\"1\")", "new Date()", "Math.random()"] { + let src = format!( + "import {{ plan, step, evidence }} from \"compass\"\nconst _x = {probe}\nexport const a = step({{ work: \"x\", accept: evidence.test({{ status: \"pass\" }}) }})\nexport default plan({{ author: \"cos\", goal: \"g\", why: \"w\", steps: [a] }})\n" + ); + let p = tmp_module(&root, "001-000000000000.ts", &src); + assert_eq!( + eval::eval_plan_file(&p).unwrap_err().kind(), + "failed", + "probe `{probe}` should be unavailable" + ); + } +} From d7c2f8a17fc9229e250151e7dca7c2b412d00afd Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:25:11 +0200 Subject: [PATCH 04/11] docs(compass): examples now evaluate; repair delta narrowed - examples/README.md: the examples are evaluated (they are the acceptance suite), no longer 'not yet evaluated'. - 02-artifacts DELTA-001: a distinct `repair` command now exists (refuses on a clean catalog, names the last intact predecessor); narrowed from open, with the remaining gap (author vs scaffold) recorded. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.delta/DELTA-001-repair-has-no-command.md | 21 ++++++++++++------- examples/README.md | 7 ++++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md b/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md index 7310200..9672ab3 100644 --- a/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md +++ b/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md @@ -1,6 +1,6 @@ # DELTA-001: Repair has no command -Status: open +Status: narrowed ## Divergence @@ -18,15 +18,22 @@ irreversible operation one keystroke from the safe one. ## Implementation -`compass verify` detects and reports rejected files and orphans. There is no -`compass repair`. The repair *path* exists — an operator can author a -damage-recording version with `compass revise` — but it is neither named, -guided, nor distinguished from ordinary revision, so nothing enforces the -separation the spec relies on. +`compass verify` is read-only and reports rejected files, orphans, and a +frontier that will not evaluate. `compass repair` now exists as a *distinct* +command: it re-runs verification and **refuses when nothing is wrong** (so the +irreversible operation is never one keystroke from the safe one), identifies the +last intact predecessor, and lists which versions are unverifiable. + +What it does not yet do is author the damage-recording version itself: it +scaffolds the `prior.revise({...})` continuation from the last intact +predecessor and directs the operator to `compass commit` it. The separation the +spec relies on is enforced (verify is read-only; repair is its own command that +refuses on a clean catalog); the authoring is guided rather than performed. ## Direction -update implementation +update implementation — close the remaining gap by having `repair` write the +damage-recording version directly rather than scaffolding it. ## Resolution Signal diff --git a/examples/README.md b/examples/README.md index ef497e5..3978d52 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,9 +5,10 @@ catalog: the version files are named by the hash of their own bytes, and each revision imports its predecessor by that name — so the lineage is genuine, not illustrative. -They are point-in-time snapshots. Compass is early and does not yet evaluate -these; they show what authoring a plan looks like and are the reference the -design is measured against. The authoring surface is specified in +Compass evaluates these: they are the acceptance suite (`tests/examples.rs`). +Each version file is read by evaluating it in the embedded engine, and reading +reproduces its content-hash filename. They also show what authoring a plan looks +like. The authoring surface is specified in [`context/06-api/`](../context/06-api/spec.md). | Example | Domain | What it shows | From 9aa10c56ee337804a340229b5ae32108369414c8 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:51:35 +0200 Subject: [PATCH 05/11] fix(nix): include examples/ in the build source closure The acceptance suite reads the committed example plans as fixtures, but the fileset excluded examples/, so the tests passed locally (examples present in the worktree) and failed under nix build (examples stripped from the sandbox source). Only nix build catches this. Adding examples/ to the fileset. --- nix/build.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/build.nix b/nix/build.nix index 74635ea..b055a19 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -41,6 +41,8 @@ pkgs.rustPlatform.buildRustPackage { ../rust-toolchain.toml ../src (lib.fileset.maybeMissing ../tests) + # the acceptance suite reads the committed example plans as fixtures + (lib.fileset.maybeMissing ../examples) ]; }; From e824356c91b362244b1cb33337baeccec2c0b74e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:52:45 +0200 Subject: [PATCH 06/11] chore: stop tracking the result symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nix build output symlink whose blob content is a /nix/store path — not something to carry in a public repo, and it churns on every build. Untrack it and gitignore it. --- .gitignore | 3 +++ result | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 120000 result diff --git a/.gitignore b/.gitignore index ade11e2..5160d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /target # prototype scratch catalogs /tmp + +# nix build output symlink (store path; never commit) +/result diff --git a/result b/result deleted file mode 120000 index e541059..0000000 --- a/result +++ /dev/null @@ -1 +0,0 @@ -/nix/store/nr0n2xq2k3kps0w0ch46f5alyvvsmmzj-compass-0.0.0 \ No newline at end of file From e3c9b7f31b57d95783824ca54e959e936d27594c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:36:29 +0200 Subject: [PATCH 07/11] fix(eval): restrict the module resolver to legitimate plan-version modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESM resolver accepted absolute and outside-the-catalog relative paths, read them via SourceLoader, and evaluated them — handing a plan a filesystem -read capability and breaking determinism, in violation of the capability boundary (0011) and locked-evaluation intent (0014). The resolver now permits only the bare `compass` prelude and content-addressed plan-version modules: a same-plan sibling `./-.ts` or a cross-plan `../..//versions/-.ts`. Paths are normalised lexically (never against the real filesystem), so no `..` or symlink can redirect resolution outside the catalog. Absolute paths, escaping references, and non-content -addressed names are refused with a clear "illegitimate import" error. The loader additionally enforces admission (content hash matches filename, per 02-artifacts) on every imported version, while sparing the entry module being authored. Shared classification helpers (`normalize_lexical`, `import_target_plan`) are exposed for the predecessor logic to reuse. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/eval.rs | 208 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 197 insertions(+), 11 deletions(-) diff --git a/src/eval.rs b/src/eval.rs index bc27a69..3bb347a 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -131,7 +131,9 @@ pub fn eval_plan_file(entry: &Path) -> Result, Eval let load_err: Arc>> = Arc::new(Mutex::new(None)); { rt.set_loader( - GraphResolver, + GraphResolver { + err: load_err.clone(), + }, SourceLoader { captured: captured.clone(), err: load_err.clone(), @@ -175,7 +177,12 @@ pub fn eval_plan_file(entry: &Path) -> Result, Eval let locked = Context::custom::<(intrinsic::Json, intrinsic::Promise, intrinsic::Proxy)>(&rt) .map_err(|e| EvalError::Failed(format!("locked context: {e}")))?; - rt.set_loader(GraphResolver, BytecodeLoader { compiled }); + rt.set_loader( + GraphResolver { + err: Arc::new(Mutex::new(None)), + }, + BytecodeLoader { compiled }, + ); locked.with(|ctx| -> Result, EvalError> { lock_globals(&ctx)?; @@ -581,19 +588,174 @@ enum Resolved { } /// Resolve an import specifier against the importing file. +/// +/// A plan may import only two things (decision 0011's capability boundary, +/// decision 0014's locked evaluation): the bare `compass` prelude, and a +/// content-addressed plan-version module — either a same-plan sibling +/// `./-.ts` or a cross-plan version `../..//versions/-.ts`. +/// Anything else — an absolute path, a `..` escaping the catalog, a name that is +/// not content-addressed — is refused here, so a plan cannot name a location on +/// this machine and thereby acquire a filesystem-read capability. Admission (the +/// content hash matching the filename, per 02-artifacts) is enforced by the +/// loader once the file is read. +/// +/// Resolution of a *well-formed* version reference must still succeed when the +/// file is merely absent, so the loader is invoked and reports it as Unresolved +/// (a predecessor that has not arrived) rather than being failed here. +/// +/// The path is normalised lexically, never against the real filesystem, so no +/// symlink or `..` can redirect resolution outside the catalog. fn resolve_spec(base: &Path, spec: &str) -> Result { if spec == COMPASS_SPECIFIER { return Ok(Resolved::Compass); } + let ext = crate::model::VERSION_EXT; + if Path::new(spec).is_absolute() { + return Err(EvalError::Failed(format!( + "illegitimate import `{spec}`: a plan may import only the `compass` prelude and \ + content-addressed plan versions, never an absolute path (decision 0011)" + ))); + } + // The basename must name content: `-.ts`. + let file = spec.rsplit('/').next().unwrap_or(spec); + if crate::model::parse_filename(file).is_none() { + return Err(EvalError::Failed(format!( + "illegitimate import `{spec}`: an import must name a content-addressed plan version \ + (`-.{ext}`) or the `compass` prelude (decision 0011)" + ))); + } let dir = base.parent().unwrap_or_else(|| Path::new(".")); - let joined = dir.join(spec); - // Resolution must succeed even when the file is absent, so that the loader - // is invoked and reports it as Unresolved (a predecessor that has not - // arrived) rather than the resolver failing it as a generic module error. - match std::fs::canonicalize(&joined) { - Ok(canon) => Ok(Resolved::File(canon)), - Err(_) => Ok(Resolved::File(joined)), + let target = normalize_lexical(dir, spec).ok_or_else(|| { + EvalError::Failed(format!( + "illegitimate import `{spec}`: a relative reference must not escape the catalog \ + (decision 0011)" + )) + })?; + // Exactly two legitimate shapes: a same-directory sibling (a same-plan + // version) or a cross-plan version under `//versions/`. + if !is_sibling(&target, dir) && !is_cross_plan_version(&target, base) { + return Err(EvalError::Failed(format!( + "illegitimate import `{spec}`: only a same-plan sibling `./-.{ext}` or a \ + cross-plan `../..//versions/-.{ext}` is permitted (decision 0011)" + ))); + } + Ok(Resolved::File(target)) +} + +/// Fold `.` and `..` in `dir/spec` purely lexically — never touching the +/// filesystem, so no symlink can redirect the result. Returns `None` if a `..` +/// would climb above the filesystem root, which is always an illegitimate escape. +pub(crate) fn normalize_lexical(dir: &Path, spec: &str) -> Option { + use std::path::Component; + let mut root = PathBuf::new(); + let mut comps: Vec = Vec::new(); + let push = |c: Component<'_>, comps: &mut Vec| -> Option<()> { + match c { + Component::CurDir => Some(()), + Component::ParentDir => comps.pop().map(|_| ()), + Component::Normal(s) => { + comps.push(s.to_os_string()); + Some(()) + } + _ => Some(()), + } + }; + for c in dir.components() { + match c { + Component::Prefix(p) => root.push(p.as_os_str()), + Component::RootDir => root.push(std::path::MAIN_SEPARATOR.to_string()), + other => push(other, &mut comps)?, + } + } + for c in Path::new(spec).components() { + match c { + // A spec must be relative; an absolute component is rejected earlier, + // but guard here too. + Component::Prefix(_) | Component::RootDir => return None, + other => push(other, &mut comps)?, + } } + let mut out = root; + for c in comps { + out.push(c); + } + Some(out) +} + +/// A same-plan sibling: the resolved file sits in the importer's own directory. +fn is_sibling(target: &Path, dir: &Path) -> bool { + target.parent() == Some(dir) +} + +/// A cross-plan version: the resolved file is `//versions/`, +/// under the *same* `plans` directory the importer itself lives beneath. This +/// permits a reference into another plan's versions/ while refusing anything +/// that is not a plan version under the shared catalog. +fn is_cross_plan_version(target: &Path, base: &Path) -> bool { + let plans_of = |file: &Path| -> Option { + let versions = file.parent()?; + if versions.file_name()?.to_str()? != "versions" { + return None; + } + let plan_dir = versions.parent()?; + // A single, concrete plan segment. + plan_dir.file_name()?.to_str()?; + let plans = plan_dir.parent()?; + if plans.file_name()?.to_str()? != "plans" { + return None; + } + Some(plans.to_path_buf()) + }; + match (plans_of(target), plans_of(base)) { + (Some(a), Some(b)) => a == b, + _ => false, + } +} + +/// The plan a version import targets, derived from the importing file's location +/// and the specifier — `None` when the specifier is not a version reference or +/// its target plan cannot be determined (e.g. a sibling outside the catalog +/// layout, which the caller treats as the current plan). This is the single +/// source of truth both the resolver (Fix 1) and commit's predecessor logic +/// (Fix 2) classify against, so they cannot drift. +pub(crate) fn import_target_plan(base: &Path, spec: &str) -> Option { + let dir = base.parent().unwrap_or_else(|| Path::new(".")); + let target = normalize_lexical(dir, spec)?; + let versions = target.parent()?; + if versions.file_name()?.to_str()? != "versions" { + return None; + } + let plan_dir = versions.parent()?; + Some(plan_dir.file_name()?.to_str()?.to_string()) +} + +/// Verify a resolved import is an admitted plan version: its content hash must +/// match the hash embedded in its filename (02-artifacts). A missing file is +/// Unresolved (a predecessor that has not arrived); present-but-mismatched is a +/// Failed read (a non-admitted or tampered file the plan must not evaluate). +fn verify_admitted(name: &str) -> Result<(), EvalError> { + let path = Path::new(name); + let file = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + let (_seq, want) = crate::model::parse_filename(file).ok_or_else(|| { + EvalError::Failed(format!( + "import `{name}` is not a content-addressed plan version" + )) + })?; + let bytes = std::fs::read(path).map_err(|_| { + EvalError::Unresolved(format!("{name} has not arrived (or was never committed)")) + })?; + let actual = crate::sha256::sha256_hex(&bytes); + if !actual.starts_with(&want) { + return Err(EvalError::Failed(format!( + "import `{file}` is not admitted: its content hashes to {} but its name claims {want} \ + (02-artifacts)", + &actual[..want.len().min(actual.len())] + ))); + } + Ok(()) } fn canonicalize(path: &Path) -> Result { @@ -620,7 +782,12 @@ fn path_name(path: &Path) -> String { // The loader over precompiled bytecode // --------------------------------------------------------------------------- -struct GraphResolver; +/// Resolves import specifiers to the two legitimate kinds of module and records +/// a clear reason when it refuses one, so an illegitimate import surfaces as the +/// resolver's own message rather than a generic host error. +struct GraphResolver { + err: Arc>>, +} impl Resolver for GraphResolver { fn resolve<'js>( @@ -633,10 +800,20 @@ impl Resolver for GraphResolver { if name == COMPASS_SPECIFIER { return Ok(COMPASS_SPECIFIER.to_string()); } + // A top-level import (`Module::import` with no importer) names a module + // this host already resolved — the entry module itself. It is not a raw + // specifier an author wrote, so it is returned as its own identity; only + // WE issue such an import, never a plan. + if base.is_empty() { + return Ok(name.to_string()); + } match resolve_spec(Path::new(base), name) { Ok(Resolved::Compass) => Ok(COMPASS_SPECIFIER.to_string()), Ok(Resolved::File(p)) => Ok(p.to_string_lossy().to_string()), - Err(_) => Err(JsError::new_resolving(base.to_string(), name.to_string())), + Err(e) => { + *self.err.lock().unwrap() = Some(e); + Err(JsError::new_resolving(base.to_string(), name.to_string())) + } } } } @@ -656,6 +833,15 @@ impl Loader for SourceLoader { name: &str, _attrs: Option>, ) -> rquickjs::Result> { + // Only imports flow through the loader; the entry module is declared + // directly, so admission is enforced here — on imported versions only, + // never on the (possibly not-yet-content-addressed) entry being authored. + if name != COMPASS_SPECIFIER { + if let Err(e) = verify_admitted(name) { + *self.err.lock().unwrap() = Some(e); + return Err(JsError::new_loading(name.to_string())); + } + } let js = match build_js(name) { Ok(js) => js, Err(e) => { From ab7b80dffde3433f58c006d34ed72679c491841b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:36:29 +0200 Subject: [PATCH 08/11] fix(commit): treat a cross-plan import as a reference, not a predecessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit resolved every imported version-file against the current plan's store and required each to be a committed predecessor, so a cross-plan reference (`../../pl_other/versions/...`) failed to resolve and the module was wrongly rejected as having an uncommitted predecessor — leaving the documented cross-plan Step reference (CMP.API-R05) unusable. An import is now a predecessor only when it is a version of the same plan; an import of another plan's version is a cross-plan reference, resolved against that other plan's store and required to be admitted there, but never made a parent of the current version. Catalog admission applies the same rule when deriving lineage, so a cross-plan reference no longer appears as an orphan predecessor edge. Both paths classify through the shared import_target_plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/catalog.rs | 20 ++++++++++++++++---- src/cmd.rs | 28 ++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/catalog.rs b/src/catalog.rs index abe076c..70cc7cc 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -282,7 +282,7 @@ fn admit_version(path: &Path, expected_plan: &str) -> Result { } let source = std::str::from_utf8(&bytes).map_err(|e| format!("not valid UTF-8: {e}"))?; - let import_prefixes = predecessor_prefixes(source, path)?; + let import_prefixes = predecessor_prefixes(source, path, expected_plan)?; Ok(Raw { hash: actual, @@ -293,8 +293,15 @@ fn admit_version(path: &Path, expected_plan: &str) -> Result { }) } -/// The hash-prefixes of the version files a module imports, read statically. -fn predecessor_prefixes(source: &str, path: &Path) -> Result, String> { +/// The hash-prefixes of the *predecessor* version files a module imports, read +/// statically. A predecessor is a version of the SAME plan; a version of another +/// plan is a cross-plan reference (CMP.API-R05), not a parent, and is excluded +/// from the lineage so it never shows as an orphan predecessor edge. +fn predecessor_prefixes( + source: &str, + path: &Path, + expected_plan: &str, +) -> Result, String> { let specs = crate::eval::import_specifiers(source, path) .map_err(|e| format!("cannot read imports: {}", e.message()))?; let mut out = Vec::new(); @@ -302,7 +309,12 @@ fn predecessor_prefixes(source: &str, path: &Path) -> Result, String // A predecessor import is a relative path to a version file. let file = spec.rsplit('/').next().unwrap_or(&spec); if let Some((_seq, prefix)) = parse_filename(file) { - out.push(prefix); + // Only a same-plan version is a predecessor. A cross-plan reference + // (target plan differs) is not part of this plan's lineage. + match crate::eval::import_target_plan(path, &spec) { + Some(other) if other != expected_plan => continue, + _ => out.push(prefix), + } } } Ok(out) diff --git a/src/cmd.rs b/src/cmd.rs index 2257e9b..dcd4feb 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -261,22 +261,42 @@ fn cmd_commit(root: &Path, path: &Path, plan_opt: Option<&str>) -> Result = Vec::new(); for spec in crate::eval::import_specifiers(&source_str, path) .map_err(|e| format!("cannot read imports: {}", e.message()))? { let file = spec.rsplit('/').next().unwrap_or(&spec); - if let Some((_seq, prefix)) = crate::model::parse_filename(file) { - match store.resolve_hash(&prefix) { + let Some((_seq, prefix)) = crate::model::parse_filename(file) else { + continue; // e.g. the `compass` prelude — not a version reference. + }; + // A target plan that differs from the current one is a cross-plan + // reference; anything else (a sibling, or a version of this same plan) + // is a predecessor. + match crate::eval::import_target_plan(path, &spec) { + Some(other) if other != plan => { + let other_store = catalog::load_plan(root, &other)?; + if other_store.resolve_hash(&prefix).is_none() { + return Err(format!( + "cross-plan reference {prefix} is not committed in {other}; \ + nothing was recorded" + )); + } + } + _ => match store.resolve_hash(&prefix) { Some(a) => parents.push(a.hash.clone()), None => { return Err(format!( "predecessor {prefix} is not committed in {plan}; nothing was recorded" )) } - } + }, } } parents.sort(); From 05f997984e3f6b26baa1b4818e81701621a6b677 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:36:29 +0200 Subject: [PATCH 09/11] fix(reconcile): refuse silent loss of a divergent same-identity step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two reconciliation sides defined the same named Step with different content, reconcile silently kept the first side and dropped the other — the silent intent loss the whole reconciliation design exists to prevent. Reconcile now detects when sides disagree on a same-identity Step (comparing work, dependencies, acceptance, supersedes, and retirement) and refuses the reconciliation unless it carries an explicit `edit` for that Step stating the surviving intent. The error names the Step and both differing sides. A Step only one side carries, or that both sides left identical, is not a conflict and is carried forward unchanged. Resolves DQ08 in favour of never asserting intent nobody wrote. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/open-questions.md | 23 ++++++---- src/prelude.js | 97 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/context/open-questions.md b/context/open-questions.md index 4a74da0..3020777 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -79,16 +79,19 @@ separately from the claimed one, or whether authorship is simply a claim like any other and is documented as such. Progress records are unaffected — Compass writes those, so their actor is observed. -**DQ08 — How does a reconciliation resolve a Step both sides edited?** -A reconciliation carries forward every Step of every predecessor, so nothing can -be lost by choosing a side. That leaves the case where two predecessors edited -the same Step differently, and the carried-forward value is ambiguous. - -Unresolved: whether the reconciliation must state the surviving intent for every -such Step and is refused otherwise, or whether an unstated conflict resolves by -some rule. The first is consistent with divergence resolving by authorship; the -second would let a reconciliation assert intent nobody wrote, which is the -failure the whole divergence model exists to avoid. +**DQ08 — Resolved.** A reconciliation carries forward every Step of every +predecessor, so nothing can be lost by choosing a side. Where two predecessors +define the same Step with *different* content, the reconciliation must state the +surviving intent with an explicit `edit` for that Step, and is **refused +otherwise** — naming the Step and both differing sides. A Step only one side +carries, or that both sides left identical, is not a conflict and is carried +forward unchanged. + +This takes the first of the two candidates, and for the reason the second was +suspect: silently keeping one side and dropping the other would let a +reconciliation assert intent nobody wrote, which is the exact failure the whole +divergence model exists to avoid. Refusal is consistent with divergence +resolving by authorship — the surviving intent is authored, never inferred. **DQ09 — What are the evaluation bounds, and who sets them?** Decision 0014 requires that evaluation be bounded in time and memory and that exceeding a bound be reported. It does not say what the bounds are. diff --git a/src/prelude.js b/src/prelude.js index 07e1236..bf390e9 100644 --- a/src/prelude.js +++ b/src/prelude.js @@ -173,24 +173,111 @@ function reviseOne(base, rev) { }); } +// A canonical, order-insensitive serialization of a criterion, so two sides +// that authored the same acceptance compare equal regardless of the order of +// `all`/`any` arguments or of an atom's attributes. +function canonPred(p) { + if (!p || typeof p !== "object") return JSON.stringify(p); + if (p.__k === "atom") { + const keys = Object.keys(p.attrs || {}).sort(); + const parts = []; + for (let i = 0; i < keys.length; i++) parts.push(keys[i] + "=" + String(p.attrs[keys[i]])); + return "atom(" + String(p.kind) + ";" + parts.join(",") + ")"; + } + if (p.__k === "all" || p.__k === "any") { + const parts = []; + const args = p.args || []; + for (let i = 0; i < args.length; i++) parts.push(canonPred(args[i])); + parts.sort(); + return p.__k + "(" + parts.join("|") + ")"; + } + if (p.__k === "not") return "not(" + canonPred(p.arg) + ")"; + return JSON.stringify(p); +} + +// The identity-independent *content* of a step: what an author could disagree +// about. Two carried-forward copies with the same content are not a conflict. +function canonStep(s) { + const deps = []; + const d = s.dependsOn || []; + for (let i = 0; i < d.length; i++) { + if (d[i] && d[i].__name !== undefined) deps.push(d[i].__name); + } + deps.sort(); + const supersedes = + s.supersedes && s.supersedes.__name !== undefined ? s.supersedes.__name : null; + return JSON.stringify({ + work: s.work, + dependsOn: deps, + supersedes: supersedes, + retired: !!s.retired, + accept: canonPred(s.accept), + }); +} + // A reconciliation is a revision with more than one predecessor. Every step of // every side is carried forward, keyed by identity, so nothing is lost by // choosing a side; the version states only what it changes. +// +// Where two sides define the SAME step with DIFFERENT content, keeping one and +// dropping the other would silently lose intent — the cardinal failure the +// reconciliation design exists to prevent (DQ08). Such a conflict is refused +// unless the reconciliation carries an explicit `edit` for that step stating the +// surviving intent. A step only one side has, or that both sides left identical, +// is not a conflict and is carried forward unchanged. function reconcile(rev) { const sides = rev.revises || []; - const seen = {}; - const carried = []; + + // Steps the reconciliation explicitly resolves via an edit. + const resolved = {}; + const edits = rev.edit || []; + for (let i = 0; i < edits.length; i++) { + if (edits[i] && edits[i].__name !== undefined) resolved[edits[i].__name] = true; + } + + const firstStep = {}; // name -> the first carried copy + const firstContent = {}; // name -> its canonical content + const firstSide = {}; // name -> the side index it first came from + const order = []; for (let i = 0; i < sides.length; i++) { const ss = sides[i].__steps || []; for (let j = 0; j < ss.length; j++) { const s = ss[j]; if (s.__name === undefined) continue; - if (!Object.prototype.hasOwnProperty.call(seen, s.__name)) { - seen[s.__name] = true; - carried.push(cloneCarry(s)); + const content = canonStep(s); + if (!Object.prototype.hasOwnProperty.call(firstStep, s.__name)) { + firstStep[s.__name] = cloneCarry(s); + firstContent[s.__name] = content; + firstSide[s.__name] = i; + order.push(s.__name); + } else if (content !== firstContent[s.__name] && !resolved[s.__name]) { + throw new Error( + "reconciliation conflict on step " + + JSON.stringify(s.__name) + + ": side " + + (firstSide[s.__name] + 1) + + " and side " + + (i + 1) + + " define it differently and no explicit edit resolves it. " + + "side " + + (firstSide[s.__name] + 1) + + " = " + + firstContent[s.__name] + + "; side " + + (i + 1) + + " = " + + content + + ". Author an explicit edit for " + + JSON.stringify(s.__name) + + " stating the surviving intent.", + ); } } } + + const carried = []; + for (let k = 0; k < order.length; k++) carried.push(firstStep[order[k]]); + const steps = applyOps(carried, rev); const goal = "goal" in rev ? rev.goal : sides.length > 0 ? sides[0].goal : undefined; return makePlan({ From ec38037d4da7c17e9471dc3f6e504838bda675fc Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:36:29 +0200 Subject: [PATCH 10/11] test(compass): cover the three import/reconcile safety fixes - imports_are_restricted_to_legitimate_modules: an absolute or catalog-escaping import is refused at eval; a valid same-plan sibling resolves. - a_cross_plan_reference_is_not_a_predecessor: a cross-plan reference commits successfully, records no parent, and does not read as an orphan. - reconciliation_refuses_divergent_same_step_without_an_explicit_edit: a divergent same-step reconcile is refused naming the step; an explicit edit resolves it; a non-conflicting reconcile still works. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/acceptance.rs | 271 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/tests/acceptance.rs b/tests/acceptance.rs index e3532f1..b64d688 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -326,6 +326,277 @@ fn a_nonterminating_plan_is_stopped_not_awaited() { assert_eq!(eval::eval_plan_file(&p).unwrap_err().kind(), "stopped"); } +/// Write `source` as an admitted (content-addressed, hash-named) version file +/// and return its filename, so a sibling or cross-plan import can reference it. +fn admit(root: &Path, plan: &str, seq: u64, source: &str) -> String { + let (p, _hash, _created) = catalog::write_version(root, plan, seq, source.as_bytes()).unwrap(); + p.file_name().unwrap().to_str().unwrap().to_string() +} + +// ---- Fix 1: the module resolver admits only legitimate modules ---- + +#[test] +fn imports_are_restricted_to_legitimate_modules() { + let root = std::env::temp_dir().join(format!("compass-imports-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let plan = "pl_imp"; + let vdir = catalog::versions_dir(&root, plan); + std::fs::create_dir_all(&vdir).unwrap(); + + // A legitimate root version, admitted so a sibling import can resolve it. + let root_src = r#"import { plan, step, evidence } from "compass" +export const a = step({ work: "do a", accept: evidence.test({ status: "pass" }) }) +export default plan({ author: "cos", goal: "g", why: "w", steps: [a] }) +"#; + let v1 = admit(&root, plan, 1, root_src); + + // (a) an absolute-path import is refused at eval as illegitimate. The import + // is used (`String(_x)`) so the TypeScript transpiler does not elide it. + let abs = tmp_module( + &vdir, + "draft_abs.ts", + "import { plan, step, evidence } from \"compass\"\nimport _x from \"/etc/passwd\"\nexport const a = step({ work: \"x\", accept: evidence.test({ status: \"pass\" }) })\nexport default plan({ author: \"cos\", goal: \"g\", why: String(_x), steps: [a] })\n", + ); + let e = eval::eval_plan_file(&abs).unwrap_err(); + assert_eq!(e.kind(), "failed", "{}", e.message()); + assert!( + e.message().contains("illegitimate import"), + "absolute import must be refused: {}", + e.message() + ); + + // (b) a relative path that escapes the catalog is refused, even when its + // basename is content-addressed. + let outside = tmp_module( + &vdir, + "draft_out.ts", + "import { plan, step, evidence } from \"compass\"\nimport _x from \"../../../../000-000000000000.ts\"\nexport const a = step({ work: \"x\", accept: evidence.test({ status: \"pass\" }) })\nexport default plan({ author: \"cos\", goal: \"g\", why: String(_x), steps: [a] })\n", + ); + let e = eval::eval_plan_file(&outside).unwrap_err(); + assert_eq!(e.kind(), "failed", "{}", e.message()); + assert!( + e.message().contains("illegitimate import"), + "an escaping import must be refused: {}", + e.message() + ); + + // (c) a legitimate same-plan sibling import resolves and evaluates. + let rev_src = format!( + r#"import prior from "./{v1}" +export default prior.revise({{ author: "cos", why: "reword", edit: [prior.steps.a.with({{ work: "do a, better" }})] }}) +"# + ); + let good = tmp_module(&vdir, "draft_good.ts", &rev_src); + let map = eval::eval_plan_file(&good).unwrap_or_else(|e| { + panic!( + "valid sibling import must resolve: [{}] {}", + e.kind(), + e.message() + ) + }); + let v = map.get(&std::fs::canonicalize(&good).unwrap()).unwrap(); + assert_eq!( + v.steps.iter().find(|s| s.name == "a").unwrap().work, + "do a, better" + ); +} + +// ---- Fix 2: a cross-plan reference commits and is not a predecessor ---- + +#[test] +fn a_cross_plan_reference_is_not_a_predecessor() { + let root = std::env::temp_dir().join(format!("compass-xplan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + catalog::init(&root).unwrap(); + + // The other plan, with an admitted version. + let dep_src = r#"import { plan, step, evidence } from "compass" +export const seed = step({ work: "Seed work", accept: evidence.test({ status: "pass" }) }) +export default plan({ author: "cos", goal: "dep", why: "the referenced plan", steps: [seed] }) +"#; + let dep_v1 = admit(&root, "pl_dep", 1, dep_src); + + // A first version of pl_main that references pl_dep's version cross-plan. + // The reference is real (it reads a value from the other plan's version) but + // is not a predecessor: this commit has no parent. + let main_src = format!( + r#"import {{ plan, step, evidence }} from "compass" +import dep from "../../pl_dep/versions/{dep_v1}" +export const local = step({{ work: "Local, mirrors " + dep.steps.seed.work, accept: evidence.test({{ status: "pass" }}) }}) +export default plan({{ author: "cos", goal: "main", why: "references pl_dep cross-plan", steps: [local] }}) +"# + ); + let vdir = catalog::versions_dir(&root, "pl_main"); + std::fs::create_dir_all(&vdir).unwrap(); + let draft = vdir.join("draft.ts"); + std::fs::write(&draft, &main_src).unwrap(); + + let out = run( + &root, + Command::Commit { + path: draft.clone(), + plan: Some("pl_main".into()), + }, + ) + .unwrap_or_else(|e| panic!("cross-plan commit must succeed, not be rejected: {e}")); + assert_eq!(out.code, 0, "{}", out.text); + assert!( + out.text.contains("created"), + "a cross-plan reference has no predecessor, so this is a creation: {}", + out.text + ); + + // The committed version records no parent, and is not an orphan. + let store = catalog::load_plan(&root, "pl_main").unwrap(); + assert_eq!(store.versions.len(), 1, "rejected: {:?}", store.rejected); + assert!( + store.versions[0].parents.is_empty(), + "a cross-plan reference must not be a parent: {:?}", + store.versions[0].parents + ); + let an = chain::analyze(&store); + assert!( + !an.is_orphan(&store.versions[0].hash), + "a cross-plan-referencing version must not read as an orphan" + ); +} + +// ---- Fix 3: reconciliation refuses silent divergent-step loss ---- + +#[test] +fn reconciliation_refuses_divergent_same_step_without_an_explicit_edit() { + let root = std::env::temp_dir().join(format!("compass-recon-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let plan = "pl_rec"; + let vdir = catalog::versions_dir(&root, plan); + std::fs::create_dir_all(&vdir).unwrap(); + + let v1_src = r#"import { plan, step, evidence } from "compass" +export const build = step({ work: "Build", accept: evidence.test({ status: "pass" }) }) +export const ship = step({ work: "Ship", accept: evidence.test({ status: "pass" }) }) +export default plan({ author: "cos", goal: "g", why: "w", steps: [build, ship] }) +"#; + let v1 = admit(&root, plan, 1, v1_src); + + // Two sides that edit the SAME step differently. + let side_a = admit( + &root, + plan, + 2, + &format!( + r#"import prior from "./{v1}" +export default prior.revise({{ author: "cos", why: "carefully", edit: [prior.steps.build.with({{ work: "Build carefully" }})] }}) +"# + ), + ); + let side_b = admit( + &root, + plan, + 2, + &format!( + r#"import prior from "./{v1}" +export default prior.revise({{ author: "dev", why: "quickly", edit: [prior.steps.build.with({{ work: "Build quickly" }})] }}) +"# + ), + ); + + // Without an explicit edit, the divergent `build` is a refused conflict. + let bad = tmp_module( + &vdir, + "recon_bad.ts", + &format!( + r#"import {{ reconcile }} from "compass" +import sa from "./{side_a}" +import sb from "./{side_b}" +export default reconcile({{ revises: [sa, sb], author: "cos", why: "merge" }}) +"# + ), + ); + let e = eval::eval_plan_file(&bad).unwrap_err(); + assert_eq!(e.kind(), "failed", "{}", e.message()); + assert!( + e.message().contains("reconciliation conflict") && e.message().contains("build"), + "the refusal must name the conflicting step: {}", + e.message() + ); + + // With an explicit edit stating the surviving intent, it succeeds. + let good = tmp_module( + &vdir, + "recon_good.ts", + &format!( + r#"import {{ reconcile }} from "compass" +import sa from "./{side_a}" +import sb from "./{side_b}" +export default reconcile({{ revises: [sa, sb], author: "cos", why: "merge", edit: [sa.steps.build.with({{ work: "Build carefully and quickly" }})] }}) +"# + ), + ); + let map = eval::eval_plan_file(&good).unwrap_or_else(|e| { + panic!( + "an explicit edit must resolve the conflict: {}", + e.message() + ) + }); + let v = map.get(&std::fs::canonicalize(&good).unwrap()).unwrap(); + assert_eq!( + v.steps.iter().find(|s| s.name == "build").unwrap().work, + "Build carefully and quickly" + ); + + // A reconciliation whose sides diverge only by ADDING different steps (no + // shared step disagrees) is not a conflict and still works. + let add_a = admit( + &root, + plan, + 2, + &format!( + r#"import {{ step, evidence }} from "compass" +import prior from "./{v1}" +export const fuzz = step({{ work: "Fuzz", dependsOn: [prior.steps.build], accept: evidence.test({{ status: "pass" }}) }}) +export default prior.revise({{ author: "cos", why: "add fuzz", add: [fuzz] }}) +"# + ), + ); + let add_b = admit( + &root, + plan, + 2, + &format!( + r#"import {{ step, evidence }} from "compass" +import prior from "./{v1}" +export const doc = step({{ work: "Doc", dependsOn: [prior.steps.build], accept: evidence.test({{ status: "pass" }}) }}) +export default prior.revise({{ author: "dev", why: "add doc", add: [doc] }}) +"# + ), + ); + let ok = tmp_module( + &vdir, + "recon_ok.ts", + &format!( + r#"import {{ reconcile }} from "compass" +import sa from "./{add_a}" +import sb from "./{add_b}" +export default reconcile({{ revises: [sa, sb], author: "cos", why: "both are worth keeping" }}) +"# + ), + ); + let map = eval::eval_plan_file(&ok) + .unwrap_or_else(|e| panic!("a non-conflicting reconcile must work: {}", e.message())); + let v = map.get(&std::fs::canonicalize(&ok).unwrap()).unwrap(); + let names: Vec<&str> = v.steps.iter().map(|s| s.name.as_str()).collect(); + assert!( + names.contains(&"build") + && names.contains(&"ship") + && names.contains(&"fuzz") + && names.contains(&"doc"), + "{names:?}" + ); +} + #[test] fn the_sandbox_grants_no_dynamic_code_or_clock() { let root = std::env::temp_dir().join(format!("compass-sandbox-{}", std::process::id())); From 4ae4ffbe650e8f0e7156717b611719d5ea6b7d24 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:44:53 +0200 Subject: [PATCH 11/11] docs(vrs): record DQ08 decision; scope cross-plan; refine convergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decision 0016: a divergent same-Step reconciliation edit must be resolved explicitly (resolves DQ08), matching the never-silently-lose-intent principle. - 06-api: the documented cross-plan dependsOn edge is not usable — validate() checks deps against the version's own steps and readiness folds within one plan. Narrowed CMP.API-R05 to the reference (which works), deferred the dependency edge to DQ11, and fixed the import path depth (../../, not ../). - 05-integrations: refined CMP.INT-R06 — the substrate cannot express completeness any more than the catalog can, so convergence is at most 'quiescent as of now', never 'you have everything'. - DQ12: variable-in-content vs relative-import+env-root machine-agnosticism. - fix the repair delta to the enforced shape (status open, bare direction). --- ...t-step-edit-must-be-resolved-explicitly.md | 64 +++++++++++++++++++ .../.delta/DELTA-001-repair-has-no-command.md | 5 +- context/05-integrations/requirements.md | 12 ++-- context/06-api/requirements.md | 12 ++-- context/06-api/spec.md | 22 +++++-- context/open-questions.md | 22 +++++++ 6 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 context/.decisions/0016-a-divergent-step-edit-must-be-resolved-explicitly.md diff --git a/context/.decisions/0016-a-divergent-step-edit-must-be-resolved-explicitly.md b/context/.decisions/0016-a-divergent-step-edit-must-be-resolved-explicitly.md new file mode 100644 index 0000000..1860c34 --- /dev/null +++ b/context/.decisions/0016-a-divergent-step-edit-must-be-resolved-explicitly.md @@ -0,0 +1,64 @@ +# A divergent Step edit must be resolved explicitly + +Status: accepted + +Resolves DQ08. + +## Context + +A reconciliation carries every Step of every predecessor forward, so no Step is +lost by omission. That leaves one case the carry-forward rule does not settle: +two predecessors that both define the *same* Step with *different* content — a +different description, a different acceptance criterion, a different dependency +set. One reconciled version must present one Step; which intent survives? + +## Evidence and Argument + +An implementation of reconciliation resolved this silently: it kept the first +predecessor's definition, ordered by the `revises` list, and dropped the other. +That is the precise failure the divergence model exists to prevent. A +reconciliation reported as settled would then assert intent nobody authored, and +would discard one author's work according to nothing more than argument order — +invisibly, with no record that a choice was even made. + +The alternative is to refuse. If two sides disagree on a Step, the reconciliation +must state the surviving intent explicitly, with an edit for that Step, and is +refused until it does. This costs the author a keystroke and buys the guarantee +that no reconciliation ever silently chooses between two authored intents. + +The two are not close. Silent resolution is fast and wrong in exactly the way +the whole design is built to avoid; explicit resolution is the same principle +divergence already follows — it is resolved by authorship, never by inference. + +A Step only one side carries is not in conflict and is carried forward. A Step +both sides left identical is not in conflict either. Only a genuine disagreement +requires a choice, and only that choice must be authored. + +## Options + +| Option | Tradeoffs | +| --- | --- | +| Refuse a divergent same-Step edit absent an explicit resolution | No reconciliation ever silently drops an authored intent; costs an explicit edit per genuine conflict | +| Keep one side by `revises` order | No keystroke; discards one author's intent invisibly, by argument order, while reporting success | +| Merge the two definitions automatically | No keystroke; invents a third intent neither author wrote, which is worse than choosing one | + +## Decision + +When two predecessors of a reconciliation define the same-identity Step with +different content, the reconciliation must carry an explicit edit for that Step +stating the surviving intent. Absent it, the reconciliation is refused, naming +the Step and both differing sides. + +Difference is judged over the whole Step — work, dependencies, acceptance, +supersession, retirement — not a subset, because any of them is intent a silent +choice could discard. + +## Consequences + +- A reconciliation never silently chooses between two authored intents; the + choice is always in the record, authored. +- The common case — sides that touched different Steps — needs no explicit edit + and reconciles directly. +- This is the same shape as the rest of the model: divergence resolves by + authorship, and here the authorship is a required edit rather than an inferred + default. diff --git a/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md b/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md index 9672ab3..e712bf7 100644 --- a/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md +++ b/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md @@ -1,6 +1,6 @@ # DELTA-001: Repair has no command -Status: narrowed +Status: open ## Divergence @@ -32,8 +32,7 @@ refuses on a clean catalog); the authoring is guided rather than performed. ## Direction -update implementation — close the remaining gap by having `repair` write the -damage-recording version directly rather than scaffolding it. +update implementation ## Resolution Signal diff --git a/context/05-integrations/requirements.md b/context/05-integrations/requirements.md index 73456b4..662be8b 100644 --- a/context/05-integrations/requirements.md +++ b/context/05-integrations/requirements.md @@ -51,10 +51,14 @@ plainly that replication is inactive, rather than being silent in a way that resembles a healthy sync. _refines: CMP-R05, CMP-R06._ -- **CMP.INT-R06 Source convergence from the substrate.** The - converged-or-arriving signal comes from the replication mechanism's own state. - It cannot be inferred from the catalog, which has no way to express - completeness. _refines: CMP-R05._ +- **CMP.INT-R06 Source the convergence signal from the substrate.** The + arriving-or-quiescent signal comes from the replication mechanism's own state, + not from the catalog, which has no way to express completeness. But the + substrate cannot express completeness either — for the same reason the catalog + cannot: nothing knows how many versions a Plan *should* have. So the strongest + honest signal is "the sync is quiescent as of now," never "you have + everything." Compass must report quiescent, receiving, or unknown, and must + not claim completeness a substrate cannot provide. _refines: CMP-R05._ - **CMP.INT-R07 Verify the policy, not just the presence.** Compass confirms the declared replication carries no-delete semantics. A mechanism that propagates diff --git a/context/06-api/requirements.md b/context/06-api/requirements.md index e01f595..338db33 100644 --- a/context/06-api/requirements.md +++ b/context/06-api/requirements.md @@ -40,10 +40,14 @@ the work, the acceptance, and the dependencies; it cannot change identity. _refines: CMP-R02, CMP-R10._ -- **CMP.API-R05 A cross-plan reference is an import.** Depending on a Step in - another Plan is importing that Plan's version and referring to the Step. This - is what makes the reference checkable, and it is why the other Plan must be - present to evaluate this one. _refines: CMP-R08, CMP-R09._ +- **CMP.API-R05 A cross-plan reference is an import.** Referring to another + Plan's version is importing it. The import is what makes the reference + checkable rather than spelled, and it is why the other Plan must be present to + evaluate this one. The imported version is a reference, never a predecessor of + the importing version. A Step *depending on* a Step in another Plan is a larger + feature with unsettled cross-Plan readiness semantics and is deferred (DQ11); + this requirement covers the reference, not the dependency edge. + _refines: CMP-R08, CMP-R09._ - **CMP.API-R06 The evidence vocabulary is open and typed.** The constructors for acceptance evidence are values a use case defines, not a fixed set the API diff --git a/context/06-api/spec.md b/context/06-api/spec.md index 48f0c9e..fac5822 100644 --- a/context/06-api/spec.md +++ b/context/06-api/spec.md @@ -119,19 +119,29 @@ an open question (DQ08). ## Cross-plan references -Depending on a Step in another Plan is importing that Plan's version and -referring to the Step. +Referring to another Plan's version is importing it. The import path reaches +across the catalog's plan-then-versions layout, so a sibling plan is two levels +up — pop `versions/`, then the plan segment: ```ts -import release from "../pl_release/versions/007-4d81f0a2.ts" - -// ... dependsOn: [release.steps.branchCut] +import release from "../../pl_release/versions/007-4d81f0a2.ts" ``` The import is what makes the reference checkable rather than spelled — and it is why the other Plan must be present to evaluate this one. A machine that has not received `pl_release` cannot read this Plan at all, and reports it as Unresolved -rather than showing an incomplete graph. +rather than showing an incomplete graph. The imported version is a reference, +not a predecessor: it does not become a parent of the importing version, and its +absence-of-admission there is an error, not an uncommitted-predecessor. + +A Step **depending on** a Step in another Plan — `dependsOn: +[release.steps.branchCut]` — is a different and larger thing, and is **not yet +supported**. A dependency is validated against the importing version's own +Steps, and readiness folds within one Plan; a dependency edge that crosses Plans +raises questions neither answers — whether the other Plan's Step gates this one, +how readiness folds across Plans, what an out-of-Plan retirement does here. Those +are open (DQ11). A cross-plan *reference* resolves today; a cross-plan +*dependency edge* does not. ## The evidence vocabulary diff --git a/context/open-questions.md b/context/open-questions.md index 3020777..5df6ecb 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -114,3 +114,25 @@ reference is itself the pin, or a manifest beside the catalog. The import-encode it option is attractive because it makes the pin a resolved reference rather than an asserted value, consistent with CMP-R10, but it entangles the library's distribution with the catalog's contents. + +**DQ11 — Can a Step depend on a Step in another Plan?** +A cross-plan *reference* (importing another Plan's version) resolves and is +checkable. A cross-plan *dependency edge* — a Step whose `dependsOn` names a Step +in another Plan — does not work: a dependency is validated against the importing +version's own Steps, and readiness folds within one Plan. Making the edge real +raises questions neither mechanism answers: does the other Plan's Step being +accepted gate this one; how does readiness fold across Plans; what does an +out-of-Plan retirement do to a dependent here; and what happens when the other +Plan diverges. Until those are settled the reference is supported and the edge is +not. + +**DQ12 — Does authored content carry variable references, or only the root?** +CMP.FS-R05 promises machine-agnostic paths via variable references inside +authored content. In practice a version references its predecessors by *relative* +import and the catalog *root* is environment-resolved, which achieves +machine-agnosticism without any variable expansion in the module resolver. Either +the requirement is satisfied by that weaker mechanism and should say so, or +variable-in-content is a real feature still to build. The relative-import form is +also what makes the flat per-Plan `versions/` layout load-bearing (a predecessor +is named by relative path), so this interacts with how a catalog may be +reorganized.